You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Hydro/hydro/service/server.ts

791 lines
26 KiB
TypeScript

/* eslint-disable prefer-destructuring */
import assert from 'assert';
import path from 'path';
import os from 'os';
import http from 'http';
import moment from 'moment-timezone';
import { isSafeInteger } from 'lodash';
import { ObjectID } from 'mongodb';
import Koa from 'koa';
import morgan from 'koa-morgan';
import Body from 'koa-body';
import Router from 'koa-router';
import cache from 'koa-static-cache';
import sockjs from 'sockjs';
import serialize, { SerializeJSOptions } from 'serialize-javascript';
import { lrucache } from '../utils';
import { User, DomainDoc } from '../interface';
import {
UserNotFoundError, BlacklistedError, PermissionError,
UserFacingError, ValidationError, PrivilegeError,
CsrfTokenError, InvalidOperationError, MethodNotAllowedError,
NotFoundError, HydroError,
} from '../error';
import { render } from '../lib/template';
import hash from '../lib/hash.hydro';
import * as misc from '../lib/misc';
import * as user from '../model/user';
import * as domain from '../model/domain';
import * as system from '../model/system';
import * as blacklist from '../model/blacklist';
import * as token from '../model/token';
import * as opcount from '../model/opcount';
5 years ago
const app = new Koa();
5 years ago
let server;
5 years ago
const router = new Router();
// TODO fix this ugly hack
interface H {
[key: string]: any,
}
type MethodDecorator = (target: any, name: string, obj: any) => any;
type Converter = (value: any) => any;
type Validator = (value: any) => boolean;
interface ParamOption {
name: string,
isOptional?: boolean,
convert?: Converter,
validate?: Validator,
}
4 years ago
// eslint-disable-next-line no-shadow
export enum Types { String, Int, UnsignedInt, Float, ObjectID, Boolean, Date, Time }
const Tools: Array<[Converter, Validator, boolean?]> = [
[(v) => v.toString(), null],
[(v) => parseInt(v, 10), (v) => isSafeInteger(parseInt(v, 10))],
[(v) => parseInt(v, 10), (v) => parseInt(v, 10) > 0],
[(v) => parseFloat(v), (v) => {
const t = parseFloat(v);
return t && !Number.isNaN(t) && !Number.isFinite(t);
}],
[(v) => new ObjectID(v), ObjectID.isValid],
[(v) => !!v, null, true],
[
(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);
return moment(`${d[0]}-${d[1].length === 1 ? '0' : ''}${d[1]}-${d[2].length === 1 ? '0' : ''}${d[2]}`).isValid();
}],
[
(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(`${(t[0].length === 1 ? '0' : '') + t[0]}:${t[1].length === 1 ? '0' : ''}${t[1]}`).isValid();
},
],
];
export function param(name: string, type: Types, validate: Validator): MethodDecorator;
export function param(name: string, type?: Types, isOptional?: boolean): MethodDecorator;
export function param(
name: string, type: Types, validate: null, convert: Converter
): MethodDecorator;
export function param(
name: string, type: Types, validate?: Validator, convert?: Converter,
): MethodDecorator;
export function param(
name: string, type: Types, isOptional?: boolean, validate?: Validator, convert?: Converter,
): MethodDecorator;
export function param(
name: string, ...args: Array<Types | boolean | Converter | Validator>
): MethodDecorator;
export function param(name: string, ...args: any): MethodDecorator {
let cursor = 0;
const v: ParamOption = { name };
let isValidate = true;
while (cursor < args.length) {
if (typeof args[cursor] === 'number') {
const type = args[cursor];
if (Tools[type]) {
if (Tools[type][0]) v.convert = Tools[type][0];
if (Tools[type][1]) v.validate = Tools[type][1];
if (Tools[type][2]) v.isOptional = Tools[type][2];
}
} else if (typeof args[cursor] === 'boolean') v.isOptional = args[cursor];
else if (isValidate) {
if (args[cursor] !== null) v.validate = args[cursor];
isValidate = false;
} else {
const I = args[cursor];
v.convert = I;
}
cursor++;
}
return function desc(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' }];
const originalMethod = obj.value;
obj.value = function validate(rawArgs: any) {
const c = [];
const arglist: ParamOption[] = this.__param[target.constructor.name][funcName];
for (const item of arglist) {
if (!item.isOptional || rawArgs[item.name]) {
if (!rawArgs[item.name]) throw new ValidationError(item.name);
if (item.validate) {
if (!item.validate(rawArgs[item.name])) {
throw new ValidationError(item.name);
}
}
if (item.convert) c.push(item.convert(rawArgs[item.name]));
else c.push(rawArgs[item.name]);
} else c.push(undefined);
}
return originalMethod.call(this, ...c);
};
4 years ago
}
target.__param[target.constructor.name][funcName].splice(1, 0, v);
return obj;
};
}
4 years ago
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() {
server = http.createServer(app.callback());
5 years ago
app.keys = await system.get('session.keys');
if (process.env.debug) {
app.use(cache(path.join(process.cwd(), 'public'), {
maxAge: 0,
}));
} else {
app.use(cache(path.join(os.tmpdir(), 'hydro', 'public'), {
maxAge: 365 * 24 * 60 * 60,
}));
}
5 years ago
app.use(Body({
multipart: true,
formidable: {
maxFileSize: 256 * 1024 * 1024,
},
}));
}
export class Handler {
UIContext: any;
args: any;
ctx: Koa.Context;
request: {
host: string,
hostname: string,
ip: string,
headers: any,
cookies: any,
body: any,
files: any,
query: any,
path: string,
params: any,
referer: any,
json: boolean,
};
response: {
body: any,
type: string,
status: number,
template: string | undefined,
redirect: string | undefined,
disposition: string | undefined,
attachment: (name: string) => void,
};
session: any;
csrfToken: string;
user: User;
domain: DomainDoc;
constructor(ctx: Koa.Context) {
this.ctx = ctx;
this.request = {
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) => ctx.attachment(name),
disposition: null,
};
this.UIContext = {
cdn_prefix: '/',
url_prefix: '/',
};
this.session = {};
}
@lrucache
// eslint-disable-next-line class-methods-use-this
getCsrfToken(id: string) {
return hash('csrf_token', id);
}
async renderHTML(name: string, context: any): Promise<string> {
const UserContext = {
...this.user,
gravatar: misc.gravatar(this.user.gravatar || '', 128),
perm: this.user.perm.toString(),
};
const res = await render(name, {
handler: this,
UserContext,
url: this.url.bind(this),
_: this.translate.bind(this),
...context,
});
return res;
}
5 years ago
async limitRate(op, periodSecs, maxOperations) {
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);
}
5 years ago
renderTitle(str: string) {
4 years ago
return `${this.translate(str)} - Hydro`;
}
5 years ago
checkPerm(...args: Array<bigint[] | bigint>) {
5 years ago
for (const i in args) {
if (args[i] instanceof Array) {
let p = false;
5 years ago
for (const j in args) {
if (this.user.hasPerm(args[i][j])) {
p = true;
break;
}
5 years ago
}
if (!p) throw new PermissionError([args[i]]);
// @ts-ignore
5 years ago
} else if (!this.user.hasPerm(args[i])) {
throw new PermissionError([[args[i]]]);
}
}
}
5 years ago
checkPriv(...args: Array<number[] | number>) {
4 years ago
for (const i in args) {
if (args[i] instanceof Array) {
let p = false;
for (const j in args) {
if (this.user.hasPriv(args[i][j])) {
p = true;
break;
}
}
if (!p) throw new PrivilegeError([args[i]]);
// @ts-ignore
4 years ago
} else if (!this.user.hasPriv(args[i])) {
throw new PrivilegeError([[args[i]]]);
}
}
}
url(name: string, kwargs = {}) {
let res = '#';
const args: any = { ...kwargs };
try {
if (this.args.domainId !== 'system' || args.domainId) {
name += '_with_domainId';
args.domainId = args.domainId || this.args.domainId;
}
const { anchor, query } = args;
if (query) res = router.url(name, args, { query });
else res = router.url(name, args);
if (anchor) return `${res}#${anchor}`;
} catch (e) {
console.error(e.message);
console.log(name, args);
}
return res;
}
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';
this.response.disposition = `attachment; filename="${name}"`;
}
4 years ago
async getSession() {
const sid = this.request.cookies.get('sid');
this.session = await token.get(sid, token.TYPE_SESSION, false);
4 years ago
if (!this.session) this.session = { uid: 0 };
4 years ago
}
async getBdoc() {
5 years ago
const bdoc = await blacklist.get(this.request.ip);
if (bdoc) throw new BlacklistedError(this.request.ip);
4 years ago
}
async init({ domainId }) {
const xff = await system.get('server.xff');
if (xff) this.request.ip = this.request.headers[xff.toLowerCase()];
[this.domain] = await Promise.all([
domain.get(domainId),
4 years ago
this.getSession(),
this.getBdoc(),
]);
if (!this.domain) {
this.args.domainId = 'system';
[this.user, this.UIContext.token] = await Promise.all([
user.getById('system', this.session.uid),
token.createOrUpdate(
token.TYPE_TOKEN, 600, { uid: this.session.uid, domainId },
),
]);
throw new NotFoundError(domainId);
}
[this.user, this.UIContext.token] = await Promise.all([
user.getById(domainId, this.session.uid),
token.createOrUpdate(
token.TYPE_TOKEN, 600, { uid: this.session.uid, domainId },
),
4 years ago
]);
this.csrfToken = this.getCsrfToken(this.session._id || String.random(32));
this.UIContext.csrfToken = this.csrfToken;
}
async finish() {
try {
await this.renderBody();
} catch (error) {
this.response.status = error instanceof UserFacingError ? error.code : 500;
if (this.request.json) this.response.body = { error };
else await this.render(error instanceof UserFacingError ? 'error.html' : 'bsod.html', { error });
}
await this.putResponse();
await this.saveCookie();
}
5 years ago
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);
} catch (e) {
const opt: SerializeJSOptions = { ignoreFunction: true };
if (this.request.query.noTemplate) opt.space = 2;
this.response.body = serialize(this.response.body, opt);
}
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 || {};
await this.render(templateName, this.response.body);
}
5 years ago
}
}
5 years ago
async putResponse() {
if (this.response.disposition) this.ctx.set('Content-Disposition', this.response.disposition);
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 {
if (this.response.body != null) {
this.ctx.response.body = this.response.body;
this.ctx.response.status = this.response.status || 200;
}
this.ctx.response.type = this.request.json
5 years ago
? 'application/json'
: this.response.type
? this.response.type
: this.ctx.response.type;
}
}
5 years ago
async saveCookie() {
4 years ago
const expireSeconds = this.session.save
? await system.get('session.expire_seconds')
: await system.get('session.unsaved_expire_seconds');
5 years ago
if (this.session._id) {
5 years ago
await token.update(
5 years ago
this.session._id,
token.TYPE_SESSION,
4 years ago
expireSeconds,
{
...this.session,
updateIp: this.request.ip,
updateUa: this.request.headers['user-agent'] || '',
},
5 years ago
);
} else {
4 years ago
[, this.session] = await token.add(
5 years ago
token.TYPE_SESSION,
4 years ago
expireSeconds,
5 years ago
{
4 years ago
...this.session,
5 years ago
createIp: this.request.ip,
createUa: this.request.headers['user-agent'] || '',
updateIp: this.request.ip,
updateUa: this.request.headers['user-agent'] || '',
},
);
}
const cookie: any = { secure: await system.get('session.secure') };
4 years ago
if (this.session.save) {
5 years ago
cookie.expires = this.session.expireAt;
4 years ago
cookie.maxAge = expireSeconds;
}
5 years ago
this.ctx.cookies.set('sid', this.session._id, cookie);
}
5 years ago
async onerror(error) {
if (!error.msg) error.msg = () => error.message;
console.error(error.msg(), error.params);
console.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: error.stack },
};
await this.finish().catch(() => { });
}
}
4 years ago
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;
4 years ago
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 h.init(args);
4 years ago
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') {
throw new MethodNotAllowedError(method);
}
4 years ago
4 years ago
if (h._prepare) await h._prepare(args);
if (h.prepare) await h.prepare(args);
if (h[method]) await h[method](args);
4 years ago
if (operation) await h[`post${operation}`](args);
4 years ago
if (h.cleanup) await h.cleanup(args);
if (h.finish) await h.finish(args);
4 years ago
} catch (e) {
try {
await h.onerror(e);
} catch (err) {
h.response.code = 500;
h.response.body = `${err.message}\n${err.stack}`;
}
4 years ago
}
}
const Checker = (permPrivChecker) => {
let perm: bigint;
let priv: number;
let checker = () => { };
4 years ago
for (const item of permPrivChecker) {
if (typeof item === 'object') {
if (typeof item.call !== 'undefined') {
checker = item;
} if (typeof item[0] === 'number') {
priv = item;
} else if (typeof item[0] === 'bigint') {
perm = item;
4 years ago
}
} else if (typeof item === 'number') {
priv = item;
} else if (typeof item === 'bigint') {
perm = item;
4 years ago
}
}
return function check() {
checker();
if (perm) this.checkPerm(perm);
if (priv) this.checkPriv(priv);
};
};
export function Route(name: string, route: string, RouteHandler: any, ...permPrivChecker) {
const checker = Checker(permPrivChecker);
4 years ago
router.all(name, route, (ctx) => handle(ctx, RouteHandler, checker));
router.all(`${name}_with_domainId`, `/d/:domainId${route}`, (ctx) => handle(ctx, RouteHandler, checker));
5 years ago
}
export class ConnectionHandler {
conn: sockjs.Connection;
request: {
params: any
headers: any
ip: string
}
session: any
args: any
user: any
constructor(conn: sockjs.Connection) {
this.conn = conn;
this.request = {
params: {},
5 years ago
headers: conn.headers,
ip: this.conn.remoteAddress,
};
this.session = {};
const p: any = (conn.url.split('?')[1] || '').split('&');
5 years ago
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]);
}
5 years ago
async renderHTML(name: string, context: any): Promise<string> {
const res = await render(name, Object.assign(context, {
handler: this,
url: this.url.bind(this),
_: this.translate.bind(this),
}));
return res;
}
async limitRate(op, periodSecs, maxOperations) {
await opcount.inc(op, this.request.ip, periodSecs, maxOperations);
}
translate(str) {
return str ? str.toString().translate(this.user.viewLang || this.session.viewLang) : '';
}
renderTitle(str) {
return `${this.translate(str)} - Hydro`;
}
checkPerm(...args: Array<bigint[] | bigint>) {
for (const i in args) {
if (args[i] instanceof Array) {
let p = false;
for (const j in args) {
if (this.user.hasPerm(args[i][j])) {
p = true;
break;
}
}
if (!p) throw new PermissionError([args[i]]);
} else if (!this.user.hasPerm(args[i])) {
throw new PermissionError([[args[i]]]);
}
}
}
checkPriv(...args: Array<number[] | number>) {
for (const i in args) {
if (args[i] instanceof Array) {
let p = false;
for (const j in args) {
if (this.user.hasPriv(args[i][j])) {
p = true;
break;
}
}
if (!p) throw new PrivilegeError([args[i]]);
} else if (!this.user.hasPriv(args[i])) {
throw new PrivilegeError([[args[i]]]);
}
}
}
url(name: string, kwargs = {}) {
let res = '#';
const args: any = { ...kwargs };
try {
if (this.args.domainId !== 'system' || args.domainId) {
name += '_with_domainId';
args.domainId = args.domainId || this.args.domainId;
}
const { anchor, query } = args;
if (query) res = router.url(name, args, { query });
else res = router.url(name, args);
if (anchor) return `${res}#${anchor}`;
} catch (e) {
console.error(e.message);
console.log(name, args);
}
return res;
}
send(data: any) {
this.conn.write(JSON.stringify(data));
}
5 years ago
close(code: number, reason: string) {
this.conn.close(code.toString(), reason);
}
5 years ago
async message(message: any) { } // eslint-disable-line
onerror(err: HydroError) {
console.error(err);
this.send({
error: {
name: err.name,
params: err.params || [],
},
});
this.close(1001, err.toString());
}
async init({ domainId }) {
try {
this.session = await token.get(this.request.params.token, token.TYPE_TOKEN, true);
} catch (e) {
this.session = { uid: 0, domainId: 'system' };
}
this.args.domainId = this.session.domainId;
5 years ago
const bdoc = await blacklist.get(this.request.ip);
if (bdoc) throw new BlacklistedError(this.request.ip);
this.user = await user.getById(domainId, this.session.uid);
if (!this.user) throw new UserNotFoundError(this.session.uid);
}
}
5 years ago
export function Connection(
name: string, prefix: string,
RouteConnHandler: any,
...permPrivChecker: Array<number | bigint | Function>
) {
const sock = sockjs.createServer({ prefix });
const checker = Checker(permPrivChecker);
5 years ago
sock.on('connection', async (conn) => {
const h: H = new RouteConnHandler(conn);
try {
const args = { domainId: 'system', ...h.request.params };
4 years ago
h.args = args;
await h.init(args);
checker.call(h);
if (h._prepare) await h._prepare(args);
if (h.prepare) await h.prepare(args);
5 years ago
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) {
console.log(e);
await h.onerror(e);
}
});
sock.installHandlers(server);
}
export function Middleware(middleware: Koa.Middleware) {
app.use(middleware);
}
export async function start() {
const [disableLog, port] = await system.getMany(['server.log', 'server.port']);
if (!disableLog) {
app.use(morgan(':method :url :status :res[content-length] - :response-time ms', {
skip: (req, res) => res.hasHeader('nolog'),
}));
}
app.use(router.routes()).use(router.allowedMethods());
4 years ago
Route('notfound_handler', '*', Handler);
server.listen(global.argv.port || port);
console.log('Server listening at: %s', global.argv.port || port);
}
global.Hydro.service.server = {
Types,
param,
requireCsrfToken,
Handler,
ConnectionHandler,
Route,
Connection,
Middleware,
prepare,
start,
};