mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-17 17:36:08 +08:00
541 lines
19 KiB
TypeScript
541 lines
19 KiB
TypeScript
import http from 'http';
|
|
import { resolve } from 'path';
|
|
import cac from 'cac';
|
|
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 WebSocket from 'ws';
|
|
import { Counter, isClass, parseMemoryMB } from '@hydrooj/utils/lib/utils';
|
|
import { Context, Service } from '../context';
|
|
import {
|
|
HydroError, InvalidOperationError, MethodNotAllowedError,
|
|
NotFoundError, PermissionError, PrivilegeError,
|
|
UserFacingError,
|
|
} from '../error';
|
|
import { DomainDoc } from '../interface';
|
|
import { Logger } from '../logger';
|
|
import { PERM, PRIV } from '../model/builtin';
|
|
import * as opcount from '../model/opcount';
|
|
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 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,
|
|
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;
|
|
session: Record<string, any>;
|
|
render: (name: string, args: any) => Promise<void>;
|
|
renderHTML: (name: string, args: any) => 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.on('error', (error) => {
|
|
if (error.code !== 'EPIPE' && error.code !== 'ECONNRESET' && !error.message.includes('Parse Error')) {
|
|
logger.error('Koa app-level error', { error });
|
|
}
|
|
});
|
|
wsServer.on('error', (error) => {
|
|
console.log('Websocket server error:', error);
|
|
});
|
|
|
|
const ignoredLimit = `,${argv.options.ignoredLimit},`;
|
|
const serializer = (showDisplayName = false) => (k: string, v: any) => {
|
|
if (k.startsWith('_') && k !== '_id') return undefined;
|
|
if (typeof v === 'bigint') return `BigInt::${v.toString()}`;
|
|
if (v instanceof User && !showDisplayName) delete v.displayName;
|
|
return v;
|
|
};
|
|
|
|
export class HandlerCommon {
|
|
render: (name: string, args?: any) => Promise<void>;
|
|
renderHTML: (name: string, args?: any) => Promise<string>;
|
|
url: (name: string, args?: any) => string;
|
|
translate: (key: string) => string;
|
|
session: Record<string, any>;
|
|
/** @deprecated */
|
|
domainId: string;
|
|
ctx: Context = global.app;
|
|
|
|
constructor(
|
|
public context: KoaContext, public args: Record<string, any>,
|
|
public 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;
|
|
}
|
|
|
|
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.domain?.ui?.name || system.get('server.name');
|
|
if (this.UiContext.extraTitleContent) return `${this.translate(str)} - ${this.UiContext.extraTitleContent} - ${name}`;
|
|
return `${this.translate(str)} - ${name}`;
|
|
}
|
|
|
|
checkPerm(...args: bigint[]) {
|
|
if (!this.user.hasPerm(...args)) {
|
|
if (this.user.hasPriv(PRIV.PRIV_USER_PROFILE)) throw new PermissionError(...args);
|
|
throw new PrivilegeError(PRIV.PRIV_USER_PROFILE);
|
|
}
|
|
}
|
|
|
|
checkPriv(...args: number[]) {
|
|
if (!this.user.hasPriv(...args)) throw new PrivilegeError(...args);
|
|
}
|
|
}
|
|
|
|
export class Handler extends HandlerCommon {
|
|
loginMethods: any;
|
|
noCheckPermView = false;
|
|
__param: Record<string, decorators.ParamOption[]>;
|
|
|
|
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 (!argv.options.benchmark) 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;
|
|
}
|
|
|
|
async onerror(error: HydroError) {
|
|
if (!error.msg) error.msg = () => error.message;
|
|
if (error instanceof UserFacingError && !process.env.DEV) error.stack = '';
|
|
if (!(error instanceof NotFoundError)) {
|
|
// eslint-disable-next-line max-len
|
|
logger.error(`User: ${this.user._id}(${this.user.uname}) ${this.request.method}: /d/${this.domain._id}${this.request.path}`, error.msg(), error.params);
|
|
if (error.stack) logger.error(error.stack);
|
|
}
|
|
if (this.user?._id === 0 && (error instanceof PermissionError || error instanceof PrivilegeError)) {
|
|
this.response.redirect = this.url('user_login', {
|
|
query: {
|
|
redirect: (this.context.originalPath || this.request.path) + this.context.search,
|
|
},
|
|
});
|
|
} else {
|
|
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 || '') },
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
async function handle(ctx: KoaContext, HandlerClass, checker) {
|
|
const {
|
|
args, request, response, user, domain, UiContext,
|
|
} = ctx.HydroContext;
|
|
Object.assign(args, ctx.params);
|
|
const h = new HandlerClass(ctx, args, request, response, user, domain, UiContext);
|
|
ctx.handler = h;
|
|
try {
|
|
const method = ctx.method.toLowerCase();
|
|
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}`, 'handler/before-prepare',
|
|
'log/__prepare', '__prepare', '_prepare', 'prepare',
|
|
'log/__prepareDone', `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}`, 'handler/after', 'cleanup',
|
|
`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.body = `${err.message}\n${err.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 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;
|
|
|
|
send(data: any) {
|
|
this.conn.send(JSON.stringify(data, serializer(this.user?.hasPerm(PERM.PREM_VIEW_DISPLAYNAME))));
|
|
}
|
|
|
|
close(code: number, reason: string) {
|
|
this.conn.close(code, reason);
|
|
}
|
|
|
|
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());
|
|
}
|
|
}
|
|
|
|
export function Connection(
|
|
name: string, prefix: string,
|
|
RouteConnHandler: any,
|
|
...permPrivChecker: Array<number | bigint | Function>
|
|
) {
|
|
const checker = Checker(permPrivChecker);
|
|
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;
|
|
try {
|
|
checker.call(h);
|
|
if (h._prepare) await h._prepare(args);
|
|
if (h.prepare) await h.prepare(args);
|
|
if (h.message) {
|
|
conn.onmessage = (e) => {
|
|
h.message(JSON.parse(e.data.toString()));
|
|
};
|
|
}
|
|
conn.onclose = () => {
|
|
bus.emit('connection/close', h);
|
|
h.cleanup?.(args);
|
|
};
|
|
await bus.parallel('connection/active', h);
|
|
} 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 {
|
|
static readonly methods = ['Route', 'Connection', 'withHandlerClass'];
|
|
private registry = {};
|
|
private registrationCount = Counter();
|
|
|
|
constructor(ctx) {
|
|
super(ctx, 'server', true);
|
|
}
|
|
|
|
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.caller?.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.caller?.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) {
|
|
Context.service('server', RouteService);
|
|
pluginContext.server = new RouteService(pluginContext);
|
|
app.keys = system.get('session.keys') as unknown as string[];
|
|
if (process.env.HYDRO_CLI) return;
|
|
const proxyMiddleware = proxy('/fs', {
|
|
target: builtinConfig.file.endPoint,
|
|
changeOrigin: true,
|
|
rewrite: (p) => p.replace('/fs', ''),
|
|
});
|
|
app.use(async (ctx, next) => {
|
|
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();
|
|
await next();
|
|
const endTime = Date.now();
|
|
if (ctx.nolog || ctx.response.headers.nolog) return;
|
|
ctx._remoteAddress = ctx.request.ip;
|
|
logger.debug(`${ctx.request.method} /${ctx.domainId || 'system'}${ctx.request.path} \
|
|
${ctx.response.status} ${endTime - startTime}ms ${ctx.response.length}`);
|
|
});
|
|
}
|
|
app.use(Body({
|
|
multipart: true,
|
|
jsonLimit: '8mb',
|
|
formLimit: '8mb',
|
|
formidable: {
|
|
maxFileSize: parseMemoryMB(system.get('server.upload') || '256m') * 1024 * 1024,
|
|
},
|
|
}));
|
|
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());
|
|
layers.forEach((layer) => router.use(layer as any));
|
|
layers.forEach((layer) => app.use(layer as any));
|
|
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) { }
|
|
});
|
|
const ctx: any = app.createContext(request, {} as any);
|
|
await domainLayer(ctx, () => baseLayer(ctx, () => layers[1](ctx, () => userLayer(ctx, () => { }))));
|
|
for (const manager of router.wsStack) {
|
|
if (manager.accept(socket, request, ctx)) return;
|
|
}
|
|
socket.close();
|
|
});
|
|
const port = system.get('server.port');
|
|
pluginContext.on('app/ready', async () => {
|
|
await new Promise((r) => {
|
|
httpServer.listen(argv.options.port || port, () => {
|
|
logger.success('Server listening at: %d', argv.options.port || port);
|
|
r(true);
|
|
});
|
|
});
|
|
pluginContext.on('dispose', () => {
|
|
httpServer.close();
|
|
wsServer.close();
|
|
});
|
|
});
|
|
}
|
|
|
|
global.Hydro.service.server = {
|
|
...decorators,
|
|
app,
|
|
httpServer,
|
|
wsServer,
|
|
router,
|
|
captureAllRoutes,
|
|
RouteService,
|
|
HandlerCommon,
|
|
Handler,
|
|
ConnectionHandler,
|
|
Route,
|
|
Connection,
|
|
apply,
|
|
};
|