mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-20 01:38:19 +08:00
core: add server-loader (#816)
This commit is contained in:
1 parent
3302480348
commit
97720efb65
57 files changed
+1328
-1131
No files matched your search
+36
-7
@@ -33,9 +33,11 @@ const config = {
|
|||||||
compilerOptions: compilerOptionsBase,
|
compilerOptions: compilerOptionsBase,
|
||||||
references: [
|
references: [
|
||||||
{ path: 'tsconfig.ui.json' },
|
{ path: 'tsconfig.ui.json' },
|
||||||
|
{ path: 'plugins/tsconfig.json' },
|
||||||
],
|
],
|
||||||
files: [],
|
files: [],
|
||||||
};
|
};
|
||||||
|
const exclude = ['**/public', '**/frontend', '**/node_modules', '**/bin', '**/dist', '**/__mocks__'];
|
||||||
const configSrc = (name) => ({
|
const configSrc = (name) => ({
|
||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
...compilerOptionsBase,
|
...compilerOptionsBase,
|
||||||
@@ -43,11 +45,7 @@ const configSrc = (name) => ({
|
|||||||
rootDir: 'src',
|
rootDir: 'src',
|
||||||
},
|
},
|
||||||
include: ['src'],
|
include: ['src'],
|
||||||
exclude: [
|
exclude,
|
||||||
'**/__mocks__',
|
|
||||||
'bin',
|
|
||||||
'dist',
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
const configFlat = (name) => ({
|
const configFlat = (name) => ({
|
||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
@@ -61,7 +59,7 @@ const configFlat = (name) => ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: ['**/*.ts'],
|
include: ['**/*.ts'],
|
||||||
exclude: ['public', 'frontend'],
|
exclude,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const name of ['plugins', 'modules']) {
|
for (const name of ['plugins', 'modules']) {
|
||||||
@@ -74,12 +72,13 @@ for (const name of ['plugins', 'modules']) {
|
|||||||
|
|
||||||
const modules = [
|
const modules = [
|
||||||
'packages/hydrooj',
|
'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());
|
].filter((i) => !i.includes('/.') && !i.includes('ui-default')).filter((i) => fs.statSync(path.resolve(process.cwd(), i)).isDirectory());
|
||||||
|
|
||||||
const UIConfig = {
|
const UIConfig = {
|
||||||
exclude: [
|
exclude: [
|
||||||
'packages/ui-default/public',
|
'packages/ui-default/public',
|
||||||
|
'**/node_modules',
|
||||||
],
|
],
|
||||||
include: ['ts', 'tsx']
|
include: ['ts', 'tsx']
|
||||||
.flatMap((ext) => ['plugins', 'modules']
|
.flatMap((ext) => ['plugins', 'modules']
|
||||||
@@ -114,6 +113,36 @@ try {
|
|||||||
'dir',
|
'dir',
|
||||||
);
|
);
|
||||||
} catch (e) { }
|
} 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) {
|
for (const package of modules) {
|
||||||
const basedir = path.resolve(process.cwd(), package);
|
const basedir = path.resolve(process.cwd(), package);
|
||||||
const files = fs.readdirSync(basedir);
|
const files = fs.readdirSync(basedir);
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
移除了 app/listen, app/load/${category} 钩子
|
||||||
|
移除了 ctx.app (请使用 ctx.root)
|
||||||
|
移除了 ctx.options (请使用 ctx.root.config)
|
||||||
|
serializer: 移除 showDisplayName 参数
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Time } from '@hydrooj/utils';
|
import { ValidationError } from './error';
|
||||||
import { ValidationError } from '../error';
|
|
||||||
import { Converter, Type, Validator } from '../lib/validator';
|
|
||||||
import { EventMap } from './bus';
|
|
||||||
import type { Handler } from './server';
|
import type { Handler } from './server';
|
||||||
|
import { Converter, Type, Validator } from './validator';
|
||||||
|
|
||||||
type MethodDecorator = (target: any, funcName: string, obj: any) => any;
|
type MethodDecorator = (target: any, funcName: string, obj: any) => any;
|
||||||
type ClassDecorator = <T extends new (...args: any[]) => any>(Class: T) => T extends new (...args: infer R) => infer S
|
type ClassDecorator = <T extends new (...args: any[]) => 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 route: DescriptorBuilder = (name, ...args) => _descriptor(_buildParam(name, 'route', ...args));
|
||||||
export const param: DescriptorBuilder = (name, ...args) => _descriptor(_buildParam(name, 'all', ...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) {
|
if (funcName) {
|
||||||
target.__subscribe ||= [];
|
target.__subscribe ||= [];
|
||||||
target.__subscribe.push({ name, target: obj.value });
|
target.__subscribe.push({ name, target: obj.value });
|
||||||
@@ -102,32 +100,3 @@ export const subscribe: (name: keyof EventMap) => MethodDecorator & ClassDecorat
|
|||||||
return c;
|
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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
@@ -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';
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ConnectionHandler, Handler } from './server';
|
||||||
|
|
||||||
|
type HandlerEvents<T extends Handler, C extends ConnectionHandler> =
|
||||||
|
Record<`handler/${HookType}/${string}`, (thisArg: T & Record<string, any>) => 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> | any;
|
||||||
|
export type HookType = 'before-prepare' | 'before' | 'before-operation' | 'after' | 'finish';
|
||||||
|
|
||||||
|
export interface ServerEvents<T extends Handler = Handler, C extends ConnectionHandler = ConnectionHandler> extends HandlerEvents<T, C> {
|
||||||
|
'handler/create': (thisArg: Handler, type: 'ws' | 'http') => VoidReturn
|
||||||
|
'handler/init': (thisArg: Handler) => VoidReturn
|
||||||
|
'handler/error': (thisArg: Handler, e: Error) => VoidReturn
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<string, import('formidable').File>;
|
||||||
|
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<string, any>;
|
||||||
|
UiContext: Record<string, any>;
|
||||||
|
domain: { _id: string };
|
||||||
|
user: { _id: number };
|
||||||
|
};
|
||||||
|
export type KoaContext = Koa.Context & {
|
||||||
|
HydroContext: HydroContext;
|
||||||
|
handler: any;
|
||||||
|
request: Koa.Request & { body: any, files: Files };
|
||||||
|
session: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logger = new Logger('server');
|
||||||
|
/** @deprecated */
|
||||||
|
export const koa = new Koa<Koa.DefaultState, KoaContext>({
|
||||||
|
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<string, any>;
|
||||||
|
args: Record<string, any>;
|
||||||
|
request: HydroRequest;
|
||||||
|
response: HydroResponse;
|
||||||
|
UiContext: Record<string, any>;
|
||||||
|
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<string, any>[]) {
|
||||||
|
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<string, any>) {
|
||||||
|
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<string, decorators.ParamOption<any>[]>;
|
||||||
|
|
||||||
|
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<string, any> = {};
|
||||||
|
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<T extends string>(
|
||||||
|
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<HandlerCommon>) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import assert from 'assert';
|
|||||||
import emojiRegex from 'emoji-regex';
|
import emojiRegex from 'emoji-regex';
|
||||||
import { isSafeInteger } from 'lodash';
|
import { isSafeInteger } from 'lodash';
|
||||||
import moment from 'moment-timezone';
|
import moment from 'moment-timezone';
|
||||||
import { ObjectId } from 'mongodb';
|
|
||||||
import sanitize from 'sanitize-filename';
|
import sanitize from 'sanitize-filename';
|
||||||
import saslprep from 'saslprep';
|
import saslprep from 'saslprep';
|
||||||
|
|
||||||
@@ -36,7 +35,7 @@ export interface Types {
|
|||||||
Float: Type<number>;
|
Float: Type<number>;
|
||||||
|
|
||||||
// Other
|
// Other
|
||||||
ObjectId: Type<ObjectId>;
|
ObjectId: Type<typeof import('mongodb').ObjectId>;
|
||||||
Boolean: Type<boolean>;
|
Boolean: Type<boolean>;
|
||||||
Date: Type<string>;
|
Date: Type<string>;
|
||||||
Time: Type<string>;
|
Time: Type<string>;
|
||||||
@@ -95,7 +94,7 @@ export const Types: Types = {
|
|||||||
PositiveInt: [(v) => +v, (v) => /^\+?[1-9][0-9]*$/.test(v.toString().trim()) && isSafeInteger(+v)],
|
PositiveInt: [(v) => +v, (v) => /^\+?[1-9][0-9]*$/.test(v.toString().trim()) && isSafeInteger(+v)],
|
||||||
Float: [(v) => +v, (v) => Number.isFinite(+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],
|
Boolean: [(v) => !!(v && !['false', 'off'].includes(v)), null, true],
|
||||||
Date: [
|
Date: [
|
||||||
(v) => {
|
(v) => {
|
||||||
@@ -188,6 +187,13 @@ export const Types: Types = {
|
|||||||
] as any,
|
] as any,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { ObjectId } = require('mongodb');
|
||||||
|
Types.ObjectId = [(v) => new ObjectId(v), ObjectId.isValid];
|
||||||
|
} catch (e) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
Types.ObjectID = Types.ObjectId;
|
Types.ObjectID = Types.ObjectId;
|
||||||
// backward compatibility
|
// backward compatibility
|
||||||
+4
-3
@@ -3,6 +3,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
|
"framework/*",
|
||||||
"plugins/*",
|
"plugins/*",
|
||||||
"modules/*"
|
"modules/*"
|
||||||
],
|
],
|
||||||
@@ -36,14 +37,14 @@
|
|||||||
"@types/autocannon": "^7.12.5",
|
"@types/autocannon": "^7.12.5",
|
||||||
"@types/chai": "^4.3.16",
|
"@types/chai": "^4.3.16",
|
||||||
"@types/cross-spawn": "^6.0.6",
|
"@types/cross-spawn": "^6.0.6",
|
||||||
"@types/node": "20.10.6",
|
"@types/node": "20.12.12",
|
||||||
"@types/semver": "^7.5.8",
|
"@types/semver": "^7.5.8",
|
||||||
"autocannon": "^7.15.0",
|
"autocannon": "^7.15.0",
|
||||||
"cac": "^6.7.14",
|
"cac": "^6.7.14",
|
||||||
"chai": "^5.1.1",
|
"chai": "^5.1.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"cross-spawn": "^7.0.3",
|
"cross-spawn": "^7.0.3",
|
||||||
"esbuild": "0.18.20",
|
"esbuild": "0.21.4",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-import-resolver-typescript": "^3.6.1",
|
"eslint-import-resolver-typescript": "^3.6.1",
|
||||||
"eslint-import-resolver-webpack": "^0.13.8",
|
"eslint-import-resolver-webpack": "^0.13.8",
|
||||||
@@ -57,7 +58,7 @@
|
|||||||
"semver": "^7.6.2",
|
"semver": "^7.6.2",
|
||||||
"simple-git": "^3.24.0",
|
"simple-git": "^3.24.0",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "5.3.3",
|
||||||
"wtfnode": "^0.9.2"
|
"wtfnode": "^0.9.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,7 +137,10 @@ rules:
|
|||||||
no-use-before-define: 0
|
no-use-before-define: 0
|
||||||
'@typescript-eslint/no-use-before-define': 1
|
'@typescript-eslint/no-use-before-define': 1
|
||||||
no-unused-vars: 0
|
no-unused-vars: 0
|
||||||
'@typescript-eslint/no-unused-vars': 1
|
'@typescript-eslint/no-unused-vars':
|
||||||
|
- error
|
||||||
|
- caughtErrorsIgnorePattern: "^_"
|
||||||
|
argsIgnorePattern: "^_"
|
||||||
|
|
||||||
no-restricted-syntax:
|
no-restricted-syntax:
|
||||||
- error
|
- error
|
||||||
|
|||||||
@@ -5,14 +5,14 @@
|
|||||||
"main": "react.yaml",
|
"main": "react.yaml",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@stylistic/eslint-plugin": "^2.1.0",
|
"@stylistic/eslint-plugin": "^2.1.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.8.0",
|
"@typescript-eslint/eslint-plugin": "^7.11.0",
|
||||||
"@typescript-eslint/parser": "^7.8.0",
|
"@typescript-eslint/parser": "^7.11.0",
|
||||||
"eslint-config-airbnb": "^19.0.4",
|
"eslint-config-airbnb": "^19.0.4",
|
||||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||||
"eslint-plugin-github": "^4.10.2",
|
"eslint-plugin-github": "^4.10.2",
|
||||||
"eslint-plugin-import": "2.29.1",
|
"eslint-plugin-import": "2.29.1",
|
||||||
"eslint-plugin-jsx-a11y": "^6.8.0",
|
"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-react-hooks": "^4.6.2",
|
||||||
"eslint-plugin-simple-import-sort": "^12.1.0"
|
"eslint-plugin-simple-import-sort": "^12.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"author": "undefined <i@undefined.moe>",
|
"author": "undefined <i@undefined.moe>",
|
||||||
"repository": "https://github.com/hydro-dev/Hydro.git",
|
"repository": "https://github.com/hydro-dev/Hydro.git",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hydrooj/utils": "workspace:*",
|
"@hydrooj/utils": "workspace:^",
|
||||||
"adm-zip": "0.5.5",
|
"adm-zip": "0.5.5",
|
||||||
"cac": "^6.7.14",
|
"cac": "^6.7.14",
|
||||||
"p-queue": "^8.0.1",
|
"p-queue": "^8.0.1",
|
||||||
|
|||||||
@@ -12,17 +12,18 @@
|
|||||||
},
|
},
|
||||||
"preferUnplugged": true,
|
"preferUnplugged": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "3.572.0",
|
"@aws-sdk/client-s3": "3.583.0",
|
||||||
"@aws-sdk/lib-storage": "3.572.0",
|
"@aws-sdk/lib-storage": "3.583.0",
|
||||||
"@aws-sdk/s3-presigned-post": "3.572.0",
|
"@aws-sdk/s3-presigned-post": "3.583.0",
|
||||||
"@aws-sdk/s3-request-presigner": "3.572.0",
|
"@aws-sdk/s3-request-presigner": "3.583.0",
|
||||||
"@graphql-tools/schema": "^10.0.3",
|
"@graphql-tools/schema": "^10.0.4",
|
||||||
"@hydrooj/utils": "workspace:*",
|
"@hydrooj/framework": "workspace:^",
|
||||||
|
"@hydrooj/utils": "workspace:^",
|
||||||
"@simplewebauthn/server": "7.4.0",
|
"@simplewebauthn/server": "7.4.0",
|
||||||
"@undefined-moe/require-resolve-hook": "^1.1.0",
|
"@undefined-moe/require-resolve-hook": "^1.1.0",
|
||||||
"adm-zip": "0.5.5",
|
"adm-zip": "0.5.5",
|
||||||
"cac": "^6.7.14",
|
"cac": "^6.7.14",
|
||||||
"cordis": "3.13.3",
|
"cordis": "3.16.0",
|
||||||
"detect-browser": "^5.3.0",
|
"detect-browser": "^5.3.0",
|
||||||
"emoji-regex": "^10.3.0",
|
"emoji-regex": "^10.3.0",
|
||||||
"emojis-list": "2.1.0",
|
"emojis-list": "2.1.0",
|
||||||
@@ -30,11 +31,7 @@
|
|||||||
"graphql": "^16.8.1",
|
"graphql": "^16.8.1",
|
||||||
"graphql-scalars": "^1.23.0",
|
"graphql-scalars": "^1.23.0",
|
||||||
"js-yaml": "^4.1.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-proxies": "^0.12.4",
|
||||||
"koa-router": "^12.0.1",
|
|
||||||
"koa-static-cache": "^5.1.4",
|
"koa-static-cache": "^5.1.4",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lru-cache": "^10.2.2",
|
"lru-cache": "^10.2.2",
|
||||||
@@ -61,11 +58,8 @@
|
|||||||
"@types/adm-zip": "^0.4.34",
|
"@types/adm-zip": "^0.4.34",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@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/koa-static-cache": "^5.1.4",
|
||||||
"@types/lodash": "^4.17.1",
|
"@types/lodash": "^4.17.4",
|
||||||
"@types/mime-types": "^2.1.4",
|
"@types/mime-types": "^2.1.4",
|
||||||
"@types/mongodb-uri": "^0.9.4",
|
"@types/mongodb-uri": "^0.9.4",
|
||||||
"@types/nodemailer": "^6.4.15",
|
"@types/nodemailer": "^6.4.15",
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import * as cordis from 'cordis';
|
import * as cordis from 'cordis';
|
||||||
import Schema from 'schemastery';
|
import Schema from 'schemastery';
|
||||||
|
import type { ServerEvents, WebService } from '@hydrooj/framework';
|
||||||
import type { DomainDoc, GeoIP, ModuleInterfaces } from './interface';
|
import type { DomainDoc, GeoIP, ModuleInterfaces } from './interface';
|
||||||
import { inject } from './lib/ui';
|
import { inject } from './lib/ui';
|
||||||
import { Loader } from './loader';
|
import { Loader } from './loader';
|
||||||
import { EventMap } from './service/bus';
|
import type { EventMap } from './service/bus';
|
||||||
import type { CheckService } from './service/check';
|
import type { CheckService } from './service/check';
|
||||||
|
import type { ConnectionHandler, Handler } from './service/server';
|
||||||
|
|
||||||
export interface Events<C extends Context = Context> extends cordis.Events<C>, EventMap { }
|
export interface Events<C extends Context = Context> extends cordis.Events<C>, EventMap, ServerEvents<Handler, ConnectionHandler> { }
|
||||||
|
|
||||||
function addScript<K>(name: string, description: string, validate: Schema<K>, run: (args: K, report: any) => boolean | Promise<boolean>) {
|
function addScript<K>(name: string, description: string, validate: Schema<K>, run: (args: K, report: any) => boolean | Promise<boolean>) {
|
||||||
if (global.Hydro.script[name]) throw new Error(`duplicate script ${name} registered.`);
|
if (global.Hydro.script[name]) throw new Error(`duplicate script ${name} registered.`);
|
||||||
@@ -26,12 +28,10 @@ export type MainScope = cordis.MainScope<Context>;
|
|||||||
|
|
||||||
export type { Disposable, ScopeStatus, Plugin } from 'cordis';
|
export type { Disposable, ScopeStatus, Plugin } from 'cordis';
|
||||||
|
|
||||||
export interface Context extends cordis.Context {
|
export interface Context extends cordis.Context, Pick<WebService, 'Route' | 'Connection' | 'withHandlerClass'> {
|
||||||
[Context.events]: Events<Context>;
|
// @ts-ignore
|
||||||
|
[Context.events]: Events<this>;
|
||||||
loader: Loader;
|
loader: Loader;
|
||||||
Route: typeof import('./service/server').Route;
|
|
||||||
Connection: typeof import('./service/server').Connection;
|
|
||||||
withHandlerClass: import('./service/server').RouteService['withHandlerClass'];
|
|
||||||
check: CheckService;
|
check: CheckService;
|
||||||
setImmediate: typeof setImmediate;
|
setImmediate: typeof setImmediate;
|
||||||
addScript: typeof addScript;
|
addScript: typeof addScript;
|
||||||
@@ -72,15 +72,6 @@ export class Context extends cordis.Context {
|
|||||||
super(config);
|
super(config);
|
||||||
this.plugin(ApiMixin);
|
this.plugin(ApiMixin);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated use `ctx.root` instead */
|
|
||||||
get app() {
|
|
||||||
return this.root;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace Context {
|
|
||||||
export type Associate<P extends string, C extends Context = Context> = cordis.Context.Associate<P, C>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const old = cordis.Registry.prototype.inject;
|
const old = cordis.Registry.prototype.inject;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import yaml from 'js-yaml';
|
|||||||
import { Context } from '../context';
|
import { Context } from '../context';
|
||||||
import { Logger } from '../logger';
|
import { Logger } from '../logger';
|
||||||
import { PRIV } from '../model/builtin';
|
import { PRIV } from '../model/builtin';
|
||||||
import * as bus from '../service/bus';
|
|
||||||
import { unwrapExports } from '../utils';
|
import { unwrapExports } from '../utils';
|
||||||
|
|
||||||
const logger = new Logger('common');
|
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');
|
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')) {
|
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[]) {
|
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');
|
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
import http from 'http';
|
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import cac from 'cac';
|
import cac from 'cac';
|
||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import Koa, { Context } from 'koa';
|
import type { Context } from 'koa';
|
||||||
import Body from 'koa-body';
|
|
||||||
import { MongoClient, WriteConcern } from 'mongodb';
|
import { MongoClient, WriteConcern } from 'mongodb';
|
||||||
import mongoUri from 'mongodb-uri';
|
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;
|
const listenPort = cac().parse().options.port || 8888;
|
||||||
let resolve;
|
let resolve;
|
||||||
|
|
||||||
@@ -82,19 +79,24 @@ async function post(ctx: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function load() {
|
export function load(ctx: PluginContext) {
|
||||||
const app = new Koa();
|
let dispose;
|
||||||
const server = http.createServer(app.callback());
|
ctx.plugin(require('@hydrooj/framework'), {
|
||||||
app.keys = ['Hydro'];
|
keys: [Math.random()],
|
||||||
app
|
proxy: false,
|
||||||
.use(Body())
|
upload: false,
|
||||||
.use((ctx) => {
|
port: listenPort,
|
||||||
if (ctx.request.method.toLowerCase() === 'post') return post(ctx);
|
});
|
||||||
return get(ctx);
|
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) => {
|
return new Promise((r) => {
|
||||||
resolve = r;
|
resolve = () => {
|
||||||
|
dispose?.();
|
||||||
|
r(null);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,7 @@ export async function apply(ctx: Context) {
|
|||||||
require('../lib/i18n');
|
require('../lib/i18n');
|
||||||
require('../utils');
|
require('../utils');
|
||||||
require('../error');
|
require('../error');
|
||||||
|
require('../service/bus').apply(ctx);
|
||||||
const config = require('../options')();
|
const config = require('../options')();
|
||||||
if (!process.env.CI && !config) {
|
if (!process.env.CI && !config) {
|
||||||
logger.info('Starting setup');
|
logger.info('Starting setup');
|
||||||
|
|||||||
@@ -1,58 +1,12 @@
|
|||||||
/* eslint-disable max-len */
|
/* 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 {
|
export * from '@hydrooj/framework/error';
|
||||||
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 RemoteOnlineJudgeError = Err('RemoteOnlineJudgeError', UserFacingError, 'RemoteOnlineJudgeError', 500);
|
export const RemoteOnlineJudgeError = Err('RemoteOnlineJudgeError', UserFacingError, 'RemoteOnlineJudgeError', 500);
|
||||||
export const SendMailError = Err('SendMailError', UserFacingError, 'Failed to send mail to {0}. (1)', 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.";
|
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 ContestNotAttendedError = Err('ContestNotAttendedError', ForbiddenError, "You haven't attended this contest yet.");
|
||||||
export const RequireProError = Err('RequireProError', ForbiddenError, 'RequireProError');
|
export const RequireProError = Err('RequireProError', ForbiddenError, 'RequireProError');
|
||||||
export const ContestAlreadyAttendedError = Err('ContestAlreadyAttendedError', ForbiddenError, "You've already attended this contest.");
|
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 HomeworkNotLiveError = Err('HomeworkNotLiveError', ForbiddenError, 'This homework is not open.');
|
||||||
export const HomeworkNotAttendedError = Err('HomeworkNotAttendedError', ForbiddenError, "You haven't claimed this homework yet.");
|
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 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 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 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.');
|
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 DiscussionNotFoundError = Err('DiscussionNotFoundError', DocumentNotFoundError, 'Discussion {1} not found.');
|
||||||
export const DiscussionNodeNotFoundError = Err('DiscussionNodeNotFoundError', DocumentNotFoundError, 'Discussion node {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 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.'
|
|
||||||
*/
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { Types } from '@hydrooj/framework/validator';
|
||||||
import { md5 } from '../utils';
|
import { md5 } from '../utils';
|
||||||
import { Types } from './validator';
|
|
||||||
|
|
||||||
type AvatarProvider = (src: string, size: number) => string;
|
type AvatarProvider = (src: string, size: number) => string;
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,20 @@ class I18nService extends Service {
|
|||||||
}
|
}
|
||||||
return null;
|
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);
|
app.provide('i18n', undefined, true);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -290,7 +290,7 @@ SystemSetting(
|
|||||||
// eslint-disable-next-line import/no-mutable-exports
|
// eslint-disable-next-line import/no-mutable-exports
|
||||||
export const langs: Record<string, LangConfig> = {};
|
export const langs: Record<string, LangConfig> = {};
|
||||||
|
|
||||||
declare module '../context' {
|
declare module 'cordis' {
|
||||||
interface Context {
|
interface Context {
|
||||||
setting: SettingService;
|
setting: SettingService;
|
||||||
}
|
}
|
||||||
@@ -319,6 +319,7 @@ export class SettingService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function apply(ctx: Context) {
|
export async function apply(ctx: Context) {
|
||||||
|
ctx.provide('setting', undefined, true);
|
||||||
ctx.plugin(SettingService);
|
ctx.plugin(SettingService);
|
||||||
logger.info('Ensuring settings');
|
logger.info('Ensuring settings');
|
||||||
const system = global.Hydro.model.system;
|
const system = global.Hydro.model.system;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { escapeRegExp, pick, uniq } from 'lodash';
|
import { escapeRegExp, pick, uniq } from 'lodash';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
import { Collection, Filter, ObjectId } from 'mongodb';
|
import { Collection, Filter, ObjectId } from 'mongodb';
|
||||||
|
import { serializer } from '@hydrooj/framework';
|
||||||
import { LoginError, UserAlreadyExistError, UserNotFoundError } from '../error';
|
import { LoginError, UserAlreadyExistError, UserNotFoundError } from '../error';
|
||||||
import {
|
import {
|
||||||
Authenticator, BaseUserDict, FileInfo, GDoc,
|
Authenticator, BaseUserDict, FileInfo, GDoc,
|
||||||
@@ -8,7 +9,6 @@ import {
|
|||||||
} from '../interface';
|
} from '../interface';
|
||||||
import avatar from '../lib/avatar';
|
import avatar from '../lib/avatar';
|
||||||
import pwhash from '../lib/hash.hydro';
|
import pwhash from '../lib/hash.hydro';
|
||||||
import serializer from '../lib/serializer';
|
|
||||||
import * as bus from '../service/bus';
|
import * as bus from '../service/bus';
|
||||||
import db from '../service/db';
|
import db from '../service/db';
|
||||||
import { Value } from '../typeutils';
|
import { Value } from '../typeutils';
|
||||||
@@ -165,16 +165,20 @@ export class User {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
serialize(options) {
|
serialize(_, h) {
|
||||||
if (!this._isPrivate) {
|
if (!this._isPrivate) {
|
||||||
const fields = ['_id', 'uname', 'mail', 'perm', 'role', 'priv', 'regat', 'loginat', 'tfa', 'authn'];
|
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 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) {
|
function handleMailLower(mail: string) {
|
||||||
const [n, d] = mail.trim().toLowerCase().split('@');
|
const [n, d] = mail.trim().toLowerCase().split('@');
|
||||||
const [name] = n.split('+');
|
const [name] = n.split('+');
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import db from './service/db';
|
|||||||
|
|
||||||
export { nanoid } from 'nanoid';
|
export { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
export {
|
||||||
|
WebService, Router, HandlerCommon, httpServer,
|
||||||
|
} from '@hydrooj/framework';
|
||||||
|
|
||||||
export * from './pipelineUtils';
|
export * from './pipelineUtils';
|
||||||
export * from './error';
|
export * from './error';
|
||||||
export * from './libs';
|
export * from './libs';
|
||||||
@@ -33,6 +37,7 @@ export * as JudgeHandler from './handler/judge';
|
|||||||
export { registerResolver, registerValue, registerUnion } from './service/api';
|
export { registerResolver, registerValue, registerUnion } from './service/api';
|
||||||
export { postJudge } from './handler/judge';
|
export { postJudge } from './handler/judge';
|
||||||
export { Collections } from './service/db';
|
export { Collections } from './service/db';
|
||||||
|
export { Handler, ConnectionHandler, requireSudo } from './service/server';
|
||||||
export { Service, Context } from './context';
|
export { Service, Context } from './context';
|
||||||
export { buildContent } from './lib/content';
|
export { buildContent } from './lib/content';
|
||||||
export { default as mime } from './lib/mime';
|
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 avatar } from './lib/avatar';
|
||||||
export { default as rank } from './lib/rank';
|
export { default as rank } from './lib/rank';
|
||||||
export { default as paginate } from './lib/paginate';
|
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 { UiContextBase } from './service/layers/base';
|
||||||
|
export * from '@hydrooj/framework/decorators';
|
||||||
|
export * from '@hydrooj/framework/validator';
|
||||||
export * as StorageService from './service/storage';
|
export * as StorageService from './service/storage';
|
||||||
export { EventMap } from './service/bus';
|
export { EventMap } from './service/bus';
|
||||||
export { db, pwsh };
|
export { db, pwsh };
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
import {
|
import {
|
||||||
BSON, Db, Filter, ObjectId, OnlyFieldsOfType,
|
BSON, Db, Filter, ObjectId, OnlyFieldsOfType,
|
||||||
} from 'mongodb';
|
} from 'mongodb';
|
||||||
|
import type { Handler, ServerEvents } from '@hydrooj/framework';
|
||||||
import pm2 from '@hydrooj/utils/lib/locate-pm2';
|
import pm2 from '@hydrooj/utils/lib/locate-pm2';
|
||||||
|
import { Context } from '../context';
|
||||||
import type { ProblemSolutionHandler } from '../handler/problem';
|
import type { ProblemSolutionHandler } from '../handler/problem';
|
||||||
import type { UserRegisterHandler } from '../handler/user';
|
import type { UserRegisterHandler } from '../handler/user';
|
||||||
import type {
|
import type {
|
||||||
@@ -11,28 +13,19 @@ import type {
|
|||||||
ScoreboardRow, Tdoc, TrainingDoc, User,
|
ScoreboardRow, Tdoc, TrainingDoc, User,
|
||||||
} from '../interface';
|
} from '../interface';
|
||||||
import type { DocType } from '../model/document';
|
import type { DocType } from '../model/document';
|
||||||
import type { ConnectionHandler, Handler } from './server';
|
|
||||||
|
|
||||||
export type Disposable = () => void;
|
export type Disposable = () => void;
|
||||||
export type VoidReturn = Promise<any> | any;
|
export type VoidReturn = Promise<any> | any;
|
||||||
type HookType = 'before-prepare' | 'before' | 'before-operation' | 'after' | 'finish';
|
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<N extends string, H extends Handler> = Record<`handler/${HookType}/${N}`, (thisArg: H) => VoidReturn>;
|
type MapHandlerEvents<N extends string, H extends Handler> = Record<`handler/${HookType}/${N}`, (thisArg: H) => VoidReturn>;
|
||||||
type KnownHandlerEvents =
|
type KnownHandlerEvents =
|
||||||
MapHandlerEvents<'UserRegister', UserRegisterHandler>
|
MapHandlerEvents<'UserRegister', UserRegisterHandler>
|
||||||
& MapHandlerEvents<'ProblemSolution', ProblemSolutionHandler>;
|
& MapHandlerEvents<'ProblemSolution', ProblemSolutionHandler>;
|
||||||
type HandlerEvents =
|
|
||||||
KnownHandlerEvents
|
|
||||||
& Record<`handler/${HookType}/${string}`, (thisArg: Handler & Record<string, any>) => 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 */
|
/* eslint-disable @typescript-eslint/naming-convention */
|
||||||
export interface EventMap extends LifecycleEvents, HandlerEvents {
|
export interface EventMap extends KnownHandlerEvents {
|
||||||
'app/started': () => void
|
|
||||||
'app/listen': () => void
|
'app/listen': () => void
|
||||||
|
'app/started': () => void
|
||||||
'app/ready': () => VoidReturn
|
'app/ready': () => VoidReturn
|
||||||
'app/exit': () => VoidReturn
|
'app/exit': () => VoidReturn
|
||||||
'app/before-reload': (entries: Set<string>) => VoidReturn
|
'app/before-reload': (entries: Set<string>) => VoidReturn
|
||||||
@@ -45,7 +38,7 @@ export interface EventMap extends LifecycleEvents, HandlerEvents {
|
|||||||
'database/config': () => VoidReturn
|
'database/config': () => VoidReturn
|
||||||
|
|
||||||
'system/setting': (args: Record<string, any>) => VoidReturn
|
'system/setting': (args: Record<string, any>) => 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/update': (type: 'server' | 'judge', $set: any) => VoidReturn
|
||||||
'monitor/collect': (info: any) => VoidReturn
|
'monitor/collect': (info: any) => VoidReturn
|
||||||
'api/update': () => void;
|
'api/update': () => void;
|
||||||
@@ -69,10 +62,6 @@ export interface EventMap extends LifecycleEvents, HandlerEvents {
|
|||||||
$set: any, $unset: OnlyFieldsOfType<DocType[T], any, true | '' | 1>
|
$set: any, $unset: OnlyFieldsOfType<DocType[T], any, true | '' | 1>
|
||||||
) => VoidReturn
|
) => VoidReturn
|
||||||
|
|
||||||
'handler/create': (thisArg: Handler) => VoidReturn
|
|
||||||
'handler/init': (thisArg: Handler) => VoidReturn
|
|
||||||
'handler/error': (thisArg: Handler, e: Error) => VoidReturn
|
|
||||||
|
|
||||||
'discussion/before-add': (payload: Partial<DiscussionDoc>) => VoidReturn
|
'discussion/before-add': (payload: Partial<DiscussionDoc>) => VoidReturn
|
||||||
'discussion/add': (payload: Partial<DiscussionDoc>) => VoidReturn
|
'discussion/add': (payload: Partial<DiscussionDoc>) => VoidReturn
|
||||||
|
|
||||||
@@ -108,21 +97,27 @@ export interface EventMap extends LifecycleEvents, HandlerEvents {
|
|||||||
}
|
}
|
||||||
/* eslint-enable @typescript-eslint/naming-convention */
|
/* eslint-enable @typescript-eslint/naming-convention */
|
||||||
|
|
||||||
try {
|
declare module 'cordis' {
|
||||||
if (!process.send || !pm2 || process.env.exec_mode !== 'cluster_mode') throw new Error();
|
interface Events extends EventMap { }
|
||||||
pm2.launchBus((err, bus) => {
|
}
|
||||||
if (err) throw new Error();
|
|
||||||
bus.on('hydro:broadcast', (packet) => {
|
export function apply(ctx: Context) {
|
||||||
(app.parallel as any)(packet.data.event, ...BSON.EJSON.parse(packet.data.payload));
|
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) => {
|
} catch (e) {
|
||||||
process.send({ type: 'hydro:broadcast', data: { event, payload: BSON.EJSON.stringify(payload) } });
|
ctx.on('bus/broadcast', (event, payload) => app.parallel(event, ...payload));
|
||||||
});
|
console.debug('Using mongodb external event bus');
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { PassThrough } from 'stream';
|
|
||||||
import type { Next } from 'koa';
|
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 { randomPick } from '@hydrooj/utils';
|
||||||
import { PERM } from '../../model/builtin';
|
import { PERM } from '../../model/builtin';
|
||||||
import * as system from '../../model/system';
|
import * as system from '../../model/system';
|
||||||
import token from '../../model/token';
|
import token from '../../model/token';
|
||||||
import type { HydroRequest, HydroResponse, KoaContext } from '../server';
|
|
||||||
|
|
||||||
export interface UiContextBase {
|
export interface UiContextBase {
|
||||||
cdn_prefix: string;
|
cdn_prefix: string;
|
||||||
@@ -21,41 +20,6 @@ export const UiContextBase: UiContextBase = {
|
|||||||
export default async (ctx: KoaContext, next: Next) => {
|
export default async (ctx: KoaContext, next: Next) => {
|
||||||
// Base Layer
|
// Base Layer
|
||||||
const { domainId, domainInfo } = ctx;
|
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 = {
|
const args = {
|
||||||
domainId, ...ctx.params, ...ctx.query, ...ctx.request.body, __start: Date.now(),
|
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.domainId = domainId;
|
||||||
UiContext.domain = domainInfo;
|
UiContext.domain = domainInfo;
|
||||||
ctx.HydroContext = {
|
ctx.HydroContext.UiContext = UiContext;
|
||||||
request, response, UiContext, domain: domainInfo, user: null, args,
|
ctx.HydroContext.domain = domainInfo;
|
||||||
};
|
ctx.HydroContext.args = args;
|
||||||
const header = ctx.request.headers.authorization;
|
const header = ctx.request.headers.authorization;
|
||||||
const sid = header
|
const sid = header
|
||||||
? header.split(' ')[1] // Accept bearer token
|
? 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;
|
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() });
|
ctx.session = Object.create(session || { uid: 0, scope: PERM.PERM_ALL.toString() });
|
||||||
await next();
|
await next();
|
||||||
|
const request = ctx.HydroContext.request;
|
||||||
const ua = request.headers['user-agent'] || '';
|
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;
|
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
|
const expireSeconds = ctx.session.save
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { KoaContext } from '@hydrooj/framework';
|
||||||
import BlackListModel from '../../model/blacklist';
|
import BlackListModel from '../../model/blacklist';
|
||||||
import DomainModel from '../../model/domain';
|
import DomainModel from '../../model/domain';
|
||||||
import * as system from '../../model/system';
|
import * as system from '../../model/system';
|
||||||
import { KoaContext } from '../server';
|
|
||||||
|
|
||||||
export default async (ctx: KoaContext, next) => {
|
export default async (ctx: KoaContext, next) => {
|
||||||
const forceDomain = /^\/d\/([^/]+)\//.exec(ctx.request.path);
|
const forceDomain = /^\/d\/([^/]+)\//.exec(ctx.request.path);
|
||||||
|
|||||||
@@ -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<string, any>[]) => {
|
|
||||||
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();
|
|
||||||
};
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import type { KoaContext } from '@hydrooj/framework';
|
||||||
import { NotFoundError } from '../../error';
|
import { NotFoundError } from '../../error';
|
||||||
import { PERM } from '../../model/builtin';
|
import { PERM } from '../../model/builtin';
|
||||||
import UserModel from '../../model/user';
|
import UserModel from '../../model/user';
|
||||||
import type { KoaContext } from '../server';
|
|
||||||
|
|
||||||
export default async (ctx: KoaContext, next) => {
|
export default async (ctx: KoaContext, next) => {
|
||||||
// User Layer
|
// User Layer
|
||||||
|
|||||||
@@ -1,222 +1,83 @@
|
|||||||
import http from 'http';
|
import fs from 'fs';
|
||||||
import { tmpdir } from 'os';
|
import { resolve } from 'path';
|
||||||
import { join, resolve } from 'path';
|
|
||||||
import cac from 'cac';
|
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 proxy from 'koa-proxies';
|
||||||
import cache from 'koa-static-cache';
|
import cache from 'koa-static-cache';
|
||||||
import type { FindCursor } from 'mongodb';
|
import { type FindCursor, ObjectId } 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 {
|
import {
|
||||||
CsrfTokenError, HydroError, InvalidOperationError,
|
ConnectionHandler as ConnectionHandlerOriginal, Handler as HandlerOriginal, HydroError, NotFoundError, UserFacingError,
|
||||||
MethodNotAllowedError, NotFoundError, PermissionError,
|
} from '@hydrooj/framework';
|
||||||
PrivilegeError, UserFacingError,
|
import { errorMessage, Time } from '@hydrooj/utils';
|
||||||
} from '../error';
|
import { Context } from '../context';
|
||||||
import { DomainDoc } from '../interface';
|
import { PermissionError, PrivilegeError } from '../error';
|
||||||
|
import type { DomainDoc } from '../interface';
|
||||||
import paginate from '../lib/paginate';
|
import paginate from '../lib/paginate';
|
||||||
import serializer from '../lib/serializer';
|
|
||||||
import { Types } from '../lib/validator';
|
|
||||||
import { Logger } from '../logger';
|
import { Logger } from '../logger';
|
||||||
import { PERM, PRIV } from '../model/builtin';
|
import { PERM, PRIV } from '../model/builtin';
|
||||||
import * as opcount from '../model/opcount';
|
import * as opcount from '../model/opcount';
|
||||||
import * as OplogModel from '../model/oplog';
|
import * as OplogModel from '../model/oplog';
|
||||||
import * as system from '../model/system';
|
import * as system from '../model/system';
|
||||||
import { User } from '../model/user';
|
|
||||||
import { builtinConfig } from '../settings';
|
import { builtinConfig } from '../settings';
|
||||||
import { errorMessage } from '../utils';
|
|
||||||
import * as bus from './bus';
|
|
||||||
import * as decorators from './decorators';
|
|
||||||
import baseLayer from './layers/base';
|
import baseLayer from './layers/base';
|
||||||
import domainLayer from './layers/domain';
|
import domainLayer from './layers/domain';
|
||||||
import rendererLayer from './layers/renderer';
|
|
||||||
import responseLayer from './layers/response';
|
|
||||||
import userLayer from './layers/user';
|
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<string, import('formidable').File>;
|
|
||||||
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<string, any>;
|
|
||||||
UiContext: Record<string, any>;
|
|
||||||
}
|
|
||||||
export interface KoaContext extends Koa.Context {
|
|
||||||
HydroContext: HydroContext;
|
|
||||||
handler: any;
|
|
||||||
request: Koa.Request & { body: any, files: Files };
|
|
||||||
session: Record<string, any>;
|
|
||||||
render: (name: string, args: any) => Promise<void>;
|
|
||||||
renderHTML: (name: string, args: any) => string | Promise<string>;
|
|
||||||
getUrl: (name: string, args: any) => string;
|
|
||||||
translate: (key: string) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const argv = cac().parse();
|
const argv = cac().parse();
|
||||||
const logger = new Logger('server');
|
|
||||||
export const app = new Koa<Koa.DefaultState, KoaContext>({
|
|
||||||
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},`;
|
const ignoredLimit = `,${argv.options.ignoredLimit},`;
|
||||||
|
|
||||||
export class HandlerCommon {
|
const logger = new Logger('server');
|
||||||
render: (name: string, args?: any) => Promise<void>;
|
|
||||||
renderHTML: (name: string, args?: any) => string | Promise<string>;
|
|
||||||
url: (name: string, args?: any) => string;
|
|
||||||
translate: (key: string) => string;
|
|
||||||
session: Record<string, any>;
|
|
||||||
ctx: Context;
|
|
||||||
/** @deprecated */
|
|
||||||
domainId: string;
|
|
||||||
|
|
||||||
constructor(
|
declare module '@hydrooj/framework' {
|
||||||
public context: KoaContext, public readonly args: Record<string, any>,
|
export interface HandlerCommon {
|
||||||
public readonly request: HydroRequest, public response: HydroResponse,
|
domain: DomainDoc;
|
||||||
public user: User, public domain: DomainDoc, public UiContext: Record<string, any>,
|
|
||||||
) {
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async limitRate(op: string, periodSecs: number, maxOperations: number, withUserId = system.get('limit.by_user')) {
|
paginate<T>(cursor: FindCursor<T>, page: number, key: string): Promise<[docs: T[], numPages: number, count: number]>;
|
||||||
if (ignoredLimit.includes(op)) return;
|
progress(message: string, params: any[]): void;
|
||||||
if (this.user && this.user.hasPriv(PRIV.PRIV_UNLIMITED_ACCESS)) return;
|
limitRate(op: string, periodSecs: number, maxOperations: number, withUserId?: boolean): Promise<void>;
|
||||||
const overrideLimit = system.get(`limit.${op}`);
|
renderTitle(str: string): string;
|
||||||
if (overrideLimit) maxOperations = overrideLimit;
|
|
||||||
let id = this.request.ip;
|
|
||||||
if (withUserId) id += `@${this.user._id}`;
|
|
||||||
await opcount.inc(op, id, periodSecs, maxOperations);
|
|
||||||
}
|
|
||||||
|
|
||||||
paginate<T>(cursor: FindCursor<T>, 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Handler extends HandlerCommon {
|
export * from '@hydrooj/framework/decorators';
|
||||||
loginMethods: any;
|
export * from '@hydrooj/framework/validator';
|
||||||
noCheckPermView = false;
|
|
||||||
notUsage = false;
|
|
||||||
allowCors = false;
|
|
||||||
__param: Record<string, decorators.ParamOption<any>[]>;
|
|
||||||
|
|
||||||
back(body?: any) {
|
/*
|
||||||
this.response.body = body || this.response.body || {};
|
* For security concern, some API requires sudo privilege to access.
|
||||||
this.response.redirect = this.request.headers.referer || '/';
|
* 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.
|
||||||
binary(data: any, name?: string) {
|
* When teachers are using a superuser account, accessing from classrooms,
|
||||||
this.response.body = data;
|
* it may lead to serious security issues.
|
||||||
this.response.template = null;
|
* !!! Please make sure that all superuser accounts have two factor authentication enabled. !!!
|
||||||
this.response.type = 'application/octet-stream';
|
*/
|
||||||
if (name) this.response.disposition = `attachment; filename="${encodeRFC5987ValueChars(name)}"`;
|
export function requireSudo(target: any, funcName: string, obj: any) {
|
||||||
}
|
const originalMethod = obj.value;
|
||||||
|
obj.value = function sudo(this: Handler, ...args: any[]) {
|
||||||
// This is beta API, may be changed in the future.
|
if (this.session.sudo && Date.now() - this.session.sudo < Time.hour) {
|
||||||
progress(message: string, params: any[]) {
|
if (this.session.sudoArgs?.referer) this.request.headers.referer = this.session.sudoArgs.referer;
|
||||||
Hydro.model.message.sendInfo(this.user._id, JSON.stringify({ message, params }));
|
this.session.sudoArgs = null;
|
||||||
}
|
return originalMethod.call(this, ...args);
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!argv.options.benchmark && !this.notUsage) await this.limitRate('global', 5, 100);
|
this.session.sudoArgs = {
|
||||||
if (!this.noCheckPermView && !this.user.hasPriv(PRIV.PRIV_VIEW_ALL_DOMAIN)) this.checkPerm(PERM.PERM_VIEW);
|
method: this.request.method,
|
||||||
this.loginMethods = Object.keys(global.Hydro.module.oauth)
|
referer: this.request.headers.referer,
|
||||||
.map((key) => ({
|
args: this.args,
|
||||||
id: key,
|
redirect: this.request.originalPath,
|
||||||
icon: global.Hydro.module.oauth[key].icon,
|
};
|
||||||
text: global.Hydro.module.oauth[key].text,
|
this.response.redirect = this.url('user_sudo');
|
||||||
}));
|
return 'cleanup';
|
||||||
if (this.context.pendingError) throw this.context.pendingError;
|
};
|
||||||
|
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) {
|
async onerror(error: HydroError) {
|
||||||
@@ -235,6 +96,7 @@ export class Handler extends HandlerCommon {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.response.status = error instanceof UserFacingError ? error.code : 500;
|
this.response.status = error instanceof UserFacingError ? error.code : 500;
|
||||||
|
logger.error(error);
|
||||||
this.response.template = error instanceof UserFacingError ? 'error.html' : 'bsod.html';
|
this.response.template = error instanceof UserFacingError ? 'error.html' : 'bsod.html';
|
||||||
this.response.body = {
|
this.response.body = {
|
||||||
UserFacingError,
|
UserFacingError,
|
||||||
@@ -244,425 +106,174 @@ export class Handler extends HandlerCommon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function serial(name: string, ...args: any[]) {
|
export interface ConnectionHandler {
|
||||||
const r = await (global.app.serial as any)(name, ...args);
|
domain: DomainDoc;
|
||||||
if (r instanceof Error) throw r;
|
ctx: Context;
|
||||||
return r;
|
|
||||||
}
|
}
|
||||||
|
export class ConnectionHandler extends ConnectionHandlerOriginal {
|
||||||
async function handle(ctx: KoaContext, HandlerClass, checker) {
|
constructor(_, ctx: Context) {
|
||||||
const {
|
super(_, ctx);
|
||||||
args, request, response, user, domain, UiContext,
|
this.ctx = ctx.extend({ domain: this.domain });
|
||||||
} = 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onerror(err: HydroError) {
|
onerror(err: HydroError) {
|
||||||
if (err instanceof UserFacingError) err.stack = this.request.path;
|
|
||||||
if (!(err instanceof NotFoundError)
|
if (!(err instanceof NotFoundError)
|
||||||
&& !((err instanceof PrivilegeError || err instanceof PermissionError) && this.user?._id === 0)) {
|
&& !((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(`Path:${this.request.path}, User:${this.user?._id}(${this.user?.uname})`);
|
||||||
logger.error(err);
|
logger.error(err);
|
||||||
}
|
}
|
||||||
this.send({
|
super.onerror(err);
|
||||||
error: {
|
|
||||||
name: err.name,
|
|
||||||
params: err.params || [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.close(4000, err.toString());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Connection(
|
export async function apply(ctx: Context) {
|
||||||
name: string, prefix: string,
|
|
||||||
RouteConnHandler: any,
|
|
||||||
...permPrivChecker: Array<number | bigint | Function>
|
|
||||||
) {
|
|
||||||
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<typeof Route>) {
|
|
||||||
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<typeof Route>) {
|
|
||||||
this.register(Route, ...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
public Connection(...args: Parameters<typeof Connection>) {
|
|
||||||
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[];
|
|
||||||
if (process.env.HYDRO_CLI) return;
|
if (process.env.HYDRO_CLI) return;
|
||||||
const proxyMiddleware = proxy('/fs', {
|
ctx.plugin(require('@hydrooj/framework'), {
|
||||||
target: builtinConfig.file.endPoint,
|
keys: system.get('session.keys'),
|
||||||
changeOrigin: true,
|
proxy: !!system.get('server.xproxy') || !!system.get('server.xff'),
|
||||||
rewrite: (p) => p.replace('/fs', ''),
|
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';
|
ctx.inject(['server'], ({ server, on }) => {
|
||||||
app.use(async (ctx, next) => {
|
const proxyMiddleware = proxy('/fs', {
|
||||||
if (ctx.request.headers.origin) {
|
target: builtinConfig.file.endPoint,
|
||||||
const host = new URL(ctx.request.headers.origin).host;
|
changeOrigin: true,
|
||||||
if (host !== ctx.request.headers.host && `,${system.get('server.cors')},`.includes(`,${host},`)) {
|
rewrite: (p) => p.replace('/fs', ''),
|
||||||
ctx.set('Access-Control-Allow-Credentials', 'true');
|
});
|
||||||
ctx.set('Access-Control-Allow-Origin', ctx.request.headers.origin);
|
server.addCaptureRoute('/fs/', async (c, next) => {
|
||||||
ctx.set('Access-Control-Allow-Headers', corsAllowHeaders);
|
if (c.request.search.toLowerCase().includes('x-amz-credential')) return await proxyMiddleware(c, next);
|
||||||
ctx.set('Vary', 'Origin');
|
c.request.path = c.path = c.path.split('/fs')[1];
|
||||||
ctx.cors = true;
|
return await next();
|
||||||
}
|
});
|
||||||
}
|
server.addHandlerLayer('init', async (c, next) => {
|
||||||
if (ctx.request.method.toLowerCase() === 'options') {
|
const init = Date.now();
|
||||||
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();
|
|
||||||
try {
|
try {
|
||||||
await next();
|
await next();
|
||||||
} finally {
|
} finally {
|
||||||
const endTime = Date.now();
|
const finish = Date.now();
|
||||||
if (!(ctx.nolog || ctx.response.headers.nolog)) {
|
if (finish - init > 5000) {
|
||||||
logger.debug(`${ctx.request.method} /${ctx.domainId || 'system'}${ctx.request.path} \
|
const method = c.request.method;
|
||||||
${ctx.response.status} ${endTime - startTime}ms ${ctx.response.length}`);
|
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');
|
for (const addon of [...global.addons].reverse()) {
|
||||||
fs.ensureDirSync(uploadDir);
|
const dir = resolve(addon, 'public');
|
||||||
logger.debug('Using upload dir: %s', uploadDir);
|
if (!fs.existsSync(dir)) continue;
|
||||||
app.use(Body({
|
server.addServerLayer(`${addon}_public`, cache(dir, {
|
||||||
multipart: true,
|
maxAge: argv.options.public ? 0 : 24 * 3600 * 1000,
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
socket.close();
|
|
||||||
});
|
server.addServerLayer('domain', domainLayer);
|
||||||
const port = system.get('server.port');
|
server.addWSLayer('domain', domainLayer);
|
||||||
pluginContext.on('app/listen', async () => {
|
server.addLayer('base', baseLayer);
|
||||||
await new Promise((r) => {
|
server.addLayer('user', userLayer);
|
||||||
httpServer.listen(argv.options.port || port, () => {
|
|
||||||
logger.success('Server listening at: %d', argv.options.port || port);
|
server.handlerMixin({
|
||||||
r(true);
|
url(name: string, ...kwargsList: Record<string, any>[]) {
|
||||||
});
|
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<T>(cursor: FindCursor<T>, 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();
|
on('handler/create', async (h) => {
|
||||||
wsServer.close();
|
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,
|
|
||||||
};
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mariadb": "^3.3.0",
|
"mariadb": "^3.3.0",
|
||||||
"mongodb": "^5.9.2",
|
"mongodb": "^5.9.2",
|
||||||
"turndown": "^7.1.3",
|
"turndown": "^7.2.0",
|
||||||
"xml2js": "^0.6.2"
|
"xml2js": "^0.6.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"node": ">=16"
|
"node": ">=16"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "0.18.20",
|
"esbuild": "0.21.4",
|
||||||
"source-map-support": "^0.5.21"
|
"source-map-support": "^0.5.21"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* eslint-disable global-require */
|
/* eslint-disable global-require */
|
||||||
import { CleanWebpackPlugin } from 'clean-webpack-plugin';
|
import { CleanWebpackPlugin } from 'clean-webpack-plugin';
|
||||||
import CopyWebpackPlugin from 'copy-webpack-plugin';
|
import CopyWebpackPlugin from 'copy-webpack-plugin';
|
||||||
import { ESBuildMinifyPlugin } from 'esbuild-loader';
|
import { EsbuildPlugin } from 'esbuild-loader';
|
||||||
import { DuplicatesPlugin } from 'inspectpack/plugin';
|
import { DuplicatesPlugin } from 'inspectpack/plugin';
|
||||||
import ExtractCssPlugin from 'mini-css-extract-plugin';
|
import ExtractCssPlugin from 'mini-css-extract-plugin';
|
||||||
import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin';
|
import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin';
|
||||||
@@ -214,7 +214,7 @@ export default function (env: { watch?: boolean, production?: boolean, measure?:
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
usedExports: true,
|
usedExports: true,
|
||||||
minimizer: [new ESBuildMinifyPlugin({
|
minimizer: [new EsbuildPlugin({
|
||||||
css: true,
|
css: true,
|
||||||
minify: true,
|
minify: true,
|
||||||
minifySyntax: true,
|
minifySyntax: true,
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { assign } from 'lodash';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
|
|
||||||
import AutoComplete, { AutoCompleteOptions } from '.';
|
import AutoComplete, { AutoCompleteOptions } from '.';
|
||||||
import CustomSelectAutoCompleteFC from './components/CustomSelectAutoComplete';
|
import CustomSelectAutoCompleteFC from './components/CustomSelectAutoComplete';
|
||||||
|
|
||||||
@@ -56,5 +54,4 @@ export default class CustomSelectAutoComplete<Multi extends boolean> extends Aut
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assign(CustomSelectAutoComplete, DOMAttachedObject);
|
|
||||||
window.Hydro.components.CustomSelectAutoComplete = CustomSelectAutoComplete;
|
window.Hydro.components.CustomSelectAutoComplete = CustomSelectAutoComplete;
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { assign } from 'lodash';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
|
|
||||||
import AutoComplete from '.';
|
import AutoComplete from '.';
|
||||||
import DomainSelectAutoCompleteFC from './components/DomainSelectAutoComplete';
|
import DomainSelectAutoCompleteFC from './components/DomainSelectAutoComplete';
|
||||||
|
|
||||||
@@ -42,5 +40,4 @@ export default class DomainSelectAutoComplete extends AutoComplete {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assign(DomainSelectAutoComplete, DOMAttachedObject);
|
|
||||||
window.Hydro.components.DomainSelectAutoComplete = DomainSelectAutoComplete;
|
window.Hydro.components.DomainSelectAutoComplete = DomainSelectAutoComplete;
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { assign } from 'lodash';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
|
|
||||||
import AutoComplete, { AutoCompleteOptions } from '.';
|
import AutoComplete, { AutoCompleteOptions } from '.';
|
||||||
import FileSelectAutoCompleteFC from './components/FileSelectAutoComplete';
|
import FileSelectAutoCompleteFC from './components/FileSelectAutoComplete';
|
||||||
|
|
||||||
@@ -59,5 +57,4 @@ export default class FileSelectAutoComplete<Multi extends boolean> extends AutoC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assign(FileSelectAutoComplete, DOMAttachedObject);
|
|
||||||
window.Hydro.components.FileSelectAutoComplete = FileSelectAutoComplete;
|
window.Hydro.components.FileSelectAutoComplete = FileSelectAutoComplete;
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { assign } from 'lodash';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
|
|
||||||
import AutoComplete from '.';
|
import AutoComplete from '.';
|
||||||
import ProblemSelectAutoCompleteFC from './components/ProblemSelectAutoComplete';
|
import ProblemSelectAutoCompleteFC from './components/ProblemSelectAutoComplete';
|
||||||
|
|
||||||
@@ -44,5 +42,4 @@ export default class ProblemSelectAutoComplete extends AutoComplete {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assign(ProblemSelectAutoComplete, DOMAttachedObject);
|
|
||||||
window.Hydro.components.ProblemSelectAutoComplete = ProblemSelectAutoComplete;
|
window.Hydro.components.ProblemSelectAutoComplete = ProblemSelectAutoComplete;
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { assign } from 'lodash';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
|
|
||||||
import AutoComplete, { AutoCompleteOptions } from '.';
|
import AutoComplete, { AutoCompleteOptions } from '.';
|
||||||
import UserSelectAutoCompleteFC from './components/UserSelectAutoComplete';
|
import UserSelectAutoCompleteFC from './components/UserSelectAutoComplete';
|
||||||
|
|
||||||
@@ -49,5 +47,4 @@ export default class UserSelectAutoComplete<Multi extends boolean> extends AutoC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assign(UserSelectAutoComplete, DOMAttachedObject);
|
|
||||||
window.Hydro.components.UserSelectAutoComplete = UserSelectAutoComplete;
|
window.Hydro.components.UserSelectAutoComplete = UserSelectAutoComplete;
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { assign } from 'lodash';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
|
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
|
||||||
@@ -108,5 +107,4 @@ export default class AutoComplete extends DOMAttachedObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assign(AutoComplete, DOMAttachedObject);
|
|
||||||
window.Hydro.components.autocomplete = AutoComplete;
|
window.Hydro.components.autocomplete = AutoComplete;
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import $ from 'jquery';
|
import $ from 'jquery';
|
||||||
import _ from 'lodash';
|
|
||||||
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
|
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
|
||||||
import { zIndexManager } from 'vj/utils';
|
import { zIndexManager } from 'vj/utils';
|
||||||
|
|
||||||
@@ -145,5 +144,3 @@ export default class DomDialog extends DOMAttachedObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_.assign(DomDialog, DOMAttachedObject);
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import $ from 'jquery';
|
import $ from 'jquery';
|
||||||
import _ from 'lodash';
|
|
||||||
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
|
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
|
||||||
import TextareaHandler from 'vj/components/editor/textareaHandler';
|
import TextareaHandler from 'vj/components/editor/textareaHandler';
|
||||||
import Notification from 'vj/components/notification';
|
import Notification from 'vj/components/notification';
|
||||||
@@ -102,5 +101,3 @@ export default class CommentBox extends DOMAttachedObject {
|
|||||||
this.detach();
|
this.detach();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_.assign(CommentBox, DOMAttachedObject);
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import _ from 'lodash';
|
|
||||||
import Drop from 'tether-drop';
|
import Drop from 'tether-drop';
|
||||||
import responsiveCutoff from 'vj/breakpoints.json';
|
import responsiveCutoff from 'vj/breakpoints.json';
|
||||||
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
|
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
|
||||||
@@ -49,5 +48,3 @@ export default class Dropdown extends DOMAttachedObject {
|
|||||||
this.$dom.trigger('vjDropdownHide');
|
this.$dom.trigger('vjDropdownHide');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_.assign(Dropdown, DOMAttachedObject);
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import $ from 'jquery';
|
import $ from 'jquery';
|
||||||
import _ from 'lodash';
|
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
|
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
|
||||||
import Notification from 'vj/components/notification';
|
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 });
|
this.editor.setPosition({ lineNumber: range.endLineNumber, column: range.endColumn });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_.assign(Editor, DOMAttachedObject);
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import _ from 'lodash';
|
|
||||||
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
|
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
|
||||||
import Editor from './index';
|
import Editor from './index';
|
||||||
|
|
||||||
@@ -28,5 +27,3 @@ export default class TextareaHandler extends DOMAttachedObject {
|
|||||||
this.$dom.focus();
|
this.$dom.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_.assign(TextareaHandler, DOMAttachedObject);
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import $ from 'jquery';
|
import $ from 'jquery';
|
||||||
import _ from 'lodash';
|
|
||||||
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
|
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
|
||||||
import { delay } from 'vj/utils';
|
import { delay } from 'vj/utils';
|
||||||
import Marker from './Marker';
|
import Marker from './Marker';
|
||||||
@@ -54,5 +53,3 @@ export default class MarkerReactive extends DOMAttachedObject {
|
|||||||
super.detach();
|
super.detach();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_.assign(MarkerReactive, DOMAttachedObject);
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import $ from 'jquery';
|
import $ from 'jquery';
|
||||||
import _ from 'lodash';
|
|
||||||
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
|
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
|
||||||
import { delay } from 'vj/utils';
|
import { delay } from 'vj/utils';
|
||||||
|
|
||||||
@@ -74,5 +73,3 @@ export default class Rotator extends DOMAttachedObject {
|
|||||||
return this.value;
|
return this.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_.assign(Rotator, DOMAttachedObject);
|
|
||||||
@@ -49,5 +49,3 @@ export default class StyledTable extends DOMAttachedObject {
|
|||||||
this.$header.trigger('sticky_kit:detach');
|
this.$header.trigger('sticky_kit:detach');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_.assign(StyledTable, DOMAttachedObject);
|
|
||||||
@@ -47,5 +47,3 @@ export default class Tooltip extends DOMAttachedObject {
|
|||||||
this.drop.open();
|
this.drop.open();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_.assign(Tooltip, DOMAttachedObject);
|
|
||||||
@@ -10,22 +10,7 @@ export interface Context {
|
|||||||
broadcast: Context['emit'];
|
broadcast: Context['emit'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Context extends cordis.Context {
|
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<P extends string, C extends Context = Context> = cordis.Context.Associate<P, C>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MainScope = cordis.MainScope<Context>;
|
export type MainScope = cordis.MainScope<Context>;
|
||||||
export type EffectScope = cordis.EffectScope<Context>;
|
export type EffectScope = cordis.EffectScope<Context>;
|
||||||
export type ForkScope = cordis.ForkScope<Context>;
|
export type ForkScope = cordis.ForkScope<Context>;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"cli": false
|
"cli": false
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@blueprintjs/core": "^5.10.2",
|
"@blueprintjs/core": "^5.10.3",
|
||||||
"@fontsource/dm-mono": "^5.0.20",
|
"@fontsource/dm-mono": "^5.0.20",
|
||||||
"@fontsource/fira-code": "^5.0.18",
|
"@fontsource/fira-code": "^5.0.18",
|
||||||
"@fontsource/inconsolata": "^5.0.18",
|
"@fontsource/inconsolata": "^5.0.18",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"@fontsource/roboto-mono": "^5.0.18",
|
"@fontsource/roboto-mono": "^5.0.18",
|
||||||
"@fontsource/source-code-pro": "^5.0.18",
|
"@fontsource/source-code-pro": "^5.0.18",
|
||||||
"@fontsource/ubuntu-mono": "^5.0.20",
|
"@fontsource/ubuntu-mono": "^5.0.20",
|
||||||
"@hydrooj/utils": "workspace:*",
|
"@hydrooj/utils": "workspace:^",
|
||||||
"@simplewebauthn/browser": "7.4.0",
|
"@simplewebauthn/browser": "7.4.0",
|
||||||
"@svgr/webpack": "^8.1.0",
|
"@svgr/webpack": "^8.1.0",
|
||||||
"@types/gulp-if": "^0.0.34",
|
"@types/gulp-if": "^0.0.34",
|
||||||
@@ -34,13 +34,13 @@
|
|||||||
"@types/nunjucks": "^3.2.6",
|
"@types/nunjucks": "^3.2.6",
|
||||||
"@types/pickadate": "^3.5.35",
|
"@types/pickadate": "^3.5.35",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/react": "^18.3.1",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/redux-logger": "^3.0.13",
|
"@types/redux-logger": "^3.0.13",
|
||||||
"@types/webpack-env": "^1.18.5",
|
"@types/webpack-env": "^1.18.5",
|
||||||
"@vscode/codicons": "^0.0.36",
|
"@vscode/codicons": "^0.0.36",
|
||||||
"ajv": "^8.13.0",
|
"ajv": "^8.14.0",
|
||||||
"allotment": "^1.20.1",
|
"allotment": "^1.20.2",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"browser-update": "^3.3.51",
|
"browser-update": "^3.3.51",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
@@ -48,14 +48,14 @@
|
|||||||
"clean-webpack-plugin": "^4.0.0",
|
"clean-webpack-plugin": "^4.0.0",
|
||||||
"clipboard": "^2.0.11",
|
"clipboard": "^2.0.11",
|
||||||
"copy-webpack-plugin": "^12.0.2",
|
"copy-webpack-plugin": "^12.0.2",
|
||||||
"cordis": "3.13.3",
|
"cordis": "3.16.0",
|
||||||
"css-loader": "^6.11.0",
|
"css-loader": "^6.11.0",
|
||||||
"diff": "^5.2.0",
|
"diff": "^5.2.0",
|
||||||
"diff-dom": "^5.1.3",
|
"diff-dom": "^5.1.3",
|
||||||
"echarts": "^5.5.0",
|
"echarts": "^5.5.0",
|
||||||
"emojis-keywords": "2.0.0",
|
"emojis-keywords": "2.0.0",
|
||||||
"emojis-list": "2.1.0",
|
"emojis-list": "2.1.0",
|
||||||
"esbuild-loader": "^2.21.0",
|
"esbuild-loader": "^4.1.0",
|
||||||
"fancy-log": "^2.0.0",
|
"fancy-log": "^2.0.0",
|
||||||
"flatpickr": "^4.6.13",
|
"flatpickr": "^4.6.13",
|
||||||
"graphiql": "^2.4.7",
|
"graphiql": "^2.4.7",
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi_up": "^6.0.2",
|
"ansi_up": "^6.0.2",
|
||||||
"cssfilter": "^0.0.10",
|
"cssfilter": "^0.0.10",
|
||||||
"esbuild": "0.18.20",
|
"esbuild": "0.21.4",
|
||||||
"fs-extra": "^11.2.0",
|
"fs-extra": "^11.2.0",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"jsesc": "^3.0.2",
|
"jsesc": "^3.0.2",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"mongodb": "^5.9.2",
|
"mongodb": "^5.9.2",
|
||||||
"reggol": "^1.6.3",
|
"reggol": "^1.6.3",
|
||||||
"search-query-parser": "^1.6.0",
|
"search-query-parser": "^1.6.0",
|
||||||
"systeminformation": "^5.22.8"
|
"systeminformation": "^5.22.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/fs-extra": "^11.0.4"
|
"@types/fs-extra": "^11.0.4"
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"preferUnplugged": true,
|
"preferUnplugged": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hydrooj/utils": "workspace:*",
|
"@hydrooj/utils": "workspace:^",
|
||||||
"jsdom": "^24.0.0",
|
"jsdom": "^24.1.0",
|
||||||
"superagent-charset": "^1.2.0",
|
"superagent-charset": "^1.2.0",
|
||||||
"superagent-proxy": "^3.0.0"
|
"superagent-proxy": "^3.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user