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.
502 lines
17 KiB
JavaScript
502 lines
17 KiB
JavaScript
const assert = require('assert');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const cluster = require('cluster');
|
|
const { ObjectID } = require('bson');
|
|
const Koa = require('koa');
|
|
const yaml = require('js-yaml');
|
|
const morgan = require('koa-morgan');
|
|
const Body = require('koa-body');
|
|
const Router = require('koa-router');
|
|
const cache = require('koa-static-cache');
|
|
const sockjs = require('sockjs');
|
|
const http = require('http');
|
|
const validator = require('../lib/validator');
|
|
const template = require('../lib/template');
|
|
const user = require('../model/user');
|
|
const system = require('../model/system');
|
|
const blacklist = require('../model/blacklist');
|
|
const token = require('../model/token');
|
|
const opcount = require('../model/opcount');
|
|
const {
|
|
UserNotFoundError, BlacklistedError, PermissionError,
|
|
UserFacingError, ValidationError,
|
|
} = require('../error');
|
|
|
|
const app = new Koa();
|
|
let server;
|
|
const router = new Router();
|
|
|
|
const _validateObjectId = (id, key) => {
|
|
if (ObjectID.isValid(id)) return new ObjectID(id);
|
|
throw new ValidationError(key);
|
|
};
|
|
const _bool = (val) => !!val;
|
|
const _splitAndTrim = (val) => val.split(',').map((i) => i.trim());
|
|
const _date = (val) => {
|
|
const d = val.split('-');
|
|
assert(d.length === 3);
|
|
return `${d[0]}-${d[1].length === 1 ? '0' : ''}${d[1]}-${d[2].length === 1 ? '0' : ''}${d[2]}`;
|
|
};
|
|
const _time = (val) => {
|
|
const t = val.split(':');
|
|
assert(t.length === 2);
|
|
return `${(t[0].length === 1 ? '0' : '') + t[0]}:${t[1].length === 1 ? '0' : ''}${t[1]}`;
|
|
};
|
|
|
|
const validate = {
|
|
tid: _validateObjectId,
|
|
rid: _validateObjectId,
|
|
did: _validateObjectId,
|
|
drid: _validateObjectId,
|
|
drrid: _validateObjectId,
|
|
psid: _validateObjectId,
|
|
psrid: _validateObjectId,
|
|
docId: _validateObjectId,
|
|
mongoId: _validateObjectId,
|
|
hidden: _bool,
|
|
rated: _bool,
|
|
category: _splitAndTrim,
|
|
tag: _splitAndTrim,
|
|
beginAtDate: _date,
|
|
beginAtTime: _time,
|
|
pid: (pid) => (Number.isSafeInteger(parseInt(pid)) ? parseInt(pid) : pid),
|
|
content: validator.checkContent,
|
|
title: validator.checkTitle,
|
|
uid: (uid) => parseInt(validator.checkUid(uid)),
|
|
password: validator.checkPassword,
|
|
mail: validator.checkEmail,
|
|
uname: validator.checkUname,
|
|
page: (page) => {
|
|
if (Number.isSafeInteger(parseInt(page))) page = parseInt(page);
|
|
if (page <= 0) throw new ValidationError('page');
|
|
return page;
|
|
},
|
|
duration: (duration) => {
|
|
if (!Number.isNaN(parseFloat(duration))) duration = parseFloat(duration);
|
|
if (duration <= 0) throw new ValidationError('duration');
|
|
return duration;
|
|
},
|
|
pids: (pids) => {
|
|
const res = pids.split(',').map((i) => i.trim());
|
|
for (const i in res) {
|
|
if (Number.isSafeInteger(parseInt(res[i]))) res[i] = parseInt(res[i]);
|
|
}
|
|
return res;
|
|
},
|
|
role: validator.checkRole,
|
|
penaltyRules: (penaltyRules) => {
|
|
try {
|
|
penaltyRules = yaml.safeLoad(penaltyRules);
|
|
} catch (e) {
|
|
throw new ValidationError('penalty_rules', 'parse error');
|
|
}
|
|
assert(typeof penaltyRules === 'object', new ValidationError('penalty_rules', 'invalid format'));
|
|
return penaltyRules;
|
|
},
|
|
yaml: (input) => {
|
|
yaml.safeLoad(input);
|
|
return input;
|
|
},
|
|
};
|
|
|
|
async function prepare() {
|
|
server = http.createServer(app.callback());
|
|
app.keys = await system.get('session.keys');
|
|
app.use(cache(path.join(os.tmpdir(), 'hydro', 'public'), {
|
|
maxAge: 365 * 24 * 60 * 60,
|
|
}));
|
|
app.use(Body({
|
|
multipart: true,
|
|
formidable: {
|
|
maxFileSize: 256 * 1024 * 1024,
|
|
},
|
|
}));
|
|
}
|
|
|
|
class Handler {
|
|
/**
|
|
* @param {import('koa').Context} ctx
|
|
*/
|
|
constructor(ctx) {
|
|
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,
|
|
};
|
|
this.response = {
|
|
body: '',
|
|
type: '',
|
|
status: null,
|
|
template: null,
|
|
redirect: null,
|
|
attachment: (name) => ctx.attachment(name),
|
|
};
|
|
this.UIContext = {
|
|
cdn_prefix: '/',
|
|
url_prefix: '/',
|
|
};
|
|
this._handler = {};
|
|
this.session = {};
|
|
}
|
|
|
|
async renderHTML(name, context) {
|
|
console.time(name);
|
|
this.hasPerm = (perm) => this.user.hasPerm(perm);
|
|
const res = await template.render(name, Object.assign(context, {
|
|
handler: this,
|
|
url: (...args) => this.url(...args),
|
|
_: (str) => (str ? str.toString().translate(this.user.viewLang || this.session.viewLang) : ''),
|
|
user: this.user,
|
|
}));
|
|
console.timeEnd(name);
|
|
return res;
|
|
}
|
|
|
|
async render(name, context) {
|
|
this.response.body = await this.renderHTML(name, context);
|
|
this.response.type = 'text/html';
|
|
}
|
|
|
|
renderTitle(str) { // eslint-disable-line class-methods-use-this
|
|
return str;
|
|
}
|
|
|
|
checkPerm(...args) {
|
|
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]]]);
|
|
}
|
|
}
|
|
}
|
|
|
|
async limitRate(op, periodSecs, maxOperations) {
|
|
await opcount.inc(op, this.request.ip, periodSecs, maxOperations);
|
|
}
|
|
|
|
back(body) {
|
|
if (body) this.response.body = body;
|
|
this.response.redirect = this.request.headers.referer || '/';
|
|
}
|
|
|
|
translate(str) {
|
|
return str ? str.toString().translate(this.user.viewLang || this.session.viewLang) : '';
|
|
}
|
|
|
|
binary(data, name) {
|
|
this.response.body = data;
|
|
this.response.template = null;
|
|
this.response.type = 'application/octet-stream';
|
|
this.response.disposition = `attachment; filename="${name}"`;
|
|
}
|
|
|
|
url(name, kwargs = {}) { // eslint-disable-line class-methods-use-this
|
|
let res = '#';
|
|
try {
|
|
delete kwargs.__keywords;
|
|
if (this.args.domainId !== 'system') {
|
|
name += '_with_domainId';
|
|
kwargs.domainId = kwargs.domainId || this.args.domainId;
|
|
}
|
|
const { anchor, query } = kwargs;
|
|
delete kwargs.anchor;
|
|
delete kwargs.query;
|
|
if (query) res = router.url(name, kwargs, { query });
|
|
else res = router.url(name, kwargs);
|
|
if (anchor) return `${res}#${anchor}`;
|
|
} catch (e) {
|
|
console.error(e.message);
|
|
}
|
|
return res;
|
|
}
|
|
|
|
async ___prepare({ domainId }) {
|
|
this.response.body = {};
|
|
this.now = new Date();
|
|
this._handler.sid = this.request.cookies.get('sid');
|
|
this._handler.save = this.request.cookies.get('save');
|
|
if (this._handler.save) this._handler.expireSeconds = await system.get('session.saved_expire_seconds');
|
|
else this._handler.expireSeconds = await system.get('session.unsaved_expire_seconds');
|
|
this.session = this._handler.sid
|
|
? await token.update(
|
|
this._handler.sid,
|
|
token.TYPE_SESSION,
|
|
this._handler.expireSeconds,
|
|
{
|
|
updateIp: this.request.ip,
|
|
updateUa: this.request.headers['user-agent'] || '',
|
|
},
|
|
) : { uid: 1 };
|
|
if (!this.session) this.session = { uid: 1 };
|
|
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);
|
|
this.csrfToken = await token.createOrUpdate(token.TYPE_CSRF_TOKEN, 600, {
|
|
path: this.request.path,
|
|
uid: this.session.uid,
|
|
});
|
|
this.preferJson = (this.request.headers.accept || '').includes('application/json');
|
|
}
|
|
|
|
async ___cleanup() {
|
|
try {
|
|
await this.renderBody();
|
|
} catch (error) {
|
|
if (this.preferJson) this.response.body = { error };
|
|
else await this.render(error instanceof UserFacingError ? 'error.html' : 'bsod.html', { error });
|
|
}
|
|
await this.putResponse();
|
|
await this.saveCookie();
|
|
}
|
|
|
|
async renderBody() {
|
|
if (!this.response.redirect && !this.preferJson) {
|
|
if (this.response.body || this.response.template) {
|
|
if (this.request.query.noTemplate || this.preferJson) return;
|
|
const templateName = this.request.query.template || this.response.template;
|
|
if (templateName) {
|
|
this.response.body = this.response.body || {};
|
|
await this.render(templateName, this.response.body);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async putResponse() {
|
|
if (this.response.disposition) this.ctx.set('Content-Disposition', this.response.disposition);
|
|
if (this.response.redirect && !this.preferJson) {
|
|
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.preferJson
|
|
? 'application/json'
|
|
: this.response.type
|
|
? this.response.type
|
|
: this.ctx.response.type;
|
|
}
|
|
}
|
|
|
|
async saveCookie() {
|
|
if (this.session._id) {
|
|
await token.update(
|
|
this.session._id,
|
|
token.TYPE_SESSION,
|
|
this._handler.expireSeconds,
|
|
this.session,
|
|
);
|
|
} else {
|
|
[this.session._id] = await token.add(
|
|
token.TYPE_SESSION,
|
|
this._handler.expireSeconds,
|
|
{
|
|
createIp: this.request.ip,
|
|
createUa: this.request.headers['user-agent'] || '',
|
|
updateIp: this.request.ip,
|
|
updateUa: this.request.headers['user-agent'] || '',
|
|
...this.session,
|
|
},
|
|
);
|
|
}
|
|
const cookie = { secure: await system.get('session.secure') };
|
|
if (this._handler.save) {
|
|
cookie.expires = this.session.expireAt;
|
|
cookie.maxAge = this._handler.expireSeconds;
|
|
this.ctx.cookies.set('save', 'true', cookie);
|
|
}
|
|
this.ctx.cookies.set('sid', this.session._id, cookie);
|
|
}
|
|
|
|
async onerror(error) {
|
|
console.error(error.message, error.params);
|
|
console.error(error.stack);
|
|
this.response.template = error instanceof UserFacingError ? 'error.html' : 'bsod.html';
|
|
this.response.body = {
|
|
error: { message: error.message, params: error.params, stack: error.stack },
|
|
};
|
|
await this.___cleanup().catch(() => { });
|
|
}
|
|
}
|
|
|
|
async function handle(ctx, HandlerClass, permission) {
|
|
const h = new HandlerClass(ctx);
|
|
try {
|
|
const method = ctx.method.toLowerCase();
|
|
const args = {
|
|
domainId: 'system', ...ctx.params, ...ctx.query, ...ctx.request.body,
|
|
};
|
|
|
|
if (h.___prepare) await h.___prepare(args);
|
|
if (permission) h.checkPerm(permission);
|
|
|
|
let checking = '';
|
|
try {
|
|
for (const key in validate) {
|
|
checking = key;
|
|
if (args[key]) {
|
|
args[key] = validate[key](args[key], key);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof ValidationError) throw e;
|
|
throw new ValidationError(`Argument ${checking} check failed`);
|
|
}
|
|
h.args = args;
|
|
if (h.__prepare) await h.__prepare(args);
|
|
if (h._prepare) await h._prepare(args);
|
|
if (h.prepare) await h.prepare(args);
|
|
|
|
if (h[`___${method}`]) await h[`___${method}`](args);
|
|
if (h[`__${method}`]) await h[`__${method}`](args);
|
|
if (h[`_${method}`]) await h[`_${method}`](args);
|
|
if (h[method]) await h[method](args);
|
|
|
|
if (method === 'post' && ctx.request.body.operation) {
|
|
const operation = `_${ctx.request.body.operation}`
|
|
.replace(/_([a-z])/gm, (s) => s[1].toUpperCase());
|
|
if (h[`${method}${operation}`]) await h[`${method}${operation}`](args);
|
|
}
|
|
|
|
if (h.cleanup) await h.cleanup(args);
|
|
if (h._cleanup) await h._cleanup(args);
|
|
if (h.__cleanup) await h.__cleanup(args);
|
|
if (h.___cleanup) await h.___cleanup(args);
|
|
} catch (e) {
|
|
if (h.onerror) await h.onerror(e);
|
|
}
|
|
}
|
|
|
|
function Route(name, route, RouteHandler, permission = null) {
|
|
router.all(name, route, (ctx) => handle(ctx, RouteHandler, permission));
|
|
router.all(`${name}_with_domainId`, `/d/:domainId${route}`, (ctx) => handle(ctx, RouteHandler, permission));
|
|
}
|
|
|
|
class ConnectionHandler {
|
|
/**
|
|
* @param {import('sockjs').Connection} conn
|
|
*/
|
|
constructor(conn) {
|
|
this.conn = conn;
|
|
this.request = {
|
|
params: {},
|
|
headers: conn.headers,
|
|
};
|
|
const p = (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]);
|
|
}
|
|
|
|
async renderHTML(name, context) {
|
|
this.hasPerm = (perm) => this.user.hasPerm(perm);
|
|
const res = await template.render(name, Object.assign(context, {
|
|
handler: this,
|
|
_: (str) => (str ? str.toString().translate(this.user.viewLang || this.session.viewLang) : ''),
|
|
user: this.user,
|
|
}));
|
|
return res;
|
|
}
|
|
|
|
send(data) {
|
|
this.conn.write(JSON.stringify(data));
|
|
}
|
|
|
|
close(code, reason) {
|
|
this.conn.close(code, reason);
|
|
}
|
|
|
|
async ___prepare({ domainId }) {
|
|
try {
|
|
this.session = await token.get(this.request.params.token, token.TYPE_CSRF_TOKEN);
|
|
await token.del(this.request.params.token, token.TYPE_CSRF_TOKEN);
|
|
} catch (e) {
|
|
this.session = { uid: 1 };
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
function Connection(name, prefix, RouteConnHandler) {
|
|
const sock = sockjs.createServer({ prefix });
|
|
sock.on('connection', async (conn) => {
|
|
const h = new RouteConnHandler(conn);
|
|
try {
|
|
const args = { domainId: 'system', ...h.request.params };
|
|
|
|
if (args.uid) args.uid = parseInt(validator.checkUid(args.uid));
|
|
if (args.page) args.page = parseInt(args.page);
|
|
if (args.rid) args.rid = new ObjectID(args.rid);
|
|
if (args.tid) args.tid = new ObjectID(args.tid);
|
|
|
|
if (h.___prepare) await h.___prepare(args);
|
|
if (h.__prepare) await h.__prepare(args);
|
|
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._cleanup) await h._cleanup(args);
|
|
if (h.__cleanup) await h.__cleanup(args);
|
|
if (h.___cleanup) await h.___cleanup(args);
|
|
});
|
|
} catch (e) {
|
|
console.log(e);
|
|
if (h.onerror) await h.onerror(e);
|
|
}
|
|
});
|
|
sock.installHandlers(server);
|
|
}
|
|
|
|
function Middleware(middleware) {
|
|
app.use(middleware);
|
|
}
|
|
|
|
function Validate(key, func) {
|
|
if (validate[key]) validate[key].push(func);
|
|
else validate[key] = [func];
|
|
}
|
|
|
|
async function start() {
|
|
app.use(morgan(':method :url :status :res[content-length] - :response-time ms'));
|
|
app.use(router.routes()).use(router.allowedMethods());
|
|
Route('notfound_handler', '*', Handler);
|
|
if (cluster.worker.id === 1) {
|
|
const port = await system.get('server.port');
|
|
server.listen(port);
|
|
console.log('Server listening at: %s', port);
|
|
} else {
|
|
server.listen();
|
|
}
|
|
}
|
|
|
|
global.Hydro.service.server = module.exports = {
|
|
Handler, ConnectionHandler, Route, Connection, Middleware, Validate, prepare, start,
|
|
};
|