import http from 'http'; import cac from 'cac'; import Koa, { Context } from 'koa'; import Body from 'koa-body'; import Compress from 'koa-compress'; import proxy from 'koa-proxies'; import cache from 'koa-static-cache'; import { filter } from 'lodash'; import tx2 from 'tx2'; import WebSocket from 'ws'; import { parseMemoryMB } from '@hydrooj/utils/lib/utils'; import { HydroError, InvalidOperationError, MethodNotAllowedError, NotFoundError, PermissionError, PrivilegeError, UserFacingError, } from '../error'; import { DomainDoc, User } 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 { 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'; export * from './decorators'; export interface HydroRequest { method: string; host: string; hostname: string; ip: string; headers: any; cookies: any; body: any; files: Record; query: any; path: 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; UiContext: Record; } export interface KoaContext extends Koa.DefaultContext { HydroContext: HydroContext; handler: any; session: Record; render: (name: string, args: any) => Promise; renderHTML: (name: string, args: any) => Promise; getUrl: (name: string, args: any) => string; translate: (key: string) => string; } const argv = cac().parse(); const logger = new Logger('server'); const reqCount = tx2.meter({ name: 'req/sec', samples: 1, timeframe: 60, }); export const app = new Koa({ keys: system.get('server.keys'), }); export const router = new Router(); export const httpServer = http.createServer(app.callback()); export const wsServer = new WebSocket.Server({ server: httpServer }); 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},`; function serializer(k: string, v: any) { if (k.startsWith('_') && k !== '_id') return undefined; if (typeof v === 'bigint') return `BigInt::${v.toString()}`; return v; } export async function prepare() { app.keys = system.get('session.keys') as unknown as string[]; app.use(proxy('/fs', { target: system.get('file.endPoint'), changeOrigin: true, rewrite: (p) => p.replace('/fs', ''), })); app.use(Compress()); for (const dir of global.publicDirs) { app.use(cache(dir, { maxAge: argv.options.public ? 0 : 24 * 3600 * 1000, })); } if (process.env.DEV) { app.use(async (ctx: Context, next: Function) => { const startTime = new Date().getTime(); await next(); const endTime = new Date().getTime(); 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(router), userLayer]; app.use(async (ctx, next) => await next().catch(console.error)).use(domainLayer); layers.forEach((layer) => router.use(layer as any)); wsServer.on('connection', async (socket, request) => { 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(); }); } export class HandlerCommon { render: (name: string, args?: any) => Promise; renderHTML: (name: string, args?: any) => Promise; url: (name: string, args?: any) => string; translate: (key: string) => string; session: Record; /** @deprecated */ domainId: string; constructor( public ctx: KoaContext, public args: Record, public request: HydroRequest, public response: HydroResponse, public user: User, public domain: DomainDoc, public UiContext: Record, ) { this.render = ctx.render.bind(ctx); this.renderHTML = ctx.renderHTML.bind(ctx); this.url = ctx.getUrl.bind(ctx); this.translate = ctx.translate.bind(ctx); this.session = ctx.session; this.domainId = args.domainId; } async limitRate(op: string, periodSecs: number, maxOperations: number) { 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; await opcount.inc(op, this.request.ip, periodSecs, maxOperations); } renderTitle(str: string) { const name = this.domain?.ui?.name || system.get('server.name'); if (this.UiContext.extraTitleContent) return `${this.ctx.translate(str)} - ${this.UiContext.extraTitleContent} - ${name}`; return `${this.ctx.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; 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="${encodeURIComponent(name)}"`; } async init() { if (!argv.options.benchmark) await this.limitRate('global', 5, 88); if (!this.noCheckPermView && !this.user.hasPriv(PRIV.PRIV_VIEW_ALL_DOMAIN)) this.checkPerm(PERM.PERM_VIEW); this.loginMethods = filter(Object.keys(global.Hydro.lib), (str) => str.startsWith('oauth_')) .map((key) => ({ id: key.split('_')[1], icon: global.Hydro.lib[key].icon, text: global.Hydro.lib[key].text, })); } async onerror(error: HydroError) { if (!error.msg) error.msg = () => error.message; if (error instanceof UserFacingError && !process.env.DEV) error.stack = ''; if (!(error instanceof NotFoundError)) { logger.error(`User: ${this.user._id}(${this.user.uname}) ${this.request.method}: ${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.ctx.getUrl('user_login', { query: { redirect: this.request.path + this.ctx.search } }); } else { this.response.status = error instanceof UserFacingError ? error.code : 500; this.response.template = error instanceof UserFacingError ? 'error.html' : 'bsod.html'; this.response.body = { error: { message: error.msg(), params: error.params, stack: errorMessage(error.stack || '') }, }; } } } async function bail(name: string, ...args: any[]) { const r = await bus.bail(name, ...args); if (r instanceof Error) throw r; } async function handle(ctx: KoaContext, HandlerClass, checker) { reqCount.mark(); 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.serial('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); } await bail('handler/init', h); await bail(`handler/before-prepare/${HandlerClass.name.replace(/Handler$/, '')}`, h); await bail('handler/before-prepare', h); h.args.__prepare = Date.now(); if (h._prepare) await h._prepare(args); if (h.prepare) await h.prepare(args); h.args.__prepareDone = Date.now(); await bail(`handler/before/${HandlerClass.name.replace(/Handler$/, '')}`, h); await bail('handler/before', h); h.args.__method = Date.now(); if (h.all) await h.all(args); if (h[method]) await h[method](args); h.args.__methodDone = Date.now(); await bail(`handler/before-operation/${HandlerClass.name.replace(/Handler$/, '')}`, h); await bail('handler/before-operation', h); if (operation) await h[`post${operation}`](args); await bail(`handler/after/${HandlerClass.name.replace(/Handler$/, '')}`, h); await bail('handler/after', h); if (h.cleanup) await h.cleanup(args); await bail(`handler/finish/${HandlerClass.name.replace(/Handler$/, '')}`, h); await bail('handler/finish', h); } catch (e) { try { await bail(`handler/error/${HandlerClass.name.replace(/Handler$/, '')}`, h, e); await bail('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))); } export class ConnectionHandler extends HandlerCommon { conn: WebSocket; send(data: any) { this.conn.send(JSON.stringify(data, serializer)); } close(code: number, reason: string) { this.conn.close(code, reason); } onerror(err: HydroError) { if (err instanceof UserFacingError) err.stack = this.ctx.HydroContext.request.path; if (!(err instanceof NotFoundError) && !((err instanceof PrivilegeError || err instanceof PermissionError) && this.user?._id === 0)) { logger.error(`Path:${this.ctx.HydroContext.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 ) { 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); 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 = () => h.cleanup?.(args); } catch (e) { await h.onerror(e); } }); } let started = false; // TODO use postInit? export async function start() { if (started) return; const port = system.get('server.port'); app.use(router.routes()).use(router.allowedMethods()); await new Promise((resolve) => { httpServer.listen(argv.options.port || port, () => { logger.success('Server listening at: %d', argv.options.port || port); started = true; resolve(true); }); }); } global.Hydro.service.server = { ...decorators, app, httpServer, wsServer, router, HandlerCommon, Handler, ConnectionHandler, Route, Connection, prepare, start, };