mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-17 17:36:08 +08:00
868 lines
31 KiB
TypeScript
868 lines
31 KiB
TypeScript
import assert from 'assert';
|
|
import { PassThrough } from 'stream';
|
|
import { resolve } from 'path';
|
|
import os from 'os';
|
|
import http from 'http';
|
|
import moment from 'moment-timezone';
|
|
import {
|
|
isSafeInteger, Dictionary, filter, cloneDeep,
|
|
} from 'lodash';
|
|
import { ObjectID } from 'mongodb';
|
|
import Koa, { Context } from 'koa';
|
|
import Body from 'koa-body';
|
|
import Compress from 'koa-compress';
|
|
import Router from 'koa-router';
|
|
import proxy from 'koa-proxies';
|
|
import cache from 'koa-static-cache';
|
|
import sockjs from 'sockjs';
|
|
import type { SetOption } from 'cookies';
|
|
import { argv } from 'yargs';
|
|
import { createHash } from 'crypto';
|
|
import * as bus from './bus';
|
|
import { errorMessage } from '../utils';
|
|
import { User, DomainDoc } from '../interface';
|
|
import { Logger } from '../logger';
|
|
import {
|
|
UserNotFoundError, BlacklistedError, PermissionError,
|
|
UserFacingError, ValidationError, PrivilegeError,
|
|
CsrfTokenError, InvalidOperationError, MethodNotAllowedError,
|
|
NotFoundError, HydroError, SystemError,
|
|
} from '../error';
|
|
import { isContent, isName, isTitle } from '../lib/validator';
|
|
import avatar from '../lib/avatar';
|
|
import user from '../model/user';
|
|
import domain from '../model/domain';
|
|
import * as system from '../model/system';
|
|
import blacklist from '../model/blacklist';
|
|
import token from '../model/token';
|
|
import * as opcount from '../model/opcount';
|
|
import { PERM, PRIV } from '../model/builtin';
|
|
|
|
const logger = new Logger('server');
|
|
export const app = new Koa();
|
|
export const server = http.createServer(app.callback());
|
|
export const router = new Router();
|
|
|
|
type MethodDecorator = (target: any, name: string, obj: any) => any;
|
|
type Converter = (value: any) => any;
|
|
type Validator = (value: any) => boolean;
|
|
interface ParamOption {
|
|
name: string,
|
|
source: 'all' | 'get' | 'post' | 'route',
|
|
isOptional?: boolean,
|
|
convert?: Converter,
|
|
validate?: Validator,
|
|
}
|
|
|
|
type Type = [Converter, Validator, boolean?];
|
|
|
|
export interface Types {
|
|
Content: Type,
|
|
Name: Type,
|
|
Title: Type,
|
|
String: Type,
|
|
Int: Type,
|
|
UnsignedInt: Type,
|
|
PositiveInt: Type,
|
|
Float: Type,
|
|
ObjectID: Type,
|
|
Boolean: Type,
|
|
Date: Type,
|
|
Time: Type,
|
|
Range: (range: Array<string | number> | Dictionary<any>) => Type,
|
|
Array: Type,
|
|
NumericArray: Type,
|
|
Set: Type,
|
|
}
|
|
|
|
export const Types: Types = {
|
|
Content: [(v) => v.toString(), isContent],
|
|
Name: [(v) => v.toString(), isName],
|
|
Title: [(v) => v.toString(), isTitle],
|
|
String: [(v) => v.toString(), null],
|
|
Int: [(v) => parseInt(v, 10), (v) => isSafeInteger(parseInt(v, 10))],
|
|
UnsignedInt: [(v) => parseInt(v, 10), (v) => parseInt(v, 10) >= 0],
|
|
PositiveInt: [(v) => parseInt(v, 10), (v) => parseInt(v, 10) > 0],
|
|
Float: [(v) => parseFloat(v), (v) => {
|
|
const t = parseFloat(v);
|
|
return t && !Number.isNaN(t) && Number.isFinite(t);
|
|
}],
|
|
// eslint-disable-next-line no-shadow
|
|
ObjectID: [(v) => new ObjectID(v), ObjectID.isValid],
|
|
Boolean: [(v) => !!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.split('-');
|
|
assert(d.length === 3);
|
|
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.split(':');
|
|
assert(t.length === 2);
|
|
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') {
|
|
if (item === parseInt(v, 10)) return parseInt(v, 10);
|
|
} else if (item === v) return v;
|
|
}
|
|
}
|
|
return v;
|
|
},
|
|
(v) => {
|
|
if (range instanceof Array) {
|
|
for (const item of range) {
|
|
if (typeof item === 'string') {
|
|
if (item === v) return true;
|
|
} else if (typeof item === 'number') {
|
|
if (item === parseInt(v, 10)) return true;
|
|
}
|
|
}
|
|
} else {
|
|
for (const key in range) {
|
|
if (key === v) return true;
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
],
|
|
Array: [(v) => {
|
|
if (v instanceof Array) return v;
|
|
return v ? [v] : [];
|
|
}, null],
|
|
NumericArray: [(v) => {
|
|
if (v instanceof Array) return v.map(Number);
|
|
return v ? [Number(v)] : [];
|
|
}, (v) => {
|
|
if (v instanceof Array) return !v.map(Number).includes(NaN);
|
|
return !Number.isNaN(+v);
|
|
}],
|
|
Set: [(v) => {
|
|
if (v instanceof Array) return new Set(v);
|
|
return v ? new Set([v]) : new Set();
|
|
}, null],
|
|
};
|
|
|
|
function _buildParam(name: string, source: 'get' | 'post' | 'all' | 'route', ...args: Array<Type | boolean | Validator | Converter>) {
|
|
let cursor = 0;
|
|
const v: ParamOption = { 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) {
|
|
return function desc(this: Handler, target: any, funcName: string, obj: any) {
|
|
if (!target.__param) target.__param = {};
|
|
if (!target.__param[target.constructor.name]) target.__param[target.constructor.name] = {};
|
|
if (!target.__param[target.constructor.name][funcName]) {
|
|
target.__param[target.constructor.name][funcName] = [{ name: 'domainId', type: 'string', source: 'route' }];
|
|
const originalMethod = obj.value;
|
|
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[] = this.__param[target.constructor.name][funcName];
|
|
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.domainId }
|
|
: this.request.body;
|
|
const value = src[item.name];
|
|
if (!item.isOptional || value) {
|
|
if (!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 c.push(undefined);
|
|
}
|
|
return originalMethod.call(this, ...c);
|
|
};
|
|
}
|
|
target.__param[target.constructor.name][funcName].splice(1, 0, v);
|
|
return obj;
|
|
};
|
|
}
|
|
|
|
type DescriptorBuilder =
|
|
((name: string, type: Type) => MethodDecorator)
|
|
& ((name: string, type: Type, validate: null, convert: Converter) => MethodDecorator)
|
|
& ((name: string, type: Type, validate?: Validator, convert?: Converter) => MethodDecorator)
|
|
& ((name: string, type?: Type, isOptional?: boolean, validate?: Validator, convert?: Converter) => MethodDecorator)
|
|
& ((name: string, ...args: Array<Type | boolean | Validator | Converter>) => 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 function requireCsrfToken(target: any, funcName: string, obj: any) {
|
|
const originalMethod = obj.value;
|
|
obj.value = async function checkCsrfToken(...args: any[]) {
|
|
if (this.getCsrfToken(this.session._id) !== this.args.csrfToken) {
|
|
throw new CsrfTokenError(this.args.csrfToken);
|
|
}
|
|
return await originalMethod.call(this, ...args);
|
|
};
|
|
return obj;
|
|
}
|
|
|
|
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());
|
|
if (argv.public) {
|
|
app.use(cache(argv.public, {
|
|
maxAge: 0,
|
|
}));
|
|
} else {
|
|
app.use(cache(resolve(os.tmpdir(), 'hydro', 'public'), {
|
|
maxAge: 30 * 24 * 60 * 60,
|
|
}));
|
|
}
|
|
if (argv.debug) {
|
|
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.request.path} ${ctx.response.status} ${endTime - startTime}ms ${ctx.response.length}`);
|
|
});
|
|
}
|
|
app.use(Body({
|
|
multipart: true,
|
|
formidable: {
|
|
maxFileSize: 256 * 1024 * 1024,
|
|
},
|
|
}));
|
|
}
|
|
|
|
export interface UiContextBase {
|
|
cdn_prefix: string;
|
|
url_prefix: string;
|
|
}
|
|
export const UiContextBase: UiContextBase = {
|
|
cdn_prefix: '/',
|
|
url_prefix: '/',
|
|
};
|
|
|
|
function serializer(k: string, v: any) {
|
|
if (k.startsWith('_') && k !== '_id') return undefined;
|
|
if (typeof v === 'bigint') return `BigInt::${v.toString()}`;
|
|
return v;
|
|
}
|
|
|
|
export class HandlerCommon {
|
|
domainId: string;
|
|
domain: DomainDoc;
|
|
user: User;
|
|
session: Record<string, any>;
|
|
request: Record<string, any>;
|
|
extraTitleContent?: string;
|
|
|
|
async limitRate(op: string, periodSecs: number, maxOperations: number) {
|
|
if (this.user && this.user.hasPriv(PRIV.PRIV_UNLIMITED_ACCESS)) return;
|
|
await opcount.inc(op, this.request.ip, periodSecs, maxOperations);
|
|
}
|
|
|
|
translate(str: string) {
|
|
if (!str) return '';
|
|
return str.toString().translate(this.user.viewLang, this.session.viewLang, system.get('server.language'));
|
|
}
|
|
|
|
renderTitle(str: string) {
|
|
if (this.extraTitleContent) return `${this.translate(str)} - ${this.extraTitleContent} - ${system.get('server.name')}`;
|
|
return `${this.translate(str)} - ${system.get('server.name')}`;
|
|
}
|
|
|
|
checkPerm(...args: bigint[]) {
|
|
// @ts-ignore
|
|
if (!this.user.hasPerm(...args)) throw new PermissionError(...args);
|
|
}
|
|
|
|
checkPriv(...args: number[]) {
|
|
// @ts-ignore
|
|
if (!this.user.hasPriv(...args)) throw new PrivilegeError(...args);
|
|
}
|
|
|
|
async renderHTML(name: string, context: any): Promise<string> {
|
|
const UserContext: any = {
|
|
...this.user,
|
|
avatar: avatar(this.user.avatar || '', 128),
|
|
perm: this.user.perm.toString(),
|
|
viewLang: this.translate('__id'),
|
|
};
|
|
if (!global.Hydro.lib.template) return JSON.stringify(context, serializer);
|
|
const res = await global.Hydro.lib.template.render(name, {
|
|
handler: this,
|
|
UserContext,
|
|
url: this.url.bind(this),
|
|
_: this.translate.bind(this),
|
|
...context,
|
|
});
|
|
return res;
|
|
}
|
|
|
|
url(name: string, kwargs: any = {}) {
|
|
let res = '#';
|
|
const args: any = {};
|
|
// eslint-disable-next-line @typescript-eslint/no-shadow
|
|
const query: any = {};
|
|
for (const key in kwargs) {
|
|
if (kwargs[key] instanceof ObjectID) args[key] = kwargs[key].toHexString();
|
|
else args[key] = encodeURIComponent(kwargs[key].toString());
|
|
}
|
|
for (const key in kwargs.query || {}) {
|
|
if (query[key] instanceof ObjectID) query[key] = kwargs.query[key].toHexString();
|
|
else query[key] = encodeURIComponent(kwargs.query[key].toString());
|
|
}
|
|
try {
|
|
const { anchor } = args;
|
|
if (args.domainId) name += '_with_domainId';
|
|
else if ((!this.request.host || this.domain?.host !== this.request.host) && this.domainId !== 'system') {
|
|
name += '_with_domainId';
|
|
args.domainId = this.domainId;
|
|
}
|
|
res = router.url(name, args, { query });
|
|
if (anchor) return `${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;
|
|
}
|
|
}
|
|
|
|
export class Handler extends HandlerCommon {
|
|
UiContext: any;
|
|
args: any;
|
|
ctx: Koa.Context;
|
|
|
|
request: {
|
|
method: string,
|
|
host: string,
|
|
hostname: string,
|
|
ip: string,
|
|
headers: any,
|
|
cookies: any,
|
|
body: any,
|
|
files: any,
|
|
query: any,
|
|
path: string,
|
|
params: any,
|
|
referer: string,
|
|
json: boolean,
|
|
};
|
|
|
|
response: {
|
|
body: any,
|
|
type: string,
|
|
status: number,
|
|
template?: string,
|
|
redirect?: string,
|
|
disposition?: string,
|
|
attachment: (name: string, stream?: any) => void,
|
|
};
|
|
|
|
csrfToken: string;
|
|
loginMethods: any;
|
|
noCheckPermView: boolean;
|
|
__param: Record<string, ParamOption[]>;
|
|
|
|
constructor(ctx: Koa.Context) {
|
|
super();
|
|
this.ctx = ctx;
|
|
this.request = {
|
|
method: ctx.request.method.toLowerCase(),
|
|
host: ctx.request.host,
|
|
hostname: ctx.request.hostname,
|
|
ip: ctx.request.ip,
|
|
headers: ctx.request.headers,
|
|
cookies: ctx.cookies,
|
|
body: ctx.request.body,
|
|
files: ctx.request.files,
|
|
query: ctx.query,
|
|
path: ctx.path,
|
|
params: ctx.params,
|
|
referer: ctx.request.headers.referer,
|
|
json: (ctx.request.headers.accept || '').includes('application/json'),
|
|
};
|
|
this.response = {
|
|
body: {},
|
|
type: '',
|
|
status: null,
|
|
template: null,
|
|
redirect: null,
|
|
attachment: (name, streamOrBuffer) => {
|
|
ctx.attachment(name);
|
|
if (streamOrBuffer instanceof Buffer) {
|
|
this.response.body = null;
|
|
ctx.body = streamOrBuffer;
|
|
} else {
|
|
this.response.body = null;
|
|
ctx.body = streamOrBuffer.pipe(new PassThrough());
|
|
}
|
|
},
|
|
disposition: null,
|
|
};
|
|
this.UiContext = cloneDeep(UiContextBase);
|
|
this.UiContext.cdn_prefix = system.get('server.cdn');
|
|
this.session = {};
|
|
const [xff, xhost] = system.getMany(['server.xff', 'server.xhost']);
|
|
if (xff) this.request.ip = this.request.headers[xff.toLowerCase()] || this.request.ip;
|
|
if (xhost) this.request.host = this.request.headers[xhost.toLowerCase()] || this.request.host;
|
|
this.noCheckPermView = false;
|
|
}
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
getCsrfToken(id: string) {
|
|
return createHash('md5').update(`csrf_token${id}`).digest('hex');
|
|
}
|
|
|
|
async render(name: string, context: any) {
|
|
this.response.body = await this.renderHTML(name, context);
|
|
this.response.type = 'text/html';
|
|
}
|
|
|
|
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 getSession() {
|
|
const sid = this.request.cookies.get('sid');
|
|
this.session = await token.get(sid, token.TYPE_SESSION);
|
|
if (!this.session) this.session = { uid: 0 };
|
|
}
|
|
|
|
async init({ domainId }) {
|
|
if (!argv.benchmark) await this.limitRate('global', 10, 88);
|
|
const [absoluteDomain, inferDomain, bdoc] = await Promise.all([
|
|
domain.get(domainId),
|
|
domain.getByHost(this.request.host),
|
|
blacklist.get(`ip::${this.request.ip}`),
|
|
this.getSession(),
|
|
]);
|
|
if (bdoc) throw new BlacklistedError(this.request.ip);
|
|
if (inferDomain && !this.request.path.startsWith('/d/')) {
|
|
this.domainId = inferDomain._id;
|
|
this.args.domainId = inferDomain._id;
|
|
this.domain = inferDomain;
|
|
domainId = inferDomain._id;
|
|
} else if (absoluteDomain) this.domain = absoluteDomain;
|
|
else {
|
|
this.args.domainId = 'system';
|
|
this.user = await user.getById('system', this.session.uid);
|
|
if (!this.user) this.user = await user.getById('system', 0);
|
|
throw new NotFoundError(domainId);
|
|
}
|
|
this.UiContext.domainId = this.domainId;
|
|
this.user = await user.getById(domainId, this.session.uid, this.session.scope);
|
|
if (!this.user) {
|
|
this.session.uid = 0;
|
|
this.session.scope = PERM.PERM_ALL.toString();
|
|
this.user = await user.getById(domainId, this.session.uid, this.session.scope);
|
|
}
|
|
if (this.user._id === 0 && this.session.viewLang) this.user.viewLang = this.session.viewLang;
|
|
this.user.avatarUrl = avatar(this.user.avatar, 128);
|
|
this.csrfToken = this.getCsrfToken(this.session._id || String.random(32));
|
|
this.UiContext.csrfToken = this.csrfToken;
|
|
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,
|
|
}));
|
|
if (!this.noCheckPermView) this.checkPerm(PERM.PERM_VIEW);
|
|
if (this.request.method === 'post' && this.request.headers.referer) {
|
|
const host = new URL(this.request.headers.referer).host;
|
|
if (host !== this.request.host) throw new CsrfTokenError(host);
|
|
}
|
|
}
|
|
|
|
async finish() {
|
|
if (!this.response.body) return;
|
|
try {
|
|
await this.renderBody();
|
|
} catch (err) {
|
|
const error = errorMessage(err);
|
|
this.response.status = error instanceof UserFacingError ? error.code : 500;
|
|
if (this.request.json) this.response.body = { error };
|
|
else {
|
|
try {
|
|
await this.render(error instanceof UserFacingError ? 'error.html' : 'bsod.html', { error });
|
|
} catch (e) {
|
|
logger.error(e);
|
|
// this.response.body.error = {};
|
|
}
|
|
}
|
|
}
|
|
await this.putResponse();
|
|
await this.saveCookie();
|
|
}
|
|
|
|
async renderBody() {
|
|
if (this.response.redirect) {
|
|
this.response.body = this.response.body || {};
|
|
this.response.body.url = this.response.redirect;
|
|
}
|
|
if (this.response.type) return;
|
|
if (
|
|
this.request.json || this.response.redirect
|
|
|| this.request.query.noTemplate || !this.response.template) {
|
|
try {
|
|
this.response.body = JSON.stringify(this.response.body, serializer);
|
|
} catch (e) {
|
|
this.response.body = new SystemError('Serialize failure', e.message);
|
|
}
|
|
this.response.type = 'application/json';
|
|
} else if (this.response.body || this.response.template) {
|
|
const templateName = this.request.query.template || this.response.template;
|
|
if (templateName) {
|
|
this.response.body = this.response.body || {};
|
|
const s = templateName.split('.');
|
|
if (global.Hydro.ui.template[`${s[0]}.${this.domainId}.${s[1]}`]) {
|
|
await this.render(`${s[0]}.${this.domainId}.${s[1]}`, this.response.body);
|
|
} else await this.render(templateName, this.response.body);
|
|
}
|
|
}
|
|
}
|
|
|
|
async putResponse() {
|
|
if (this.response.disposition) this.ctx.set('Content-Disposition', this.response.disposition);
|
|
if (!this.response.body) return;
|
|
if (this.response.redirect && !this.request.json) {
|
|
this.ctx.response.type = 'application/octet-stream';
|
|
this.ctx.response.status = 302;
|
|
this.ctx.redirect(this.response.redirect);
|
|
} else {
|
|
this.ctx.response.body = this.response.body;
|
|
this.ctx.response.status = this.response.status || 200;
|
|
this.ctx.response.type = this.request.json
|
|
? 'application/json'
|
|
: this.response.type
|
|
? this.response.type
|
|
: this.ctx.response.type;
|
|
}
|
|
}
|
|
|
|
async saveCookie() {
|
|
const expireSeconds = this.session.save
|
|
? system.get('session.saved_expire_seconds')
|
|
: system.get('session.unsaved_expire_seconds');
|
|
const $update = {
|
|
updateIp: this.request.ip,
|
|
updateUa: this.request.headers['user-agent'] || '',
|
|
};
|
|
const $create = {
|
|
createIp: this.request.ip,
|
|
createUa: this.request.headers['user-agent'] || '',
|
|
createHost: this.request.host,
|
|
};
|
|
if (this.session._id) {
|
|
await token.update(this.session._id, token.TYPE_SESSION, expireSeconds, { ...this.session, ...$update });
|
|
} else {
|
|
[, this.session] = await token.add(token.TYPE_SESSION, expireSeconds, { ...this.session, ...$update, ...$create });
|
|
}
|
|
const cookie: SetOption = {
|
|
secure: !!system.get('session.secure'),
|
|
httpOnly: false,
|
|
};
|
|
this.ctx.cookies.set('sid', this.session._id, cookie);
|
|
}
|
|
|
|
async onerror(error: HydroError) {
|
|
if (!error.msg) error.msg = () => error.message;
|
|
if (error instanceof UserFacingError) error.stack = '';
|
|
if (!(error instanceof NotFoundError)) {
|
|
logger.error(this.request.path, error.msg(), error.params);
|
|
logger.error(error.stack);
|
|
}
|
|
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) },
|
|
};
|
|
await this.finish().catch(() => { });
|
|
}
|
|
}
|
|
|
|
async function handle(ctx, HandlerClass, checker) {
|
|
global.Hydro.stat.reqCount++;
|
|
const args = {
|
|
domainId: 'system', ...ctx.params, ...ctx.query, ...ctx.request.body,
|
|
};
|
|
const h = new HandlerClass(ctx);
|
|
h.args = args;
|
|
h.domainId = args.domainId;
|
|
try {
|
|
const method = ctx.method.toLowerCase();
|
|
let operation: string;
|
|
if (method === 'post' && ctx.request.body.operation) {
|
|
operation = `_${ctx.request.body.operation}`
|
|
.replace(/_([a-z])/gm, (s) => s[1].toUpperCase());
|
|
}
|
|
|
|
await bus.serial('handler/create', h);
|
|
|
|
await h.init(args);
|
|
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 bus.serial('handler/init', h);
|
|
await bus.serial(`handler/before-prepare/${HandlerClass.name.replace(/Handler$/, '')}`, h);
|
|
if (h._prepare) await h._prepare(args);
|
|
if (h.prepare) await h.prepare(args);
|
|
await bus.serial(`handler/before/${HandlerClass.name.replace(/Handler$/, '')}`, h);
|
|
if (h[method]) await h[method](args);
|
|
if (operation) await h[`post${operation}`](args);
|
|
await bus.serial(`handler/after/${HandlerClass.name.replace(/Handler$/, '')}`, h);
|
|
if (h.cleanup) await h.cleanup(args);
|
|
if (h.finish) await h.finish(args);
|
|
await bus.serial(`handler/finish/${HandlerClass.name.replace(/Handler$/, '')}`, h);
|
|
} catch (e) {
|
|
try {
|
|
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() {
|
|
checker();
|
|
if (perm) this.checkPerm(perm); // lgtm [js/trivial-conditional]
|
|
if (priv) this.checkPriv(priv);
|
|
};
|
|
};
|
|
|
|
export function Route(name: string, path: string, RouteHandler: any, ...permPrivChecker) {
|
|
const checker = Checker(permPrivChecker);
|
|
router.all(name, path, (ctx) => handle(ctx, RouteHandler, checker));
|
|
router.all(`${name}_with_domainId`, `/d/:domainId${path}`, (ctx) => handle(ctx, RouteHandler, checker));
|
|
}
|
|
|
|
export class ConnectionHandler extends HandlerCommon {
|
|
conn: sockjs.Connection;
|
|
args: Record<string, any>;
|
|
request: {
|
|
params: Record<string, any>;
|
|
headers: Record<string, string>;
|
|
ip: string;
|
|
};
|
|
|
|
constructor(conn: sockjs.Connection) {
|
|
super();
|
|
this.conn = conn;
|
|
this.request = {
|
|
params: {},
|
|
headers: conn.headers,
|
|
ip: this.conn.remoteAddress,
|
|
};
|
|
this.session = {};
|
|
const p: any = (conn.url.split('?')[1] || '').split('&');
|
|
for (const i in p) p[i] = p[i].split('=');
|
|
for (const i in p) this.request.params[p[i][0]] = decodeURIComponent(p[i][1]);
|
|
}
|
|
|
|
send(data: any) {
|
|
this.conn.write(JSON.stringify(data, serializer));
|
|
}
|
|
|
|
close(code: number, reason: string) {
|
|
this.conn.close(code.toString(), reason);
|
|
}
|
|
|
|
async message(message: any) { } // eslint-disable-line
|
|
|
|
onerror(err: HydroError) {
|
|
if (err instanceof UserFacingError) err.stack = this.conn.pathname;
|
|
if (!(err instanceof NotFoundError)) logger.error(err);
|
|
this.send({
|
|
error: {
|
|
name: err.name,
|
|
params: err.params || [],
|
|
},
|
|
});
|
|
this.close(4000, err.toString());
|
|
}
|
|
|
|
async getSession(cookieHeader: string) {
|
|
const cookies: any = {};
|
|
const ref = cookieHeader.split(';');
|
|
for (let j = 0; j < ref.length; j++) {
|
|
const cookie = ref[j];
|
|
const parts = cookie.split('=');
|
|
cookies[parts[0].trim()] = (parts[1] || '').trim();
|
|
}
|
|
this.session = await token.get(cookies.sid || '', token.TYPE_SESSION);
|
|
if (!this.session) this.session = { uid: 0, domainId: 'system' };
|
|
}
|
|
|
|
@param('cookie', Types.String)
|
|
async init(domainId: string, cookie: string) {
|
|
[this.domain] = await Promise.all([
|
|
domain.get(domainId),
|
|
this.getSession(cookie),
|
|
]);
|
|
const [bdoc, udoc] = await Promise.all([
|
|
blacklist.get(this.request.ip),
|
|
user.getById(domainId, this.session.uid, this.session.scope),
|
|
]);
|
|
if (bdoc) throw new BlacklistedError(this.request.ip);
|
|
if (!udoc) throw new UserNotFoundError(this.session.uid);
|
|
this.user = udoc;
|
|
if (this.user._id === 0 && this.session.viewLang) this.user.viewLang = this.session.viewLang;
|
|
}
|
|
}
|
|
|
|
export function Connection(
|
|
name: string, prefix: string,
|
|
RouteConnHandler: any,
|
|
...permPrivChecker: Array<number | bigint | Function>
|
|
) {
|
|
const log = (v: string, fmt: string, ...args: any[]) => logger.debug(fmt, ...args);
|
|
const sock = sockjs.createServer({ prefix, log });
|
|
const checker = Checker(permPrivChecker);
|
|
sock.on('connection', async (conn) => {
|
|
const h: Dictionary<any> = new RouteConnHandler(conn);
|
|
try {
|
|
const args = { domainId: 'system', ...h.request.params };
|
|
h.args = args;
|
|
h.domainId = args.domainId;
|
|
const cookie = await new Promise((r) => {
|
|
conn.once('data', r);
|
|
});
|
|
args.cookie = cookie;
|
|
await h.init(args);
|
|
conn.write(JSON.stringify({ event: 'auth' }));
|
|
checker.call(h);
|
|
|
|
if (h._prepare) await h._prepare(args);
|
|
if (h.prepare) await h.prepare(args);
|
|
if (h.message) {
|
|
conn.on('data', (data) => {
|
|
h.message(JSON.parse(data));
|
|
});
|
|
}
|
|
conn.on('close', async () => {
|
|
if (h.cleanup) await h.cleanup(args);
|
|
if (h.finish) await h.finish(args);
|
|
});
|
|
} catch (e) {
|
|
logger.warn('%o', e);
|
|
await h.onerror(e);
|
|
}
|
|
});
|
|
sock.installHandlers(server);
|
|
}
|
|
|
|
let started = false;
|
|
|
|
// TODO use postInit?
|
|
export function start() {
|
|
if (started) return;
|
|
const port = system.get('server.port');
|
|
app.use(router.routes()).use(router.allowedMethods());
|
|
server.listen(argv.port || port);
|
|
logger.success('Server listening at: %d', argv.port || port);
|
|
started = true;
|
|
}
|
|
|
|
global.Hydro.service.server = {
|
|
Types,
|
|
app,
|
|
server,
|
|
router,
|
|
UiContextBase,
|
|
get,
|
|
query,
|
|
post,
|
|
route,
|
|
param,
|
|
requireCsrfToken,
|
|
HandlerCommon,
|
|
Handler,
|
|
ConnectionHandler,
|
|
Route,
|
|
Connection,
|
|
prepare,
|
|
start,
|
|
};
|