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
@@ -2,7 +2,9 @@
|
||||
import {
|
||||
BSON, Db, Filter, ObjectId, OnlyFieldsOfType,
|
||||
} from 'mongodb';
|
||||
import type { Handler, ServerEvents } from '@hydrooj/framework';
|
||||
import pm2 from '@hydrooj/utils/lib/locate-pm2';
|
||||
import { Context } from '../context';
|
||||
import type { ProblemSolutionHandler } from '../handler/problem';
|
||||
import type { UserRegisterHandler } from '../handler/user';
|
||||
import type {
|
||||
@@ -11,28 +13,19 @@ import type {
|
||||
ScoreboardRow, Tdoc, TrainingDoc, User,
|
||||
} from '../interface';
|
||||
import type { DocType } from '../model/document';
|
||||
import type { ConnectionHandler, Handler } from './server';
|
||||
|
||||
export type Disposable = () => void;
|
||||
export type VoidReturn = Promise<any> | any;
|
||||
type HookType = 'before-prepare' | 'before' | 'before-operation' | 'after' | 'finish';
|
||||
type ModuleCategories = 'lib' | 'locale' | 'template' | 'script' | 'model' | 'setting' | 'handler' | 'service' | 'addon';
|
||||
type LifecycleEvents = Record<`app/load/${ModuleCategories}`, () => VoidReturn>;
|
||||
type MapHandlerEvents<N extends string, H extends Handler> = Record<`handler/${HookType}/${N}`, (thisArg: H) => VoidReturn>;
|
||||
type KnownHandlerEvents =
|
||||
MapHandlerEvents<'UserRegister', UserRegisterHandler>
|
||||
& MapHandlerEvents<'ProblemSolution', ProblemSolutionHandler>;
|
||||
type HandlerEvents =
|
||||
KnownHandlerEvents
|
||||
& Record<`handler/${HookType}/${string}`, (thisArg: Handler & Record<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 */
|
||||
export interface EventMap extends LifecycleEvents, HandlerEvents {
|
||||
'app/started': () => void
|
||||
export interface EventMap extends KnownHandlerEvents {
|
||||
'app/listen': () => void
|
||||
'app/started': () => void
|
||||
'app/ready': () => VoidReturn
|
||||
'app/exit': () => VoidReturn
|
||||
'app/before-reload': (entries: Set<string>) => VoidReturn
|
||||
@@ -45,7 +38,7 @@ export interface EventMap extends LifecycleEvents, HandlerEvents {
|
||||
'database/config': () => 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/collect': (info: any) => VoidReturn
|
||||
'api/update': () => void;
|
||||
@@ -69,10 +62,6 @@ export interface EventMap extends LifecycleEvents, HandlerEvents {
|
||||
$set: any, $unset: OnlyFieldsOfType<DocType[T], any, true | '' | 1>
|
||||
) => 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/add': (payload: Partial<DiscussionDoc>) => VoidReturn
|
||||
|
||||
@@ -108,21 +97,27 @@ export interface EventMap extends LifecycleEvents, HandlerEvents {
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
|
||||
try {
|
||||
if (!process.send || !pm2 || process.env.exec_mode !== 'cluster_mode') throw new Error();
|
||||
pm2.launchBus((err, bus) => {
|
||||
if (err) throw new Error();
|
||||
bus.on('hydro:broadcast', (packet) => {
|
||||
(app.parallel as any)(packet.data.event, ...BSON.EJSON.parse(packet.data.payload));
|
||||
declare module 'cordis' {
|
||||
interface Events extends EventMap { }
|
||||
}
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
try {
|
||||
if (!process.send || !pm2 || process.env.exec_mode !== 'cluster_mode') throw new Error();
|
||||
pm2.launchBus((err, bus) => {
|
||||
if (err) throw new Error();
|
||||
bus.on('hydro:broadcast', (packet) => {
|
||||
(app.parallel as any)(packet.data.event, ...BSON.EJSON.parse(packet.data.payload));
|
||||
});
|
||||
ctx.on('bus/broadcast', (event, payload) => {
|
||||
process.send({ type: 'hydro:broadcast', data: { event, payload: BSON.EJSON.stringify(payload) } });
|
||||
});
|
||||
console.debug('Using pm2 event bus');
|
||||
});
|
||||
app.on('bus/broadcast', (event, payload) => {
|
||||
process.send({ type: 'hydro:broadcast', data: { event, payload: BSON.EJSON.stringify(payload) } });
|
||||
});
|
||||
console.debug('Using pm2 event bus');
|
||||
});
|
||||
} catch (e) {
|
||||
app.on('bus/broadcast', (event, payload) => app.parallel(event, ...payload));
|
||||
console.debug('Using mongodb external event bus');
|
||||
} catch (e) {
|
||||
ctx.on('bus/broadcast', (event, payload) => app.parallel(event, ...payload));
|
||||
console.debug('Using mongodb external event bus');
|
||||
}
|
||||
}
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { Time } from '@hydrooj/utils';
|
||||
import { ValidationError } from '../error';
|
||||
import { Converter, Type, Validator } from '../lib/validator';
|
||||
import { EventMap } from './bus';
|
||||
import type { Handler } from './server';
|
||||
|
||||
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
|
||||
? new (...args: R) => S : never;
|
||||
export interface ParamOption<T> {
|
||||
name: string,
|
||||
source: 'all' | 'get' | 'post' | 'route',
|
||||
isOptional?: boolean | 'convert',
|
||||
convert?: Converter<T>,
|
||||
validate?: Validator,
|
||||
}
|
||||
|
||||
function _buildParam(name: string, source: 'get' | 'post' | 'all' | 'route', ...args: Array<Type<any> | boolean | Validator | Converter<any>>) {
|
||||
let cursor = 0;
|
||||
const v: ParamOption<any> = { name, source };
|
||||
let isValidate = true;
|
||||
while (cursor < args.length) {
|
||||
const current = args[cursor];
|
||||
if (current instanceof Array) {
|
||||
const type = current;
|
||||
if (type[0]) v.convert = type[0];
|
||||
if (type[1]) v.validate = type[1];
|
||||
if (type[2]) v.isOptional = type[2];
|
||||
} else if (typeof current === 'boolean') v.isOptional = current;
|
||||
else if (isValidate) {
|
||||
if (current !== null) v.validate = current;
|
||||
isValidate = false;
|
||||
} else v.convert = current;
|
||||
cursor++;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function _descriptor(v: ParamOption<any>) {
|
||||
return function desc(this: Handler, target: any, funcName: string, obj: any) {
|
||||
target.__param ||= {};
|
||||
target.__param[target.constructor.name] ||= {};
|
||||
if (!target.__param[target.constructor.name][funcName]) {
|
||||
const originalMethod = obj.value;
|
||||
const val = originalMethod.toString();
|
||||
const firstArg = val.split(')')[0]?.split(',')[0]?.split('(')[1]?.trim() || '';
|
||||
const domainIdStyle = firstArg.toLowerCase().startsWith('domainid');
|
||||
target.__param[target.constructor.name][funcName] = [];
|
||||
obj.value = function validate(this: Handler, rawArgs: any, ...extra: any[]) {
|
||||
if (typeof rawArgs !== 'object' || extra.length) return originalMethod.call(this, rawArgs, ...extra);
|
||||
const c = [];
|
||||
const arglist: ParamOption<any>[] = target.__param[target.constructor.name][funcName];
|
||||
if (typeof rawArgs.domainId !== 'string' || !rawArgs.domainId) throw new ValidationError('domainId');
|
||||
for (const item of arglist) {
|
||||
const src = item.source === 'all'
|
||||
? rawArgs
|
||||
: item.source === 'get'
|
||||
? this.request.query
|
||||
: item.source === 'route'
|
||||
? { ...this.request.params, domainId: this.args.domainId }
|
||||
: this.request.body;
|
||||
const value = src[item.name];
|
||||
if (!item.isOptional || value) {
|
||||
if (value === undefined || value === null || value === '') throw new ValidationError(item.name);
|
||||
if (item.validate && !item.validate(value)) throw new ValidationError(item.name);
|
||||
if (item.convert) c.push(item.convert(value));
|
||||
else c.push(value);
|
||||
} else if (item.isOptional === 'convert') {
|
||||
c.push(item.convert ? item.convert(value) : value);
|
||||
} else c.push(undefined);
|
||||
}
|
||||
return domainIdStyle ? originalMethod.call(this, rawArgs.domainId, ...c) : originalMethod.call(this, rawArgs, ...c);
|
||||
};
|
||||
}
|
||||
target.__param[target.constructor.name][funcName].unshift(v);
|
||||
return obj;
|
||||
};
|
||||
}
|
||||
|
||||
type DescriptorBuilder =
|
||||
((name: string, type: Type<any>) => MethodDecorator)
|
||||
& ((name: string, type: Type<any>, validate: null, convert: Converter<any>) => MethodDecorator)
|
||||
& ((name: string, type: Type<any>, validate?: Validator, convert?: Converter<any>) => MethodDecorator)
|
||||
& ((name: string, type?: Type<any>, isOptional?: boolean, validate?: Validator, convert?: Converter<any>) => MethodDecorator)
|
||||
& ((name: string, ...args: Array<Type<any> | boolean | Validator | Converter<any>>) => MethodDecorator);
|
||||
|
||||
export const get: DescriptorBuilder = (name, ...args) => _descriptor(_buildParam(name, 'get', ...args));
|
||||
export const query: DescriptorBuilder = (name, ...args) => _descriptor(_buildParam(name, 'get', ...args));
|
||||
export const post: DescriptorBuilder = (name, ...args) => _descriptor(_buildParam(name, 'post', ...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 subscribe: (name: keyof EventMap) => MethodDecorator & ClassDecorator = (name) => (target, funcName?, obj?) => {
|
||||
if (funcName) {
|
||||
target.__subscribe ||= [];
|
||||
target.__subscribe.push({ name, target: obj.value });
|
||||
return obj;
|
||||
}
|
||||
return (...args) => {
|
||||
const c = new target(...args); // eslint-disable-line new-cap
|
||||
c.__subscribe = [{ name, target: c.send }];
|
||||
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;
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { PassThrough } from 'stream';
|
||||
import type { Next } from 'koa';
|
||||
import { cloneDeep, omit, pick } from 'lodash';
|
||||
import { cloneDeep, omit } from 'lodash';
|
||||
import type { KoaContext } from '@hydrooj/framework';
|
||||
import { randomPick } from '@hydrooj/utils';
|
||||
import { PERM } from '../../model/builtin';
|
||||
import * as system from '../../model/system';
|
||||
import token from '../../model/token';
|
||||
import type { HydroRequest, HydroResponse, KoaContext } from '../server';
|
||||
|
||||
export interface UiContextBase {
|
||||
cdn_prefix: string;
|
||||
@@ -21,41 +20,6 @@ export const UiContextBase: UiContextBase = {
|
||||
export default async (ctx: KoaContext, next: Next) => {
|
||||
// Base Layer
|
||||
const { domainId, domainInfo } = ctx;
|
||||
const [xff, xhost] = system.getMany(['server.xff', 'server.xhost']);
|
||||
const request: HydroRequest = {
|
||||
method: ctx.request.method.toLowerCase(),
|
||||
host: ctx.request.headers[xhost?.toLowerCase() || ''] as string || ctx.request.host,
|
||||
hostname: ctx.request.hostname,
|
||||
ip: ctx.request.headers[xff?.toLowerCase() || ''] as string || ctx.request.ip,
|
||||
headers: ctx.request.headers,
|
||||
cookies: ctx.cookies,
|
||||
...pick(ctx, ['query', 'path', 'params', 'originalPath', 'querystring']),
|
||||
body: ctx.request.body,
|
||||
files: ctx.request.files as any,
|
||||
referer: ctx.request.headers.referer || '',
|
||||
json: (ctx.request.headers.accept || '').includes('application/json'),
|
||||
websocket: ctx.request.headers.upgrade === 'websocket',
|
||||
};
|
||||
request.ip = request.ip.split(',')[0].trim();
|
||||
const response: HydroResponse = {
|
||||
body: {},
|
||||
type: '',
|
||||
status: null,
|
||||
template: null,
|
||||
redirect: null,
|
||||
attachment: (name, streamOrBuffer) => {
|
||||
ctx.attachment(name);
|
||||
if (streamOrBuffer instanceof Buffer) {
|
||||
response.body = null;
|
||||
ctx.body = streamOrBuffer;
|
||||
} else {
|
||||
response.body = null;
|
||||
ctx.body = streamOrBuffer.pipe(new PassThrough());
|
||||
}
|
||||
},
|
||||
addHeader: (name: string, value: string) => ctx.set(name, value),
|
||||
disposition: null,
|
||||
};
|
||||
const args = {
|
||||
domainId, ...ctx.params, ...ctx.query, ...ctx.request.body, __start: Date.now(),
|
||||
};
|
||||
@@ -68,9 +32,9 @@ export default async (ctx: KoaContext, next: Next) => {
|
||||
}
|
||||
UiContext.domainId = domainId;
|
||||
UiContext.domain = domainInfo;
|
||||
ctx.HydroContext = {
|
||||
request, response, UiContext, domain: domainInfo, user: null, args,
|
||||
};
|
||||
ctx.HydroContext.UiContext = UiContext;
|
||||
ctx.HydroContext.domain = domainInfo;
|
||||
ctx.HydroContext.args = args;
|
||||
const header = ctx.request.headers.authorization;
|
||||
const sid = header
|
||||
? header.split(' ')[1] // Accept bearer token
|
||||
@@ -78,6 +42,7 @@ export default async (ctx: KoaContext, next: Next) => {
|
||||
const session = sid ? await token.get(sid instanceof Array ? sid[0] : sid, token.TYPE_SESSION) : null;
|
||||
ctx.session = Object.create(session || { uid: 0, scope: PERM.PERM_ALL.toString() });
|
||||
await next();
|
||||
const request = ctx.HydroContext.request;
|
||||
const ua = request.headers['user-agent'] || '';
|
||||
if (!ctx.session.uid && system.get('server.ignoreUA').replace(/\r/g, '').split('\n').filter((i) => i && ua.includes(i)).length) return;
|
||||
const expireSeconds = ctx.session.save
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { KoaContext } from '@hydrooj/framework';
|
||||
import BlackListModel from '../../model/blacklist';
|
||||
import DomainModel from '../../model/domain';
|
||||
import * as system from '../../model/system';
|
||||
import { KoaContext } from '../server';
|
||||
|
||||
export default async (ctx: KoaContext, next) => {
|
||||
const forceDomain = /^\/d\/([^/]+)\//.exec(ctx.request.path);
|
||||
|
||||
@@ -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 { PERM } from '../../model/builtin';
|
||||
import UserModel from '../../model/user';
|
||||
import type { KoaContext } from '../server';
|
||||
|
||||
export default async (ctx: KoaContext, next) => {
|
||||
// User Layer
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { IncomingMessage } from 'http';
|
||||
import KoaRouter from 'koa-router';
|
||||
import { pathToRegexp } from 'path-to-regexp';
|
||||
import type WebSocket from 'ws';
|
||||
import type { KoaContext } from './server';
|
||||
|
||||
type WebSocketCallback = (socket: WebSocket, request: IncomingMessage, ctx: KoaContext) => void;
|
||||
function remove<T>(list: T[], item: T) {
|
||||
const index = list.indexOf(item);
|
||||
if (index >= 0) list.splice(index, 1);
|
||||
}
|
||||
|
||||
export class WebSocketLayer {
|
||||
clients = new Set<WebSocket>();
|
||||
regexp: RegExp;
|
||||
|
||||
constructor(path: Parameters<typeof pathToRegexp>[0], public callback?: WebSocketCallback) {
|
||||
this.regexp = pathToRegexp(path);
|
||||
}
|
||||
|
||||
accept(socket: WebSocket, request: IncomingMessage, ctx: KoaContext) {
|
||||
if (!this.regexp.test(new URL(request.url, `http://${request.headers.host}`).pathname)) return false;
|
||||
this.clients.add(socket);
|
||||
socket.on('close', () => {
|
||||
this.clients.delete(socket);
|
||||
});
|
||||
this.callback?.(socket, request, ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
close() {
|
||||
for (const socket of this.clients) {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Router extends KoaRouter {
|
||||
wsStack: WebSocketLayer[] = [];
|
||||
disposeLastOp = () => null;
|
||||
|
||||
/**
|
||||
* hack into router methods to make sure that koa middlewares are disposable
|
||||
*/
|
||||
register(...args: Parameters<KoaRouter['register']>) {
|
||||
const layer = super.register(...args);
|
||||
this.disposeLastOp = () => remove(this.stack, layer);
|
||||
return layer;
|
||||
}
|
||||
|
||||
ws(path: Parameters<typeof pathToRegexp>[0], callback?: WebSocketCallback) {
|
||||
const layer = new WebSocketLayer(path, callback);
|
||||
this.wsStack.push(layer);
|
||||
this.disposeLastOp = () => {
|
||||
layer.close();
|
||||
remove(this.wsStack, layer);
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
}
|
||||
@@ -1,222 +1,83 @@
|
||||
import http from 'http';
|
||||
import { tmpdir } from 'os';
|
||||
import { join, resolve } from 'path';
|
||||
import fs from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import cac from 'cac';
|
||||
import type { Files } from 'formidable';
|
||||
import fs from 'fs-extra';
|
||||
import Koa from 'koa';
|
||||
import Body from 'koa-body';
|
||||
import Compress from 'koa-compress';
|
||||
import proxy from 'koa-proxies';
|
||||
import cache from 'koa-static-cache';
|
||||
import type { FindCursor } from 'mongodb';
|
||||
import { Shorty } from 'shorty.js';
|
||||
import WebSocket from 'ws';
|
||||
import { Counter, isClass, parseMemoryMB } from '@hydrooj/utils/lib/utils';
|
||||
import { Context, Service } from '../context';
|
||||
import { type FindCursor, ObjectId } from 'mongodb';
|
||||
import {
|
||||
CsrfTokenError, HydroError, InvalidOperationError,
|
||||
MethodNotAllowedError, NotFoundError, PermissionError,
|
||||
PrivilegeError, UserFacingError,
|
||||
} from '../error';
|
||||
import { DomainDoc } from '../interface';
|
||||
ConnectionHandler as ConnectionHandlerOriginal, Handler as HandlerOriginal, HydroError, NotFoundError, UserFacingError,
|
||||
} from '@hydrooj/framework';
|
||||
import { errorMessage, Time } from '@hydrooj/utils';
|
||||
import { Context } from '../context';
|
||||
import { PermissionError, PrivilegeError } from '../error';
|
||||
import type { DomainDoc } from '../interface';
|
||||
import paginate from '../lib/paginate';
|
||||
import serializer from '../lib/serializer';
|
||||
import { Types } from '../lib/validator';
|
||||
import { Logger } from '../logger';
|
||||
import { PERM, PRIV } from '../model/builtin';
|
||||
import * as opcount from '../model/opcount';
|
||||
import * as OplogModel from '../model/oplog';
|
||||
import * as system from '../model/system';
|
||||
import { User } from '../model/user';
|
||||
import { builtinConfig } from '../settings';
|
||||
import { errorMessage } from '../utils';
|
||||
import * as bus from './bus';
|
||||
import * as decorators from './decorators';
|
||||
import baseLayer from './layers/base';
|
||||
import domainLayer from './layers/domain';
|
||||
import rendererLayer from './layers/renderer';
|
||||
import responseLayer from './layers/response';
|
||||
import userLayer from './layers/user';
|
||||
import { Router } from './router';
|
||||
import { encodeRFC5987ValueChars } from './storage';
|
||||
|
||||
export * from './decorators';
|
||||
export * from '../lib/validator';
|
||||
|
||||
export interface HydroRequest {
|
||||
method: string;
|
||||
host: string;
|
||||
hostname: string;
|
||||
ip: string;
|
||||
headers: Koa.Request['headers'];
|
||||
cookies: any;
|
||||
body: any;
|
||||
files: Record<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 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},`;
|
||||
|
||||
export class HandlerCommon {
|
||||
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;
|
||||
const logger = new Logger('server');
|
||||
|
||||
constructor(
|
||||
public context: KoaContext, public readonly args: Record<string, any>,
|
||||
public readonly request: HydroRequest, public response: HydroResponse,
|
||||
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,
|
||||
});
|
||||
}
|
||||
declare module '@hydrooj/framework' {
|
||||
export interface HandlerCommon {
|
||||
domain: DomainDoc;
|
||||
|
||||
async limitRate(op: string, periodSecs: number, maxOperations: number, withUserId = system.get('limit.by_user')) {
|
||||
if (ignoredLimit.includes(op)) return;
|
||||
if (this.user && this.user.hasPriv(PRIV.PRIV_UNLIMITED_ACCESS)) return;
|
||||
const overrideLimit = system.get(`limit.${op}`);
|
||||
if (overrideLimit) maxOperations = overrideLimit;
|
||||
let id = this.request.ip;
|
||||
if (withUserId) id += `@${this.user._id}`;
|
||||
await opcount.inc(op, id, periodSecs, maxOperations);
|
||||
}
|
||||
|
||||
paginate<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);
|
||||
paginate<T>(cursor: FindCursor<T>, page: number, key: string): Promise<[docs: T[], numPages: number, count: number]>;
|
||||
progress(message: string, params: any[]): void;
|
||||
limitRate(op: string, periodSecs: number, maxOperations: number, withUserId?: boolean): Promise<void>;
|
||||
renderTitle(str: string): string;
|
||||
}
|
||||
}
|
||||
|
||||
export class Handler extends HandlerCommon {
|
||||
loginMethods: any;
|
||||
noCheckPermView = false;
|
||||
notUsage = false;
|
||||
allowCors = false;
|
||||
__param: Record<string, decorators.ParamOption<any>[]>;
|
||||
export * from '@hydrooj/framework/decorators';
|
||||
export * from '@hydrooj/framework/validator';
|
||||
|
||||
back(body?: any) {
|
||||
this.response.body = body || this.response.body || {};
|
||||
this.response.redirect = this.request.headers.referer || '/';
|
||||
}
|
||||
|
||||
binary(data: any, name?: string) {
|
||||
this.response.body = data;
|
||||
this.response.template = null;
|
||||
this.response.type = 'application/octet-stream';
|
||||
if (name) this.response.disposition = `attachment; filename="${encodeRFC5987ValueChars(name)}"`;
|
||||
}
|
||||
|
||||
// This is beta API, may be changed in the future.
|
||||
progress(message: string, params: any[]) {
|
||||
Hydro.model.message.sendInfo(this.user._id, JSON.stringify({ message, params }));
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.request.method === 'post' && this.request.headers.referer && !this.context.cors && !this.allowCors) {
|
||||
try {
|
||||
const host = new URL(this.request.headers.referer).host;
|
||||
if (host !== this.request.host) this.context.pendingError = new CsrfTokenError(host);
|
||||
} catch (e) {
|
||||
this.context.pendingError = new CsrfTokenError();
|
||||
}
|
||||
/*
|
||||
* For security concern, some API requires sudo privilege to access.
|
||||
* And for some superadmin operations,
|
||||
* we do not allow them using a password to perform the sudo operation,
|
||||
* as most user choose to use "remember password" option.
|
||||
* When teachers are using a superuser account, accessing from classrooms,
|
||||
* it may lead to serious security issues.
|
||||
* !!! Please make sure that all superuser accounts have two factor authentication enabled. !!!
|
||||
*/
|
||||
export function requireSudo(target: any, funcName: string, obj: any) {
|
||||
const originalMethod = obj.value;
|
||||
obj.value = function sudo(this: Handler, ...args: any[]) {
|
||||
if (this.session.sudo && Date.now() - this.session.sudo < Time.hour) {
|
||||
if (this.session.sudoArgs?.referer) this.request.headers.referer = this.session.sudoArgs.referer;
|
||||
this.session.sudoArgs = null;
|
||||
return originalMethod.call(this, ...args);
|
||||
}
|
||||
if (!argv.options.benchmark && !this.notUsage) await this.limitRate('global', 5, 100);
|
||||
if (!this.noCheckPermView && !this.user.hasPriv(PRIV.PRIV_VIEW_ALL_DOMAIN)) this.checkPerm(PERM.PERM_VIEW);
|
||||
this.loginMethods = Object.keys(global.Hydro.module.oauth)
|
||||
.map((key) => ({
|
||||
id: key,
|
||||
icon: global.Hydro.module.oauth[key].icon,
|
||||
text: global.Hydro.module.oauth[key].text,
|
||||
}));
|
||||
if (this.context.pendingError) throw this.context.pendingError;
|
||||
this.session.sudoArgs = {
|
||||
method: this.request.method,
|
||||
referer: this.request.headers.referer,
|
||||
args: this.args,
|
||||
redirect: this.request.originalPath,
|
||||
};
|
||||
this.response.redirect = this.url('user_sudo');
|
||||
return 'cleanup';
|
||||
};
|
||||
return obj;
|
||||
}
|
||||
|
||||
export interface Handler {
|
||||
domain: DomainDoc;
|
||||
ctx: Context;
|
||||
}
|
||||
export class Handler extends HandlerOriginal {
|
||||
constructor(_, ctx: Context) {
|
||||
super(_, ctx);
|
||||
this.ctx = ctx.extend({ domain: this.domain });
|
||||
}
|
||||
|
||||
async onerror(error: HydroError) {
|
||||
@@ -235,6 +96,7 @@ export class Handler extends HandlerCommon {
|
||||
});
|
||||
} else {
|
||||
this.response.status = error instanceof UserFacingError ? error.code : 500;
|
||||
logger.error(error);
|
||||
this.response.template = error instanceof UserFacingError ? 'error.html' : 'bsod.html';
|
||||
this.response.body = {
|
||||
UserFacingError,
|
||||
@@ -244,425 +106,174 @@ export class Handler extends HandlerCommon {
|
||||
}
|
||||
}
|
||||
|
||||
async function serial(name: string, ...args: any[]) {
|
||||
const r = await (global.app.serial as any)(name, ...args);
|
||||
if (r instanceof Error) throw r;
|
||||
return r;
|
||||
export interface ConnectionHandler {
|
||||
domain: DomainDoc;
|
||||
ctx: Context;
|
||||
}
|
||||
|
||||
async function handle(ctx: KoaContext, HandlerClass, checker) {
|
||||
const {
|
||||
args, request, response, user, domain, UiContext,
|
||||
} = ctx.HydroContext;
|
||||
Object.assign(args, ctx.params);
|
||||
const init = Date.now();
|
||||
const h = new HandlerClass(ctx, args, request, response, user, domain, UiContext);
|
||||
ctx.handler = h;
|
||||
const method = ctx.method.toLowerCase();
|
||||
try {
|
||||
const operation = (method === 'post' && ctx.request.body?.operation)
|
||||
? `_${ctx.request.body.operation}`.replace(/_([a-z])/gm, (s) => s[1].toUpperCase())
|
||||
: '';
|
||||
|
||||
await bus.parallel('handler/create', h);
|
||||
|
||||
if (checker) checker.call(h);
|
||||
if (method === 'post') {
|
||||
if (operation) {
|
||||
if (typeof h[`post${operation}`] !== 'function') {
|
||||
throw new InvalidOperationError(operation);
|
||||
}
|
||||
} else if (typeof h.post !== 'function') {
|
||||
throw new MethodNotAllowedError(method);
|
||||
}
|
||||
} else if (typeof h[method] !== 'function' && typeof h.all !== 'function') {
|
||||
throw new MethodNotAllowedError(method);
|
||||
}
|
||||
|
||||
const name = HandlerClass.name.replace(/Handler$/, '');
|
||||
const steps = [
|
||||
'init', 'handler/init',
|
||||
`handler/before-prepare/${name}#${method}`, `handler/before-prepare/${name}`, 'handler/before-prepare',
|
||||
'log/__prepare', '__prepare', '_prepare', 'prepare', 'log/__prepareDone',
|
||||
`handler/before/${name}#${method}`, `handler/before/${name}`, 'handler/before',
|
||||
'log/__method', 'all', method, 'log/__methodDone',
|
||||
...operation ? [
|
||||
`handler/before-operation/${name}`, 'handler/before-operation',
|
||||
`post${operation}`, 'log/__operationDone',
|
||||
] : [], 'after',
|
||||
`handler/after/${name}#${method}`, `handler/after/${name}`, 'handler/after',
|
||||
'cleanup',
|
||||
`handler/finish/${name}#${method}`, `handler/finish/${name}`, 'handler/finish',
|
||||
];
|
||||
|
||||
let current = 0;
|
||||
while (current < steps.length) {
|
||||
const step = steps[current];
|
||||
let control;
|
||||
if (step.startsWith('log/')) h.args[step.slice(4)] = Date.now();
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
else if (step.startsWith('handler/')) control = await serial(step, h);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
else if (typeof h[step] === 'function') control = await h[step](args);
|
||||
if (control) {
|
||||
const index = steps.findIndex((i) => control === i);
|
||||
if (index === -1) throw new Error(`Invalid control: ${control}`);
|
||||
if (index <= current) {
|
||||
logger.warn('Returning to previous step is not recommended:', step, '->', control);
|
||||
}
|
||||
current = index;
|
||||
} else current++;
|
||||
}
|
||||
} catch (e) {
|
||||
try {
|
||||
await serial(`handler/error/${HandlerClass.name.replace(/Handler$/, '')}`, h, e);
|
||||
await serial('handler/error', h, e);
|
||||
await h.onerror(e);
|
||||
} catch (err) {
|
||||
h.response.code = 500;
|
||||
h.response.type = 'text/plain';
|
||||
h.response.body = `${err.message}\n${err.stack}`;
|
||||
}
|
||||
} finally {
|
||||
const finish = Date.now();
|
||||
if (finish - init > 5000) {
|
||||
const id = await OplogModel.log(h, 'slow_request', { method, processtime: finish - init });
|
||||
logger.warn(`Slow handler: ID=${id}, `, method, ctx.path, `${finish - init}ms`);
|
||||
}
|
||||
// TODO metrics: calc avg response time
|
||||
}
|
||||
}
|
||||
|
||||
const Checker = (permPrivChecker) => {
|
||||
let perm: bigint;
|
||||
let priv: number;
|
||||
let checker = () => { };
|
||||
for (const item of permPrivChecker) {
|
||||
if (typeof item === 'object') {
|
||||
if (typeof item.call !== 'undefined') {
|
||||
checker = item;
|
||||
} else if (typeof item[0] === 'number') {
|
||||
priv = item;
|
||||
} else if (typeof item[0] === 'bigint') {
|
||||
perm = item;
|
||||
}
|
||||
} else if (typeof item === 'number') {
|
||||
priv = item;
|
||||
} else if (typeof item === 'bigint') {
|
||||
perm = item;
|
||||
}
|
||||
}
|
||||
return function check(this: Handler) {
|
||||
checker();
|
||||
if (perm) this.checkPerm(perm);
|
||||
if (priv) this.checkPriv(priv);
|
||||
};
|
||||
};
|
||||
|
||||
export function Route(name: string, path: string, RouteHandler: any, ...permPrivChecker) {
|
||||
router.all(name, path, (ctx) => handle(ctx as any, RouteHandler, Checker(permPrivChecker)));
|
||||
return router.disposeLastOp;
|
||||
}
|
||||
|
||||
export class ConnectionHandler extends HandlerCommon {
|
||||
conn: WebSocket;
|
||||
compression: Shorty;
|
||||
counter = 0;
|
||||
|
||||
resetCompression() {
|
||||
this.counter = 0;
|
||||
this.compression = new Shorty();
|
||||
this.conn.send('shorty');
|
||||
}
|
||||
|
||||
send(data: any) {
|
||||
let payload = JSON.stringify(data, serializer({
|
||||
showDisplayName: this.user?.hasPerm(PERM.PERM_VIEW_DISPLAYNAME),
|
||||
}));
|
||||
if (this.compression) {
|
||||
if (this.counter > 1000) this.resetCompression();
|
||||
payload = this.compression.deflate(payload);
|
||||
this.counter++;
|
||||
}
|
||||
this.conn.send(payload);
|
||||
}
|
||||
|
||||
close(code: number, reason: string) {
|
||||
this.conn.close(code, reason);
|
||||
export class ConnectionHandler extends ConnectionHandlerOriginal {
|
||||
constructor(_, ctx: Context) {
|
||||
super(_, ctx);
|
||||
this.ctx = ctx.extend({ domain: this.domain });
|
||||
}
|
||||
|
||||
onerror(err: HydroError) {
|
||||
if (err instanceof UserFacingError) err.stack = this.request.path;
|
||||
if (!(err instanceof NotFoundError)
|
||||
&& !((err instanceof PrivilegeError || err instanceof PermissionError) && this.user?._id === 0)) {
|
||||
logger.error(`Path:${this.request.path}, User:${this.user?._id}(${this.user?.uname})`);
|
||||
logger.error(err);
|
||||
}
|
||||
this.send({
|
||||
error: {
|
||||
name: err.name,
|
||||
params: err.params || [],
|
||||
},
|
||||
});
|
||||
this.close(4000, err.toString());
|
||||
super.onerror(err);
|
||||
}
|
||||
}
|
||||
|
||||
export function Connection(
|
||||
name: string, prefix: string,
|
||||
RouteConnHandler: any,
|
||||
...permPrivChecker: Array<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[];
|
||||
export async function apply(ctx: Context) {
|
||||
if (process.env.HYDRO_CLI) return;
|
||||
const proxyMiddleware = proxy('/fs', {
|
||||
target: builtinConfig.file.endPoint,
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace('/fs', ''),
|
||||
ctx.plugin(require('@hydrooj/framework'), {
|
||||
keys: system.get('session.keys'),
|
||||
proxy: !!system.get('server.xproxy') || !!system.get('server.xff'),
|
||||
cors: system.get('server.cors') || '',
|
||||
upload: system.get('server.upload') || '256m',
|
||||
port: argv.options.port || system.get('server.port'),
|
||||
xff: system.get('server.xff'),
|
||||
xhost: system.get('server.xhost'),
|
||||
});
|
||||
const corsAllowHeaders = 'x-requested-with, accept, origin, content-type, upgrade-insecure-requests';
|
||||
app.use(async (ctx, next) => {
|
||||
if (ctx.request.headers.origin) {
|
||||
const host = new URL(ctx.request.headers.origin).host;
|
||||
if (host !== ctx.request.headers.host && `,${system.get('server.cors')},`.includes(`,${host},`)) {
|
||||
ctx.set('Access-Control-Allow-Credentials', 'true');
|
||||
ctx.set('Access-Control-Allow-Origin', ctx.request.headers.origin);
|
||||
ctx.set('Access-Control-Allow-Headers', corsAllowHeaders);
|
||||
ctx.set('Vary', 'Origin');
|
||||
ctx.cors = true;
|
||||
}
|
||||
}
|
||||
if (ctx.request.method.toLowerCase() === 'options') {
|
||||
ctx.body = 'ok';
|
||||
return null;
|
||||
}
|
||||
for (const key in captureAllRoutes) {
|
||||
if (ctx.path.startsWith(key)) return captureAllRoutes[key](ctx, next);
|
||||
}
|
||||
if (!ctx.path.startsWith('/fs/')) return await next();
|
||||
if (ctx.request.search.toLowerCase().includes('x-amz-credential')) return await proxyMiddleware(ctx, next);
|
||||
ctx.request.path = ctx.path = ctx.path.split('/fs')[1];
|
||||
return await next();
|
||||
});
|
||||
app.use(Compress());
|
||||
for (const addon of [...global.addons].reverse()) {
|
||||
const dir = resolve(addon, 'public');
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
app.use(cache(dir, {
|
||||
maxAge: argv.options.public ? 0 : 24 * 3600 * 1000,
|
||||
}));
|
||||
}
|
||||
if (process.env.DEV) {
|
||||
app.use(async (ctx: Koa.Context, next: Function) => {
|
||||
const startTime = Date.now();
|
||||
ctx.inject(['server'], ({ server, on }) => {
|
||||
const proxyMiddleware = proxy('/fs', {
|
||||
target: builtinConfig.file.endPoint,
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace('/fs', ''),
|
||||
});
|
||||
server.addCaptureRoute('/fs/', async (c, next) => {
|
||||
if (c.request.search.toLowerCase().includes('x-amz-credential')) return await proxyMiddleware(c, next);
|
||||
c.request.path = c.path = c.path.split('/fs')[1];
|
||||
return await next();
|
||||
});
|
||||
server.addHandlerLayer('init', async (c, next) => {
|
||||
const init = Date.now();
|
||||
try {
|
||||
await next();
|
||||
} finally {
|
||||
const endTime = Date.now();
|
||||
if (!(ctx.nolog || ctx.response.headers.nolog)) {
|
||||
logger.debug(`${ctx.request.method} /${ctx.domainId || 'system'}${ctx.request.path} \
|
||||
${ctx.response.status} ${endTime - startTime}ms ${ctx.response.length}`);
|
||||
const finish = Date.now();
|
||||
if (finish - init > 5000) {
|
||||
const method = c.request.method;
|
||||
const id = await OplogModel.log(c.handler, 'slow_request', { method, processtime: finish - init });
|
||||
logger.warn(`Slow handler: ID=${id}, `, method, c.path, `${finish - init}ms`);
|
||||
}
|
||||
// TODO metrics: calc avg response time
|
||||
}
|
||||
});
|
||||
}
|
||||
const uploadDir = join(tmpdir(), 'hydro', 'upload', process.env.NODE_APP_INSTANCE || '0');
|
||||
fs.ensureDirSync(uploadDir);
|
||||
logger.debug('Using upload dir: %s', uploadDir);
|
||||
app.use(Body({
|
||||
multipart: true,
|
||||
jsonLimit: '8mb',
|
||||
formLimit: '8mb',
|
||||
formidable: {
|
||||
uploadDir,
|
||||
maxFileSize: parseMemoryMB(system.get('server.upload') || '256m') * 1024 * 1024,
|
||||
keepExtensions: true,
|
||||
},
|
||||
}));
|
||||
pluginContext.on('app/exit', () => {
|
||||
fs.emptyDirSync(uploadDir);
|
||||
});
|
||||
const layers = [baseLayer, rendererLayer(router, logger), responseLayer(logger), userLayer];
|
||||
app.use(async (ctx, next) => await next().catch(console.error)).use(domainLayer);
|
||||
app.use(router.routes()).use(router.allowedMethods());
|
||||
for (const layer of layers) {
|
||||
router.use(layer as any);
|
||||
app.use(layer);
|
||||
}
|
||||
app.use((ctx) => handle(ctx, NotFoundHandler, () => true));
|
||||
wsServer.on('connection', async (socket, request) => {
|
||||
socket.on('error', (err) => {
|
||||
logger.warn('Websocket Error: %s', err.message);
|
||||
try {
|
||||
socket.close(1003, 'Websocket Error');
|
||||
} catch (e) { }
|
||||
});
|
||||
socket.pause();
|
||||
const ctx: any = app.createContext(request, {} as any);
|
||||
await domainLayer(ctx, () => baseLayer(ctx, () => layers[1](ctx, () => userLayer(ctx, () => { }))));
|
||||
for (const manager of router.wsStack) {
|
||||
if (manager.accept(socket, request, ctx)) return;
|
||||
|
||||
for (const addon of [...global.addons].reverse()) {
|
||||
const dir = resolve(addon, 'public');
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
server.addServerLayer(`${addon}_public`, cache(dir, {
|
||||
maxAge: argv.options.public ? 0 : 24 * 3600 * 1000,
|
||||
}));
|
||||
}
|
||||
socket.close();
|
||||
});
|
||||
const port = system.get('server.port');
|
||||
pluginContext.on('app/listen', async () => {
|
||||
await new Promise((r) => {
|
||||
httpServer.listen(argv.options.port || port, () => {
|
||||
logger.success('Server listening at: %d', argv.options.port || port);
|
||||
r(true);
|
||||
});
|
||||
|
||||
server.addServerLayer('domain', domainLayer);
|
||||
server.addWSLayer('domain', domainLayer);
|
||||
server.addLayer('base', baseLayer);
|
||||
server.addLayer('user', userLayer);
|
||||
|
||||
server.handlerMixin({
|
||||
url(name: string, ...kwargsList: Record<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();
|
||||
wsServer.close();
|
||||
|
||||
on('handler/create', async (h) => {
|
||||
h.user = h.context.HydroContext.user as any;
|
||||
h.domain = h.context.HydroContext.domain as any;
|
||||
h.translate = h.translate.bind(h);
|
||||
h.url = h.url.bind(h);
|
||||
h.ctx = h.ctx.extend({
|
||||
domain: h.domain,
|
||||
});
|
||||
if (!argv.options.benchmark && !h.notUsage) await h.limitRate('global', 5, 100);
|
||||
h.loginMethods = Object.keys(global.Hydro.module.oauth)
|
||||
.map((key) => ({
|
||||
id: key,
|
||||
icon: global.Hydro.module.oauth[key].icon,
|
||||
text: global.Hydro.module.oauth[key].text,
|
||||
}));
|
||||
if (!h.noCheckPermView && !h.user.hasPriv(PRIV.PRIV_VIEW_ALL_DOMAIN)) h.checkPerm(PERM.PERM_VIEW);
|
||||
});
|
||||
|
||||
on('app/listen', () => {
|
||||
server.listen();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
global.Hydro.service.server = {
|
||||
...decorators,
|
||||
Types,
|
||||
app,
|
||||
httpServer,
|
||||
wsServer,
|
||||
router,
|
||||
captureAllRoutes,
|
||||
RouteService,
|
||||
HandlerCommon,
|
||||
Handler,
|
||||
ConnectionHandler,
|
||||
Route,
|
||||
Connection,
|
||||
apply,
|
||||
};
|
||||
Reference in new issue
Block a user