main
ts 235 lines 11.2 KB
Raw
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 import Koa from 'koa'
4 import {
5 API_VERSION, MIME_AUTO, FRONTEND_URI, HTTP_METHOD_NOT_ALLOWED, HTTP_NO_CONTENT, HTTP_NOT_FOUND,
6 PLUGINS_PUB_URI, VERSION, SPECIAL_URI, ICONS_URI, DEV
7 } from './const'
8 import { serveFile } from './serveFile'
9 import { getPluginConfigFields, getPluginInfo, mapPlugins, pluginsConfig } from './plugins'
10 import { authApis } from './api.auth'
11 import { ApiError } from './apiMiddleware'
12 import { join, extname, sep } from 'path'
13 import { readdir } from 'fs/promises'
14 import {
15 CFG, debounceAsync, formatBytes, FRONTEND_OPTIONS, isPrimitive, newObj, onlyTruthy, parseFile,
16 enforceStarting, statWithTimeout, shortenAgent
17 } from './misc'
18 import { favicon, title } from './adminApis'
19 import { getAllSections, getSection } from './customHtml'
20 import _ from 'lodash'
21 import { defineConfig, getConfig } from './config'
22 import { getLangData } from './lang'
23 import { dontOverwriteUploading } from './upload'
24 import { customizedIcons, CustomizedIcons } from './icons'
25 import { getProxyDetected } from './middlewares'
26
27 const size1024 = defineConfig(CFG.size_1024, false, x => formatBytes.k = x ? 1024 : 1000) // we both configure formatBytes, and also provide a compiled version (number instead of boolean)
28 const splitUploads = defineConfig(CFG.split_uploads, 0)
29 export const logGui = defineConfig(CFG.log_gui, false)
30 _.each(FRONTEND_OPTIONS, (v,k) => defineConfig(k, v)) // define default values
31
32 function serveStatic(uri: string): Koa.Middleware {
33 const folder = (DEV ? 'dist/' : '') + uri.slice(2,-1)
34 const root = join(__dirname, '..', folder)
35 prewarmGuiAssets(root) // keep pkg snapshot files in memory before the reported runtime access loss can happen
36 .catch(e => console.error(`GUI asset prewarm failed: ${e?.code || e}`))
37 return async ctx => {
38 if (!logGui.get())
39 ctx.state.dontLog = true
40 if(ctx.method === 'OPTIONS') {
41 ctx.status = HTTP_NO_CONTENT
42 ctx.set({ Allow: 'OPTIONS, GET' })
43 return
44 }
45 if (ctx.method !== 'GET')
46 return ctx.status = HTTP_METHOD_NOT_ALLOWED
47 const serveApp = shouldServeApp(ctx)
48 const fullPath = join(root, serveApp ? '/index.html': ctx.path)
49 const cached = await parseGuiFile(fullPath)
50 .catch(e => {
51 if (!/^(?:ENOENT|EISDIR)$/.test(e?.code)) // not supposed to happen, and yet a user reported a strange behavior
52 console.error(`serveStatic/parseFile: ${String(e)}`)
53 return null
54 })
55 if (cached === null)
56 return ctx.status = HTTP_NOT_FOUND
57 if (!serveApp) {
58 const c = cached.content
59 return serveFile(ctx, fullPath, MIME_AUTO, { ...cached, content: !c.length ? c : adjustBundlerLinks(ctx, uri, c) })
60 }
61 // we don't cache the index as it's small and may prevent plugins change to apply
62 ctx.body = await treatIndex(ctx, uri, String(cached.content))
63 }
64
65 async function prewarmGuiAssets(dir: string) {
66 for (const entry of await readdir(dir, { withFileTypes: true })) {
67 const fullPath = join(dir, entry.name)
68 if (entry.isDirectory()) {
69 await prewarmGuiAssets(fullPath)
70 continue
71 }
72 if (!entry.isFile())
73 continue
74 await parseGuiFile(fullPath)
75 }
76 }
77
78 function parseGuiFile(fullPath: string) {
79 return parseFile(fullPath, x => x, DEV ? 1000 : Infinity) // cache raw bytes so reverse-proxy URL rewriting can still use the current request context
80 }
81
82 }
83
84 function shouldServeApp(ctx: Koa.Context) {
85 return ctx.state.serveApp ||= ctx.path.endsWith('/') && !ctx.headers.upgrade // skip websockets
86 }
87
88 function adjustBundlerLinks(ctx: Koa.Context, uri: string, data: string | Buffer) {
89 const ext = extname(ctx.path)
90 const filesUri = ctx.state.revProxyPath + uri
91 return ext && !ext.match(/\.(css|html|js|ts|scss)/) ? data
92 : String(data).replace(/((?:import[ (]| from )['"])\//g, `$1${filesUri}`)
93 .replace(/(=function\(([\w$]+)\)\{return)[`'"]\/[`'"]\+\2\}/g, `$1${JSON.stringify(filesUri)}+$2}`) // vite's preload helper uses the configured absolute base for dynamic dependency hints
94 }
95
96 const getFaviconTimestamp = debounceAsync(async () => {
97 const f = favicon.get()
98 return !f ? 0 : statWithTimeout(f).then(x => x?.mtimeMs || 0, () => 0)
99 }, { retain: 5_000 })
100
101 async function treatIndex(ctx: Koa.Context, filesUri: string, body: string) {
102 const session = await authApis.refresh_session({}, ctx)
103 ctx.set('etag', '')
104 ctx.set('Cache-Control', 'no-store, no-cache, must-revalidate')
105 ctx.type = 'html'
106
107 const isFrontend = filesUri === FRONTEND_URI ? ' ' : '' // as a string will allow neater code later
108
109 const pub = ctx.state.revProxyPath + PLUGINS_PUB_URI
110
111 // expose plugins' configs that are declared with 'frontend' attribute
112 const plugins = Object.fromEntries(onlyTruthy(mapPlugins((pl,name) => {
113 let configs = newObj(getPluginConfigFields(name), (v, k, skip) =>
114 !v.frontend ? skip() :
115 adjustValueByConfig(pluginsConfig.get()?.[name]?.[k], pl.getData().config?.[k])
116 )
117 configs = getPluginInfo(name).onFrontendConfig?.(configs) || configs
118 return !_.isEmpty(configs) && [name, configs]
119 })))
120 const timestamp = await getFaviconTimestamp()
121 const lang = await getLangData(ctx)
122 return body
123 .replace(/((?:src|href) *= *['"])\/?(?!([a-z]+:\/)?\/)(?!\?)/g, '$1' + ctx.state.revProxyPath + filesUri)
124 .replace(/<(\/)?(head|body)>/g, (all, isClose, name) => { // must make these changes in one .replace call, otherwise we may encounter head/body tags due to customHtml. This simple trick makes html parsing unnecessary.
125 const isHead = name === 'head'
126 const isBody = !isHead
127 const isOpen = !isClose
128 if (isHead && isOpen)
129 return all + `
130 <script>
131 HFS = ${JSON.stringify({
132 VERSION,
133 API_VERSION,
134 SPECIAL_URI, PLUGINS_PUB_URI, FRONTEND_URI,
135 pathSeparator: sep,
136 session: session instanceof ApiError ? null : session,
137 plugins,
138 loadScripts: Object.fromEntries(mapPlugins((p, id) => [id, p.frontend_js?.map(f => f.includes('//') ? f : pub + id + '/' + f)])),
139 prefixUrl: ctx.state.revProxyPath || '',
140 proxyDetected: Boolean(getProxyDetected()),
141 dontOverwriteUploading: dontOverwriteUploading.get(),
142 splitUploads: splitUploads.get(),
143 kb: size1024.compiled(),
144 forceTheme: mapPlugins(p => _.isString(p.isTheme) ? p.isTheme : undefined).find(Boolean),
145 customHtml: _.omit(getAllSections(), ['top', 'bottom', 'htmlHead', 'style']), // exclude the sections we already apply in this phase
146 ...newObj(FRONTEND_OPTIONS, (v, k) => getConfig(k)),
147 icons: Object.assign({}, ...mapPlugins(p => iconsToObj(p.icons, p.id + '/')), iconsToObj(customizedIcons)), // name-to-uri
148 lang
149 }, null, 4).replace(/<(\/script)/g, '<"+"$1') /*avoid breaking our script container*/}
150 document.documentElement.setAttribute('ver', HFS.VERSION.split('-')[0])
151 document.documentElement.setAttribute('browser', ${JSON.stringify(shortenAgent(ctx.get('user-agent')))})
152 </script>
153 ${isFrontend && `
154 <title>${title.get()}</title>
155 <link rel="shortcut icon" href="${ctx.state.revProxyPath}/favicon.ico?${timestamp}" />
156 ${getSection('htmlHead')}`}
157 `
158 function iconsToObj(icons: CustomizedIcons, pre='') {
159 return icons && _.mapValues(icons, (v, k) => ctx.state.revProxyPath + ICONS_URI + pre + k)
160 }
161
162 if (isBody && isOpen)
163 return `${all}
164 ${isFrontend && getSection('top')}
165 <style>
166 :root {
167 ${_.map(plugins, (configs, pluginName) => // make plugin configs accessible via css
168 _.map(configs, (v, k) => {
169 v = serializeCss(v)
170 return typeof v === 'string' && `\n--${pluginName}-${k}: ${v};`
171 }).filter(Boolean).join('')).join('')}
172 }
173 ${isFrontend && getSection('style')}
174 </style>
175 ${isFrontend && mapPlugins((plug,id) =>
176 plug.frontend_css?.map(f =>
177 `<link rel='stylesheet' type='text/css' href='${f.includes('//') ? f : pub + id + '/' + f}' plugin=${JSON.stringify(id)}/>`))
178 .flat().filter(Boolean).join('\n')}
179 `
180 if (isBody && isClose)
181 return getSection('bottom') + all
182 return all // unchanged
183 })
184
185 function adjustValueByConfig(v: any, cfg: any) {
186 v ??= cfg.defaultValue
187 const {type} = cfg
188 if (v && type === 'vfs_path') {
189 v = enforceStarting('/', v)
190 const { root } = ctx.state
191 if (root)
192 if (v.startsWith(root))
193 v = v.slice(root.length - 1)
194 else
195 return
196 if (ctx.state.revProxyPath)
197 v = ctx.state.revProxyPath + v
198 }
199 else if (type === 'array' && Array.isArray(v))
200 v = v.map(x => _.mapValues(x, (xv, xk) => adjustValueByConfig(xv, cfg.fields[xk])))
201 return v
202 }
203
204 }
205
206 function serializeCss(v: any) {
207 return typeof v === 'string' && /^#[0-9a-fA-F]{3,8}|rgba?\(.+\)$/.test(v) ? v // colors
208 : isPrimitive(v) ? JSON.stringify(v)?.replace(/</g, '&lt;') : undefined
209 }
210
211 function serveProxied(port: string | undefined, uri: string) { // used for development only
212 if (!port)
213 return
214 console.debug('Proxied on port', port)
215 let proxy: Koa.Middleware
216 import('koa-better-http-proxy').then(lib => // dynamic import to avoid having this in final distribution
217 proxy = lib.default('127.0.0.1:'+port, {
218 parseReqBody: false, // the dev GUI proxy serves app/assets, so avoid koa-better-http-proxy trying to reread ctx.req
219 proxyReqPathResolver: (ctx) =>
220 shouldServeApp(ctx) ? '/' : ctx.path,
221 userResDecorator(_res, data, ctx) {
222 return shouldServeApp(ctx) ? treatIndex(ctx, uri, String(data))
223 : adjustBundlerLinks(ctx, uri, data)
224 }
225 }) )
226 return function (ctx, _next) {
227 if (!logGui.get())
228 ctx.state.dontLog = true
229 return proxy(ctx, async () => {})
230 } as Koa.Middleware
231 }
232
233 export function serveGuiFiles(proxyPort:string | undefined, uri:string) {
234 return serveProxied(proxyPort, uri) || serveStatic(uri)
235 }