| 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, '<') : 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 | } |