core: add server-loader (#816)

This commit is contained in:
undefined authored and GitHub committed 2024-06-03 19:29:41 +08:00
1 parent 3302480348
commit 97720efb65
57 files changed
+1328 -1131

No files matched your search

+126
View File
@@ -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);
}
}
}
};
+102
View File
@@ -0,0 +1,102 @@
import { ValidationError } from './error';
import type { Handler } from './server';
import { Converter, Type, Validator } from './validator';
type MethodDecorator = (target: any, funcName: string, obj: any) => any;
type ClassDecorator = <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: string) => 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;
};
};
+71
View File
@@ -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;
+7
View File
@@ -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';
+16
View File
@@ -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
}
+33
View File
@@ -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"
}
}
+60
View File
@@ -0,0 +1,60 @@
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;
}
}
+11
View File
@@ -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;
};
}
+674
View File
@@ -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);
}
+199
View File
@@ -0,0 +1,199 @@
import assert from 'assert';
import emojiRegex from 'emoji-regex';
import { isSafeInteger } from 'lodash';
import moment from 'moment-timezone';
import sanitize from 'sanitize-filename';
import saslprep from 'saslprep';
type InputType = string | number | Record<string, any> | any[];
export type Converter<T> = (value: any) => T;
export type Validator<Loose extends boolean = true> = (value: Loose extends true ? any : InputType) => boolean;
export type Type<T> = readonly [Converter<T>, Validator<false>?, (boolean | 'convert')?];
export interface Types {
// String outputs
Content: Type<string>;
Key: Type<string>;
/** @deprecated */
Name: Type<string>;
Username: Type<string>;
Password: Type<string>;
UidOrName: Type<string>;
Email: Type<string>;
Filename: Type<string>;
DomainId: Type<string>;
ProblemId: Type<string | number>;
Role: Type<string>;
Title: Type<string>;
Emoji: Type<string>;
String: Type<string>;
// Number outputs
Int: Type<number>;
UnsignedInt: Type<number>;
PositiveInt: Type<number>;
Float: Type<number>;
// Other
ObjectId: Type<typeof import('mongodb').ObjectId>;
Boolean: Type<boolean>;
Date: Type<string>;
Time: Type<string>;
Range: <T extends string | number>(range: Array<T> | Record<string, any>) => Type<T>;
/** @deprecated */
Array: Type<any[]>;
NumericArray: Type<number[]>;
CommaSeperatedArray: Type<string[]>;
Set: Type<Set<any>>;
Any: Type<any>;
ArrayOf: <T extends Type<any>>(type: T) => (T extends Type<infer R> ? Type<R[]> : never);
AnyOf: <T extends Type<any>>(...type: T[]) => (T extends Type<infer R> ? Type<R> : never);
}
const basicString = <T = string>(regex?: RegExp, cb?: (i: string) => boolean, convert?: (i: string) => T) => [
convert || ((v) => v.toString()),
(v) => {
const res = v.toString();
if (regex && !regex.test(res)) return false;
if (cb && !cb(res)) return false;
return !!res.length;
},
] as [(v) => string, (v) => boolean];
const saslprepString = <T = string>(regex?: RegExp, cb?: (i: string) => boolean, convert?: (i: string) => T) => [
convert || ((v) => saslprep(v.toString().trim())),
(v) => {
try {
const res = saslprep(v.toString().trim());
if (regex && !regex.test(res)) return false;
if (cb && !cb(res)) return false;
return !!res.length;
} catch (e) {
return false;
}
},
] as [(v) => string, (v) => boolean];
export const Types: Types = {
Content: [(v) => v.toString().trim(), (v) => v?.toString()?.trim() && v.toString().trim().length < 65536],
Key: saslprepString(/^[a-zA-Z0-9-_]+$/),
/** @deprecated */
Name: saslprepString(/^.{1,255}$/),
Filename: saslprepString(/^[^\\/?#~!|*]{1,255}$/, (i) => sanitize(i) === i),
UidOrName: saslprepString(/^(.{3,31}|[\u4e00-\u9fa5]{2}|-?[0-9]+)$/),
Username: saslprepString(/^(.{3,31}|[\u4e00-\u9fa5]{2})$/),
Password: basicString(/^.{6,255}$/),
ProblemId: saslprepString(/^[a-zA-Z0-9]+$/i, () => true, (s) => (Number.isSafeInteger(+s) ? +s : s)),
Email: saslprepString(/^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+$/i),
DomainId: saslprepString(/^[a-zA-Z][a-zA-Z0-9_]{3,31}$/),
Role: saslprepString(/^[_0-9A-Za-z\u4e00-\u9fa5]{1,31}$/i),
Title: basicString(/^.{1,64}$/, (i) => !!i.trim()),
String: basicString(),
Int: [(v) => +v, (v) => /^[+-]?[0-9]+$/.test(v.toString().trim()) && isSafeInteger(+v)],
UnsignedInt: [(v) => +v, (v) => /^(-0|\+?[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)],
ObjectId: [() => { throw new Error('mongodb package not found'); }, () => true],
Boolean: [(v) => !!(v && !['false', 'off'].includes(v)), null, true],
Date: [
(v) => {
const d = v.split('-');
assert(d.length === 3);
return `${d[0]}-${d[1].length === 1 ? '0' : ''}${d[1]}-${d[2].length === 1 ? '0' : ''}${d[2]}`;
},
(v) => {
const d = v.toString().split('-');
if (d.length !== 3) return false;
const st = `${d[0]}-${d[1].length === 1 ? '0' : ''}${d[1]}-${d[2].length === 1 ? '0' : ''}${d[2]}`;
return moment(st).isValid();
},
],
Time: [
(v) => {
const t = v.split(':');
assert(t.length === 2);
return `${(t[0].length === 1 ? '0' : '') + t[0]}:${t[1].length === 1 ? '0' : ''}${t[1]}`;
},
(v) => {
const t = v.toString().split(':');
if (t.length !== 2) return false;
return moment(`2020-01-01 ${(t[0].length === 1 ? '0' : '') + t[0]}:${t[1].length === 1 ? '0' : ''}${t[1]}`).isValid();
},
],
Range: (range) => [
(v) => {
if (range instanceof Array) {
for (const item of range) {
if (typeof item === 'number' && item === +v) return +v;
if (item === v) return v;
}
}
return v;
},
(v) => {
if (range instanceof Array) {
for (const item of range) {
if (typeof item === 'string' && item === v) return true;
if (typeof item === 'number' && item === +v) return true;
}
} else {
for (const key in range) {
if (key === v) return true;
}
}
return false;
},
],
/** @deprecated suggested to use Types.ArrayOf instead. */
Array: [(v) => {
if (v instanceof Array) return v;
return v ? [v] : [];
}, null],
NumericArray: [(v) => {
if (v instanceof Array) return v.map(Number);
return v.split(',').map(Number);
}, (v) => {
if (v instanceof Array) return v.map(Number).every(Number.isSafeInteger);
return v.toString().split(',').map(Number).every(Number.isSafeInteger);
}],
CommaSeperatedArray: [
(v) => v.toString().replace(/,/g, ',').split(',').map((e) => e.trim()).filter((i) => i),
(v) => !!v.toString(),
],
Set: [(v) => {
if (v instanceof Array) return new Set(v);
return v ? new Set([v]) : new Set();
}, null],
Emoji: [
(v: string) => v.matchAll(emojiRegex()).next().value[0],
(v) => emojiRegex().test(v.toString()),
],
Any: [(v) => v, null],
ArrayOf: (type) => [
(v) => {
const arr = v instanceof Array ? v : [v];
return arr.map((i) => type[0](i));
},
(v) => {
if (!type[1]) return true;
const arr = v instanceof Array ? v : [v];
return arr.every((i) => type[1](i));
},
] as any,
AnyOf: (...types) => [
(v) => types.find((type) => type[1](v))[0](v),
(v) => types.some((type) => type[1](v)),
] as any,
};
try {
const { ObjectId } = require('mongodb');
Types.ObjectId = [(v) => new ObjectId(v), ObjectId.isValid];
} catch (e) {
}
// @ts-ignore
Types.ObjectID = Types.ObjectId;
// backward compatibility