diff --git a/packages/hydrooj/src/model/domain.ts b/packages/hydrooj/src/model/domain.ts index 87ff9174c..9ebab356b 100644 --- a/packages/hydrooj/src/model/domain.ts +++ b/packages/hydrooj/src/model/domain.ts @@ -297,7 +297,7 @@ bus.on('ready', () => Promise.all([ ), ])); bus.on('domain/delete-cache', (domainId: string) => { - cache.del(`id::${domainId}`); + cache.delete(`id::${domainId}`); }); export default DomainModel; global.Hydro.model.domain = DomainModel; diff --git a/packages/hydrooj/src/model/setting.ts b/packages/hydrooj/src/model/setting.ts index c3c2be41c..447629158 100644 --- a/packages/hydrooj/src/model/setting.ts +++ b/packages/hydrooj/src/model/setting.ts @@ -203,6 +203,7 @@ SystemSetting( Setting('setting_server', 'server.xff', null, 'text', 'server.xff', 'IP Header'), Setting('setting_server', 'server.xhost', null, 'text', 'server.xhost', 'Hostname Header'), Setting('setting_server', 'server.xproxy', false, 'boolean', 'server.xproxy', 'Use reverse_proxy'), + Setting('setting_server', 'server.cors', '', 'text', 'server.cors', 'CORS domains'), Setting('setting_server', 'server.language', 'zh_CN', langRange, 'server.language', 'Default display language'), Setting('setting_server', 'server.login', true, 'boolean', 'server.login', 'Allow builtin-login', FLAG_PRO), Setting('setting_server', 'server.message', true, 'boolean', 'server.message', 'Allow users send messages'), diff --git a/packages/hydrooj/src/service/layers/base.ts b/packages/hydrooj/src/service/layers/base.ts index fd304f220..740ee7981 100644 --- a/packages/hydrooj/src/service/layers/base.ts +++ b/packages/hydrooj/src/service/layers/base.ts @@ -19,10 +19,15 @@ export const UiContextBase: UiContextBase = { export default async (ctx: KoaContext, next: Next) => { // Base Layer const { domainId, domainInfo } = ctx; + const [xff, xhost] = system.getMany(['server.xff', 'server.xhost']); const request: HydroRequest = { method: ctx.request.method.toLowerCase(), - ...pick(ctx.request, ['host', 'hostname', 'ip', 'headers']), - ...pick(ctx, ['query', 'path', 'params', 'originalPath', 'querystring', 'cookies']), + host: ctx.request.headers[xhost?.toLowerCase() || ''] as string || ctx.request.host, + hostname: ctx.request.hostname, + ip: ctx.request.headers[xff?.toLowerCase() || ''] as string || ctx.request.ip, + headers: ctx.request.headers, + cookies: ctx.cookies, + ...pick(ctx, ['query', 'path', 'params', 'originalPath', 'querystring']), body: ctx.request.body, files: ctx.request.files as any, referer: ctx.request.headers.referer || '', diff --git a/packages/hydrooj/src/service/layers/user.ts b/packages/hydrooj/src/service/layers/user.ts index f9dfd9f78..ae0904f8f 100644 --- a/packages/hydrooj/src/service/layers/user.ts +++ b/packages/hydrooj/src/service/layers/user.ts @@ -21,7 +21,7 @@ export default async (ctx: KoaContext, next) => { ctx.pendingError = new NotFoundError(args.domainId); args.domainId = 'system'; } - if (request.method === 'post' && request.headers.referer) { + if (request.method === 'post' && request.headers.referer && !ctx.cors) { const host = new URL(request.headers.referer).host; if (host !== request.host) ctx.pendingError = new CsrfTokenError(host); } diff --git a/packages/hydrooj/src/service/server.ts b/packages/hydrooj/src/service/server.ts index 618f271a6..fec3d59a2 100644 --- a/packages/hydrooj/src/service/server.ts +++ b/packages/hydrooj/src/service/server.ts @@ -97,7 +97,6 @@ export const httpServer = http.createServer(app.callback()); export const wsServer = new WebSocket.Server({ server: httpServer }); export const captureAllRoutes = {}; app.proxy = !!system.get('server.xproxy') || !!system.get('server.xff'); -app.proxyIpHeader = system.get('server.xff'); app.on('error', (error) => { if (error.code !== 'EPIPE' && error.code !== 'ECONNRESET' && !error.message.includes('Parse Error')) { logger.error('Koa app-level error', { error }); @@ -461,7 +460,22 @@ export async function apply(pluginContext: Context) { changeOrigin: true, rewrite: (p) => p.replace('/fs', ''), }); + const corsAllowHeaders = 'x-requested-with, accept, origin, content-type, upgrade-insecure-requests'; app.use(async (ctx, next) => { + if (ctx.request.headers.origin) { + const host = new URL(ctx.request.headers.origin).host; + if (host !== ctx.request.headers.host && `,${system.get('server.cors')},`.includes(`,${host},`)) { + ctx.set('Access-Control-Allow-Credentials', 'true'); + ctx.set('Access-Control-Allow-Origin', ctx.request.headers.origin); + ctx.set('Access-Control-Allow-Headers', corsAllowHeaders); + ctx.set('Vary', 'Origin'); + ctx.cors = true; + } + } + if (ctx.request.method.toLowerCase() === 'options') { + ctx.body = 'ok'; + return null; + } for (const key in captureAllRoutes) { if (ctx.path.startsWith(key)) return captureAllRoutes[key](ctx, next); } diff --git a/packages/ui-default/backendlib/builder.ts b/packages/ui-default/backendlib/builder.ts index 868641373..6dd21ddfd 100644 --- a/packages/ui-default/backendlib/builder.ts +++ b/packages/ui-default/backendlib/builder.ts @@ -102,8 +102,7 @@ export async function buildUI() { ...entryPoints.map((i) => `import '${relative(tmp, i)}';`), ].join('\n')); const pages = entry.outputFiles.map((i) => i.text); - const payload = [`window.LANGS=${JSON.stringify(SettingModel.langs)};`, ...pages]; - const str = JSON.stringify(payload); + const str = `window.LANGS=${JSON.stringify(SettingModel.langs)};${pages.join('\n')}`; vfs['entry.js'] = str; UiContextBase.constantVersion = hashes['entry.js'] = sha1(str).substring(0, 8); logger.info('+ %s-%s: %s', 'entry', hashes['entry.js'].substring(0, 6), size(str.length)); @@ -121,9 +120,8 @@ class UiConstantsHandler extends Handler { @param('name', Types.String, true) async all(domainId: string, name: string) { - this.response.type = name ? 'application/javascript' : 'application/json'; + this.response.type = 'application/javascript'; name ||= 'entry.js'; - console.log([name, vfs[name], vfs]); if (!vfs[name]) throw new NotFoundError(name); this.response.addHeader('ETag', hashes[name]); this.response.body = vfs[name]; @@ -132,7 +130,6 @@ class UiConstantsHandler extends Handler { } export async function apply(ctx: Context) { - buildUI(); ctx.Route('constant', '/constant/:version', UiConstantsHandler); ctx.Route('constant', '/lazy/:version/:name', UiConstantsHandler); ctx.on('app/started', updateLogo); @@ -146,4 +143,5 @@ export async function apply(ctx: Context) { ctx.on('system/setting', () => triggerHotUpdate()); ctx.on('app/watch/change', triggerHotUpdate); ctx.on('app/watch/unlink', triggerHotUpdate); + debouncedBuildUI(); } diff --git a/packages/ui-default/entry.js b/packages/ui-default/entry.js index a64fc4ffc..745793ea9 100644 --- a/packages/ui-default/entry.js +++ b/packages/ui-default/entry.js @@ -3,7 +3,6 @@ import * as bus from './bus'; window.Hydro = { extraPages: [], - preload: [], components: {}, utils: {}, node_modules: {}, @@ -48,13 +47,11 @@ document.addEventListener('DOMContentLoaded', async () => { try { __webpack_public_path__ = UiContext.cdn_prefix; } catch (e) { } const [data, HydroExports] = await Promise.all([ - fetch(`/constant/${UiContext.constantVersion}`, { cache: 'force-cache' }).then((r) => r.json()), + fetch(`/constant/${UiContext.constantVersion}.js`).then((r) => r.text()), import('./api'), ]); Object.assign(window, { HydroExports }); - eval(data[0]); // eslint-disable-line no-eval - data.shift(); - window.Hydro.preload = data; + eval(data); // eslint-disable-line no-eval import('./hydro'); }, false); diff --git a/packages/ui-default/hydro.ts b/packages/ui-default/hydro.ts index ca0eddd87..870de14de 100644 --- a/packages/ui-default/hydro.ts +++ b/packages/ui-default/hydro.ts @@ -53,8 +53,6 @@ async function animate() { } async function load() { - for (const page of window.Hydro.preload) await eval(page); // eslint-disable-line no-eval - const pageLoader = new PageLoader(); const currentPageName = document.documentElement.getAttribute('data-page'); diff --git a/packages/ui-default/index.ts b/packages/ui-default/index.ts index 3c6077ee2..1ad31c837 100644 --- a/packages/ui-default/index.ts +++ b/packages/ui-default/index.ts @@ -153,7 +153,7 @@ class RichMediaHandler extends Handler { async post({ domainId, items }) { const res = []; - for (const item of items) { + for (const item of items || []) { if (item.domainId && item.domainId === domainId) delete item.domainId; if (item.type === 'user') res.push(this.renderUser(domainId, item).catch(() => '')); else if (item.type === 'problem') res.push(this.renderProblem(domainId, item).catch(() => '')); diff --git a/packages/ui-default/service-worker.ts b/packages/ui-default/service-worker.ts index 86752c5ba..3b412c6e4 100644 --- a/packages/ui-default/service-worker.ts +++ b/packages/ui-default/service-worker.ts @@ -5,6 +5,7 @@ /* eslint-disable no-restricted-globals */ import 'streamsaver/sw.js'; +// TODO not working self.addEventListener('notificationclick', (event) => { console.log('On notification click: ', event.notification.tag); event.notification.close(); @@ -19,53 +20,48 @@ self.addEventListener('notificationclick', (event) => { }); const PRECACHE = `precache-${process.env.VERSION}`; -const DO_NOT_CACHE = ['vditor', '.worker.js', 'fonts', 'i.monaco']; +const DO_NOT_PRECACHE = ['vditor', '.worker.js', 'fonts', 'i.monaco']; -function shouldCache(name: string, request?: Request) { - if (!name.split('?')[0].split('/').pop()) return false; - if (!name.split('?')[0].split('/').pop().includes('.')) return false; - if (process.env.NODE_ENV !== 'production' && (name.includes('.hot-update.') || name.includes('?version='))) return false; - // For files download, a response is formatted as string - if (request && request.headers.get('Pragma') === 'no-cache') return false; +function shouldCachePath(path: string) { + if (!path.split('?')[0].split('/').pop()) return false; + if (!path.split('?')[0].split('/').pop().includes('.')) return false; + if (process.env.NODE_ENV !== 'production' && (path.includes('.hot-update.') || path.includes('?version='))) return false; return true; } +function shouldCache(request: Request) { + if (!shouldCachePath(request.url)) return false; + // For files download, a response is formatted as string + if (request.headers.get('Pragma') === 'no-cache') return false; + return ['get', 'head', 'options'].includes(request.method.toLowerCase()); +} function shouldPreCache(name: string) { - if (!shouldCache(name)) return false; - if (DO_NOT_CACHE.filter((i) => name.includes(i)).length) return false; + if (!shouldCachePath(name)) return false; + if (DO_NOT_PRECACHE.filter((i) => name.includes(i)).length) return false; return true; } interface ServiceWorkerConfig { - base?: string; - hosts?: string[]; - targets?: string[]; + /** enabled hosts */ + hosts: string[]; + /** service domains */ + domains: string[]; preload?: string; } let config: ServiceWorkerConfig = null; -async function get(url: string) { - const pending = [url, ...(config?.targets || []).map((i) => url.replace(config.base, i))]; - let response: Promise; - for (const source of pending) { - response = fetch(source); - try { - console.log('From ', source); - const r = await response; - if (r.ok) return r; - } catch (error) { - console.warn(source, ' Load fail ', error); - } - } - return response; +async function initConfig() { + const res = await fetch('/sw-config'); + config = await res.json(); + config.hosts ||= []; + config.domains ||= [location.host]; } self.addEventListener('install', (event) => event.waitUntil((async () => { - const [cache, manifest, cfg] = await Promise.all([ + const [cache, manifest] = await Promise.all([ caches.open(PRECACHE), fetch('/manifest.json').then((res) => res.json()), - fetch('/sw-config').then((res) => res.json()), + initConfig(), ]); - config = cfg; if (process.env.NODE_ENV === 'production' && config?.preload) { const files = Object.values(manifest).filter(shouldPreCache) .map((i: string) => new URL(i, config.preload).toString()); @@ -77,41 +73,91 @@ self.addEventListener('install', (event) => event.waitUntil((async () => { self.addEventListener('activate', (event) => { const valid = [PRECACHE]; event.waitUntil((async () => { - const [names, cfg] = await Promise.all([ + const [names] = await Promise.all([ caches.keys(), - fetch('/sw-config').then((res) => res.json()), + initConfig(), ]); - config = cfg; + config.domains ||= ['beta.hydro.ac', 'next.hydro.ac']; + console.log('Config: ', config); await Promise.all(names.filter((name) => !valid.includes(name)).map((p) => caches.delete(p))); self.clients.claim(); })()); }); -self.addEventListener('fetch', (event: FetchEvent) => { - if (!['get', 'head'].includes(event.request.method.toLowerCase())) return; - if (!shouldCache(event.request.url, event.request) && config?.base && config?.targets?.length) { - if (new URL(event.request.url).origin !== location.origin) return; - event.respondWith(get(event.request.url)); - return; - } - // Only handle whitelisted origins; - if (!config?.hosts?.some((i) => event.request.url.startsWith(i))) return; - if (!shouldCache(event.request.url, event.request)) return; - const Accept = event.request.headers.get('Accept'); - event.respondWith((async () => { - const cachedResponse = await caches.match(event.request.url); - if (cachedResponse) return cachedResponse; - console.log(`Caching ${event.request.url}`); - const [cache, response] = await Promise.all([ - caches.open(PRECACHE), - fetch(event.request.url, { headers: { Accept } }), // Fetch from url to prevent opaque response - ]); - if (response.ok) { - cache.put(event.request.url, response.clone()); - return response; +async function get(request: Request) { + const isResource = shouldCache(request); + for (const target of config.domains || []) { + const source = new URL(request.url); + source.host = target; + try { + console.log('From ', source.toString()); + const r = await fetch(source, { + method: request.method, + credentials: isResource ? 'same-origin' : 'include', + headers: request.headers, + }); + if (r.ok) { + console.log('Load success from ', source.toString()); + return r; + } + } catch (error) { + console.warn(source.toString(), ' Load fail ', error); } - // If response fails, re-fetch the original request to prevent - // errors caused by different headers and do not cache them - return fetch(event.request); - })()); + } + return fetch(request); +} + +async function cachedRespond(request: Request) { + const cachedResponse = await caches.match(request.url); + if (cachedResponse) return cachedResponse; + console.log(`Caching ${request.url}`); + const [cache, response] = await Promise.all([ + caches.open(PRECACHE), + get(request), + ]); + if (response.ok) { + cache.put(request.url, response.clone()); + return response; + } + return fetch(request); +} + +self.addEventListener('fetch', (event: FetchEvent) => { + if (!['get', 'post', 'head'].includes(event.request.method.toLowerCase())) return; + if (!config) return; // Don't do anything when not initialized + const url = new URL(event.request.url); + const rewritable = config.domains.length > 1 + && config.domains.includes(url.hostname) && url.origin === location.origin; + // Only handle whitelisted origins; + if (!config.hosts.some((i) => event.request.url.startsWith(i))) return; + + if (shouldCache(event.request)) { + event.respondWith((async () => { + if (rewritable) { + const targets = config.domains.map((i) => { + const t = new URL(event.request.url); + t.host = i; + return t.toString(); + }); + const results = await Promise.all(targets.map((i) => caches.match(i))); + if (results.find((i) => i)) return results.find((i) => i); + return cachedRespond(event.request); + } + const cachedResponse = await caches.match(url); + if (cachedResponse) return cachedResponse; + console.log(`Caching ${event.request.url}`); + const [cache, response] = await Promise.all([ + caches.open(PRECACHE), + fetch(url, { headers: event.request.headers }), // Fetch from url to prevent opaque response + ]); + if (response.ok) { + cache.put(url, response.clone()); + return response; + } + console.log(`Failed to cache ${event.request.url}`, response); + // If response fails, re-fetch the original request to prevent + // errors caused by different headers and do not cache them + return fetch(event.request); + })()); + } else if (rewritable) event.respondWith(get(event.request)); });