From 97720efb65b8baaac06a56d5e080a85e899124a3 Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 3 Jun 2024 19:29:41 +0800 Subject: [PATCH] core: add server-loader (#816) --- build/prepare.js | 43 +- changelog | 4 + framework/framework/base.ts | 126 +++ .../framework}/decorators.ts | 37 +- framework/framework/error.ts | 71 ++ framework/framework/index.ts | 7 + framework/framework/interface.ts | 16 + framework/framework/package.json | 33 + .../service => framework/framework}/router.ts | 0 framework/framework/serializer.ts | 11 + framework/framework/server.ts | 674 +++++++++++++++ .../lib => framework/framework}/validator.ts | 12 +- package.json | 7 +- packages/eslint-config/base.yaml | 5 +- packages/eslint-config/package.json | 6 +- packages/hydrojudge/package.json | 2 +- packages/hydrooj/package.json | 24 +- packages/hydrooj/src/context.ts | 23 +- packages/hydrooj/src/entry/common.ts | 5 - packages/hydrooj/src/entry/setup.ts | 36 +- packages/hydrooj/src/entry/worker.ts | 1 + packages/hydrooj/src/error.ts | 115 +-- packages/hydrooj/src/lib/avatar.ts | 2 +- packages/hydrooj/src/lib/i18n.ts | 14 + packages/hydrooj/src/lib/serializer.ts | 16 - packages/hydrooj/src/model/setting.ts | 3 +- packages/hydrooj/src/model/user.ts | 12 +- packages/hydrooj/src/plugin-api.ts | 13 +- packages/hydrooj/src/service/bus.ts | 55 +- packages/hydrooj/src/service/layers/base.ts | 47 +- packages/hydrooj/src/service/layers/domain.ts | 2 +- .../hydrooj/src/service/layers/renderer.ts | 78 -- .../hydrooj/src/service/layers/response.ts | 75 -- packages/hydrooj/src/service/layers/user.ts | 2 +- packages/hydrooj/src/service/server.ts | 793 +++++------------- packages/migrate/package.json | 2 +- packages/register/package.json | 2 +- packages/ui-default/build/config/webpack.ts | 4 +- .../autocomplete/CustomSelectAutoComplete.tsx | 3 - .../autocomplete/DomainSelectAutoComplete.tsx | 3 - .../autocomplete/FileSelectAutoComplete.tsx | 3 - .../ProblemSelectAutoComplete.tsx | 3 - .../autocomplete/UserSelectAutoComplete.tsx | 3 - .../components/autocomplete/index.tsx | 2 - .../ui-default/components/dialog/DomDialog.ts | 3 - .../components/discussion/CommentBox.js | 3 - .../components/dropdown/Dropdown.js | 3 - .../ui-default/components/editor/index.ts | 3 - .../components/editor/textareaHandler.ts | 3 - .../components/marker/MarkerReactive.js | 3 - .../ui-default/components/rotator/index.js | 3 - .../components/table/StyledTable.js | 2 - .../ui-default/components/tooltip/Tooltip.js | 2 - packages/ui-default/context.ts | 17 +- packages/ui-default/package.json | 16 +- packages/utils/package.json | 2 +- packages/vjudge/package.json | 4 +- 57 files changed, 1328 insertions(+), 1131 deletions(-) create mode 100644 changelog create mode 100644 framework/framework/base.ts rename {packages/hydrooj/src/service => framework/framework}/decorators.ts (76%) create mode 100644 framework/framework/error.ts create mode 100644 framework/framework/index.ts create mode 100644 framework/framework/interface.ts create mode 100644 framework/framework/package.json rename {packages/hydrooj/src/service => framework/framework}/router.ts (100%) create mode 100644 framework/framework/serializer.ts create mode 100644 framework/framework/server.ts rename {packages/hydrooj/src/lib => framework/framework}/validator.ts (96%) delete mode 100644 packages/hydrooj/src/lib/serializer.ts delete mode 100644 packages/hydrooj/src/service/layers/renderer.ts delete mode 100644 packages/hydrooj/src/service/layers/response.ts diff --git a/build/prepare.js b/build/prepare.js index 3d7402f8e..6e00c7c00 100644 --- a/build/prepare.js +++ b/build/prepare.js @@ -33,9 +33,11 @@ const config = { compilerOptions: compilerOptionsBase, references: [ { path: 'tsconfig.ui.json' }, + { path: 'plugins/tsconfig.json' }, ], files: [], }; +const exclude = ['**/public', '**/frontend', '**/node_modules', '**/bin', '**/dist', '**/__mocks__']; const configSrc = (name) => ({ compilerOptions: { ...compilerOptionsBase, @@ -43,11 +45,7 @@ const configSrc = (name) => ({ rootDir: 'src', }, include: ['src'], - exclude: [ - '**/__mocks__', - 'bin', - 'dist', - ], + exclude, }); const configFlat = (name) => ({ compilerOptions: { @@ -61,7 +59,7 @@ const configFlat = (name) => ({ }, }, include: ['**/*.ts'], - exclude: ['public', 'frontend'], + exclude, }); for (const name of ['plugins', 'modules']) { @@ -74,12 +72,13 @@ for (const name of ['plugins', 'modules']) { const modules = [ 'packages/hydrooj', - ...['packages', 'plugins', 'modules'].flatMap((i) => fs.readdirSync(path.resolve(process.cwd(), i)).map((j) => `${i}/${j}`)), + ...['packages', 'framework', 'modules'].flatMap((i) => fs.readdirSync(path.resolve(process.cwd(), i)).map((j) => `${i}/${j}`)), ].filter((i) => !i.includes('/.') && !i.includes('ui-default')).filter((i) => fs.statSync(path.resolve(process.cwd(), i)).isDirectory()); const UIConfig = { exclude: [ 'packages/ui-default/public', + '**/node_modules', ], include: ['ts', 'tsx'] .flatMap((ext) => ['plugins', 'modules'] @@ -114,6 +113,36 @@ try { 'dir', ); } catch (e) { } + +const pluginsConfig = { + include: [ + '**/*.ts', + ], + exclude, + compilerOptions: { + target: 'es2020', + module: 'commonjs', + esModuleInterop: true, + moduleResolution: 'node', + jsx: 'react', + sourceMap: false, + composite: true, + strictBindCallApply: true, + resolveJsonModule: true, + experimentalDecorators: true, + incremental: true, + rootDir: '.', + baseUrl: '.', + outDir: path.join(baseOutDir, 'plugins'), + paths: { + 'vj/*': [ + '../packages/ui-default/*', + ], + }, + }, +}; +fs.writeFileSync(path.resolve(process.cwd(), 'plugins', 'tsconfig.json'), JSON.stringify(pluginsConfig)); + for (const package of modules) { const basedir = path.resolve(process.cwd(), package); const files = fs.readdirSync(basedir); diff --git a/changelog b/changelog new file mode 100644 index 000000000..bbb462391 --- /dev/null +++ b/changelog @@ -0,0 +1,4 @@ +移除了 app/listen, app/load/${category} 钩子 +移除了 ctx.app (请使用 ctx.root) +移除了 ctx.options (请使用 ctx.root.config) +serializer: 移除 showDisplayName 参数 \ No newline at end of file diff --git a/framework/framework/base.ts b/framework/framework/base.ts new file mode 100644 index 000000000..052d61781 --- /dev/null +++ b/framework/framework/base.ts @@ -0,0 +1,126 @@ +import { PassThrough } from 'stream'; +import type { Next } from 'koa'; +import { pick } from 'lodash'; +import { + HydroRequest, HydroResponse, KoaContext, serializer, +} from '@hydrooj/framework'; +import { errorMessage } from '@hydrooj/utils/lib/utils'; +import { SystemError, UserFacingError } from './error'; + +export default (logger, xff, xhost) => async (ctx: KoaContext, next: Next) => { + // Base Layer + const request: HydroRequest = { + method: ctx.request.method.toLowerCase(), + host: ctx.request.headers[xhost?.toLowerCase() || ''] as string || ctx.request.host, + ip: (ctx.request.headers[xff?.toLowerCase() || ''] as string || ctx.request.ip).split(',')[0].trim(), + ...pick(ctx, ['cookies', 'query', 'path', 'params', 'originalPath', 'querystring']), + ...pick(ctx.request, ['headers', 'body', 'hostname']), + files: ctx.request.files as any, + referer: ctx.request.headers.referer || '', + json: (ctx.request.headers.accept || '').includes('application/json'), + websocket: ctx.request.headers.upgrade === 'websocket', + }; + const response: HydroResponse = { + body: {}, + type: '', + status: null, + template: null, + redirect: null, + attachment: (name, streamOrBuffer) => { + ctx.attachment(name); + if (streamOrBuffer instanceof Buffer) { + response.body = null; + ctx.body = streamOrBuffer; + } else { + response.body = null; + ctx.body = streamOrBuffer.pipe(new PassThrough()); + } + }, + addHeader: (name: string, value: string) => ctx.set(name, value), + disposition: null, + }; + const args = { + ...ctx.params, ...ctx.query, ...ctx.request.body, __start: Date.now(), + }; + ctx.HydroContext = { request, response, args } as any; + try { + await next(); + if (request.websocket) return; + const handler = ctx.handler; + if (!handler) { + logger.error('No handler found on request', request); + ctx.response.status = 500; + return; + } + const { UiContext, user } = ctx.HydroContext; + if (response.redirect) { + response.body ||= {}; + response.body.url = response.redirect; + } + if (!response.type) { + if (response.pjax && args.pjax) { + const html = await handler.renderHTML(response.pjax, response.body); + response.body = { fragments: [{ html }] }; + response.type = 'application/json'; + } else if ( + request.json || response.redirect + || request.query.noTemplate || !response.template) { + // Send raw data + try { + if (typeof response.body === 'object') { + response.body.UiContext = UiContext; + response.body.UserContext = user; + } + response.body = JSON.stringify(response.body, serializer(false, handler)); + } catch (e) { + response.body = new SystemError('Serialize failure', e.message); + } + response.type = 'application/json'; + } else if (response.template) { + const s = response.template.split('.'); + let templateName = `${s[0]}.${args.domainId}.${s[1]}`; + if (!global.Hydro.ui.template[templateName]) templateName = response.template; + response.body = await handler.renderHTML(templateName, response.body || {}); + response.type = 'text/html'; + } + } + if (response.disposition) ctx.set('Content-Disposition', response.disposition); + if (response.etag) { + ctx.set('ETag', response.etag); + ctx.set('Cache-Control', 'public'); + } + } catch (err) { + const error = errorMessage(err); + response.status = error instanceof UserFacingError ? error.code : 500; + if (request.json) response.body = { error }; + else { + try { + response.body = await ctx.handler.renderHTML( + error instanceof UserFacingError ? 'error.html' : 'bsod.html', + { UserFacingError, error }, + ); + response.type = 'text/html'; + } catch (e) { + logger.error(e); + // this.response.body.error = {}; + } + } + } finally { + if (!request.websocket) { + if (response.etag && request.headers['if-none-match'] === response.etag) { + ctx.response.status = 304; + } else if (response.redirect && !request.json) { + ctx.response.type = 'application/octet-stream'; + ctx.response.status = 302; + ctx.redirect(response.redirect); + } else if (response.body) { + ctx.body = response.body; + ctx.response.status = response.status || 200; + ctx.response.type = response.type + || (request.json + ? 'application/json' + : ctx.response.type); + } + } + } +}; diff --git a/packages/hydrooj/src/service/decorators.ts b/framework/framework/decorators.ts similarity index 76% rename from packages/hydrooj/src/service/decorators.ts rename to framework/framework/decorators.ts index a1247960c..16104db2f 100644 --- a/packages/hydrooj/src/service/decorators.ts +++ b/framework/framework/decorators.ts @@ -1,8 +1,6 @@ -import { Time } from '@hydrooj/utils'; -import { ValidationError } from '../error'; -import { Converter, Type, Validator } from '../lib/validator'; -import { EventMap } from './bus'; +import { ValidationError } from './error'; import type { Handler } from './server'; +import { Converter, Type, Validator } from './validator'; type MethodDecorator = (target: any, funcName: string, obj: any) => any; type ClassDecorator = any>(Class: T) => T extends new (...args: infer R) => infer S @@ -90,7 +88,7 @@ export const post: DescriptorBuilder = (name, ...args) => _descriptor(_buildPara export const route: DescriptorBuilder = (name, ...args) => _descriptor(_buildParam(name, 'route', ...args)); export const param: DescriptorBuilder = (name, ...args) => _descriptor(_buildParam(name, 'all', ...args)); -export const subscribe: (name: keyof EventMap) => MethodDecorator & ClassDecorator = (name) => (target, funcName?, obj?) => { +export const subscribe: (name: string) => MethodDecorator & ClassDecorator = (name) => (target, funcName?, obj?) => { if (funcName) { target.__subscribe ||= []; target.__subscribe.push({ name, target: obj.value }); @@ -102,32 +100,3 @@ export const subscribe: (name: keyof EventMap) => MethodDecorator & ClassDecorat return c; }; }; - -/* - * For security concern, some API requires sudo privilege to access. - * And for some superadmin operations, - * we do not allow them using a password to perform the sudo operation, - * as most user choose to use "remember password" option. - * When teachers are using a superuser account, accessing from classrooms, - * it may lead to serious security issues. - * !!! Please make sure that all superuser accounts have two factor authentication enabled. !!! - */ -export function requireSudo(target: any, funcName: string, obj: any) { - const originalMethod = obj.value; - obj.value = function sudo(this: Handler, ...args: any[]) { - if (this.session.sudo && Date.now() - this.session.sudo < Time.hour) { - if (this.session.sudoArgs?.referer) this.request.headers.referer = this.session.sudoArgs.referer; - this.session.sudoArgs = null; - return originalMethod.call(this, ...args); - } - this.session.sudoArgs = { - method: this.request.method, - referer: this.request.headers.referer, - args: this.args, - redirect: this.request.originalPath, - }; - this.response.redirect = this.url('user_sudo'); - return 'cleanup'; - }; - return obj; -} diff --git a/framework/framework/error.ts b/framework/framework/error.ts new file mode 100644 index 000000000..2f430b87e --- /dev/null +++ b/framework/framework/error.ts @@ -0,0 +1,71 @@ +/* eslint-disable max-len */ +/* eslint-disable func-names */ + +interface IHydroError { + new(...args: any[]): HydroError +} + +export class HydroError extends Error { + params: any[]; + code: number; + + constructor(...params: any[]) { + super(); + this.params = params; + } + + msg() { + return 'HydroError'; + } + + get message() { + return this.msg(); + } +} + +const Err = (name: string, Class: IHydroError, ...info: Array<(() => string) | string | number>) => { + let msg: () => string; + let code: number; + for (const item of info) { + if (typeof item === 'number') { + code = item; + } else if (typeof item === 'string') { + msg = function () { return item; }; + } else if (typeof item === 'function') { + msg = item; + } + } + // eslint-disable-next-line @typescript-eslint/no-shadow + return class HydroError extends Class { + name = name; + constructor(...args: any[]) { + super(...args); + if (msg) this.msg = msg; + if (code) this.code = code; + } + }; +}; + +export const UserFacingError = Err('UserFacingError', HydroError, 'UserFacingError', 400); +export const SystemError = Err('SystemError', HydroError, 'SystemError', 500); + +export const BadRequestError = Err('BadRequestError', UserFacingError, 'BadRequestError', 400); +export const ForbiddenError = Err('ForbiddenError', UserFacingError, 'ForbiddenError', 403); +export const NotFoundError = Err('NotFoundError', UserFacingError, 'NotFoundError', 404); +export const MethodNotAllowedError = Err('MethodNotAllowedError', UserFacingError, 'MethodNotAllowedError', 405); + +export const ValidationError = Err('ValidationError', ForbiddenError, function (this: HydroError) { + if (this.params.length === 3) { + return this.params[1] + ? 'Field {0} or {1} validation failed. ({2})' + : 'Field {0} validation failed. ({2})'; + } + return this.params[1] + ? 'Field {0} or {1} validation failed.' + : 'Field {0} validation failed.'; +}); +export const CsrfTokenError = Err('CsrfTokenError', ForbiddenError, 'CsrfTokenError'); +export const InvalidOperationError = Err('InvalidOperationError', MethodNotAllowedError); +export const FileTooLargeError = Err('FileTooLargeError', ValidationError, 'The uploaded file is too long.'); + +export const CreateError = Err; diff --git a/framework/framework/index.ts b/framework/framework/index.ts new file mode 100644 index 000000000..51c500287 --- /dev/null +++ b/framework/framework/index.ts @@ -0,0 +1,7 @@ +export * from './server'; +export { default as serializer } from './serializer'; +export * from './decorators'; +export * from './validator'; +export * from './error'; +export * from './router'; +export * from './interface'; diff --git a/framework/framework/interface.ts b/framework/framework/interface.ts new file mode 100644 index 000000000..3291c9d67 --- /dev/null +++ b/framework/framework/interface.ts @@ -0,0 +1,16 @@ +import type { ConnectionHandler, Handler } from './server'; + +type HandlerEvents = + Record<`handler/${HookType}/${string}`, (thisArg: T & Record) => VoidReturn> + & Record<`handler/${HookType}`, (thisArg: T) => VoidReturn> + & Record<`handler/register/${string}`, (HandlerClass: new (...args: any[]) => T) => VoidReturn> + & Record<`connection/${'create' | 'active' | 'close'}`, (thisArg: C) => VoidReturn>; + +export type VoidReturn = Promise | any; +export type HookType = 'before-prepare' | 'before' | 'before-operation' | 'after' | 'finish'; + +export interface ServerEvents extends HandlerEvents { + 'handler/create': (thisArg: Handler, type: 'ws' | 'http') => VoidReturn + 'handler/init': (thisArg: Handler) => VoidReturn + 'handler/error': (thisArg: Handler, e: Error) => VoidReturn +} diff --git a/framework/framework/package.json b/framework/framework/package.json new file mode 100644 index 000000000..12ed76220 --- /dev/null +++ b/framework/framework/package.json @@ -0,0 +1,33 @@ +{ + "name": "@hydrooj/framework", + "version": "0.0.2", + "main": "index.ts", + "peerDependencies": { + "cordis": "*" + }, + "dependencies": { + "@hydrooj/utils": "workspace:^", + "emoji-regex": "^10.3.0", + "fs-extra": "^11.2.0", + "koa": "^2.15.3", + "koa-body": "^6.0.1", + "koa-compress": "^5.1.1", + "koa-router": "^12.0.1", + "lodash": "^4.17.21", + "moment-timezone": "^0.5.45", + "path-to-regexp": "^6.2.2", + "sanitize-filename": "^1.6.3", + "saslprep": "^1.0.3", + "shorty.js": "^1.0.1", + "ws": "^8.17.0" + }, + "devDependencies": { + "@types/fs-extra": "^11.0.4", + "@types/koa": "^2.15.0", + "@types/koa-compress": "^4.0.6", + "@types/koa-router": "^7.4.8", + "@types/lodash": "^4.17.4", + "@types/saslprep": "^1.0.3", + "@types/ws": "^8.5.10" + } +} diff --git a/packages/hydrooj/src/service/router.ts b/framework/framework/router.ts similarity index 100% rename from packages/hydrooj/src/service/router.ts rename to framework/framework/router.ts diff --git a/framework/framework/serializer.ts b/framework/framework/serializer.ts new file mode 100644 index 000000000..9dd63e4a5 --- /dev/null +++ b/framework/framework/serializer.ts @@ -0,0 +1,11 @@ +import type { HandlerCommon } from './server'; + +export default function serializer(ignoreSerializeFunction = false, h?: HandlerCommon) { + return (k: string, v: any) => { + if (k.startsWith('_') && k !== '_id') return undefined; + if (typeof v === 'bigint') return `BigInt::${v.toString()}`; + if (!ignoreSerializeFunction && v && typeof v === 'object' + && 'serialize' in v && typeof v.serialize === 'function') return v.serialize(h); + return v; + }; +} diff --git a/framework/framework/server.ts b/framework/framework/server.ts new file mode 100644 index 000000000..9f06935a8 --- /dev/null +++ b/framework/framework/server.ts @@ -0,0 +1,674 @@ +import http from 'http'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { Context, Service } from 'cordis'; +import type { Files } from 'formidable'; +import fs from 'fs-extra'; +import Koa from 'koa'; +import Body from 'koa-body'; +import Compress from 'koa-compress'; +import { Shorty } from 'shorty.js'; +import WebSocket from 'ws'; +import { + Counter, errorMessage, isClass, Logger, parseMemoryMB, +} from '@hydrooj/utils/lib/utils'; +import base from './base'; +import * as decorators from './decorators'; +import { + CsrfTokenError, HydroError, InvalidOperationError, + MethodNotAllowedError, NotFoundError, UserFacingError, +} from './error'; +import { Router } from './router'; +import serializer from './serializer'; + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent +export function encodeRFC5987ValueChars(str: string) { + return ( + encodeURIComponent(str) + // Note that although RFC3986 reserves "!", RFC5987 does not, + // so we do not need to escape it + .replace(/['()]/g, escape) // i.e., %27 %28 %29 + .replace(/\*/g, '%2A') + // The following are not required for percent-encoding per RFC5987, + // so we can allow for a little better readability over the wire: |`^ + .replace(/%(?:7C|60|5E)/g, unescape) + ); +} + +export interface HydroRequest { + method: string; + host: string; + hostname: string; + ip: string; + headers: Koa.Request['headers']; + cookies: any; + body: any; + files: Record; + query: any; + querystring: string; + path: string; + originalPath: string; + params: any; + referer: string; + json: boolean; + websocket: boolean; +} +export interface HydroResponse { + body: any; + type: string; + status: number; + template?: string; + /** If set, and pjax content was request from client, + * The template will be used for rendering. + */ + pjax?: string; + redirect?: string; + disposition?: string; + etag?: string; + attachment: (name: string, stream?: any) => void; + addHeader: (name: string, value: string) => void; +} +type HydroContext = { + request: HydroRequest; + response: HydroResponse; + args: Record; + UiContext: Record; + domain: { _id: string }; + user: { _id: number }; +}; +export type KoaContext = Koa.Context & { + HydroContext: HydroContext; + handler: any; + request: Koa.Request & { body: any, files: Files }; + session: Record; +}; + +const logger = new Logger('server'); +/** @deprecated */ +export const koa = new Koa({ + keys: [Math.random().toString(16).substring(2)], +}); +export const router = new Router(); +export const httpServer = http.createServer(koa.callback()); +export const wsServer = new WebSocket.Server({ server: httpServer }); +koa.on('error', (error) => { + if (error.code !== 'EPIPE' && error.code !== 'ECONNRESET' && !error.message.includes('Parse Error')) { + logger.error('Koa app-level error', { error }); + } +}); +wsServer.on('error', (error) => { + console.log('Websocket server error:', error); +}); + +export interface UserModel { + _id: number; +} + +export interface HandlerCommon { } +export class HandlerCommon { + session: Record; + args: Record; + request: HydroRequest; + response: HydroResponse; + UiContext: Record; + user: UserModel; + + constructor(public context: KoaContext, public ctx: Context) { + this.renderHTML = this.renderHTML.bind(this); + this.url = this.url.bind(this); + this.session = context.session; + this.args = context.HydroContext.args; + this.request = context.HydroContext.request; + this.response = context.HydroContext.response; + this.UiContext = context.HydroContext.UiContext; + this.ctx = ctx.extend({}); + } + + checkPerm(..._: bigint[]) { + throw new Error('checkPerm was not implemented'); + } + + checkPriv(..._: number[]) { + throw new Error('checkPriv was not implemented'); + } + + url(name: string, ...kwargsList: Record[]) { + if (name === '#') return '#'; + let res = '#'; + const args: any = {}; + const query: any = {}; + for (const kwargs of kwargsList) { + for (const key in kwargs) { + args[key] = kwargs[key].toString().replace(/\//g, '%2F'); + } + for (const key in kwargs.query || {}) { + query[key] = kwargs.query[key].toString(); + } + } + try { + const { anchor } = args; + res = router.url(name, args, { query }).toString(); + if (anchor) res = `${res}#${anchor}`; + } catch (e) { + logger.warn(e.message); + logger.info('%s %o', name, args); + if (!e.message.includes('Expected') || !e.message.includes('to match')) logger.info('%s', e.stack); + } + return res; + } + + translate(str: string) { + return str; + } + + renderHTML(templateName: string, args: Record) { + const type = templateName.split('.')[1]; + const engine = global.Hydro.module.render[type] + || (() => JSON.stringify(args, serializer(false, this))); + return engine(templateName, { + handler: this, + UserContext: this.user, + url: this.url, + _: this.translate, + ...args, + }); + } +} + +export class Handler extends HandlerCommon { + loginMethods: any; + noCheckPermView = false; + notUsage = false; + allowCors = false; + __param: Record[]>; + + back(body?: any) { + this.response.body = body || this.response.body || {}; + this.response.redirect = this.request.headers.referer || '/'; + } + + binary(data: any, name?: string) { + this.response.body = data; + this.response.template = null; + this.response.type = 'application/octet-stream'; + if (name) this.response.disposition = `attachment; filename="${encodeRFC5987ValueChars(name)}"`; + } + + async init() { + if (this.request.method === 'post' && this.request.headers.referer && !this.context.cors && !this.allowCors) { + try { + const host = new URL(this.request.headers.referer).host; + if (host !== this.request.host) throw new CsrfTokenError(host); + } catch (e) { + throw e instanceof CsrfTokenError ? e : new CsrfTokenError(); + } + } + } + + async onerror(error: HydroError) { + error.msg ||= () => error.message; + if (error instanceof UserFacingError && !process.env.DEV) error.stack = ''; + this.response.status = error instanceof UserFacingError ? error.code : 500; + this.response.template = error instanceof UserFacingError ? 'error.html' : 'bsod.html'; + this.response.body = { + UserFacingError, + error: { message: error.msg(), params: error.params, stack: errorMessage(error.stack || '') }, + }; + } +} + +const Checker = (permPrivChecker) => { + let perm: bigint; + let priv: number; + let checker = () => { }; + for (const item of permPrivChecker) { + if (typeof item === 'object') { + if (typeof item.call !== 'undefined') { + checker = item; + } else if (typeof item[0] === 'number') { + priv = item; + } else if (typeof item[0] === 'bigint') { + perm = item; + } + } else if (typeof item === 'number') { + priv = item; + } else if (typeof item === 'bigint') { + perm = item; + } + } + return function check(this: Handler) { + checker(); + if (perm) this.checkPerm(perm); + if (priv) this.checkPriv(priv); + }; +}; + +export class ConnectionHandler extends HandlerCommon { + conn: WebSocket; + compression: Shorty; + counter = 0; + + resetCompression() { + this.counter = 0; + this.compression = new Shorty(); + this.conn.send('shorty'); + } + + send(data: any) { + let payload = JSON.stringify(data, serializer(false, this)); + if (this.compression) { + if (this.counter > 1000) this.resetCompression(); + payload = this.compression.deflate(payload); + this.counter++; + } + this.conn.send(payload); + } + + close(code: number, reason: string) { + this.conn.close(code, reason); + } + + onerror(err: HydroError) { + if (err instanceof UserFacingError) err.stack = this.request.path; + this.send({ + error: { + name: err.name, + params: err.params || [], + }, + }); + this.close(4000, err.toString()); + } +} + +class NotFoundHandler extends Handler { + prepare() { throw new NotFoundError(this.request.path); } + all() { } +} + +async function executeMiddlewareStack(context: any, middlewares: { name: string, func: Function }[]) { + const first = middlewares[0]; + if (!first) return Promise.resolve(); + context.__timers ||= {}; + context.__timers[`${first.name}.start`] = Date.now(); + const next = () => executeMiddlewareStack(context, middlewares.slice(1)); + try { + return await first.func(context, next); + } finally { + context.__timers[`${first.name}.end`] = Date.now(); + } +} + +interface WebServiceConfig { + keys: string[]; + proxy: boolean; + cors?: string; + upload?: string; + port: number; + xff?: string; + xhost?: string; +} + +export class WebService extends Service { + private registry: Record = {}; + private registrationCount = Counter(); + private serverLayers = []; + private handlerLayers = []; + private wsLayers = []; + private captureAllRoutes = {}; + + server = koa; + router = router; + HandlerCommon = HandlerCommon; + Handler = Handler; + ConnectionHandler = ConnectionHandler; + + constructor(ctx: Context, public config: WebServiceConfig) { + super(ctx, 'server', true); + ctx.mixin('server', ['Route', 'Connection', 'withHandlerClass']); + this.server.keys = this.config.keys; + this.server.proxy = this.config.proxy; + const corsAllowHeaders = 'x-requested-with, accept, origin, content-type, upgrade-insecure-requests'; + this.server.use(Compress()); + this.server.use(async (c, next) => { + if (c.request.headers.origin && this.config.cors) { + const host = new URL(c.request.headers.origin).host; + if (host !== c.request.headers.host && `,${this.config.cors},`.includes(`,${host},`)) { + c.set('Access-Control-Allow-Credentials', 'true'); + c.set('Access-Control-Allow-Origin', c.request.headers.origin); + c.set('Access-Control-Allow-Headers', corsAllowHeaders); + c.set('Vary', 'Origin'); + c.cors = true; + } + } + if (c.request.method.toLowerCase() === 'options') { + c.body = 'ok'; + return null; + } + for (const key in this.captureAllRoutes) { + if (c.path.startsWith(key)) return this.captureAllRoutes[key](c, next); + } + return await next(); + }); + if (process.env.DEV) { + this.server.use(async (c: Koa.Context, next: Function) => { + const startTime = Date.now(); + try { + await next(); + } finally { + const endTime = Date.now(); + if (!(c.nolog || c.response.headers.nolog)) { + logger.debug(`${c.request.method} /${c.domainId || 'system'}${c.request.path} \ +${c.response.status} ${endTime - startTime}ms ${c.response.length}`); + } + } + }); + } + if (this.config.upload) { + const uploadDir = join(tmpdir(), 'hydro', 'upload', process.env.NODE_APP_INSTANCE || '0'); + fs.ensureDirSync(uploadDir); + logger.debug('Using upload dir: %s', uploadDir); + this.server.use(Body({ + multipart: true, + jsonLimit: '8mb', + formLimit: '8mb', + formidable: { + uploadDir, + maxFileSize: parseMemoryMB(this.config.upload) * 1024 * 1024, + keepExtensions: true, + }, + })); + this.ctx.on('dispose', () => { + fs.emptyDirSync(uploadDir); + }); + } else { + this.server.use(Body({ + multipart: true, + jsonLimit: '8mb', + formLimit: '8mb', + })); + } + this.router.use((c, next) => executeMiddlewareStack(c, [ + ...this.handlerLayers, + { + name: '404', + func: async (t: any, n) => { + await n(); + if (!t.handler) this.handleHttp(t, NotFoundHandler, () => true); + }, + }, + { name: 'logic', func: next }, + ]).catch(console.error)); + this.server.use((c) => executeMiddlewareStack(c, [ + ...this.serverLayers, + { name: 'routes', func: router.routes() }, + { name: 'methods', func: router.allowedMethods() }, + ])); + this.addLayer('base', base(logger, this.config.xff, this.config.xhost)); + wsServer.on('connection', async (socket, request) => { + socket.on('error', (err) => { + logger.warn('Websocket Error: %s', err.message); + try { + socket.close(1003, 'Websocket Error'); + } catch (e) { } + }); + socket.pause(); + const c: any = koa.createContext(request, {} as any); + await executeMiddlewareStack(c, this.wsLayers); + for (const manager of router.wsStack) { + if (manager.accept(socket, request, c)) return; + } + socket.close(); + }); + } + + async listen() { + this.ctx.on('dispose', () => { + httpServer.close(); + wsServer.close(); + }); + await new Promise((r) => { + httpServer.listen(this.config.port, () => { + logger.success('Server listening at: %d', this.config.port); + r(true); + }); + }); + } + + private async handleHttp(ctx: KoaContext, HandlerClass, checker) { + const { args } = ctx.HydroContext; + Object.assign(args, ctx.params); + const h = new HandlerClass(ctx, this.ctx); + ctx.handler = h; + const method = ctx.method.toLowerCase(); + try { + const operation = (method === 'post' && ctx.request.body?.operation) + ? `_${ctx.request.body.operation}`.replace(/_([a-z])/gm, (s) => s[1].toUpperCase()) + : ''; + + await this.ctx.parallel('handler/create', h, 'http'); + + if (checker) checker.call(h); + if (method === 'post') { + if (operation) { + if (typeof h[`post${operation}`] !== 'function') { + throw new InvalidOperationError(operation); + } + } else if (typeof h.post !== 'function') { + throw new MethodNotAllowedError(method); + } + } else if (typeof h[method] !== 'function' && typeof h.all !== 'function') { + throw new MethodNotAllowedError(method); + } + + const name = HandlerClass.name.replace(/Handler$/, ''); + const steps = [ + 'init', 'handler/init', + `handler/before-prepare/${name}#${method}`, `handler/before-prepare/${name}`, 'handler/before-prepare', + 'log/__prepare', '__prepare', '_prepare', 'prepare', 'log/__prepareDone', + `handler/before/${name}#${method}`, `handler/before/${name}`, 'handler/before', + 'log/__method', 'all', method, 'log/__methodDone', + ...operation ? [ + `handler/before-operation/${name}`, 'handler/before-operation', + `post${operation}`, 'log/__operationDone', + ] : [], 'after', + `handler/after/${name}#${method}`, `handler/after/${name}`, 'handler/after', + 'cleanup', + `handler/finish/${name}#${method}`, `handler/finish/${name}`, 'handler/finish', + ]; + + let current = 0; + while (current < steps.length) { + const step = steps[current]; + let control; + if (step.startsWith('log/')) h.args[step.slice(4)] = Date.now(); + // eslint-disable-next-line no-await-in-loop + else if (step.startsWith('handler/')) control = await this.ctx.serial(step as any, h); + // eslint-disable-next-line no-await-in-loop + else if (typeof h[step] === 'function') control = await h[step](args); + if (control) { + const index = steps.findIndex((i) => control === i); + if (index === -1) throw new Error(`Invalid control: ${control}`); + if (index <= current) { + logger.warn('Returning to previous step is not recommended:', step, '->', control); + } + current = index; + } else current++; + } + } catch (e) { + try { + await this.ctx.serial(`handler/error/${HandlerClass.name.replace(/Handler$/, '')}`, h, e); + await this.ctx.serial('handler/error', h, e); + await h.onerror(e); + } catch (err) { + logger.error(err); + h.response.status = 500; + h.response.type = 'text/plain'; + h.response.body = `${err.message}\n${err.stack}`; + } + } + } + + private async handleWS(ctx: KoaContext, HandlerClass, checker, conn, layer) { + const { args } = ctx.HydroContext; + const h = new HandlerClass(ctx, this.ctx); + await this.ctx.parallel('connection/create', h); + ctx.handler = h; + h.conn = conn; + const disposables = []; + try { + await this.ctx.parallel('handler/create', h, 'ws'); + checker.call(h); + if (args.shorty) h.resetCompression(); + if (h._prepare) await h._prepare(args); + if (h.prepare) await h.prepare(args); + // eslint-disable-next-line @typescript-eslint/no-shadow + for (const { name, target } of h.__subscribe || []) disposables.push(this.ctx.on(name, target.bind(h))); + let lastHeartbeat = Date.now(); + let closed = false; + let interval: NodeJS.Timeout; + const clean = () => { + if (closed) return; + closed = true; + this.ctx.emit('connection/close', h); + layer.clients.delete(conn); + if (interval) clearInterval(interval); + for (const d of disposables) d(); + h.cleanup?.(args); + }; + interval = setInterval(() => { + if (Date.now() - lastHeartbeat > 80000) { + clean(); + conn.terminate(); + } + if (Date.now() - lastHeartbeat > 30000) conn.send('ping'); + }, 40000); + conn.on('pong', () => { + lastHeartbeat = Date.now(); + }); + conn.onmessage = (e) => { + lastHeartbeat = Date.now(); + if (e.data === 'pong') return; + if (e.data === 'ping') { + conn.send('pong'); + return; + } + let payload; + try { + payload = JSON.parse(e.data.toString()); + } catch { + conn.close(); + } + try { + h.message?.(payload); + } catch (err) { + logger.error(e); + } + }; + await this.ctx.parallel('connection/active', h); + if (conn.readyState === conn.OPEN) { + conn.on('close', clean); + conn.resume(); + } else clean(); + } catch (e) { + await h.onerror(e); + } + } + + private register(type: 'route' | 'conn', routeName: string, path: string, HandlerClass: any, ...permPrivChecker) { + const name = HandlerClass.name; + if (!isClass(HandlerClass)) throw new Error('Invalid registration.'); + if (this.registrationCount[name] && this.registry[name] !== HandlerClass) { + logger.warn('Route with name %s already exists.', name); + } + this.registry[name] = HandlerClass; + this.registrationCount[name]++; + if (type === 'route') { + router.all(routeName, path, (ctx) => this.handleHttp(ctx as any, HandlerClass, Checker(permPrivChecker))); + } else { + const checker = Checker(permPrivChecker); + const layer = router.ws(path, async (conn, _req, ctx) => { + await this.handleWS(ctx as any, HandlerClass, checker, conn, layer); + }); + } + const dispose = router.disposeLastOp; + // @ts-ignore + this.ctx.parallel(`handler/register/${name}`, HandlerClass); + this[Context.current]?.on('dispose', () => { + this.registrationCount[name]--; + if (!this.registrationCount[name]) delete this.registry[name]; + dispose(); + }); + } + + public withHandlerClass( + name: T, callback: (HandlerClass: T extends `${string}ConnectionHandler` ? typeof ConnectionHandler : typeof Handler) => any, + ) { + if (this.registry[name]) callback(this.registry[name]); + // @ts-ignore + this.ctx.on(`handler/register/${name}`, callback); + this[Context.current]?.on('dispose', () => { + // @ts-ignore + this.ctx.off(`handler/register/${name}`, callback); + }); + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + public Route(name: string, path: string, RouteHandler: typeof Handler, ...permPrivChecker) { + return this.register('route', name, path, RouteHandler, ...permPrivChecker); + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + public Connection(name: string, path: string, RouteHandler: typeof ConnectionHandler, ...permPrivChecker) { + return this.register('conn', name, path, RouteHandler, ...permPrivChecker); + } + + private registerLayer(name: 'serverLayers' | 'handlerLayers' | 'wsLayers', layer: any) { + this[name].push(layer); + const dispose = () => { + this[name] = this[name].filter((i) => i !== layer); + }; + this[Context.current]?.on('dispose', dispose); + return dispose; + } + + public addServerLayer(name: string, func: any) { + return this.registerLayer('serverLayers', { name, func }); + } + + public addHandlerLayer(name: string, func: any) { + return this.registerLayer('handlerLayers', { name, func }); + } + + public addWSLayer(name: string, func: any) { + return this.registerLayer('wsLayers', { name, func }); + } + + public addLayer(name: string, layer: any) { + this.addHandlerLayer(name, layer); + this.addWSLayer(name, layer); + } + + public addCaptureRoute(prefix: string, cb: any) { + this.captureAllRoutes[prefix] = cb; + } + + public handlerMixin(MixinClass: Partial) { + for (const val of Object.getOwnPropertyNames(MixinClass)) { + HandlerCommon.prototype[val] = MixinClass[val]; + } + } +} + +declare module 'cordis' { + interface Context { + server: WebService; + Route: WebService['Route']; + Connection: WebService['Connection']; + withHandlerClass: WebService['withHandlerClass']; + } +} + +export async function apply(ctx: Context, config) { + ctx.provide('server', undefined, true); + ctx.server = new WebService(ctx, config); +} diff --git a/packages/hydrooj/src/lib/validator.ts b/framework/framework/validator.ts similarity index 96% rename from packages/hydrooj/src/lib/validator.ts rename to framework/framework/validator.ts index 910b11c6f..2dcf7149d 100644 --- a/packages/hydrooj/src/lib/validator.ts +++ b/framework/framework/validator.ts @@ -2,7 +2,6 @@ import assert from 'assert'; import emojiRegex from 'emoji-regex'; import { isSafeInteger } from 'lodash'; import moment from 'moment-timezone'; -import { ObjectId } from 'mongodb'; import sanitize from 'sanitize-filename'; import saslprep from 'saslprep'; @@ -36,7 +35,7 @@ export interface Types { Float: Type; // Other - ObjectId: Type; + ObjectId: Type; Boolean: Type; Date: Type; Time: Type; @@ -95,7 +94,7 @@ export const Types: Types = { PositiveInt: [(v) => +v, (v) => /^\+?[1-9][0-9]*$/.test(v.toString().trim()) && isSafeInteger(+v)], Float: [(v) => +v, (v) => Number.isFinite(+v)], - ObjectId: [(v) => new ObjectId(v), ObjectId.isValid], + ObjectId: [() => { throw new Error('mongodb package not found'); }, () => true], Boolean: [(v) => !!(v && !['false', 'off'].includes(v)), null, true], Date: [ (v) => { @@ -188,6 +187,13 @@ export const Types: Types = { ] as any, }; +try { + const { ObjectId } = require('mongodb'); + Types.ObjectId = [(v) => new ObjectId(v), ObjectId.isValid]; +} catch (e) { + +} + // @ts-ignore Types.ObjectID = Types.ObjectId; // backward compatibility diff --git a/package.json b/package.json index 8bdcec6de..6a4ac6290 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "workspaces": [ "packages/*", + "framework/*", "plugins/*", "modules/*" ], @@ -36,14 +37,14 @@ "@types/autocannon": "^7.12.5", "@types/chai": "^4.3.16", "@types/cross-spawn": "^6.0.6", - "@types/node": "20.10.6", + "@types/node": "20.12.12", "@types/semver": "^7.5.8", "autocannon": "^7.15.0", "cac": "^6.7.14", "chai": "^5.1.1", "cross-env": "^7.0.3", "cross-spawn": "^7.0.3", - "esbuild": "0.18.20", + "esbuild": "0.21.4", "eslint": "^8.57.0", "eslint-import-resolver-typescript": "^3.6.1", "eslint-import-resolver-webpack": "^0.13.8", @@ -57,7 +58,7 @@ "semver": "^7.6.2", "simple-git": "^3.24.0", "supertest": "^7.0.0", - "typescript": "^5.4.5", + "typescript": "5.3.3", "wtfnode": "^0.9.2" } } diff --git a/packages/eslint-config/base.yaml b/packages/eslint-config/base.yaml index 6f4b2e3b7..ef20ae57a 100644 --- a/packages/eslint-config/base.yaml +++ b/packages/eslint-config/base.yaml @@ -137,7 +137,10 @@ rules: no-use-before-define: 0 '@typescript-eslint/no-use-before-define': 1 no-unused-vars: 0 - '@typescript-eslint/no-unused-vars': 1 + '@typescript-eslint/no-unused-vars': + - error + - caughtErrorsIgnorePattern: "^_" + argsIgnorePattern: "^_" no-restricted-syntax: - error diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index a1bb9d3c2..b54b8a380 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -5,14 +5,14 @@ "main": "react.yaml", "dependencies": { "@stylistic/eslint-plugin": "^2.1.0", - "@typescript-eslint/eslint-plugin": "^7.8.0", - "@typescript-eslint/parser": "^7.8.0", + "@typescript-eslint/eslint-plugin": "^7.11.0", + "@typescript-eslint/parser": "^7.11.0", "eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb-typescript": "^18.0.0", "eslint-plugin-github": "^4.10.2", "eslint-plugin-import": "2.29.1", "eslint-plugin-jsx-a11y": "^6.8.0", - "eslint-plugin-react": "^7.34.1", + "eslint-plugin-react": "^7.34.2", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-simple-import-sort": "^12.1.0" }, diff --git a/packages/hydrojudge/package.json b/packages/hydrojudge/package.json index 4d5386abc..6dc24e72d 100644 --- a/packages/hydrojudge/package.json +++ b/packages/hydrojudge/package.json @@ -6,7 +6,7 @@ "author": "undefined ", "repository": "https://github.com/hydro-dev/Hydro.git", "dependencies": { - "@hydrooj/utils": "workspace:*", + "@hydrooj/utils": "workspace:^", "adm-zip": "0.5.5", "cac": "^6.7.14", "p-queue": "^8.0.1", diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index e6ee680ff..6ca663d53 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -12,17 +12,18 @@ }, "preferUnplugged": true, "dependencies": { - "@aws-sdk/client-s3": "3.572.0", - "@aws-sdk/lib-storage": "3.572.0", - "@aws-sdk/s3-presigned-post": "3.572.0", - "@aws-sdk/s3-request-presigner": "3.572.0", - "@graphql-tools/schema": "^10.0.3", - "@hydrooj/utils": "workspace:*", + "@aws-sdk/client-s3": "3.583.0", + "@aws-sdk/lib-storage": "3.583.0", + "@aws-sdk/s3-presigned-post": "3.583.0", + "@aws-sdk/s3-request-presigner": "3.583.0", + "@graphql-tools/schema": "^10.0.4", + "@hydrooj/framework": "workspace:^", + "@hydrooj/utils": "workspace:^", "@simplewebauthn/server": "7.4.0", "@undefined-moe/require-resolve-hook": "^1.1.0", "adm-zip": "0.5.5", "cac": "^6.7.14", - "cordis": "3.13.3", + "cordis": "3.16.0", "detect-browser": "^5.3.0", "emoji-regex": "^10.3.0", "emojis-list": "2.1.0", @@ -30,11 +31,7 @@ "graphql": "^16.8.1", "graphql-scalars": "^1.23.0", "js-yaml": "^4.1.0", - "koa": "^2.15.3", - "koa-body": "^6.0.1", - "koa-compress": "^5.1.1", "koa-proxies": "^0.12.4", - "koa-router": "^12.0.1", "koa-static-cache": "^5.1.4", "lodash": "^4.17.21", "lru-cache": "^10.2.2", @@ -61,11 +58,8 @@ "@types/adm-zip": "^0.4.34", "@types/fs-extra": "^11.0.4", "@types/js-yaml": "^4.0.9", - "@types/koa": "^2.15.0", - "@types/koa-compress": "^4.0.6", - "@types/koa-router": "^7.4.8", "@types/koa-static-cache": "^5.1.4", - "@types/lodash": "^4.17.1", + "@types/lodash": "^4.17.4", "@types/mime-types": "^2.1.4", "@types/mongodb-uri": "^0.9.4", "@types/nodemailer": "^6.4.15", diff --git a/packages/hydrooj/src/context.ts b/packages/hydrooj/src/context.ts index aae3bdf8b..89b70fd42 100644 --- a/packages/hydrooj/src/context.ts +++ b/packages/hydrooj/src/context.ts @@ -1,12 +1,14 @@ import * as cordis from 'cordis'; import Schema from 'schemastery'; +import type { ServerEvents, WebService } from '@hydrooj/framework'; import type { DomainDoc, GeoIP, ModuleInterfaces } from './interface'; import { inject } from './lib/ui'; import { Loader } from './loader'; -import { EventMap } from './service/bus'; +import type { EventMap } from './service/bus'; import type { CheckService } from './service/check'; +import type { ConnectionHandler, Handler } from './service/server'; -export interface Events extends cordis.Events, EventMap { } +export interface Events extends cordis.Events, EventMap, ServerEvents { } function addScript(name: string, description: string, validate: Schema, run: (args: K, report: any) => boolean | Promise) { if (global.Hydro.script[name]) throw new Error(`duplicate script ${name} registered.`); @@ -26,12 +28,10 @@ export type MainScope = cordis.MainScope; export type { Disposable, ScopeStatus, Plugin } from 'cordis'; -export interface Context extends cordis.Context { - [Context.events]: Events; +export interface Context extends cordis.Context, Pick { + // @ts-ignore + [Context.events]: Events; loader: Loader; - Route: typeof import('./service/server').Route; - Connection: typeof import('./service/server').Connection; - withHandlerClass: import('./service/server').RouteService['withHandlerClass']; check: CheckService; setImmediate: typeof setImmediate; addScript: typeof addScript; @@ -72,15 +72,6 @@ export class Context extends cordis.Context { super(config); this.plugin(ApiMixin); } - - /** @deprecated use `ctx.root` instead */ - get app() { - return this.root; - } -} - -export namespace Context { - export type Associate

= cordis.Context.Associate; } const old = cordis.Registry.prototype.inject; diff --git a/packages/hydrooj/src/entry/common.ts b/packages/hydrooj/src/entry/common.ts index 995e79b46..bcd61d899 100644 --- a/packages/hydrooj/src/entry/common.ts +++ b/packages/hydrooj/src/entry/common.ts @@ -10,7 +10,6 @@ import yaml from 'js-yaml'; import { Context } from '../context'; import { Logger } from '../logger'; import { PRIV } from '../model/builtin'; -import * as bus from '../service/bus'; import { unwrapExports } from '../utils'; const logger = new Logger('common'); @@ -55,7 +54,6 @@ const getLoader = (type: LoadTask, filename: string) => async function loader(pe } } } - await bus.parallel(`app/load/${type}`); }; export const handler = getLoader('handler', 'handler'); @@ -95,7 +93,6 @@ export async function locale(pending: string[], fail: string[]) { } } } - await bus.parallel('app/load/locale'); } export async function setting(pending: string[], fail: string[], modelSetting: typeof import('../model/setting')) { @@ -137,7 +134,6 @@ export async function setting(pending: string[], fail: string[], modelSetting: t } } } - await bus.parallel('app/load/setting'); } export async function template(pending: string[], fail: string[]) { @@ -161,5 +157,4 @@ export async function template(pending: string[], fail: string[]) { } } } - await bus.parallel('app/load/template'); } diff --git a/packages/hydrooj/src/entry/setup.ts b/packages/hydrooj/src/entry/setup.ts index 332eac48f..2814c29f1 100644 --- a/packages/hydrooj/src/entry/setup.ts +++ b/packages/hydrooj/src/entry/setup.ts @@ -1,15 +1,12 @@ -import http from 'http'; import os from 'os'; import path from 'path'; import cac from 'cac'; import fs from 'fs-extra'; -import Koa, { Context } from 'koa'; -import Body from 'koa-body'; +import type { Context } from 'koa'; import { MongoClient, WriteConcern } from 'mongodb'; import mongoUri from 'mongodb-uri'; -import { Logger } from '../logger'; +import { Context as PluginContext } from '../context'; -const logger = new Logger('setup'); const listenPort = cac().parse().options.port || 8888; let resolve; @@ -82,19 +79,24 @@ async function post(ctx: Context) { } } -export function load() { - const app = new Koa(); - const server = http.createServer(app.callback()); - app.keys = ['Hydro']; - app - .use(Body()) - .use((ctx) => { - if (ctx.request.method.toLowerCase() === 'post') return post(ctx); - return get(ctx); +export function load(ctx: PluginContext) { + let dispose; + ctx.plugin(require('@hydrooj/framework'), { + keys: [Math.random()], + proxy: false, + upload: false, + port: listenPort, + }); + ctx.inject(['server'], ({ server }) => { + dispose = server.addServerLayer('setup', (c) => { + if (c.request.method.toLowerCase() === 'post') return post(c); + return get(c); }); - server.listen(listenPort); - logger.success('Server listening at: %d', listenPort); + }); return new Promise((r) => { - resolve = r; + resolve = () => { + dispose?.(); + r(null); + }; }); } diff --git a/packages/hydrooj/src/entry/worker.ts b/packages/hydrooj/src/entry/worker.ts index 90e0bd79c..024ca0f18 100644 --- a/packages/hydrooj/src/entry/worker.ts +++ b/packages/hydrooj/src/entry/worker.ts @@ -21,6 +21,7 @@ export async function apply(ctx: Context) { require('../lib/i18n'); require('../utils'); require('../error'); + require('../service/bus').apply(ctx); const config = require('../options')(); if (!process.env.CI && !config) { logger.info('Starting setup'); diff --git a/packages/hydrooj/src/error.ts b/packages/hydrooj/src/error.ts index 9b001439b..fcea8a00d 100644 --- a/packages/hydrooj/src/error.ts +++ b/packages/hydrooj/src/error.ts @@ -1,58 +1,12 @@ /* eslint-disable max-len */ -/* eslint-disable func-names */ +// eslint-disable-next-line simple-import-sort/imports +import { + CreateError as Err, + HydroError, UserFacingError, + BadRequestError, ForbiddenError, NotFoundError, +} from '@hydrooj/framework'; -interface IHydroError { - new(...args: any[]): HydroError -} - -export class HydroError extends Error { - params: any[]; - code: number; - - constructor(...params: any[]) { - super(); - this.params = params; - } - - msg() { - return 'HydroError'; - } - - get message() { - return this.msg(); - } -} - -const Err = (name: string, Class: IHydroError, ...info: Array<(() => string) | string | number>) => { - let msg: () => string; - let code: number; - for (const item of info) { - if (typeof item === 'number') { - code = item; - } else if (typeof item === 'string') { - msg = function () { return item; }; - } else if (typeof item === 'function') { - msg = item; - } - } - // eslint-disable-next-line @typescript-eslint/no-shadow - return class HydroError extends Class { - name = name; - constructor(...args: any[]) { - super(...args); - if (msg) this.msg = msg; - if (code) this.code = code; - } - }; -}; - -export const UserFacingError = Err('UserFacingError', HydroError, 'UserFacingError', 400); -export const SystemError = Err('SystemError', HydroError, 'SystemError', 500); - -export const BadRequestError = Err('BadRequestError', UserFacingError, 'BadRequestError', 400); -export const ForbiddenError = Err('ForbiddenError', UserFacingError, 'ForbiddenError', 403); -export const NotFoundError = Err('NotFoundError', UserFacingError, 'NotFoundError', 404); -export const MethodNotAllowedError = Err('MethodNotAllowedError', UserFacingError, 'MethodNotAllowedError', 405); +export * from '@hydrooj/framework/error'; export const RemoteOnlineJudgeError = Err('RemoteOnlineJudgeError', UserFacingError, 'RemoteOnlineJudgeError', 500); export const SendMailError = Err('SendMailError', UserFacingError, 'Failed to send mail to {0}. (1)', 500); @@ -77,16 +31,6 @@ export const PrivilegeError = Err('PrivilegeError', ForbiddenError, function (th } return "You don't have the required privilege."; }); -export const ValidationError = Err('ValidationError', ForbiddenError, function (this: HydroError) { - if (this.params.length === 3) { - return this.params[1] - ? 'Field {0} or {1} validation failed. ({2})' - : 'Field {0} validation failed. ({2})'; - } - return this.params[1] - ? 'Field {0} or {1} validation failed.' - : 'Field {0} validation failed.'; -}); export const ContestNotAttendedError = Err('ContestNotAttendedError', ForbiddenError, "You haven't attended this contest yet."); export const RequireProError = Err('RequireProError', ForbiddenError, 'RequireProError'); export const ContestAlreadyAttendedError = Err('ContestAlreadyAttendedError', ForbiddenError, "You've already attended this contest."); @@ -97,7 +41,6 @@ export const TrainingAlreadyEnrollError = Err('TrainingAlreadyEnrollError', Forb export const HomeworkNotLiveError = Err('HomeworkNotLiveError', ForbiddenError, 'This homework is not open.'); export const HomeworkNotAttendedError = Err('HomeworkNotAttendedError', ForbiddenError, "You haven't claimed this homework yet."); export const RoleAlreadyExistError = Err('RoleAlreadyExistError', ForbiddenError, 'This role already exists.'); -export const CsrfTokenError = Err('CsrfTokenError', ForbiddenError, 'CsrfTokenError'); export const DomainAlreadyExistsError = Err('DomainAlreadyExistsError', ForbiddenError, 'The domain {0} already exists.'); export const DomainJoinForbiddenError = Err('DomainJoinForbiddenError', ForbiddenError, 'You are not allowed to join the domain. The link is either invalid or expired.'); export const DomainJoinAlreadyMemberError = Err('DomainJoinAlreadyMemberError', ForbiddenError, 'Failed to join the domain. You are already a member.'); @@ -137,48 +80,4 @@ export const ContestNotFoundError = Err('ContestNotFoundError', DocumentNotFound export const DiscussionNotFoundError = Err('DiscussionNotFoundError', DocumentNotFoundError, 'Discussion {1} not found.'); export const DiscussionNodeNotFoundError = Err('DiscussionNodeNotFoundError', DocumentNotFoundError, 'Discussion node {1} not found.'); -export const InvalidOperationError = Err('InvalidOperationError', MethodNotAllowedError); export const NotLaunchedByPM2Error = Err('NotLaunchedByPM2Error', BadRequestError, 'Not launched by PM2.'); -export const FileTooLargeError = Err('FileTooLargeError', ValidationError, 'The uploaded file is too long.'); - -global.Hydro.error = module.exports; - -/* -class FileTypeNotAllowedError(ValidationError): - @property - def message(self): - return 'This type of files are not allowed to be uploaded.' - -class InvalidTokenDigestError(ForbiddenError): - pass - -class DiscussionCategoryAlreadyExistError(ForbiddenError): - @property - def message(self): - return 'Discussion category {1} already exists.' - -class DiscussionCategoryNotFoundError(NotFoundError): - @property - def message(self): - return 'Discussion category {1} not found.' - -class DiscussionNodeAlreadyExistError(ForbiddenError): - @property - def message(self): - return 'Discussion node {1} already exists.' - -class TrainingRequirementNotSatisfiedError(ForbiddenError): - @property - def message(self): - return 'Training requirement is not satisfied.' - -class UsageExceededError(ForbiddenError): - @property - def message(self): - return 'Usage exceeded.' - -class InvalidArgumentError(BadRequestError): - @property - def message(self): - return 'Argument {0} is invalid.' -*/ diff --git a/packages/hydrooj/src/lib/avatar.ts b/packages/hydrooj/src/lib/avatar.ts index a493a5404..809415fe7 100644 --- a/packages/hydrooj/src/lib/avatar.ts +++ b/packages/hydrooj/src/lib/avatar.ts @@ -1,5 +1,5 @@ +import { Types } from '@hydrooj/framework/validator'; import { md5 } from '../utils'; -import { Types } from './validator'; type AvatarProvider = (src: string, size: number) => string; diff --git a/packages/hydrooj/src/lib/i18n.ts b/packages/hydrooj/src/lib/i18n.ts index a59b23a0c..eb1ae1f20 100644 --- a/packages/hydrooj/src/lib/i18n.ts +++ b/packages/hydrooj/src/lib/i18n.ts @@ -33,6 +33,20 @@ class I18nService extends Service { } return null; } + + translate(str: string, languages: string[]) { + if (languages[0]?.startsWith('en')) { + // For most use cases, source text equals to translated text in English. + // So if it doesn't exist, we should use the original text instead of fallback. + return app.i18n.get(str, languages[0]) || app.i18n.get(str, 'en') || this.toString(); + } + for (const language of languages.filter(Boolean)) { + const curr = app.i18n.get(str, language) || app.i18n.get(str, language.split('_')[0]) + || app.i18n.get(str, language.split('-')[0]); + if (curr) return curr; + } + return this.toString(); + } } app.provide('i18n', undefined, true); diff --git a/packages/hydrooj/src/lib/serializer.ts b/packages/hydrooj/src/lib/serializer.ts deleted file mode 100644 index 61f300478..000000000 --- a/packages/hydrooj/src/lib/serializer.ts +++ /dev/null @@ -1,16 +0,0 @@ -interface SerializeOptions { - showDisplayName: boolean; - currentSerializer?: (k: string, v: any) => any; -} - -export default function serializer(options: SerializeOptions, ignoreSerializeFunction = false) { - options.currentSerializer = (k: string, v: any) => { - if (typeof k.startsWith !== 'function') console.log(k, v); - if (k.startsWith('_') && k !== '_id') return undefined; - if (typeof v === 'bigint') return `BigInt::${v.toString()}`; - if (!ignoreSerializeFunction && v && typeof v === 'object' - && 'serialize' in v && typeof v.serialize === 'function') return v.serialize(options); - return v; - }; - return options.currentSerializer; -} diff --git a/packages/hydrooj/src/model/setting.ts b/packages/hydrooj/src/model/setting.ts index c9677e467..8b7d7f1d2 100644 --- a/packages/hydrooj/src/model/setting.ts +++ b/packages/hydrooj/src/model/setting.ts @@ -290,7 +290,7 @@ SystemSetting( // eslint-disable-next-line import/no-mutable-exports export const langs: Record = {}; -declare module '../context' { +declare module 'cordis' { interface Context { setting: SettingService; } @@ -319,6 +319,7 @@ export class SettingService extends Service { } export async function apply(ctx: Context) { + ctx.provide('setting', undefined, true); ctx.plugin(SettingService); logger.info('Ensuring settings'); const system = global.Hydro.model.system; diff --git a/packages/hydrooj/src/model/user.ts b/packages/hydrooj/src/model/user.ts index e95b512ee..eb2bda954 100644 --- a/packages/hydrooj/src/model/user.ts +++ b/packages/hydrooj/src/model/user.ts @@ -1,6 +1,7 @@ import { escapeRegExp, pick, uniq } from 'lodash'; import { LRUCache } from 'lru-cache'; import { Collection, Filter, ObjectId } from 'mongodb'; +import { serializer } from '@hydrooj/framework'; import { LoginError, UserAlreadyExistError, UserNotFoundError } from '../error'; import { Authenticator, BaseUserDict, FileInfo, GDoc, @@ -8,7 +9,6 @@ import { } from '../interface'; import avatar from '../lib/avatar'; import pwhash from '../lib/hash.hydro'; -import serializer from '../lib/serializer'; import * as bus from '../service/bus'; import db from '../service/db'; import { Value } from '../typeutils'; @@ -165,16 +165,20 @@ export class User { return user; } - serialize(options) { + serialize(_, h) { if (!this._isPrivate) { const fields = ['_id', 'uname', 'mail', 'perm', 'role', 'priv', 'regat', 'loginat', 'tfa', 'authn']; - if (options.showDisplayName) fields.push('displayName'); + if (h?.user?.hasPerm(PERM.PERM_VIEW_DISPLAYNAME)) fields.push('displayName'); return pick(this, fields); } - return JSON.stringify(this, serializer({ showDisplayName: options.showDisplayName }, true)); + return JSON.stringify(this, serializer(true, h)); } } +declare module '@hydrooj/framework' { + interface UserModel extends User { } +} + function handleMailLower(mail: string) { const [n, d] = mail.trim().toLowerCase().split('@'); const [name] = n.split('+'); diff --git a/packages/hydrooj/src/plugin-api.ts b/packages/hydrooj/src/plugin-api.ts index a7bb1fa99..c58de3abf 100644 --- a/packages/hydrooj/src/plugin-api.ts +++ b/packages/hydrooj/src/plugin-api.ts @@ -3,6 +3,10 @@ import db from './service/db'; export { nanoid } from 'nanoid'; +export { + WebService, Router, HandlerCommon, httpServer, +} from '@hydrooj/framework'; + export * from './pipelineUtils'; export * from './error'; export * from './libs'; @@ -33,6 +37,7 @@ export * as JudgeHandler from './handler/judge'; export { registerResolver, registerValue, registerUnion } from './service/api'; export { postJudge } from './handler/judge'; export { Collections } from './service/db'; +export { Handler, ConnectionHandler, requireSudo } from './service/server'; export { Service, Context } from './context'; export { buildContent } from './lib/content'; export { default as mime } from './lib/mime'; @@ -40,13 +45,9 @@ export { default as rating } from './lib/rating'; export { default as avatar } from './lib/avatar'; export { default as rank } from './lib/rank'; export { default as paginate } from './lib/paginate'; -export * from './lib/validator'; -export * from './service/decorators'; -export { - Handler, ConnectionHandler, captureAllRoutes, - httpServer, wsServer, router, -} from './service/server'; export { UiContextBase } from './service/layers/base'; +export * from '@hydrooj/framework/decorators'; +export * from '@hydrooj/framework/validator'; export * as StorageService from './service/storage'; export { EventMap } from './service/bus'; export { db, pwsh }; diff --git a/packages/hydrooj/src/service/bus.ts b/packages/hydrooj/src/service/bus.ts index d8395d6d7..ff06eed1a 100644 --- a/packages/hydrooj/src/service/bus.ts +++ b/packages/hydrooj/src/service/bus.ts @@ -2,7 +2,9 @@ import { BSON, Db, Filter, ObjectId, OnlyFieldsOfType, } from 'mongodb'; +import type { Handler, ServerEvents } from '@hydrooj/framework'; import pm2 from '@hydrooj/utils/lib/locate-pm2'; +import { Context } from '../context'; import type { ProblemSolutionHandler } from '../handler/problem'; import type { UserRegisterHandler } from '../handler/user'; import type { @@ -11,28 +13,19 @@ import type { ScoreboardRow, Tdoc, TrainingDoc, User, } from '../interface'; import type { DocType } from '../model/document'; -import type { ConnectionHandler, Handler } from './server'; export type Disposable = () => void; export type VoidReturn = Promise | any; type HookType = 'before-prepare' | 'before' | 'before-operation' | 'after' | 'finish'; -type ModuleCategories = 'lib' | 'locale' | 'template' | 'script' | 'model' | 'setting' | 'handler' | 'service' | 'addon'; -type LifecycleEvents = Record<`app/load/${ModuleCategories}`, () => VoidReturn>; type MapHandlerEvents = Record<`handler/${HookType}/${N}`, (thisArg: H) => VoidReturn>; type KnownHandlerEvents = MapHandlerEvents<'UserRegister', UserRegisterHandler> & MapHandlerEvents<'ProblemSolution', ProblemSolutionHandler>; -type HandlerEvents = - KnownHandlerEvents - & Record<`handler/${HookType}/${string}`, (thisArg: Handler & Record) => VoidReturn> - & Record<`handler/${HookType}`, (thisArg: Handler) => VoidReturn> - & Record<`handler/register/${string}`, (HandlerClass: typeof Handler) => VoidReturn> - & Record<`connection/${'create' | 'active' | 'close'}`, (thisArg: ConnectionHandler) => VoidReturn>; /* eslint-disable @typescript-eslint/naming-convention */ -export interface EventMap extends LifecycleEvents, HandlerEvents { - 'app/started': () => void +export interface EventMap extends KnownHandlerEvents { 'app/listen': () => void + 'app/started': () => void 'app/ready': () => VoidReturn 'app/exit': () => VoidReturn 'app/before-reload': (entries: Set) => VoidReturn @@ -45,7 +38,7 @@ export interface EventMap extends LifecycleEvents, HandlerEvents { 'database/config': () => VoidReturn 'system/setting': (args: Record) => VoidReturn - 'bus/broadcast': (event: keyof EventMap, ...args: any[]) => VoidReturn + 'bus/broadcast': (event: keyof EventMap | keyof ServerEvents, payload: any) => VoidReturn 'monitor/update': (type: 'server' | 'judge', $set: any) => VoidReturn 'monitor/collect': (info: any) => VoidReturn 'api/update': () => void; @@ -69,10 +62,6 @@ export interface EventMap extends LifecycleEvents, HandlerEvents { $set: any, $unset: OnlyFieldsOfType ) => VoidReturn - 'handler/create': (thisArg: Handler) => VoidReturn - 'handler/init': (thisArg: Handler) => VoidReturn - 'handler/error': (thisArg: Handler, e: Error) => VoidReturn - 'discussion/before-add': (payload: Partial) => VoidReturn 'discussion/add': (payload: Partial) => VoidReturn @@ -108,21 +97,27 @@ export interface EventMap extends LifecycleEvents, HandlerEvents { } /* eslint-enable @typescript-eslint/naming-convention */ -try { - if (!process.send || !pm2 || process.env.exec_mode !== 'cluster_mode') throw new Error(); - pm2.launchBus((err, bus) => { - if (err) throw new Error(); - bus.on('hydro:broadcast', (packet) => { - (app.parallel as any)(packet.data.event, ...BSON.EJSON.parse(packet.data.payload)); +declare module 'cordis' { + interface Events extends EventMap { } +} + +export function apply(ctx: Context) { + try { + if (!process.send || !pm2 || process.env.exec_mode !== 'cluster_mode') throw new Error(); + pm2.launchBus((err, bus) => { + if (err) throw new Error(); + bus.on('hydro:broadcast', (packet) => { + (app.parallel as any)(packet.data.event, ...BSON.EJSON.parse(packet.data.payload)); + }); + ctx.on('bus/broadcast', (event, payload) => { + process.send({ type: 'hydro:broadcast', data: { event, payload: BSON.EJSON.stringify(payload) } }); + }); + console.debug('Using pm2 event bus'); }); - app.on('bus/broadcast', (event, payload) => { - process.send({ type: 'hydro:broadcast', data: { event, payload: BSON.EJSON.stringify(payload) } }); - }); - console.debug('Using pm2 event bus'); - }); -} catch (e) { - app.on('bus/broadcast', (event, payload) => app.parallel(event, ...payload)); - console.debug('Using mongodb external event bus'); + } catch (e) { + ctx.on('bus/broadcast', (event, payload) => app.parallel(event, ...payload)); + console.debug('Using mongodb external event bus'); + } } export default app; diff --git a/packages/hydrooj/src/service/layers/base.ts b/packages/hydrooj/src/service/layers/base.ts index 08d27bc67..97850ce83 100644 --- a/packages/hydrooj/src/service/layers/base.ts +++ b/packages/hydrooj/src/service/layers/base.ts @@ -1,11 +1,10 @@ -import { PassThrough } from 'stream'; import type { Next } from 'koa'; -import { cloneDeep, omit, pick } from 'lodash'; +import { cloneDeep, omit } from 'lodash'; +import type { KoaContext } from '@hydrooj/framework'; import { randomPick } from '@hydrooj/utils'; import { PERM } from '../../model/builtin'; import * as system from '../../model/system'; import token from '../../model/token'; -import type { HydroRequest, HydroResponse, KoaContext } from '../server'; export interface UiContextBase { cdn_prefix: string; @@ -21,41 +20,6 @@ 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(), - 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 || '', - json: (ctx.request.headers.accept || '').includes('application/json'), - websocket: ctx.request.headers.upgrade === 'websocket', - }; - request.ip = request.ip.split(',')[0].trim(); - const response: HydroResponse = { - body: {}, - type: '', - status: null, - template: null, - redirect: null, - attachment: (name, streamOrBuffer) => { - ctx.attachment(name); - if (streamOrBuffer instanceof Buffer) { - response.body = null; - ctx.body = streamOrBuffer; - } else { - response.body = null; - ctx.body = streamOrBuffer.pipe(new PassThrough()); - } - }, - addHeader: (name: string, value: string) => ctx.set(name, value), - disposition: null, - }; const args = { domainId, ...ctx.params, ...ctx.query, ...ctx.request.body, __start: Date.now(), }; @@ -68,9 +32,9 @@ export default async (ctx: KoaContext, next: Next) => { } UiContext.domainId = domainId; UiContext.domain = domainInfo; - ctx.HydroContext = { - request, response, UiContext, domain: domainInfo, user: null, args, - }; + ctx.HydroContext.UiContext = UiContext; + ctx.HydroContext.domain = domainInfo; + ctx.HydroContext.args = args; const header = ctx.request.headers.authorization; const sid = header ? header.split(' ')[1] // Accept bearer token @@ -78,6 +42,7 @@ export default async (ctx: KoaContext, next: Next) => { const session = sid ? await token.get(sid instanceof Array ? sid[0] : sid, token.TYPE_SESSION) : null; ctx.session = Object.create(session || { uid: 0, scope: PERM.PERM_ALL.toString() }); await next(); + const request = ctx.HydroContext.request; const ua = request.headers['user-agent'] || ''; if (!ctx.session.uid && system.get('server.ignoreUA').replace(/\r/g, '').split('\n').filter((i) => i && ua.includes(i)).length) return; const expireSeconds = ctx.session.save diff --git a/packages/hydrooj/src/service/layers/domain.ts b/packages/hydrooj/src/service/layers/domain.ts index f38a0b3cd..93a43bb14 100644 --- a/packages/hydrooj/src/service/layers/domain.ts +++ b/packages/hydrooj/src/service/layers/domain.ts @@ -1,7 +1,7 @@ +import { KoaContext } from '@hydrooj/framework'; import BlackListModel from '../../model/blacklist'; import DomainModel from '../../model/domain'; import * as system from '../../model/system'; -import { KoaContext } from '../server'; export default async (ctx: KoaContext, next) => { const forceDomain = /^\/d\/([^/]+)\//.exec(ctx.request.path); diff --git a/packages/hydrooj/src/service/layers/renderer.ts b/packages/hydrooj/src/service/layers/renderer.ts deleted file mode 100644 index 83cf9a2da..000000000 --- a/packages/hydrooj/src/service/layers/renderer.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { ObjectId } from 'mongodb'; -import avatar from '../../lib/avatar'; -import serializer from '../../lib/serializer'; -import { PERM } from '../../model/builtin'; -import * as system from '../../model/system'; -import type { KoaContext } from '../server'; - -export default (router, logger) => async (ctx: KoaContext, next) => { - const { request, response } = ctx.HydroContext; - ctx.renderHTML = (templateName, args) => { - const user = ctx.HydroContext.user; - const UserContext: any = { - ...(user || {}), - avatar: avatar(user?.avatar || '', 128), - viewLang: ctx.translate('__id'), - }; - const type = templateName.split('.')[1]; - const engine = global.Hydro.module.render[type] - || (() => JSON.stringify(args, serializer({ - showDisplayName: user?.hasPerm(PERM.PERM_VIEW_DISPLAYNAME), - }))); - return engine(templateName, { - handler: ctx.handler, - UserContext, - url: ctx.getUrl, - _: ctx.translate, - ...args, - }); - }; - ctx.render = async (name, args) => { - response.body = await ctx.renderHTML(name, args); - response.type = 'text/html'; - }; - ctx.getUrl = (name: string, ...kwargsList: Record[]) => { - if (name === '#') return '#'; - let res = '#'; - const args: any = {}; - const query: any = {}; - for (const kwargs of kwargsList) { - for (const key in kwargs) { - if (kwargs[key] instanceof ObjectId) args[key] = kwargs[key].toHexString(); - else args[key] = kwargs[key].toString().replace(/\//g, '%2F'); - } - for (const key in kwargs.query || {}) { - if (query[key] instanceof ObjectId) query[key] = kwargs.query[key].toHexString(); - else query[key] = kwargs.query[key].toString(); - } - } - try { - const { anchor } = args; - let withDomainId = args.domainId || false; - const domainId = ctx.HydroContext.args.domainId; - const host = ctx.HydroContext.domain?.host; - if (domainId !== 'system' && ( - !request.host - || (host instanceof Array - ? (!host.includes(request.host)) - : request.host !== host) - )) withDomainId ||= domainId; - res = router.url(name, args, { query }).toString(); - if (anchor) res = `${res}#${anchor}`; - if (withDomainId) res = `/d/${withDomainId}${res}`; - } catch (e) { - logger.warn(e.message); - logger.info('%s %o', name, args); - if (!e.message.includes('Expected') || !e.message.includes('to match')) logger.info('%s', e.stack); - } - return res; - }; - ctx.translate = (str: string) => { - if (!str) return ''; - const lang = ctx.HydroContext.user?.viewLang || ctx.session?.viewLang; - return lang - ? str.toString().translate(lang, ...ctx.acceptsLanguages()) - : str.toString().translate(...ctx.acceptsLanguages(), system.get('server.language')); - }; - await next(); -}; diff --git a/packages/hydrooj/src/service/layers/response.ts b/packages/hydrooj/src/service/layers/response.ts deleted file mode 100644 index f71bf91c2..000000000 --- a/packages/hydrooj/src/service/layers/response.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { errorMessage } from '@hydrooj/utils/lib/utils'; -import { SystemError, UserFacingError } from '../../error'; -import serializer from '../../lib/serializer'; -import { PERM } from '../../model/builtin'; -import type { KoaContext } from '../server'; - -export default (logger) => async (ctx: KoaContext, next) => { - const { request, response } = ctx.HydroContext; - try { - await next(); - const { UiContext, user, args } = ctx.HydroContext; - if (response.redirect) { - response.body ||= {}; - response.body.url = response.redirect; - } - if (!response.type) { - if (response.pjax && args.pjax) { - const html = await ctx.renderHTML(response.pjax, response.body); - response.body = { fragments: [{ html }] }; - response.type = 'application/json'; - } else if ( - request.json || response.redirect - || request.query.noTemplate || !response.template) { - // Send raw data - try { - if (typeof response.body === 'object') { - response.body.UiContext = UiContext; - response.body.UserContext = user; - } - response.body = JSON.stringify(response.body, serializer({ showDisplayName: user?.hasPerm(PERM.PERM_VIEW_DISPLAYNAME) })); - } catch (e) { - response.body = new SystemError('Serialize failure', e.message); - } - response.type = 'application/json'; - } else if (response.template) { - const s = response.template.split('.'); - let templateName = `${s[0]}.${args.domainId}.${s[1]}`; - if (!global.Hydro.ui.template[templateName]) templateName = response.template; - await ctx.render(templateName, response.body || {}); - } - } - if (response.disposition) ctx.set('Content-Disposition', response.disposition); - if (response.etag) { - ctx.set('ETag', response.etag); - ctx.set('Cache-Control', 'public'); - } - } catch (err) { - const error = errorMessage(err); - response.status = error instanceof UserFacingError ? error.code : 500; - if (request.json) response.body = { error }; - else { - try { - await ctx.render(error instanceof UserFacingError ? 'error.html' : 'bsod.html', { UserFacingError, error }); - } catch (e) { - logger.error(e); - // this.response.body.error = {}; - } - } - } finally { - if (response.etag && request.headers['if-none-match'] === response.etag) { - ctx.response.status = 304; - } else if (response.redirect && !request.json) { - ctx.response.type = 'application/octet-stream'; - ctx.response.status = 302; - ctx.redirect(response.redirect); - } else if (response.body) { - ctx.body = response.body; - ctx.response.status = response.status || 200; - ctx.response.type = response.type - || (request.json - ? 'application/json' - : ctx.response.type); - } - } -}; diff --git a/packages/hydrooj/src/service/layers/user.ts b/packages/hydrooj/src/service/layers/user.ts index 264720f4d..60575f45e 100644 --- a/packages/hydrooj/src/service/layers/user.ts +++ b/packages/hydrooj/src/service/layers/user.ts @@ -1,7 +1,7 @@ +import type { KoaContext } from '@hydrooj/framework'; import { NotFoundError } from '../../error'; import { PERM } from '../../model/builtin'; import UserModel from '../../model/user'; -import type { KoaContext } from '../server'; export default async (ctx: KoaContext, next) => { // User Layer diff --git a/packages/hydrooj/src/service/server.ts b/packages/hydrooj/src/service/server.ts index fe05cebc5..a3c352aab 100644 --- a/packages/hydrooj/src/service/server.ts +++ b/packages/hydrooj/src/service/server.ts @@ -1,222 +1,83 @@ -import http from 'http'; -import { tmpdir } from 'os'; -import { join, resolve } from 'path'; +import fs from 'fs'; +import { resolve } from 'path'; import cac from 'cac'; -import type { Files } from 'formidable'; -import fs from 'fs-extra'; -import Koa from 'koa'; -import Body from 'koa-body'; -import Compress from 'koa-compress'; import proxy from 'koa-proxies'; import cache from 'koa-static-cache'; -import type { FindCursor } from 'mongodb'; -import { Shorty } from 'shorty.js'; -import WebSocket from 'ws'; -import { Counter, isClass, parseMemoryMB } from '@hydrooj/utils/lib/utils'; -import { Context, Service } from '../context'; +import { type FindCursor, ObjectId } from 'mongodb'; import { - CsrfTokenError, HydroError, InvalidOperationError, - MethodNotAllowedError, NotFoundError, PermissionError, - PrivilegeError, UserFacingError, -} from '../error'; -import { DomainDoc } from '../interface'; + ConnectionHandler as ConnectionHandlerOriginal, Handler as HandlerOriginal, HydroError, NotFoundError, UserFacingError, +} from '@hydrooj/framework'; +import { errorMessage, Time } from '@hydrooj/utils'; +import { Context } from '../context'; +import { PermissionError, PrivilegeError } from '../error'; +import type { DomainDoc } from '../interface'; import paginate from '../lib/paginate'; -import serializer from '../lib/serializer'; -import { Types } from '../lib/validator'; import { Logger } from '../logger'; import { PERM, PRIV } from '../model/builtin'; import * as opcount from '../model/opcount'; import * as OplogModel from '../model/oplog'; import * as system from '../model/system'; -import { User } from '../model/user'; import { builtinConfig } from '../settings'; -import { errorMessage } from '../utils'; -import * as bus from './bus'; -import * as decorators from './decorators'; import baseLayer from './layers/base'; import domainLayer from './layers/domain'; -import rendererLayer from './layers/renderer'; -import responseLayer from './layers/response'; import userLayer from './layers/user'; -import { Router } from './router'; -import { encodeRFC5987ValueChars } from './storage'; - -export * from './decorators'; -export * from '../lib/validator'; - -export interface HydroRequest { - method: string; - host: string; - hostname: string; - ip: string; - headers: Koa.Request['headers']; - cookies: any; - body: any; - files: Record; - query: any; - querystring: string; - path: string; - originalPath: string; - params: any; - referer: string; - json: boolean; - websocket: boolean; -} -export interface HydroResponse { - body: any; - type: string; - status: number; - template?: string; - /** If set, and pjax content was request from client, - * The template will be used for rendering. - */ - pjax?: string; - redirect?: string; - disposition?: string; - etag?: string; - attachment: (name: string, stream?: any) => void; - addHeader: (name: string, value: string) => void; -} -interface HydroContext { - request: HydroRequest; - response: HydroResponse; - user: User; - domain?: DomainDoc; - args: Record; - UiContext: Record; -} -export interface KoaContext extends Koa.Context { - HydroContext: HydroContext; - handler: any; - request: Koa.Request & { body: any, files: Files }; - session: Record; - render: (name: string, args: any) => Promise; - renderHTML: (name: string, args: any) => string | Promise; - getUrl: (name: string, args: any) => string; - translate: (key: string) => string; -} const argv = cac().parse(); -const logger = new Logger('server'); -export const app = new Koa({ - keys: system.get('server.keys'), -}); -export const router = new Router(); -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.on('error', (error) => { - if (error.code !== 'EPIPE' && error.code !== 'ECONNRESET' && !error.message.includes('Parse Error')) { - logger.error('Koa app-level error', { error }); - } -}); -wsServer.on('error', (error) => { - console.log('Websocket server error:', error); -}); - const ignoredLimit = `,${argv.options.ignoredLimit},`; -export class HandlerCommon { - render: (name: string, args?: any) => Promise; - renderHTML: (name: string, args?: any) => string | Promise; - url: (name: string, args?: any) => string; - translate: (key: string) => string; - session: Record; - ctx: Context; - /** @deprecated */ - domainId: string; +const logger = new Logger('server'); - constructor( - public context: KoaContext, public readonly args: Record, - public readonly request: HydroRequest, public response: HydroResponse, - public user: User, public domain: DomainDoc, public UiContext: Record, - ) { - this.render = context.render.bind(context); - this.renderHTML = context.renderHTML.bind(context); - this.url = context.getUrl.bind(context); - this.translate = context.translate.bind(context); - this.session = context.session; - this.domainId = args.domainId; - this.ctx = global.app.extend({ - domain: this.domain, - }); - } +declare module '@hydrooj/framework' { + export interface HandlerCommon { + domain: DomainDoc; - async limitRate(op: string, periodSecs: number, maxOperations: number, withUserId = system.get('limit.by_user')) { - if (ignoredLimit.includes(op)) return; - if (this.user && this.user.hasPriv(PRIV.PRIV_UNLIMITED_ACCESS)) return; - const overrideLimit = system.get(`limit.${op}`); - if (overrideLimit) maxOperations = overrideLimit; - let id = this.request.ip; - if (withUserId) id += `@${this.user._id}`; - await opcount.inc(op, id, periodSecs, maxOperations); - } - - paginate(cursor: FindCursor, page: number, key: string) { - return paginate(cursor, page, this.ctx.setting.get(`pagination.${key}`)); - } - - renderTitle(str: string) { - const name = this.ctx.setting.get('server.name'); - if (this.UiContext.extraTitleContent) return `${this.translate(str)} - ${this.UiContext.extraTitleContent} - ${name}`; - return `${this.translate(str)} - ${name}`; - } - - checkPerm(...args: bigint[]) { - if (!this.user.hasPerm(...args)) { - if (this.user.hasPriv(PRIV.PRIV_USER_PROFILE)) throw new PermissionError(...args); - throw new PrivilegeError(PRIV.PRIV_USER_PROFILE); - } - } - - checkPriv(...args: number[]) { - if (!this.user.hasPriv(...args)) throw new PrivilegeError(...args); + paginate(cursor: FindCursor, page: number, key: string): Promise<[docs: T[], numPages: number, count: number]>; + progress(message: string, params: any[]): void; + limitRate(op: string, periodSecs: number, maxOperations: number, withUserId?: boolean): Promise; + renderTitle(str: string): string; } } -export class Handler extends HandlerCommon { - loginMethods: any; - noCheckPermView = false; - notUsage = false; - allowCors = false; - __param: Record[]>; +export * from '@hydrooj/framework/decorators'; +export * from '@hydrooj/framework/validator'; - back(body?: any) { - this.response.body = body || this.response.body || {}; - this.response.redirect = this.request.headers.referer || '/'; - } - - binary(data: any, name?: string) { - this.response.body = data; - this.response.template = null; - this.response.type = 'application/octet-stream'; - if (name) this.response.disposition = `attachment; filename="${encodeRFC5987ValueChars(name)}"`; - } - - // This is beta API, may be changed in the future. - progress(message: string, params: any[]) { - Hydro.model.message.sendInfo(this.user._id, JSON.stringify({ message, params })); - } - - async init() { - if (this.request.method === 'post' && this.request.headers.referer && !this.context.cors && !this.allowCors) { - try { - const host = new URL(this.request.headers.referer).host; - if (host !== this.request.host) this.context.pendingError = new CsrfTokenError(host); - } catch (e) { - this.context.pendingError = new CsrfTokenError(); - } +/* + * For security concern, some API requires sudo privilege to access. + * And for some superadmin operations, + * we do not allow them using a password to perform the sudo operation, + * as most user choose to use "remember password" option. + * When teachers are using a superuser account, accessing from classrooms, + * it may lead to serious security issues. + * !!! Please make sure that all superuser accounts have two factor authentication enabled. !!! + */ +export function requireSudo(target: any, funcName: string, obj: any) { + const originalMethod = obj.value; + obj.value = function sudo(this: Handler, ...args: any[]) { + if (this.session.sudo && Date.now() - this.session.sudo < Time.hour) { + if (this.session.sudoArgs?.referer) this.request.headers.referer = this.session.sudoArgs.referer; + this.session.sudoArgs = null; + return originalMethod.call(this, ...args); } - if (!argv.options.benchmark && !this.notUsage) await this.limitRate('global', 5, 100); - if (!this.noCheckPermView && !this.user.hasPriv(PRIV.PRIV_VIEW_ALL_DOMAIN)) this.checkPerm(PERM.PERM_VIEW); - this.loginMethods = Object.keys(global.Hydro.module.oauth) - .map((key) => ({ - id: key, - icon: global.Hydro.module.oauth[key].icon, - text: global.Hydro.module.oauth[key].text, - })); - if (this.context.pendingError) throw this.context.pendingError; + this.session.sudoArgs = { + method: this.request.method, + referer: this.request.headers.referer, + args: this.args, + redirect: this.request.originalPath, + }; + this.response.redirect = this.url('user_sudo'); + return 'cleanup'; + }; + return obj; +} + +export interface Handler { + domain: DomainDoc; + ctx: Context; +} +export class Handler extends HandlerOriginal { + constructor(_, ctx: Context) { + super(_, ctx); + this.ctx = ctx.extend({ domain: this.domain }); } async onerror(error: HydroError) { @@ -235,6 +96,7 @@ export class Handler extends HandlerCommon { }); } else { this.response.status = error instanceof UserFacingError ? error.code : 500; + logger.error(error); this.response.template = error instanceof UserFacingError ? 'error.html' : 'bsod.html'; this.response.body = { UserFacingError, @@ -244,425 +106,174 @@ export class Handler extends HandlerCommon { } } -async function serial(name: string, ...args: any[]) { - const r = await (global.app.serial as any)(name, ...args); - if (r instanceof Error) throw r; - return r; +export interface ConnectionHandler { + domain: DomainDoc; + ctx: Context; } - -async function handle(ctx: KoaContext, HandlerClass, checker) { - const { - args, request, response, user, domain, UiContext, - } = ctx.HydroContext; - Object.assign(args, ctx.params); - const init = Date.now(); - const h = new HandlerClass(ctx, args, request, response, user, domain, UiContext); - ctx.handler = h; - const method = ctx.method.toLowerCase(); - try { - const operation = (method === 'post' && ctx.request.body?.operation) - ? `_${ctx.request.body.operation}`.replace(/_([a-z])/gm, (s) => s[1].toUpperCase()) - : ''; - - await bus.parallel('handler/create', h); - - if (checker) checker.call(h); - if (method === 'post') { - if (operation) { - if (typeof h[`post${operation}`] !== 'function') { - throw new InvalidOperationError(operation); - } - } else if (typeof h.post !== 'function') { - throw new MethodNotAllowedError(method); - } - } else if (typeof h[method] !== 'function' && typeof h.all !== 'function') { - throw new MethodNotAllowedError(method); - } - - const name = HandlerClass.name.replace(/Handler$/, ''); - const steps = [ - 'init', 'handler/init', - `handler/before-prepare/${name}#${method}`, `handler/before-prepare/${name}`, 'handler/before-prepare', - 'log/__prepare', '__prepare', '_prepare', 'prepare', 'log/__prepareDone', - `handler/before/${name}#${method}`, `handler/before/${name}`, 'handler/before', - 'log/__method', 'all', method, 'log/__methodDone', - ...operation ? [ - `handler/before-operation/${name}`, 'handler/before-operation', - `post${operation}`, 'log/__operationDone', - ] : [], 'after', - `handler/after/${name}#${method}`, `handler/after/${name}`, 'handler/after', - 'cleanup', - `handler/finish/${name}#${method}`, `handler/finish/${name}`, 'handler/finish', - ]; - - let current = 0; - while (current < steps.length) { - const step = steps[current]; - let control; - if (step.startsWith('log/')) h.args[step.slice(4)] = Date.now(); - // eslint-disable-next-line no-await-in-loop - else if (step.startsWith('handler/')) control = await serial(step, h); - // eslint-disable-next-line no-await-in-loop - else if (typeof h[step] === 'function') control = await h[step](args); - if (control) { - const index = steps.findIndex((i) => control === i); - if (index === -1) throw new Error(`Invalid control: ${control}`); - if (index <= current) { - logger.warn('Returning to previous step is not recommended:', step, '->', control); - } - current = index; - } else current++; - } - } catch (e) { - try { - await serial(`handler/error/${HandlerClass.name.replace(/Handler$/, '')}`, h, e); - await serial('handler/error', h, e); - await h.onerror(e); - } catch (err) { - h.response.code = 500; - h.response.type = 'text/plain'; - h.response.body = `${err.message}\n${err.stack}`; - } - } finally { - const finish = Date.now(); - if (finish - init > 5000) { - const id = await OplogModel.log(h, 'slow_request', { method, processtime: finish - init }); - logger.warn(`Slow handler: ID=${id}, `, method, ctx.path, `${finish - init}ms`); - } - // TODO metrics: calc avg response time - } -} - -const Checker = (permPrivChecker) => { - let perm: bigint; - let priv: number; - let checker = () => { }; - for (const item of permPrivChecker) { - if (typeof item === 'object') { - if (typeof item.call !== 'undefined') { - checker = item; - } else if (typeof item[0] === 'number') { - priv = item; - } else if (typeof item[0] === 'bigint') { - perm = item; - } - } else if (typeof item === 'number') { - priv = item; - } else if (typeof item === 'bigint') { - perm = item; - } - } - return function check(this: Handler) { - checker(); - if (perm) this.checkPerm(perm); - if (priv) this.checkPriv(priv); - }; -}; - -export function Route(name: string, path: string, RouteHandler: any, ...permPrivChecker) { - router.all(name, path, (ctx) => handle(ctx as any, RouteHandler, Checker(permPrivChecker))); - return router.disposeLastOp; -} - -export class ConnectionHandler extends HandlerCommon { - conn: WebSocket; - compression: Shorty; - counter = 0; - - resetCompression() { - this.counter = 0; - this.compression = new Shorty(); - this.conn.send('shorty'); - } - - send(data: any) { - let payload = JSON.stringify(data, serializer({ - showDisplayName: this.user?.hasPerm(PERM.PERM_VIEW_DISPLAYNAME), - })); - if (this.compression) { - if (this.counter > 1000) this.resetCompression(); - payload = this.compression.deflate(payload); - this.counter++; - } - this.conn.send(payload); - } - - close(code: number, reason: string) { - this.conn.close(code, reason); +export class ConnectionHandler extends ConnectionHandlerOriginal { + constructor(_, ctx: Context) { + super(_, ctx); + this.ctx = ctx.extend({ domain: this.domain }); } onerror(err: HydroError) { - if (err instanceof UserFacingError) err.stack = this.request.path; if (!(err instanceof NotFoundError) && !((err instanceof PrivilegeError || err instanceof PermissionError) && this.user?._id === 0)) { logger.error(`Path:${this.request.path}, User:${this.user?._id}(${this.user?.uname})`); logger.error(err); } - this.send({ - error: { - name: err.name, - params: err.params || [], - }, - }); - this.close(4000, err.toString()); + super.onerror(err); } } -export function Connection( - name: string, prefix: string, - RouteConnHandler: any, - ...permPrivChecker: Array -) { - const checker = Checker(permPrivChecker); - const layer = router.ws(prefix, async (conn, _, ctx) => { - const { - args, request, response, user, domain, UiContext, - } = ctx.HydroContext; - const h = new RouteConnHandler(ctx, args, request, response, user, domain, UiContext); - await bus.parallel('connection/create', h); - ctx.handler = h; - h.conn = conn; - const disposables = []; - try { - checker.call(h); - if (args.shorty) h.resetCompression(); - if (h._prepare) await h._prepare(args); - if (h.prepare) await h.prepare(args); - // eslint-disable-next-line @typescript-eslint/no-shadow - for (const { name, target } of h.__subscribe || []) disposables.push(bus.on(name, target.bind(h))); - let lastHeartbeat = Date.now(); - let closed = false; - let interval: NodeJS.Timeout; - const clean = () => { - if (closed) return; - closed = true; - bus.emit('connection/close', h); - layer.clients.delete(conn); - if (interval) clearInterval(interval); - for (const d of disposables) d(); - h.cleanup?.(args); - }; - interval = setInterval(() => { - if (Date.now() - lastHeartbeat > 80000) { - clean(); - conn.terminate(); - } - if (Date.now() - lastHeartbeat > 30000) conn.send('ping'); - }, 40000); - conn.on('pong', () => { - lastHeartbeat = Date.now(); - }); - conn.onmessage = (e) => { - lastHeartbeat = Date.now(); - if (e.data === 'pong') return; - if (e.data === 'ping') { - conn.send('pong'); - return; - } - let payload; - try { - payload = JSON.parse(e.data.toString()); - } catch { - conn.close(); - } - try { - h.message?.(payload); - } catch (err) { - logger.error(e); - } - }; - await bus.parallel('connection/active', h); - if (conn.readyState === conn.OPEN) { - conn.on('close', clean); - conn.resume(); - } else clean(); - } catch (e) { - await h.onerror(e); - } - }); - return router.disposeLastOp; -} - -class NotFoundHandler extends Handler { - prepare() { throw new NotFoundError(this.request.path); } - all() { } -} - -export class RouteService extends Service { - private registry = {}; - private registrationCount = Counter(); - - constructor(ctx: Context) { - super(ctx, 'server', true); - ctx.mixin('server', ['Route', 'Connection', 'withHandlerClass']); - } - - private register(func: typeof Route | typeof Connection, ...args: Parameters) { - const HandlerClass = args[2]; - const name = HandlerClass.name; - if (!isClass(HandlerClass)) throw new Error('Invalid registration.'); - if (this.registrationCount[name] && this.registry[name] !== HandlerClass) { - logger.warn('Route with name %s already exists.', name); - } - this.registry[name] = HandlerClass; - this.registrationCount[name]++; - const res = func(...args); - this.ctx.parallel(`handler/register/${name}`, HandlerClass); - this[Context.current]?.on('dispose', () => { - this.registrationCount[name]--; - if (!this.registrationCount[name]) delete this.registry[name]; - res(); - }); - } - - public withHandlerClass(name: string, callback: (HandlerClass: typeof HandlerCommon) => any) { - if (this.registry[name]) callback(this.registry[name]); - this.ctx.on(`handler/register/${name}`, callback); - this[Context.current]?.on('dispose', () => { - this.ctx.off(`handler/register/${name}`, callback); - }); - } - - // eslint-disable-next-line @typescript-eslint/naming-convention - public Route(...args: Parameters) { - this.register(Route, ...args); - } - - // eslint-disable-next-line @typescript-eslint/naming-convention - public Connection(...args: Parameters) { - this.register(Connection, ...args); - } -} - -declare module '../context' { - interface Context { - server: RouteService; - } -} - -export async function apply(pluginContext: Context) { - pluginContext.provide('server', undefined, true); - pluginContext.server = new RouteService(pluginContext); - app.keys = system.get('session.keys') as unknown as string[]; +export async function apply(ctx: Context) { if (process.env.HYDRO_CLI) return; - const proxyMiddleware = proxy('/fs', { - target: builtinConfig.file.endPoint, - changeOrigin: true, - rewrite: (p) => p.replace('/fs', ''), + ctx.plugin(require('@hydrooj/framework'), { + keys: system.get('session.keys'), + proxy: !!system.get('server.xproxy') || !!system.get('server.xff'), + cors: system.get('server.cors') || '', + upload: system.get('server.upload') || '256m', + port: argv.options.port || system.get('server.port'), + xff: system.get('server.xff'), + xhost: system.get('server.xhost'), }); - 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); - } - if (!ctx.path.startsWith('/fs/')) return await next(); - if (ctx.request.search.toLowerCase().includes('x-amz-credential')) return await proxyMiddleware(ctx, next); - ctx.request.path = ctx.path = ctx.path.split('/fs')[1]; - return await next(); - }); - app.use(Compress()); - for (const addon of [...global.addons].reverse()) { - const dir = resolve(addon, 'public'); - if (!fs.existsSync(dir)) continue; - app.use(cache(dir, { - maxAge: argv.options.public ? 0 : 24 * 3600 * 1000, - })); - } - if (process.env.DEV) { - app.use(async (ctx: Koa.Context, next: Function) => { - const startTime = Date.now(); + ctx.inject(['server'], ({ server, on }) => { + const proxyMiddleware = proxy('/fs', { + target: builtinConfig.file.endPoint, + changeOrigin: true, + rewrite: (p) => p.replace('/fs', ''), + }); + server.addCaptureRoute('/fs/', async (c, next) => { + if (c.request.search.toLowerCase().includes('x-amz-credential')) return await proxyMiddleware(c, next); + c.request.path = c.path = c.path.split('/fs')[1]; + return await next(); + }); + server.addHandlerLayer('init', async (c, next) => { + const init = Date.now(); try { await next(); } finally { - const endTime = Date.now(); - if (!(ctx.nolog || ctx.response.headers.nolog)) { - logger.debug(`${ctx.request.method} /${ctx.domainId || 'system'}${ctx.request.path} \ -${ctx.response.status} ${endTime - startTime}ms ${ctx.response.length}`); + const finish = Date.now(); + if (finish - init > 5000) { + const method = c.request.method; + const id = await OplogModel.log(c.handler, 'slow_request', { method, processtime: finish - init }); + logger.warn(`Slow handler: ID=${id}, `, method, c.path, `${finish - init}ms`); } + // TODO metrics: calc avg response time } }); - } - const uploadDir = join(tmpdir(), 'hydro', 'upload', process.env.NODE_APP_INSTANCE || '0'); - fs.ensureDirSync(uploadDir); - logger.debug('Using upload dir: %s', uploadDir); - app.use(Body({ - multipart: true, - jsonLimit: '8mb', - formLimit: '8mb', - formidable: { - uploadDir, - maxFileSize: parseMemoryMB(system.get('server.upload') || '256m') * 1024 * 1024, - keepExtensions: true, - }, - })); - pluginContext.on('app/exit', () => { - fs.emptyDirSync(uploadDir); - }); - const layers = [baseLayer, rendererLayer(router, logger), responseLayer(logger), userLayer]; - app.use(async (ctx, next) => await next().catch(console.error)).use(domainLayer); - app.use(router.routes()).use(router.allowedMethods()); - for (const layer of layers) { - router.use(layer as any); - app.use(layer); - } - app.use((ctx) => handle(ctx, NotFoundHandler, () => true)); - wsServer.on('connection', async (socket, request) => { - socket.on('error', (err) => { - logger.warn('Websocket Error: %s', err.message); - try { - socket.close(1003, 'Websocket Error'); - } catch (e) { } - }); - socket.pause(); - const ctx: any = app.createContext(request, {} as any); - await domainLayer(ctx, () => baseLayer(ctx, () => layers[1](ctx, () => userLayer(ctx, () => { })))); - for (const manager of router.wsStack) { - if (manager.accept(socket, request, ctx)) return; + + for (const addon of [...global.addons].reverse()) { + const dir = resolve(addon, 'public'); + if (!fs.existsSync(dir)) continue; + server.addServerLayer(`${addon}_public`, cache(dir, { + maxAge: argv.options.public ? 0 : 24 * 3600 * 1000, + })); } - socket.close(); - }); - const port = system.get('server.port'); - pluginContext.on('app/listen', async () => { - await new Promise((r) => { - httpServer.listen(argv.options.port || port, () => { - logger.success('Server listening at: %d', argv.options.port || port); - r(true); - }); + + server.addServerLayer('domain', domainLayer); + server.addWSLayer('domain', domainLayer); + server.addLayer('base', baseLayer); + server.addLayer('user', userLayer); + + server.handlerMixin({ + url(name: string, ...kwargsList: Record[]) { + if (name === '#') return '#'; + let res = '#'; + const args: any = {}; + const query: any = {}; + for (const kwargs of kwargsList) { + for (const key in kwargs) { + if (kwargs[key] instanceof ObjectId) args[key] = kwargs[key].toHexString(); + else args[key] = kwargs[key].toString().replace(/\//g, '%2F'); + } + for (const key in kwargs.query || {}) { + if (query[key] instanceof ObjectId) query[key] = kwargs.query[key].toHexString(); + else query[key] = kwargs.query[key].toString(); + } + } + try { + const { anchor } = args; + let withDomainId = args.domainId || false; + const domainId = this.args.domainId; + const host = this.domain?.host; + if (domainId !== 'system' && ( + !this.request.host + || (host instanceof Array + ? (!host.includes(this.request.host)) + : this.request.host !== host) + )) withDomainId ||= domainId; + res = this.context.router.url.call(this.context.router, name, args, { query }).toString(); + if (anchor) res = `${res}#${anchor}`; + if (withDomainId) res = `/d/${withDomainId}${res}`; + } catch (e) { + logger.warn(e.message); + logger.info('%s %o', name, args); + if (!e.message.includes('Expected') || !e.message.includes('to match')) logger.info('%s', e.stack); + } + return res; + }, + translate(str: string) { + if (!str) return ''; + const lang = this.user?.viewLang || this.session?.viewLang; + const res = lang + ? str.toString().translate(lang, ...this.context.acceptsLanguages()) + : str.toString().translate(...this.context.acceptsLanguages(), system.get('server.language')); + return res; + }, + paginate(cursor: FindCursor, page: number, key: string) { + return paginate(cursor, page, this.ctx.setting.get(`pagination.${key}`)); + }, + checkPerm(...args: bigint[]) { + if (!this.user.hasPerm(...args)) { + if (this.user.hasPriv(PRIV.PRIV_USER_PROFILE)) throw new PermissionError(...args); + throw new PrivilegeError(PRIV.PRIV_USER_PROFILE); + } + }, + checkPriv(...args: number[]) { + if (!this.user.hasPriv(...args)) throw new PrivilegeError(...args); + }, + progress(message: string, params: any[]) { + Hydro.model.message.sendInfo(this.user._id, JSON.stringify({ message, params })); + }, + async limitRate( + op: string, periodSecs: number, maxOperations: number, withUserId = system.get('limit.by_user'), + ) { + if (ignoredLimit.includes(op)) return; + if (this.user && this.user.hasPriv(PRIV.PRIV_UNLIMITED_ACCESS)) return; + const overrideLimit = system.get(`limit.${op}`); + if (overrideLimit) maxOperations = overrideLimit; + let id = this.request.ip; + if (withUserId) id += `@${this.user._id}`; + await opcount.inc(op, id, periodSecs, maxOperations); + }, + renderTitle(str: string) { + const name = this.ctx.setting.get('server.name'); + if (this.UiContext.extraTitleContent) return `${this.translate(str)} - ${this.UiContext.extraTitleContent} - ${name}`; + return `${this.translate(str)} - ${name}`; + }, }); - pluginContext.on('dispose', () => { - httpServer.close(); - wsServer.close(); + + on('handler/create', async (h) => { + h.user = h.context.HydroContext.user as any; + h.domain = h.context.HydroContext.domain as any; + h.translate = h.translate.bind(h); + h.url = h.url.bind(h); + h.ctx = h.ctx.extend({ + domain: h.domain, + }); + if (!argv.options.benchmark && !h.notUsage) await h.limitRate('global', 5, 100); + h.loginMethods = Object.keys(global.Hydro.module.oauth) + .map((key) => ({ + id: key, + icon: global.Hydro.module.oauth[key].icon, + text: global.Hydro.module.oauth[key].text, + })); + if (!h.noCheckPermView && !h.user.hasPriv(PRIV.PRIV_VIEW_ALL_DOMAIN)) h.checkPerm(PERM.PERM_VIEW); + }); + + on('app/listen', () => { + server.listen(); }); }); } - -global.Hydro.service.server = { - ...decorators, - Types, - app, - httpServer, - wsServer, - router, - captureAllRoutes, - RouteService, - HandlerCommon, - Handler, - ConnectionHandler, - Route, - Connection, - apply, -}; diff --git a/packages/migrate/package.json b/packages/migrate/package.json index 669cf10b1..0372f6029 100644 --- a/packages/migrate/package.json +++ b/packages/migrate/package.json @@ -8,7 +8,7 @@ "dependencies": { "mariadb": "^3.3.0", "mongodb": "^5.9.2", - "turndown": "^7.1.3", + "turndown": "^7.2.0", "xml2js": "^0.6.2" }, "devDependencies": { diff --git a/packages/register/package.json b/packages/register/package.json index 72d9b955a..a8a60a18e 100644 --- a/packages/register/package.json +++ b/packages/register/package.json @@ -7,7 +7,7 @@ "node": ">=16" }, "dependencies": { - "esbuild": "0.18.20", + "esbuild": "0.21.4", "source-map-support": "^0.5.21" }, "devDependencies": { diff --git a/packages/ui-default/build/config/webpack.ts b/packages/ui-default/build/config/webpack.ts index 186423f7b..3855d9df8 100644 --- a/packages/ui-default/build/config/webpack.ts +++ b/packages/ui-default/build/config/webpack.ts @@ -1,7 +1,7 @@ /* eslint-disable global-require */ import { CleanWebpackPlugin } from 'clean-webpack-plugin'; import CopyWebpackPlugin from 'copy-webpack-plugin'; -import { ESBuildMinifyPlugin } from 'esbuild-loader'; +import { EsbuildPlugin } from 'esbuild-loader'; import { DuplicatesPlugin } from 'inspectpack/plugin'; import ExtractCssPlugin from 'mini-css-extract-plugin'; import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin'; @@ -214,7 +214,7 @@ export default function (env: { watch?: boolean, production?: boolean, measure?: }, }, usedExports: true, - minimizer: [new ESBuildMinifyPlugin({ + minimizer: [new EsbuildPlugin({ css: true, minify: true, minifySyntax: true, diff --git a/packages/ui-default/components/autocomplete/CustomSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/CustomSelectAutoComplete.tsx index 501a6949d..c8f3c82a3 100644 --- a/packages/ui-default/components/autocomplete/CustomSelectAutoComplete.tsx +++ b/packages/ui-default/components/autocomplete/CustomSelectAutoComplete.tsx @@ -1,7 +1,5 @@ -import { assign } from 'lodash'; import React from 'react'; import ReactDOM from 'react-dom/client'; -import DOMAttachedObject from 'vj/components/DOMAttachedObject'; import AutoComplete, { AutoCompleteOptions } from '.'; import CustomSelectAutoCompleteFC from './components/CustomSelectAutoComplete'; @@ -56,5 +54,4 @@ export default class CustomSelectAutoComplete extends Aut } } -assign(CustomSelectAutoComplete, DOMAttachedObject); window.Hydro.components.CustomSelectAutoComplete = CustomSelectAutoComplete; diff --git a/packages/ui-default/components/autocomplete/DomainSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/DomainSelectAutoComplete.tsx index b2c75ddb7..cd85102de 100644 --- a/packages/ui-default/components/autocomplete/DomainSelectAutoComplete.tsx +++ b/packages/ui-default/components/autocomplete/DomainSelectAutoComplete.tsx @@ -1,7 +1,5 @@ -import { assign } from 'lodash'; import React from 'react'; import ReactDOM from 'react-dom/client'; -import DOMAttachedObject from 'vj/components/DOMAttachedObject'; import AutoComplete from '.'; import DomainSelectAutoCompleteFC from './components/DomainSelectAutoComplete'; @@ -42,5 +40,4 @@ export default class DomainSelectAutoComplete extends AutoComplete { } } -assign(DomainSelectAutoComplete, DOMAttachedObject); window.Hydro.components.DomainSelectAutoComplete = DomainSelectAutoComplete; diff --git a/packages/ui-default/components/autocomplete/FileSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/FileSelectAutoComplete.tsx index 2d8814fda..e128c9e6b 100644 --- a/packages/ui-default/components/autocomplete/FileSelectAutoComplete.tsx +++ b/packages/ui-default/components/autocomplete/FileSelectAutoComplete.tsx @@ -1,7 +1,5 @@ -import { assign } from 'lodash'; import React from 'react'; import ReactDOM from 'react-dom/client'; -import DOMAttachedObject from 'vj/components/DOMAttachedObject'; import AutoComplete, { AutoCompleteOptions } from '.'; import FileSelectAutoCompleteFC from './components/FileSelectAutoComplete'; @@ -59,5 +57,4 @@ export default class FileSelectAutoComplete extends AutoC } } -assign(FileSelectAutoComplete, DOMAttachedObject); window.Hydro.components.FileSelectAutoComplete = FileSelectAutoComplete; diff --git a/packages/ui-default/components/autocomplete/ProblemSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/ProblemSelectAutoComplete.tsx index 4cd2e1eae..daf773229 100644 --- a/packages/ui-default/components/autocomplete/ProblemSelectAutoComplete.tsx +++ b/packages/ui-default/components/autocomplete/ProblemSelectAutoComplete.tsx @@ -1,7 +1,5 @@ -import { assign } from 'lodash'; import React from 'react'; import ReactDOM from 'react-dom/client'; -import DOMAttachedObject from 'vj/components/DOMAttachedObject'; import AutoComplete from '.'; import ProblemSelectAutoCompleteFC from './components/ProblemSelectAutoComplete'; @@ -44,5 +42,4 @@ export default class ProblemSelectAutoComplete extends AutoComplete { } } -assign(ProblemSelectAutoComplete, DOMAttachedObject); window.Hydro.components.ProblemSelectAutoComplete = ProblemSelectAutoComplete; diff --git a/packages/ui-default/components/autocomplete/UserSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/UserSelectAutoComplete.tsx index 78f61891e..5976da3c4 100644 --- a/packages/ui-default/components/autocomplete/UserSelectAutoComplete.tsx +++ b/packages/ui-default/components/autocomplete/UserSelectAutoComplete.tsx @@ -1,7 +1,5 @@ -import { assign } from 'lodash'; import React from 'react'; import ReactDOM from 'react-dom/client'; -import DOMAttachedObject from 'vj/components/DOMAttachedObject'; import AutoComplete, { AutoCompleteOptions } from '.'; import UserSelectAutoCompleteFC from './components/UserSelectAutoComplete'; @@ -49,5 +47,4 @@ export default class UserSelectAutoComplete extends AutoC } } -assign(UserSelectAutoComplete, DOMAttachedObject); window.Hydro.components.UserSelectAutoComplete = UserSelectAutoComplete; diff --git a/packages/ui-default/components/autocomplete/index.tsx b/packages/ui-default/components/autocomplete/index.tsx index c1213cb16..2c603c9f0 100644 --- a/packages/ui-default/components/autocomplete/index.tsx +++ b/packages/ui-default/components/autocomplete/index.tsx @@ -1,4 +1,3 @@ -import { assign } from 'lodash'; import React from 'react'; import ReactDOM from 'react-dom/client'; import DOMAttachedObject from 'vj/components/DOMAttachedObject'; @@ -108,5 +107,4 @@ export default class AutoComplete extends DOMAttachedObject { } } -assign(AutoComplete, DOMAttachedObject); window.Hydro.components.autocomplete = AutoComplete; diff --git a/packages/ui-default/components/dialog/DomDialog.ts b/packages/ui-default/components/dialog/DomDialog.ts index f03ef52b5..cc94128d1 100644 --- a/packages/ui-default/components/dialog/DomDialog.ts +++ b/packages/ui-default/components/dialog/DomDialog.ts @@ -1,5 +1,4 @@ import $ from 'jquery'; -import _ from 'lodash'; import DOMAttachedObject from 'vj/components/DOMAttachedObject'; import { zIndexManager } from 'vj/utils'; @@ -145,5 +144,3 @@ export default class DomDialog extends DOMAttachedObject { } } } - -_.assign(DomDialog, DOMAttachedObject); diff --git a/packages/ui-default/components/discussion/CommentBox.js b/packages/ui-default/components/discussion/CommentBox.js index 3f4f27b26..29ba78969 100644 --- a/packages/ui-default/components/discussion/CommentBox.js +++ b/packages/ui-default/components/discussion/CommentBox.js @@ -1,5 +1,4 @@ import $ from 'jquery'; -import _ from 'lodash'; import DOMAttachedObject from 'vj/components/DOMAttachedObject'; import TextareaHandler from 'vj/components/editor/textareaHandler'; import Notification from 'vj/components/notification'; @@ -102,5 +101,3 @@ export default class CommentBox extends DOMAttachedObject { this.detach(); } } - -_.assign(CommentBox, DOMAttachedObject); diff --git a/packages/ui-default/components/dropdown/Dropdown.js b/packages/ui-default/components/dropdown/Dropdown.js index a75c19110..f76b94bdc 100644 --- a/packages/ui-default/components/dropdown/Dropdown.js +++ b/packages/ui-default/components/dropdown/Dropdown.js @@ -1,4 +1,3 @@ -import _ from 'lodash'; import Drop from 'tether-drop'; import responsiveCutoff from 'vj/breakpoints.json'; import DOMAttachedObject from 'vj/components/DOMAttachedObject'; @@ -49,5 +48,3 @@ export default class Dropdown extends DOMAttachedObject { this.$dom.trigger('vjDropdownHide'); } } - -_.assign(Dropdown, DOMAttachedObject); diff --git a/packages/ui-default/components/editor/index.ts b/packages/ui-default/components/editor/index.ts index c18034c74..c7573ac1f 100644 --- a/packages/ui-default/components/editor/index.ts +++ b/packages/ui-default/components/editor/index.ts @@ -1,5 +1,4 @@ import $ from 'jquery'; -import _ from 'lodash'; import { nanoid } from 'nanoid'; import DOMAttachedObject from 'vj/components/DOMAttachedObject'; import Notification from 'vj/components/notification'; @@ -281,5 +280,3 @@ export default class Editor extends DOMAttachedObject { this.editor.setPosition({ lineNumber: range.endLineNumber, column: range.endColumn }); } } - -_.assign(Editor, DOMAttachedObject); diff --git a/packages/ui-default/components/editor/textareaHandler.ts b/packages/ui-default/components/editor/textareaHandler.ts index 90924319f..af3d5104c 100644 --- a/packages/ui-default/components/editor/textareaHandler.ts +++ b/packages/ui-default/components/editor/textareaHandler.ts @@ -1,4 +1,3 @@ -import _ from 'lodash'; import DOMAttachedObject from 'vj/components/DOMAttachedObject'; import Editor from './index'; @@ -28,5 +27,3 @@ export default class TextareaHandler extends DOMAttachedObject { this.$dom.focus(); } } - -_.assign(TextareaHandler, DOMAttachedObject); diff --git a/packages/ui-default/components/marker/MarkerReactive.js b/packages/ui-default/components/marker/MarkerReactive.js index 92a2272db..b15b91191 100644 --- a/packages/ui-default/components/marker/MarkerReactive.js +++ b/packages/ui-default/components/marker/MarkerReactive.js @@ -1,5 +1,4 @@ import $ from 'jquery'; -import _ from 'lodash'; import DOMAttachedObject from 'vj/components/DOMAttachedObject'; import { delay } from 'vj/utils'; import Marker from './Marker'; @@ -54,5 +53,3 @@ export default class MarkerReactive extends DOMAttachedObject { super.detach(); } } - -_.assign(MarkerReactive, DOMAttachedObject); diff --git a/packages/ui-default/components/rotator/index.js b/packages/ui-default/components/rotator/index.js index 912e610cf..cec77a709 100644 --- a/packages/ui-default/components/rotator/index.js +++ b/packages/ui-default/components/rotator/index.js @@ -1,5 +1,4 @@ import $ from 'jquery'; -import _ from 'lodash'; import DOMAttachedObject from 'vj/components/DOMAttachedObject'; import { delay } from 'vj/utils'; @@ -74,5 +73,3 @@ export default class Rotator extends DOMAttachedObject { return this.value; } } - -_.assign(Rotator, DOMAttachedObject); diff --git a/packages/ui-default/components/table/StyledTable.js b/packages/ui-default/components/table/StyledTable.js index facd19e13..6b06781d1 100644 --- a/packages/ui-default/components/table/StyledTable.js +++ b/packages/ui-default/components/table/StyledTable.js @@ -49,5 +49,3 @@ export default class StyledTable extends DOMAttachedObject { this.$header.trigger('sticky_kit:detach'); } } - -_.assign(StyledTable, DOMAttachedObject); diff --git a/packages/ui-default/components/tooltip/Tooltip.js b/packages/ui-default/components/tooltip/Tooltip.js index 09fc4e72c..c3ffcf410 100644 --- a/packages/ui-default/components/tooltip/Tooltip.js +++ b/packages/ui-default/components/tooltip/Tooltip.js @@ -47,5 +47,3 @@ export default class Tooltip extends DOMAttachedObject { this.drop.open(); } } - -_.assign(Tooltip, DOMAttachedObject); diff --git a/packages/ui-default/context.ts b/packages/ui-default/context.ts index 237ac5efe..e9582823d 100644 --- a/packages/ui-default/context.ts +++ b/packages/ui-default/context.ts @@ -10,22 +10,7 @@ export interface Context { broadcast: Context['emit']; } -export class Context extends cordis.Context { - /** @deprecated use `ctx.root` instead */ - get app() { - return this.root; - } - - /** @deprecated use `root.config` instead */ - get options() { - return this.root.config; - } -} - -export namespace Context { - export type Associate

= cordis.Context.Associate; -} - +export class Context extends cordis.Context { } export type MainScope = cordis.MainScope; export type EffectScope = cordis.EffectScope; export type ForkScope = cordis.ForkScope; diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 9694a56fb..44216943f 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -14,7 +14,7 @@ "cli": false }, "devDependencies": { - "@blueprintjs/core": "^5.10.2", + "@blueprintjs/core": "^5.10.3", "@fontsource/dm-mono": "^5.0.20", "@fontsource/fira-code": "^5.0.18", "@fontsource/inconsolata": "^5.0.18", @@ -23,7 +23,7 @@ "@fontsource/roboto-mono": "^5.0.18", "@fontsource/source-code-pro": "^5.0.18", "@fontsource/ubuntu-mono": "^5.0.20", - "@hydrooj/utils": "workspace:*", + "@hydrooj/utils": "workspace:^", "@simplewebauthn/browser": "7.4.0", "@svgr/webpack": "^8.1.0", "@types/gulp-if": "^0.0.34", @@ -34,13 +34,13 @@ "@types/nunjucks": "^3.2.6", "@types/pickadate": "^3.5.35", "@types/qrcode": "^1.5.5", - "@types/react": "^18.3.1", + "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@types/redux-logger": "^3.0.13", "@types/webpack-env": "^1.18.5", "@vscode/codicons": "^0.0.36", - "ajv": "^8.13.0", - "allotment": "^1.20.1", + "ajv": "^8.14.0", + "allotment": "^1.20.2", "autoprefixer": "^10.4.19", "browser-update": "^3.3.51", "chalk": "^5.3.0", @@ -48,14 +48,14 @@ "clean-webpack-plugin": "^4.0.0", "clipboard": "^2.0.11", "copy-webpack-plugin": "^12.0.2", - "cordis": "3.13.3", + "cordis": "3.16.0", "css-loader": "^6.11.0", "diff": "^5.2.0", "diff-dom": "^5.1.3", "echarts": "^5.5.0", "emojis-keywords": "2.0.0", "emojis-list": "2.1.0", - "esbuild-loader": "^2.21.0", + "esbuild-loader": "^4.1.0", "fancy-log": "^2.0.0", "flatpickr": "^4.6.13", "graphiql": "^2.4.7", @@ -127,7 +127,7 @@ "dependencies": { "ansi_up": "^6.0.2", "cssfilter": "^0.0.10", - "esbuild": "0.18.20", + "esbuild": "0.21.4", "fs-extra": "^11.2.0", "js-yaml": "^4.1.0", "jsesc": "^3.0.2", diff --git a/packages/utils/package.json b/packages/utils/package.json index 3c5be43f7..2bfb2ba6d 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -16,7 +16,7 @@ "mongodb": "^5.9.2", "reggol": "^1.6.3", "search-query-parser": "^1.6.0", - "systeminformation": "^5.22.8" + "systeminformation": "^5.22.9" }, "devDependencies": { "@types/fs-extra": "^11.0.4" diff --git a/packages/vjudge/package.json b/packages/vjudge/package.json index ccf57bef8..283f9ecf8 100644 --- a/packages/vjudge/package.json +++ b/packages/vjudge/package.json @@ -8,8 +8,8 @@ "license": "AGPL-3.0-or-later", "preferUnplugged": true, "dependencies": { - "@hydrooj/utils": "workspace:*", - "jsdom": "^24.0.0", + "@hydrooj/utils": "workspace:^", + "jsdom": "^24.1.0", "superagent-charset": "^1.2.0", "superagent-proxy": "^3.0.0" },