mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-17 09:36:08 +08:00
core, ui, judge: rewrite http & ws layer (#352)
This commit is contained in:
1 parent
3d2384f31a
commit
5af082eac8
37 files changed
+868
-889
No files matched your search
@@ -0,0 +1,7 @@
|
||||
## Changes for hydrooj@3:
|
||||
|
||||
- 重构了 server 结构:
|
||||
- - 移除了 `Handler.init` `Handler.finish` 方法。
|
||||
- - 移除了 `handler/finish` 钩子。
|
||||
- - 移除了 `requireCsrfToken` 。
|
||||
- - 移除了 `sockjs`,转为使用原生 `WebSocket` 。
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hydrooj/hydrojudge",
|
||||
"bin": "bin/hydrojudge.js",
|
||||
"version": "2.17.4",
|
||||
"version": "2.18.0",
|
||||
"main": "package.json",
|
||||
"author": "undefined <i@undefined.moe>",
|
||||
"repository": "https://github.com/hydro-dev/Hydro.git",
|
||||
|
||||
@@ -278,16 +278,16 @@ export default class Hydro {
|
||||
|
||||
async consume(queue: Queue<any>) {
|
||||
log.info('正在连接 %sjudge/conn', this.config.server_url);
|
||||
const res = await this.axios.get('judge/conn/info');
|
||||
this.ws = new WebSocket(`${this.config.server_url.replace(/^http/i, 'ws')}judge/conn/websocket?t=${res.data.entropy}`);
|
||||
this.ws.on('open', () => {
|
||||
this.ws.send(this.config.cookie);
|
||||
global.onDestroy.push(() => this.ws.close());
|
||||
const content = this.config.minPriority !== undefined
|
||||
? `{"key":"prio","prio":${this.config.minPriority}}`
|
||||
: '{"key":"ping"}';
|
||||
setInterval(() => this.ws.send(content), 30000);
|
||||
this.ws = new WebSocket(`${this.config.server_url.replace(/^http/i, 'ws')}judge/conn`, {
|
||||
headers: {
|
||||
Cookie: `sid=${this.config.cookie}`,
|
||||
},
|
||||
});
|
||||
global.onDestroy.push(() => this.ws.close());
|
||||
const content = this.config.minPriority !== undefined
|
||||
? `{"key":"prio","prio":${this.config.minPriority}}`
|
||||
: '{"key":"ping"}';
|
||||
setInterval(() => this.ws?.send?.(content), 30000);
|
||||
this.ws.on('message', (data) => {
|
||||
const request = JSON.parse(data.toString());
|
||||
if (request.language) this.language = request.language;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hydrooj",
|
||||
"version": "3.11.22",
|
||||
"version": "3.12.0",
|
||||
"bin": "bin/hydrooj.js",
|
||||
"main": "src/loader",
|
||||
"module": "src/loader",
|
||||
@@ -47,7 +47,6 @@
|
||||
"require-resolve-hook": "^1.1.0",
|
||||
"semver": "^7.3.6",
|
||||
"serialize-javascript": "^6.0.0",
|
||||
"sockjs": "^0.3.24",
|
||||
"source-map-support": "^0.5.21",
|
||||
"superagent": "^7.1.2",
|
||||
"thirty-two": "^1.0.2",
|
||||
@@ -70,7 +69,6 @@
|
||||
"@types/notp": "^2.0.2",
|
||||
"@types/semver": "^7.3.9",
|
||||
"@types/serialize-javascript": "^5.0.2",
|
||||
"@types/sockjs": "^0.3.33",
|
||||
"@types/source-map-support": "^0.5.4",
|
||||
"@types/superagent": "^4.1.15",
|
||||
"moment": "^2.29.2",
|
||||
|
||||
@@ -40,8 +40,8 @@ registerResolver(
|
||||
'Query', 'contest(id: ObjectID!)', 'Contest',
|
||||
async (arg, ctx) => {
|
||||
arg.id = new ObjectID(arg.id);
|
||||
ctx.tdoc = await contest.get(ctx.domainId, new ObjectID(arg.id));
|
||||
if (!ctx.tdoc) throw new ContestNotFoundError(ctx.domainId, arg.id);
|
||||
ctx.tdoc = await contest.get(ctx.args.domainId, new ObjectID(arg.id));
|
||||
if (!ctx.tdoc) throw new ContestNotFoundError(ctx.args.domainId, arg.id);
|
||||
return ctx.tdoc;
|
||||
},
|
||||
'Get a contest by ID',
|
||||
|
||||
@@ -422,7 +422,7 @@ class HomeMessagesConnectionHandler extends ConnectionHandler {
|
||||
|
||||
async onMessageReceived(uid: number, mdoc: MessageDoc) {
|
||||
if (uid !== this.user._id) return;
|
||||
const udoc = (await user.getById(this.domainId, mdoc.from))!;
|
||||
const udoc = (await user.getById(this.args.domainId, mdoc.from))!;
|
||||
udoc.avatarUrl = avatar(udoc.avatar, 64);
|
||||
this.send({ udoc, mdoc });
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ registerValue('Problem', [
|
||||
registerResolver(
|
||||
'Query', 'problem(id: Int, pid: String)', 'Problem',
|
||||
async (arg, ctx) => {
|
||||
const pdoc = await problem.get(ctx.domainId, arg.pid || arg.id);
|
||||
const pdoc = await problem.get(ctx.args.domainId, arg.pid || arg.id);
|
||||
if (!pdoc) return null;
|
||||
if (pdoc.hidden) ctx.checkPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN);
|
||||
if (pdoc.assign.length && !ctx.user.own(pdoc)) {
|
||||
@@ -90,14 +90,14 @@ registerResolver(
|
||||
registerResolver(
|
||||
'ProblemManage', 'delete', 'Boolean!',
|
||||
async (arg, ctx) => {
|
||||
const tdocs = await contest.getRelated(ctx.domainId, ctx.pdoc.docId);
|
||||
const tdocs = await contest.getRelated(ctx.args.domainId, ctx.pdoc.docId);
|
||||
if (tdocs.length) throw new BadRequestError('Problem already used by contest {0}', tdocs[0]._id);
|
||||
return problem.del(ctx.pdoc.domainId, ctx.pdoc.docId);
|
||||
},
|
||||
);
|
||||
registerResolver(
|
||||
'ProblemManage', 'edit(title: String, content: String, tag: [String], hidden: Boolean, assign: [String])', 'Problem!',
|
||||
(arg, ctx) => problem.edit(ctx.domainId, ctx.pdoc.docId, arg),
|
||||
(arg, ctx) => problem.edit(ctx.args.domainId, ctx.pdoc.docId, arg),
|
||||
);
|
||||
|
||||
function buildQuery(udoc: User) {
|
||||
@@ -150,7 +150,7 @@ export class ProblemMainHandler extends ProblemHandler {
|
||||
let pcountRelation = 'eq';
|
||||
if (category.length) query.$and = category.map((tag) => ({ tag }));
|
||||
if (q) category.push(q);
|
||||
if (category.length) this.extraTitleContent = category.join(',');
|
||||
if (category.length) this.UiContext.extraTitleContent = category.join(',');
|
||||
let total = 0;
|
||||
if (q) {
|
||||
if (search) {
|
||||
@@ -360,7 +360,7 @@ export class ProblemDetailHandler extends ProblemHandler {
|
||||
tsdoc: this.tsdoc,
|
||||
};
|
||||
this.response.template = 'problem_detail.html';
|
||||
this.extraTitleContent = this.pdoc.title;
|
||||
this.UiContext.extraTitleContent = this.pdoc.title;
|
||||
}
|
||||
|
||||
@query('tid', Types.ObjectID, true)
|
||||
@@ -402,9 +402,9 @@ export class ProblemDetailHandler extends ProblemHandler {
|
||||
}
|
||||
if (!this.response.body.tdoc) {
|
||||
if (this.psdoc?.rid) {
|
||||
this.response.body.rdoc = await record.get(this.domainId, this.psdoc.rid);
|
||||
this.response.body.rdoc = await record.get(this.args.domainId, this.psdoc.rid);
|
||||
}
|
||||
this.response.body.ctdocs = await contest.getRelated(this.domainId, this.pdoc.docId);
|
||||
this.response.body.ctdocs = await contest.getRelated(this.args.domainId, this.pdoc.docId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,7 +436,7 @@ export class ProblemDetailHandler extends ProblemHandler {
|
||||
|
||||
async postDelete() {
|
||||
if (!this.user.own(this.pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) this.checkPerm(PERM.PERM_EDIT_PROBLEM);
|
||||
const tdocs = await contest.getRelated(this.domainId, this.pdoc.docId);
|
||||
const tdocs = await contest.getRelated(this.args.domainId, this.pdoc.docId);
|
||||
if (tdocs.length) throw new BadRequestError('Problem already used by contest {0}', tdocs[0]._id);
|
||||
await problem.del(this.pdoc.domainId, this.pdoc.docId);
|
||||
this.response.redirect = this.url('problem_main');
|
||||
@@ -500,7 +500,7 @@ export class ProblemSubmitHandler extends ProblemDetailHandler {
|
||||
const rdoc = await record.get(domainId, rid);
|
||||
if (!pretest) {
|
||||
await Promise.all([
|
||||
problem.inc(this.domainId, this.pdoc.docId, 'nSubmit', 1),
|
||||
problem.inc(domainId, this.pdoc.docId, 'nSubmit', 1),
|
||||
problem.incStatus(domainId, this.pdoc.docId, this.user._id, 'nSubmit', 1),
|
||||
domain.incUserInDomain(domainId, this.user._id, 'nSubmit'),
|
||||
tid && contest.updateStatus(domainId, tid, this.user._id, rid, this.pdoc.docId),
|
||||
|
||||
@@ -250,14 +250,14 @@ class RecordMainConnectionHandler extends ConnectionHandler {
|
||||
async message(msg: { rids: string[] }) {
|
||||
if (!(msg.rids instanceof Array)) return;
|
||||
const rids = msg.rids.map((id) => new ObjectID(id));
|
||||
const rdocs = await record.getMulti(this.domainId, { _id: { $in: rids } }).project(buildProjection(record.PROJECTION_LIST)).toArray();
|
||||
const rdocs = await record.getMulti(this.args.domainId, { _id: { $in: rids } }).project(buildProjection(record.PROJECTION_LIST)).toArray();
|
||||
for (const rdoc of rdocs) this.onRecordChange(rdoc);
|
||||
}
|
||||
|
||||
async onRecordChange(rdoc: RecordDoc) {
|
||||
if (!this.all) {
|
||||
if (!this.pretest && rdoc.input) return;
|
||||
if (rdoc.domainId !== this.domainId) return;
|
||||
if (rdoc.domainId !== this.args.domainId) return;
|
||||
if (rdoc.contest && ![this.tid, '000000000000000000000000'].includes(rdoc.contest.toString())) return;
|
||||
if (this.tid && contest.isLocked(this.tdoc)) return;
|
||||
if (this.tid && !contest.canShowSelfRecord.call(this, this.tdoc, true)) return;
|
||||
@@ -267,7 +267,7 @@ class RecordMainConnectionHandler extends ConnectionHandler {
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [udoc, pdoc] = await Promise.all([
|
||||
user.getById(this.domainId, rdoc.uid),
|
||||
user.getById(this.args.domainId, rdoc.uid),
|
||||
problem.get(rdoc.domainId, rdoc.pid),
|
||||
]);
|
||||
const tdoc = this.tid ? this.tdoc || await contest.get(rdoc.domainId, new ObjectID(this.tid)) : null;
|
||||
|
||||
@@ -71,25 +71,25 @@ registerResolver(
|
||||
);
|
||||
|
||||
registerResolver('Query', 'user(id: Int, uname: String, mail: String)', 'User', (arg, ctx) => {
|
||||
if (arg.id) return user.getById(ctx.domainId, arg.id);
|
||||
if (arg.mail) return user.getByEmail(ctx.domainId, arg.mail);
|
||||
if (arg.uname) return user.getByUname(ctx.domainId, arg.uname);
|
||||
if (arg.id) return user.getById(ctx.args.domainId, arg.id);
|
||||
if (arg.mail) return user.getByEmail(ctx.args.domainId, arg.mail);
|
||||
if (arg.uname) return user.getByUname(ctx.args.domainId, arg.uname);
|
||||
return ctx.user;
|
||||
}, `Get a user by id, uname, or mail.
|
||||
Returns current user if no argument is provided.`);
|
||||
|
||||
registerResolver('Query', 'users(ids: [Int], search: String, limit: Int, exact: Boolean)', '[User]', async (arg, ctx) => {
|
||||
if (arg.ids?.length) {
|
||||
const res = await user.getList(ctx.domainId, arg.ids);
|
||||
const res = await user.getList(ctx.args.domainId, arg.ids);
|
||||
return Object.keys(res).map((id) => res[+id]);
|
||||
}
|
||||
if (!arg.search) return [];
|
||||
const udoc = await user.getById(ctx.domainId, +arg.search)
|
||||
|| await user.getByUname(ctx.domainId, arg.search)
|
||||
|| await user.getByEmail(ctx.domainId, arg.search);
|
||||
const udoc = await user.getById(ctx.args.domainId, +arg.search)
|
||||
|| await user.getByUname(ctx.args.domainId, arg.search)
|
||||
|| await user.getByEmail(ctx.args.domainId, arg.search);
|
||||
const udocs: User[] = arg.exact
|
||||
? []
|
||||
: await user.getPrefixList(ctx.domainId, arg.search, Math.min(arg.limit || 10, 10));
|
||||
: await user.getPrefixList(ctx.args.domainId, arg.search, Math.min(arg.limit || 10, 10));
|
||||
if (udoc && !udocs.find((i) => i._id === udoc._id)) {
|
||||
udocs.pop();
|
||||
udocs.unshift(udoc);
|
||||
@@ -329,7 +329,7 @@ class UserDetailHandler extends Handler {
|
||||
this.response.body = {
|
||||
isSelfProfile, udoc, sdoc, pdocs, tags,
|
||||
};
|
||||
this.extraTitleContent = udoc.uname;
|
||||
this.UiContext.extraTitleContent = udoc.uname;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import moment from 'moment';
|
||||
import moment from 'moment-timezone';
|
||||
import * as bus from '../service/bus';
|
||||
import db from '../service/db';
|
||||
import { ArgMethod } from '../utils';
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function log<T extends Handler>(handler: T, type: string, data: any
|
||||
_id: new ObjectID(),
|
||||
type,
|
||||
time: new Date(),
|
||||
domainId: handler.domainId,
|
||||
domainId: handler.args.domainId,
|
||||
ua: handler.request.headers?.['user-agent'],
|
||||
referer: handler.request.headers?.referer,
|
||||
args,
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import assert from 'assert';
|
||||
import emojiRegex from 'emoji-regex';
|
||||
import { isSafeInteger } from 'lodash';
|
||||
import moment from 'moment-timezone';
|
||||
import { ObjectID } from 'mongodb';
|
||||
import { ValidationError } from '../error';
|
||||
import { isContent, isName, isTitle } from '../lib/validator';
|
||||
import type { Handler } from './server';
|
||||
|
||||
type MethodDecorator = (target: any, name: string, obj: any) => any;
|
||||
type Converter = (value: any) => any;
|
||||
type Validator = (value: any) => boolean;
|
||||
export 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> | Record<string, any>) => Type,
|
||||
Array: Type,
|
||||
NumericArray: Type,
|
||||
CommaSeperatedArray: Type,
|
||||
Set: Type,
|
||||
Emoji: Type,
|
||||
}
|
||||
|
||||
export const Types: Types = {
|
||||
Content: [(v) => v.toString().trim(), isContent],
|
||||
Name: [(v) => v.toString().trim(), isName],
|
||||
Title: [(v) => v.toString().trim(), 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) => +v, (v) => Number.isFinite(+v)],
|
||||
// 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);
|
||||
}],
|
||||
CommaSeperatedArray: [
|
||||
(v) => v.toString().replace(/,/g, ',').split(',').map((e) => e.trim()).filter((i) => i),
|
||||
(v) => v.toString(),
|
||||
],
|
||||
Set: [(v) => {
|
||||
if (v instanceof Array) return new Set(v);
|
||||
return v ? new Set([v]) : new Set();
|
||||
}, null],
|
||||
Emoji: [
|
||||
(v: string) => v.matchAll(emojiRegex()).next().value[0],
|
||||
(v) => emojiRegex().test(v),
|
||||
],
|
||||
};
|
||||
|
||||
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.args.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));
|
||||
@@ -0,0 +1,88 @@
|
||||
import { PassThrough } from 'stream';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import * as system from 'hydrooj/src/model/system';
|
||||
import token from 'hydrooj/src/model/token';
|
||||
import type { HydroRequest, HydroResponse } from 'hydrooj/src/service/server';
|
||||
|
||||
export interface UiContextBase {
|
||||
cdn_prefix: string;
|
||||
url_prefix: string;
|
||||
}
|
||||
export const UiContextBase: UiContextBase = {
|
||||
cdn_prefix: '/',
|
||||
url_prefix: '/',
|
||||
};
|
||||
|
||||
export default async (ctx, next) => {
|
||||
// Base Layer
|
||||
const { domainId, domainInfo } = ctx;
|
||||
const isWebsocket = ctx.request.headers.upgrade === 'websocket';
|
||||
const [xff, xhost] = system.getMany(['server.xff', 'server.xhost']);
|
||||
const ip = ctx.request.headers[xff?.toLowerCase() || ''] as string || ctx.request.ip;
|
||||
const host = ctx.request.headers[xhost?.toLowerCase() || ''] as string || ctx.request.host;
|
||||
const request: HydroRequest = {
|
||||
method: ctx.request.method.toLowerCase(),
|
||||
host,
|
||||
hostname: ctx.request.hostname,
|
||||
ip,
|
||||
headers: ctx.request.headers,
|
||||
cookies: ctx.cookies,
|
||||
body: ctx.request.body,
|
||||
files: ctx.request.files as any,
|
||||
query: ctx.query,
|
||||
path: ctx.path,
|
||||
params: ctx.params,
|
||||
referer: ctx.request.headers.referer || '',
|
||||
json: (ctx.request.headers.accept || '').includes('application/json'),
|
||||
websocket: isWebsocket,
|
||||
};
|
||||
const response: HydroResponse = {
|
||||
body: {},
|
||||
type: '',
|
||||
status: null,
|
||||
template: null,
|
||||
redirect: null,
|
||||
attachment: (name, streamOrBuffer) => {
|
||||
ctx.attachment(name);
|
||||
if (streamOrBuffer instanceof Buffer) {
|
||||
response.body = null;
|
||||
ctx.body = streamOrBuffer;
|
||||
} else {
|
||||
response.body = null;
|
||||
ctx.body = streamOrBuffer.pipe(new PassThrough());
|
||||
}
|
||||
},
|
||||
addHeader: (name: string, value: string) => ctx.set(name, value),
|
||||
disposition: null,
|
||||
};
|
||||
const args = {
|
||||
domainId, ...ctx.params, ...ctx.query, ...ctx.request.body, __start: Date.now(),
|
||||
};
|
||||
const UiContext: any = cloneDeep(UiContextBase);
|
||||
if (!process.env.DEV) UiContext.cdn_prefix = system.get('server.cdn');
|
||||
UiContext.domainId = domainId;
|
||||
UiContext.domain = domainInfo;
|
||||
ctx.HydroContext = {
|
||||
request, response, UiContext, domain: domainInfo, user: null, args,
|
||||
};
|
||||
await next();
|
||||
const ua = request.headers['user-agent'] || '';
|
||||
if (!ctx.session.uid && system.get('server.ignoreUA').replace(/\r/g, '').split('\n').filter((i) => i && ua.includes(i)).length) return;
|
||||
const expireSeconds = ctx.session.save
|
||||
? system.get('session.saved_expire_seconds')
|
||||
: system.get('session.unsaved_expire_seconds');
|
||||
Object.assign(ctx.session, { updateIp: ip, updateUa: ua });
|
||||
if (ctx.session._id) {
|
||||
await token.update(ctx.session._id, token.TYPE_SESSION, expireSeconds, ctx.session);
|
||||
} else {
|
||||
Object.assign(ctx.session, { createIp: ip, createUa: ua, createHost: host });
|
||||
[ctx.session._id] = await token.add(token.TYPE_SESSION, expireSeconds, ctx.session);
|
||||
}
|
||||
if (!request.websocket) {
|
||||
ctx.cookies.set('sid', ctx.session._id, {
|
||||
expires: new Date(Date.now() + expireSeconds * 1000),
|
||||
secure: !!system.get('session.secure'),
|
||||
httpOnly: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import BlackListModel from 'hydrooj/src/model/blacklist';
|
||||
import DomainModel from 'hydrooj/src/model/domain';
|
||||
import * as system from 'hydrooj/src/model/system';
|
||||
import token from 'hydrooj/src/model/token';
|
||||
|
||||
export default async (ctx, next) => {
|
||||
const forceDomain = /^\/d\/([^/]+)\//.exec(ctx.request.path);
|
||||
ctx.path = ctx.request.path = ctx.request.path.replace(/^\/d\/[^/]+\//, '/');
|
||||
const [xff, xhost] = system.getMany(['server.xff', 'server.xhost']);
|
||||
const ip = ctx.request.headers[xff?.toLowerCase() || ''] as string || ctx.request.ip;
|
||||
const host = ctx.request.headers[xhost?.toLowerCase() || ''] as string || ctx.request.host;
|
||||
const header = ctx.request.headers.authorization;
|
||||
const sid = header
|
||||
? header.split(' ')[1] // Accept bearer token
|
||||
: ctx.cookies.get('sid') || ctx.query.sid; // FIXME maybe a better way for shared conn?
|
||||
let domainId = forceDomain?.[1] || 'system';
|
||||
const [absoluteDomain, inferDomain, bdoc, session] = await Promise.all([
|
||||
DomainModel.get(domainId),
|
||||
forceDomain ? Promise.resolve() : DomainModel.getByHost(host),
|
||||
BlackListModel.get(`ip::${ip}`),
|
||||
token.get(sid, token.TYPE_SESSION),
|
||||
]);
|
||||
if (bdoc) {
|
||||
ctx.body = 'blacklisted'; // Just return 404 if blacklisted
|
||||
return;
|
||||
}
|
||||
if (inferDomain) domainId = inferDomain._id;
|
||||
ctx.domainId = domainId;
|
||||
ctx.domainInfo = inferDomain || absoluteDomain;
|
||||
ctx.session = session || { uid: 0 };
|
||||
await next();
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ObjectID } from 'mongodb';
|
||||
import avatar from 'hydrooj/src/lib/avatar';
|
||||
import * as system from 'hydrooj/src/model/system';
|
||||
import type { KoaContext } from '../server';
|
||||
|
||||
function serializer(k: string, v: any) {
|
||||
if (k.startsWith('_') && k !== '_id') return undefined;
|
||||
if (typeof v === 'bigint') return `BigInt::${v.toString()}`;
|
||||
return v;
|
||||
}
|
||||
|
||||
export default (router, logger) => async (ctx: KoaContext, next) => {
|
||||
const { request, response } = ctx.HydroContext;
|
||||
ctx.renderHTML = (templateName, args) => {
|
||||
const UserContext: any = {
|
||||
...(ctx.HydroContext.user || {}),
|
||||
avatar: avatar(ctx.HydroContext.user?.avatar || '', 128),
|
||||
viewLang: ctx.translate('__id'),
|
||||
};
|
||||
const engine = global.Hydro.lib.template?.render || (() => JSON.stringify(args, serializer));
|
||||
return engine(templateName, {
|
||||
handler: ctx.handler,
|
||||
UserContext,
|
||||
url: ctx.getUrl,
|
||||
_: ctx.translate,
|
||||
...args,
|
||||
});
|
||||
};
|
||||
ctx.render = async (name, args) => {
|
||||
response.body = await ctx.renderHTML(name, args);
|
||||
response.type = 'text/html';
|
||||
};
|
||||
ctx.getUrl = (name: string, kwargs: any = {}) => {
|
||||
let res = '#';
|
||||
const args: any = {};
|
||||
const query: any = {};
|
||||
for (const key in kwargs) {
|
||||
if (kwargs[key] instanceof ObjectID) args[key] = kwargs[key].toHexString();
|
||||
else args[key] = kwargs[key].toString().replace(/\//g, '%2F');
|
||||
}
|
||||
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;
|
||||
let withDomainId = args.domainId || false;
|
||||
const domainId = ctx.HydroContext.args.domainId;
|
||||
const host = ctx.HydroContext.domain?.host;
|
||||
if (domainId !== 'system' && (
|
||||
!request.host
|
||||
|| (host instanceof Array
|
||||
? (!host.includes(request.host))
|
||||
: request.host !== host)
|
||||
)) withDomainId = domainId;
|
||||
res = router.url(name, args, { query }).toString();
|
||||
if (anchor) res = `${res}#${anchor}`;
|
||||
if (withDomainId) res = `/d/${withDomainId}${res}`;
|
||||
} 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;
|
||||
};
|
||||
ctx.translate = (str: string) => {
|
||||
if (!str) return '';
|
||||
const lang = ctx.HydroContext.user?.viewLang || ctx.session?.viewLang;
|
||||
return str.toString().translate(lang, ...ctx.acceptsLanguages(), system.get('server.language'));
|
||||
};
|
||||
await next();
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { errorMessage } from '@hydrooj/utils/lib/utils';
|
||||
import { SystemError, UserFacingError } from 'hydrooj/src/error';
|
||||
import type { KoaContext } from '../server';
|
||||
|
||||
function serializer(k: string, v: any) {
|
||||
if (k.startsWith('_') && k !== '_id') return undefined;
|
||||
if (typeof v === 'bigint') return `BigInt::${v.toString()}`;
|
||||
return v;
|
||||
}
|
||||
|
||||
export default (logger) => async (ctx: KoaContext, next) => {
|
||||
const {
|
||||
request, response, UiContext, user, args,
|
||||
} = ctx.HydroContext;
|
||||
try {
|
||||
await next();
|
||||
if (response.redirect) {
|
||||
response.body ||= {};
|
||||
response.body.url = response.redirect;
|
||||
}
|
||||
if (!response.type) {
|
||||
if (
|
||||
request.json || response.redirect
|
||||
|| request.query.noTemplate || !response.template) {
|
||||
// Send raw data
|
||||
try {
|
||||
if (typeof response.body === 'object') {
|
||||
response.body.UiContext = UiContext;
|
||||
response.body.UserContext = user;
|
||||
}
|
||||
response.body = JSON.stringify(response.body, serializer);
|
||||
} catch (e) {
|
||||
response.body = new SystemError('Serialize failure', e.message);
|
||||
}
|
||||
response.type = 'application/json';
|
||||
} else if (response.template) {
|
||||
const s = response.template.split('.');
|
||||
let templateName = `${s[0]}.${args.domainId}.${s[1]}`;
|
||||
if (!global.Hydro.ui.template[templateName]) templateName = response.template;
|
||||
await ctx.render(templateName, response.body || {});
|
||||
}
|
||||
}
|
||||
if (response.disposition) ctx.set('Content-Disposition', response.disposition);
|
||||
if (response.etag) {
|
||||
ctx.set('ETag', response.etag);
|
||||
ctx.set('Cache-Control', 'public');
|
||||
}
|
||||
} catch (err) {
|
||||
const error = errorMessage(err);
|
||||
response.status = error instanceof UserFacingError ? error.code : 500;
|
||||
if (request.json) response.body = { error };
|
||||
else {
|
||||
try {
|
||||
await ctx.render(error instanceof UserFacingError ? 'error.html' : 'bsod.html', { error });
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
// this.response.body.error = {};
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (response.etag && request.headers['if-none-match'] === response.etag) {
|
||||
ctx.response.status = 304;
|
||||
} else if (response.redirect && !request.json) {
|
||||
ctx.response.type = 'application/octet-stream';
|
||||
ctx.response.status = 302;
|
||||
ctx.redirect(response.redirect);
|
||||
} else if (response.body) {
|
||||
ctx.body = response.body;
|
||||
ctx.response.status = response.status || 200;
|
||||
ctx.response.type = response.type
|
||||
|| (request.json
|
||||
? 'application/json'
|
||||
: ctx.response.type);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CsrfTokenError, NotFoundError } from 'hydrooj/src/error';
|
||||
import avatar from 'hydrooj/src/lib/avatar';
|
||||
import { PERM } from 'hydrooj/src/model/builtin';
|
||||
import UserModel from 'hydrooj/src/model/user';
|
||||
import type { KoaContext } from '../server';
|
||||
|
||||
export default async (ctx: KoaContext, next) => {
|
||||
// User Layer
|
||||
const { request, args, domain } = ctx.HydroContext;
|
||||
const domainId = domain ? args.domainId : 'system';
|
||||
let user = await UserModel.getById(domainId, ctx.session.uid, ctx.session.scope);
|
||||
if (!user) {
|
||||
ctx.session.uid = 0;
|
||||
ctx.session.scope = PERM.PERM_ALL.toString();
|
||||
user = await UserModel.getById(domainId, ctx.session.uid, ctx.session.scope);
|
||||
}
|
||||
if (user._id === 0) delete user.viewLang;
|
||||
user.avatarUrl = avatar(user.avatar, 128);
|
||||
ctx.HydroContext.user = user;
|
||||
if (!domain) throw new NotFoundError(args.domainId);
|
||||
if (request.method === 'post' && request.headers.referer) {
|
||||
const host = new URL(request.headers.referer).host;
|
||||
if (host !== request.host) throw new CsrfTokenError(host);
|
||||
}
|
||||
await next();
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { IncomingMessage } from 'http';
|
||||
import KoaRouter from 'koa-router';
|
||||
import parseUrl from 'parseurl';
|
||||
import { pathToRegexp } from 'path-to-regexp';
|
||||
import tx2 from 'tx2';
|
||||
import type WebSocket from 'ws';
|
||||
import type { KoaContext } from './server';
|
||||
|
||||
type WebSocketCallback = (socket: WebSocket, request: IncomingMessage, ctx: KoaContext) => void;
|
||||
const connCount = tx2.counter({ name: 'connections' });
|
||||
|
||||
export class WebSocketLayer {
|
||||
clients = new Set<WebSocket>();
|
||||
regexp: RegExp;
|
||||
|
||||
constructor(private router: Router, path: Parameters<typeof pathToRegexp>[0], public callback?: WebSocketCallback) {
|
||||
this.regexp = pathToRegexp(path);
|
||||
}
|
||||
|
||||
accept(socket: WebSocket, request: IncomingMessage, ctx: KoaContext) {
|
||||
if (!this.regexp.test(parseUrl(request).pathname)) return false;
|
||||
this.clients.add(socket);
|
||||
connCount.inc();
|
||||
socket.on('close', () => {
|
||||
connCount.dec();
|
||||
this.clients.delete(socket);
|
||||
});
|
||||
this.callback?.(socket, request, ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
close() {
|
||||
for (const socket of this.clients) {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Router extends KoaRouter {
|
||||
wsStack: WebSocketLayer[] = [];
|
||||
|
||||
/**
|
||||
* hack into router methods to make sure that koa middlewares are disposable
|
||||
*/
|
||||
register(...args: Parameters<KoaRouter['register']>) {
|
||||
const layer = super.register(...args);
|
||||
// TODO handle unregister
|
||||
return layer;
|
||||
}
|
||||
|
||||
ws(path: Parameters<typeof pathToRegexp>[0], callback?: WebSocketCallback) {
|
||||
const layer = new WebSocketLayer(this, path, callback);
|
||||
this.wsStack.push(layer);
|
||||
// TODO handle unregistry
|
||||
return layer;
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,80 @@
|
||||
import assert from 'assert';
|
||||
import { createHash } from 'crypto';
|
||||
import http from 'http';
|
||||
import { PassThrough } from 'stream';
|
||||
import cac from 'cac';
|
||||
import Cookies from 'cookies';
|
||||
import emojiRegex from 'emoji-regex';
|
||||
import Koa, { Context } from 'koa';
|
||||
import Body from 'koa-body';
|
||||
import Compress from 'koa-compress';
|
||||
import proxy from 'koa-proxies';
|
||||
import Router from 'koa-router';
|
||||
import cache from 'koa-static-cache';
|
||||
import {
|
||||
cloneDeep, Dictionary, filter, isSafeInteger,
|
||||
} from 'lodash';
|
||||
import moment from 'moment-timezone';
|
||||
import { ObjectID } from 'mongodb';
|
||||
import sockjs from 'sockjs';
|
||||
import { filter } from 'lodash';
|
||||
import tx2 from 'tx2';
|
||||
import WebSocket from 'ws';
|
||||
import { parseMemoryMB } from '@hydrooj/utils/lib/utils';
|
||||
import {
|
||||
BlacklistedError, CsrfTokenError, HydroError,
|
||||
InvalidOperationError, MethodNotAllowedError, NotFoundError,
|
||||
PermissionError, PrivilegeError, SystemError,
|
||||
UserFacingError, UserNotFoundError, ValidationError,
|
||||
HydroError, InvalidOperationError, MethodNotAllowedError,
|
||||
NotFoundError, PermissionError, PrivilegeError,
|
||||
UserFacingError,
|
||||
} from '../error';
|
||||
import { DomainDoc, User } from '../interface';
|
||||
import avatar from '../lib/avatar';
|
||||
import { isContent, isName, isTitle } from '../lib/validator';
|
||||
import { Logger } from '../logger';
|
||||
import blacklist from '../model/blacklist';
|
||||
import { PERM, PRIV } from '../model/builtin';
|
||||
import domain from '../model/domain';
|
||||
import * as opcount from '../model/opcount';
|
||||
import * as system from '../model/system';
|
||||
import token from '../model/token';
|
||||
import user from '../model/user';
|
||||
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<string, import('formidable').File>;
|
||||
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<string, any>;
|
||||
UiContext: Record<string, any>;
|
||||
}
|
||||
export interface KoaContext extends Koa.DefaultContext {
|
||||
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');
|
||||
@@ -46,220 +83,28 @@ const reqCount = tx2.meter({
|
||||
samples: 1,
|
||||
timeframe: 60,
|
||||
});
|
||||
const connCount = tx2.counter({ name: 'connections' });
|
||||
export const app = new Koa();
|
||||
export const server = http.createServer(app.callback());
|
||||
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 });
|
||||
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(123, error);
|
||||
});
|
||||
|
||||
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,
|
||||
CommaSeperatedArray: Type,
|
||||
Set: Type,
|
||||
Emoji: Type,
|
||||
}
|
||||
|
||||
export const Types: Types = {
|
||||
Content: [(v) => v.toString().trim(), isContent],
|
||||
Name: [(v) => v.toString().trim(), isName],
|
||||
Title: [(v) => v.toString().trim(), 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) => +v, (v) => Number.isFinite(+v)],
|
||||
// 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);
|
||||
}],
|
||||
CommaSeperatedArray: [
|
||||
(v) => v.toString().replace(/,/g, ',').split(',').map((e) => e.trim()).filter((i) => i),
|
||||
(v) => v.toString(),
|
||||
],
|
||||
Set: [(v) => {
|
||||
if (v instanceof Array) return new Set(v);
|
||||
return v ? new Set([v]) : new Set();
|
||||
}, null],
|
||||
Emoji: [
|
||||
(v: string) => v.matchAll(emojiRegex()).next().value[0],
|
||||
(v) => emojiRegex().test(v),
|
||||
],
|
||||
};
|
||||
|
||||
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++;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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', {
|
||||
@@ -280,7 +125,8 @@ export async function prepare() {
|
||||
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}`);
|
||||
logger.debug(`${ctx.request.method} /${ctx.domainId || 'system'}${ctx.request.path} \
|
||||
${ctx.response.status} ${endTime - startTime}ms ${ctx.response.length}`);
|
||||
});
|
||||
}
|
||||
app.use(Body({
|
||||
@@ -291,32 +137,40 @@ export async function prepare() {
|
||||
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));
|
||||
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 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;
|
||||
}
|
||||
|
||||
const ignoredLimit = `,${argv.options.ignoredLimit},`;
|
||||
|
||||
export class HandlerCommon {
|
||||
domainId: string;
|
||||
domain: DomainDoc;
|
||||
user: User;
|
||||
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>;
|
||||
request: Record<string, any>;
|
||||
extraTitleContent?: string;
|
||||
/** @deprecated */
|
||||
domainId: string;
|
||||
|
||||
constructor(
|
||||
public ctx: 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 = 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;
|
||||
@@ -326,15 +180,10 @@ export class HandlerCommon {
|
||||
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) {
|
||||
const name = this.domain?.ui?.name || system.get('server.name');
|
||||
if (this.extraTitleContent) return `${this.translate(str)} - ${this.extraTitleContent} - ${name}`;
|
||||
return `${this.translate(str)} - ${name}`;
|
||||
if (this.UiContext.extraTitleContent) return `${this.ctx.translate(str)} - ${this.UiContext.extraTitleContent} - ${name}`;
|
||||
return `${this.ctx.translate(str)} - ${name}`;
|
||||
}
|
||||
|
||||
checkPerm(...args: bigint[]) {
|
||||
@@ -347,326 +196,34 @@ export class HandlerCommon {
|
||||
checkPriv(...args: number[]) {
|
||||
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] = kwargs[key].toString().replace(/\//g, '%2F');
|
||||
}
|
||||
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.domainId !== 'system' && (
|
||||
!this.request.host
|
||||
|| (this.domain?.host instanceof Array
|
||||
? (!this.domain.host.includes(this.request.host))
|
||||
: this.request.host !== this.domain?.host)
|
||||
)) {
|
||||
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;
|
||||
|
||||
declare request: {
|
||||
method: string,
|
||||
host: string,
|
||||
hostname: string,
|
||||
ip: string,
|
||||
headers: any,
|
||||
cookies: any,
|
||||
body: any,
|
||||
files: Record<string, import('formidable').File>,
|
||||
query: any,
|
||||
path: string,
|
||||
params: any,
|
||||
referer: string,
|
||||
json: boolean,
|
||||
};
|
||||
|
||||
response: {
|
||||
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,
|
||||
};
|
||||
|
||||
csrfToken: string;
|
||||
loginMethods: any;
|
||||
noCheckPermView = false;
|
||||
notUsage: 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 as any,
|
||||
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());
|
||||
}
|
||||
},
|
||||
addHeader: (name: string, value: string) => this.ctx.set(name, value),
|
||||
disposition: null,
|
||||
};
|
||||
this.UiContext = cloneDeep(UiContextBase);
|
||||
if (!process.env.DEV) 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;
|
||||
}
|
||||
|
||||
// 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';
|
||||
}
|
||||
__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) {
|
||||
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 header = this.request.headers['authorization'];
|
||||
const sid = header
|
||||
? header.split(' ')[1] // Just accept bearer token
|
||||
: this.request.cookies.get('sid');
|
||||
this.session = await token.get(sid, token.TYPE_SESSION);
|
||||
if (!this.session) this.session = { uid: 0 };
|
||||
}
|
||||
|
||||
async init({ domainId }) {
|
||||
let error;
|
||||
if (!argv.options.benchmark && !this.notUsage) await this.limitRate('global', 5, 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 (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);
|
||||
this.domain = inferDomain || await domain.get('system');
|
||||
error = new NotFoundError(domainId);
|
||||
}
|
||||
this.UiContext.domainId = this.domainId;
|
||||
this.UiContext.domain = this.domain;
|
||||
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) delete this.user.viewLang;
|
||||
this.user.avatarUrl = avatar(this.user.avatar, 128);
|
||||
this.csrfToken = this.getCsrfToken(this.session._id || String.random(32));
|
||||
this.UiContext.csrfToken = this.csrfToken;
|
||||
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,
|
||||
}));
|
||||
if (error) throw error;
|
||||
if (bdoc) throw new BlacklistedError(this.request.ip);
|
||||
if (!this.noCheckPermView && !this.user.hasPriv(PRIV.PRIV_VIEW_ALL_DOMAIN)) 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 {
|
||||
if (typeof this.response.body === 'object') {
|
||||
this.response.body.UiContext = this.UiContext;
|
||||
this.response.body.UserContext = this.user;
|
||||
}
|
||||
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.etag) {
|
||||
this.ctx.set('ETag', this.response.etag);
|
||||
this.ctx.set('Cache-Control', 'public');
|
||||
}
|
||||
if (this.response.etag && this.request.headers['if-none-match'] === this.response.etag) {
|
||||
this.ctx.response.status = 304;
|
||||
} else 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.body = this.response.body;
|
||||
this.ctx.response.status = this.response.status || 200;
|
||||
this.ctx.response.type = this.response.type
|
||||
|| (this.request.json
|
||||
? 'application/json'
|
||||
: this.ctx.response.type);
|
||||
}
|
||||
}
|
||||
|
||||
async saveCookie() {
|
||||
const ua = this.request.headers['user-agent'] || '';
|
||||
if (!this.session.uid && system.get('server.ignoreUA').replace(/\r/g, '').split('\n').filter((i) => i && ua.includes(i)).length) return;
|
||||
const expireSeconds = this.session.save
|
||||
? system.get('session.saved_expire_seconds')
|
||||
: system.get('session.unsaved_expire_seconds');
|
||||
const $update = {
|
||||
updateIp: this.request.ip,
|
||||
updateUa: ua,
|
||||
};
|
||||
const $create = {
|
||||
createIp: this.request.ip,
|
||||
createUa: ua,
|
||||
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: Cookies.SetOption = {
|
||||
expires: new Date(Date.now() + expireSeconds * 1010),
|
||||
secure: !!system.get('session.secure'),
|
||||
httpOnly: false,
|
||||
};
|
||||
this.ctx.cookies.set('sid', this.session._id, cookie);
|
||||
}
|
||||
|
||||
async onerror(error: HydroError) {
|
||||
@@ -677,7 +234,7 @@ export class Handler extends HandlerCommon {
|
||||
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.request.path + this.ctx.search } });
|
||||
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';
|
||||
@@ -685,7 +242,6 @@ export class Handler extends HandlerCommon {
|
||||
error: { message: error.msg(), params: error.params, stack: errorMessage(error.stack || '') },
|
||||
};
|
||||
}
|
||||
await this.finish().catch(() => { });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -694,14 +250,14 @@ async function bail(name: string, ...args: any[]) {
|
||||
if (r instanceof Error) throw r;
|
||||
}
|
||||
|
||||
async function handle(ctx, HandlerClass, checker) {
|
||||
async function handle(ctx: KoaContext, HandlerClass, checker) {
|
||||
reqCount.mark();
|
||||
const args = {
|
||||
domainId: 'system', ...ctx.params, ...ctx.query, ...ctx.request.body, __start: Date.now(),
|
||||
};
|
||||
const h = new HandlerClass(ctx);
|
||||
h.args = args;
|
||||
h.domainId = args.domainId;
|
||||
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)
|
||||
@@ -710,7 +266,6 @@ async function handle(ctx, HandlerClass, checker) {
|
||||
|
||||
await bus.serial('handler/create', h);
|
||||
|
||||
await h.init(args);
|
||||
if (checker) checker.call(h);
|
||||
if (method === 'post') {
|
||||
if (operation) {
|
||||
@@ -743,7 +298,6 @@ async function handle(ctx, HandlerClass, checker) {
|
||||
await bail(`handler/after/${HandlerClass.name.replace(/Handler$/, '')}`, h);
|
||||
await bail('handler/after', h);
|
||||
if (h.cleanup) await h.cleanup(args);
|
||||
if (h.finish) await h.finish(args);
|
||||
await bail(`handler/finish/${HandlerClass.name.replace(/Handler$/, '')}`, h);
|
||||
await bail('handler/finish', h);
|
||||
} catch (e) {
|
||||
@@ -779,55 +333,31 @@ const Checker = (permPrivChecker) => {
|
||||
}
|
||||
return function check(this: Handler) {
|
||||
checker();
|
||||
if (perm) this.checkPerm(perm); // lgtm [js/trivial-conditional]
|
||||
if (perm) this.checkPerm(perm);
|
||||
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));
|
||||
router.all(name, path, (ctx) => handle(ctx as any, RouteHandler, Checker(permPrivChecker)));
|
||||
}
|
||||
|
||||
export class ConnectionHandler extends HandlerCommon {
|
||||
conn: sockjs.Connection;
|
||||
args: Record<string, any>;
|
||||
declare 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]);
|
||||
}
|
||||
conn: WebSocket;
|
||||
|
||||
send(data: any) {
|
||||
this.conn.write(JSON.stringify(data, serializer));
|
||||
this.conn.send(JSON.stringify(data, serializer));
|
||||
}
|
||||
|
||||
close(code: number, reason: string) {
|
||||
this.conn.close(code.toString(), reason);
|
||||
this.conn.close(code, reason);
|
||||
}
|
||||
|
||||
async message(message: any) { } // eslint-disable-line
|
||||
|
||||
onerror(err: HydroError) {
|
||||
if (err instanceof UserFacingError) err.stack = this.conn.pathname;
|
||||
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.conn.pathname}, User:${this.user?._id}(${this.user?.uname})`);
|
||||
logger.error(`Path:${this.ctx.HydroContext.request.path}, User:${this.user?._id}(${this.user?.uname})`);
|
||||
logger.error(err);
|
||||
}
|
||||
this.send({
|
||||
@@ -838,34 +368,6 @@ export class ConnectionHandler extends HandlerCommon {
|
||||
});
|
||||
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)
|
||||
|| { 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(
|
||||
@@ -873,26 +375,16 @@ export function Connection(
|
||||
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) => {
|
||||
if (!conn) return;
|
||||
const h: Dictionary<any> = new RouteConnHandler(conn);
|
||||
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 {
|
||||
const args = { domainId: 'system', ...h.request.params };
|
||||
h.args = args;
|
||||
h.domainId = args.domainId;
|
||||
if (!h.noAuth) {
|
||||
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);
|
||||
}
|
||||
|
||||
checker.call(h);
|
||||
if (h._prepare) await h._prepare(args);
|
||||
if (h.prepare) await h.prepare(args);
|
||||
if (h.message) {
|
||||
@@ -900,17 +392,13 @@ export function Connection(
|
||||
h.message(JSON.parse(data));
|
||||
});
|
||||
}
|
||||
connCount.inc();
|
||||
conn.on('close', async () => {
|
||||
if (h.cleanup) await h.cleanup(args);
|
||||
if (h.finish) await h.finish(args);
|
||||
connCount.dec();
|
||||
});
|
||||
} catch (e) {
|
||||
await h.onerror(e);
|
||||
}
|
||||
});
|
||||
sock.installHandlers(server);
|
||||
}
|
||||
|
||||
let started = false;
|
||||
@@ -921,7 +409,7 @@ export async function start() {
|
||||
const port = system.get('server.port');
|
||||
app.use(router.routes()).use(router.allowedMethods());
|
||||
await new Promise((resolve) => {
|
||||
server.listen(argv.options.port || port, () => {
|
||||
httpServer.listen(argv.options.port || port, () => {
|
||||
logger.success('Server listening at: %d', argv.options.port || port);
|
||||
started = true;
|
||||
resolve(true);
|
||||
@@ -930,17 +418,11 @@ export async function start() {
|
||||
}
|
||||
|
||||
global.Hydro.service.server = {
|
||||
Types,
|
||||
...decorators,
|
||||
app,
|
||||
server,
|
||||
httpServer,
|
||||
wsServer,
|
||||
router,
|
||||
UiContextBase,
|
||||
get,
|
||||
query,
|
||||
post,
|
||||
route,
|
||||
param,
|
||||
requireCsrfToken,
|
||||
HandlerCommon,
|
||||
Handler,
|
||||
ConnectionHandler,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as system from 'hydrooj/src/model/system';
|
||||
import { UiContextBase } from 'hydrooj/src/service/server';
|
||||
import { UiContextBase } from 'hydrooj/src/service/layers/base';
|
||||
|
||||
declare module 'hydrooj/src/service/server' {
|
||||
declare module 'hydrooj/src/service/layers/base' {
|
||||
interface UiContextBase {
|
||||
onlyofficeApi?: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hydrooj/onlyoffice",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"main": "package.json",
|
||||
"repository": "git@github.com:hydro-dev/Hydro.git",
|
||||
"author": "undefined <i@undefined.moe>",
|
||||
|
||||
@@ -6,60 +6,62 @@ import i18n from 'vj/utils/i18n';
|
||||
import tpl from 'vj/utils/tpl';
|
||||
|
||||
const messagePage = new AutoloadPage('messagePage', (pagename) => {
|
||||
if (pagename === 'home_messages') return;
|
||||
let visible = true;
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
const state = document.visibilityState;
|
||||
if (state === 'hidden' && visible) visible = false;
|
||||
if (state === 'visible' && !visible) visible = true;
|
||||
});
|
||||
if (pagename === 'home_messages' || !UserContext._id) return;
|
||||
|
||||
async function init() {
|
||||
if (UserContext.unreadMsg) {
|
||||
new VjNotification({
|
||||
type: 'info',
|
||||
message: i18n(`You have ${UserContext.unreadMsg > 1 ? 'new messages' : 'a new message'}.`),
|
||||
duration: 5000,
|
||||
action: () => window.open('/home/messages', '_blank'),
|
||||
}).show();
|
||||
}
|
||||
|
||||
const { default: SockJs } = await import('../socket');
|
||||
|
||||
const sock = new SockJs('/home/messages-conn', true, true);
|
||||
sock.onmessage = (message) => {
|
||||
const msg = JSON.parse(message.data);
|
||||
console.log('Received message', msg);
|
||||
if (msg.mdoc.flag & FLAG_ALERT) {
|
||||
// Is alert
|
||||
new InfoDialog({
|
||||
cancelByClickingBack: false,
|
||||
$body: tpl`
|
||||
<div class="typo">
|
||||
${{ templateRaw: true, html: i18n('Message from {0}:', `<div data-user>${msg.mdoc.from}</div>`) }}
|
||||
<p>${i18n(msg.mdoc.content)}</p>
|
||||
</div>`,
|
||||
}).open();
|
||||
} else if (msg.udoc._id === 1 && msg.mdoc.flag & 4) {
|
||||
new VjNotification({
|
||||
message: i18n('You received a system message, click here to view.'),
|
||||
duration: 15000,
|
||||
action: () => window.open(`/home/messages?uid=${msg.udoc._id}`, '_blank'),
|
||||
}).show();
|
||||
} else {
|
||||
// Is message
|
||||
new VjNotification({
|
||||
title: msg.udoc.uname,
|
||||
avatar: msg.udoc.avatarUrl,
|
||||
message: msg.mdoc.content,
|
||||
duration: 15000,
|
||||
action: () => window.open(`/home/messages?uid=${msg.udoc._id}`, '_blank'),
|
||||
}).show();
|
||||
}
|
||||
};
|
||||
if (UserContext.unreadMsg) {
|
||||
new VjNotification({
|
||||
type: 'info',
|
||||
message: i18n(`You have ${UserContext.unreadMsg > 1 ? 'new messages' : 'a new message'}.`),
|
||||
duration: 5000,
|
||||
action: () => window.open('/home/messages', '_blank'),
|
||||
}).show();
|
||||
}
|
||||
|
||||
if (UserContext._id !== 0) init();
|
||||
const onmessage = (message) => {
|
||||
const msg = JSON.parse(message);
|
||||
console.log('Received message', msg);
|
||||
if (msg.mdoc.flag & FLAG_ALERT) {
|
||||
// Is alert
|
||||
return new InfoDialog({
|
||||
cancelByClickingBack: false,
|
||||
$body: tpl`
|
||||
<div class="typo">
|
||||
${{ templateRaw: true, html: i18n('Message from {0}:', `<div data-user>${msg.mdoc.from}</div>`) }}
|
||||
<p>${i18n(msg.mdoc.content)}</p>
|
||||
</div>`,
|
||||
}).open();
|
||||
}
|
||||
if (msg.udoc._id === 1 && msg.mdoc.flag & 4) {
|
||||
// System notification
|
||||
return new VjNotification({
|
||||
message: i18n('You received a system message, click here to view.'),
|
||||
duration: 15000,
|
||||
action: () => window.open(`/home/messages?uid=${msg.udoc._id}`, '_blank'),
|
||||
}).show();
|
||||
}
|
||||
// Is message
|
||||
return new VjNotification({
|
||||
title: msg.udoc.uname,
|
||||
avatar: msg.udoc.avatarUrl,
|
||||
message: msg.mdoc.content,
|
||||
duration: 15000,
|
||||
action: () => window.open(`/home/messages?uid=${msg.udoc._id}`, '_blank'),
|
||||
}).show();
|
||||
};
|
||||
|
||||
const path = new URL('/home/messages-conn', window.location.href).toString();
|
||||
const worker = new SharedWorker('/sharedworker.js', { name: 'Hydro Messages Shared Worker' });
|
||||
worker.port.start();
|
||||
worker.port.postMessage({ type: 'init', path, cookie: document.cookie });
|
||||
worker.port.onmessage = (e) => {
|
||||
if (e.data.type !== 'message') return;
|
||||
if (document.hidden) {
|
||||
worker.port.postMessage({ type: 'fail' });
|
||||
} else {
|
||||
worker.port.postMessage({ type: 'success' });
|
||||
onmessage(e.data.payload);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export default messagePage;
|
||||
@@ -35,7 +35,6 @@ function handlerSwitchAccount(ev) {
|
||||
</div>
|
||||
`,
|
||||
onDispatch(action) {
|
||||
console.log(action, userSelector.value());
|
||||
if (action === 'ok' && userSelector.value() === null) {
|
||||
userSelector.focus();
|
||||
return false;
|
||||
|
||||
@@ -1,88 +1,38 @@
|
||||
import SockJS from 'sockjs-client';
|
||||
import $ from 'jquery';
|
||||
import Notification from 'vj/components/notification/index';
|
||||
import i18n from 'vj/utils/i18n';
|
||||
import tpl from 'vj/utils/tpl';
|
||||
|
||||
const notification = tpl`<div style="position: fixed; left: 0px; bottom: -100px; width: 100%; \
|
||||
background: rgb(255, 241, 184); z-index: 2147483647; color: rgb(97, 52, 0); \
|
||||
text-align: center; font-size: 18px; font-family: Consolas, Menlo, Courier, monospace; \
|
||||
padding: 8px 0px; box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 8px;" id="disconnect_notification" \
|
||||
>${i18n('Disconnected from the server, trying to reconnect...')}</div>`;
|
||||
|
||||
const $el = $(notification);
|
||||
$el.appendTo('body');
|
||||
$el.hide();
|
||||
function create() {
|
||||
$el.show();
|
||||
$el.animate({ bottom: 0 }, 100);
|
||||
}
|
||||
function remove() {
|
||||
$el.animate({ bottom: -100 }, 100, () => $el.remove());
|
||||
}
|
||||
import ReconnectingWebSocket from 'reconnecting-websocket';
|
||||
|
||||
export default class Sock {
|
||||
constructor(url, showNotification = true, useShared = false) {
|
||||
this.url = url;
|
||||
this.shared = false;
|
||||
this.isreconnect = false;
|
||||
this.retryCount = 0;
|
||||
if (useShared && window.SharedWorker) this.initShared();
|
||||
else this.init();
|
||||
this.showNotification = showNotification;
|
||||
}
|
||||
|
||||
onauth() {
|
||||
remove();
|
||||
this.retryCount = 0;
|
||||
if (this.onopen) this.onopen(this.sock);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.sock = new SockJS(this.url);
|
||||
// SockJS wouldn't send cookie. hack.
|
||||
this.sock.onopen = () => this.send(document.cookie);
|
||||
constructor(url) {
|
||||
const i = new URL(url, window.location.href);
|
||||
i.protocol = i.protocol.replace('http', 'ws');
|
||||
this.url = i.toString();
|
||||
this.closed = false;
|
||||
this.sock = new ReconnectingWebSocket(this.url);
|
||||
this.sock.onopen = () => {
|
||||
console.log('Connected');
|
||||
this.onopen?.(this.sock);
|
||||
};
|
||||
this.sock.onclose = ({ code, reason }) => {
|
||||
this.retryCount++;
|
||||
console.warn('Connection closed, ', code, reason);
|
||||
if (code >= 4000) this.closed = true;
|
||||
if (!this.closed) {
|
||||
this.isreconnect = setTimeout(this.init.bind(this), 3000);
|
||||
}
|
||||
if (this.showNotification && this.retryCount > 3) create();
|
||||
if (this.onclose) this.onclose(code, reason);
|
||||
this.onclose?.(code, reason);
|
||||
};
|
||||
this.sock.onmessage = (message) => {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Sock.onmessage: ', message);
|
||||
const msg = JSON.parse(message.data);
|
||||
if (msg.event === 'auth') {
|
||||
if (msg.error === 'PermissionError' || msg.error === 'PrivilegeError') {
|
||||
if (this.showNotification) Notification.info(i18n('Connect fail: Permission denied.'));
|
||||
this.closed = true;
|
||||
} else this.onauth();
|
||||
} else if (this.onmessage) this.onmessage(message);
|
||||
this.close();
|
||||
}
|
||||
} else this.onmessage?.(message);
|
||||
};
|
||||
}
|
||||
|
||||
initShared() {
|
||||
this.shared = true;
|
||||
const worker = new SharedWorker('/sharedworker.js', { name: 'Hydro Shared Connections Worker' });
|
||||
worker.port.start();
|
||||
const path = `${new URL(this.url, window.location.href).href.replace('http', 'ws')}/websocket`;
|
||||
worker.port.postMessage({ type: 'sharedConn', path, cookie: document.cookie });
|
||||
worker.port.onmessage = (e) => {
|
||||
if (e.data.type === 'message') this.onmessage({ data: e.data.payload });
|
||||
};
|
||||
this.worker = worker;
|
||||
}
|
||||
|
||||
send(data) {
|
||||
if (this.shared) this.worker.port.postMessage({ type: 'message', path: this.url, payload: data });
|
||||
else this.sock.send(data);
|
||||
this.sock.send(data);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
if (!this.shared) this.sock.close();
|
||||
this.sock?.close?.();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hydrooj/ui-default",
|
||||
"version": "4.34.40",
|
||||
"version": "4.35.0",
|
||||
"author": "undefined <i@undefined.moe>",
|
||||
"license": "AGPL-3.0",
|
||||
"main": "hydro.js",
|
||||
@@ -26,7 +26,6 @@
|
||||
"@types/katex": "^0.14.0",
|
||||
"@types/qrcode": "^1.4.2",
|
||||
"@types/redux-logger": "^3.0.9",
|
||||
"@types/sockjs-client": "^1.5.1",
|
||||
"@undefined-moe/monaco-yaml": "^2.5.1",
|
||||
"ansi_up": "^5.1.0",
|
||||
"autoprefixer": "^9.8.8",
|
||||
@@ -89,7 +88,6 @@
|
||||
"redux-thunk": "^2.4.1",
|
||||
"rupture": "^0.7.1",
|
||||
"slideout": "^1.0.1",
|
||||
"sockjs-client": "^1.6.0",
|
||||
"speed-measure-webpack-plugin": "^1.5.0",
|
||||
"sticky-kit": "^1.1.3",
|
||||
"style-loader": "^2.0.0",
|
||||
|
||||
@@ -23,7 +23,7 @@ const page = new NamedPage('home_messages', () => {
|
||||
}
|
||||
|
||||
async function mountComponent() {
|
||||
const { default: SockJs } = await import('../components/socket');
|
||||
const { default: WebSocket } = await import('../components/socket');
|
||||
const { default: MessagePadApp } = await import('../components/messagepad');
|
||||
const { default: MessagePadReducer } = await import('../components/messagepad/reducers');
|
||||
const {
|
||||
@@ -32,7 +32,7 @@ const page = new NamedPage('home_messages', () => {
|
||||
|
||||
reduxStore = store;
|
||||
|
||||
const sock = new SockJs('/home/messages-conn');
|
||||
const sock = new WebSocket('/home/messages-conn');
|
||||
sock.onmessage = (message) => {
|
||||
const msg = JSON.parse(message.data);
|
||||
store.dispatch({
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { NamedPage } from 'vj/misc/Page';
|
||||
|
||||
const page = new NamedPage('manage_dashboard', async () => {
|
||||
const { default: SockJs } = await import('../components/socket');
|
||||
const { default: WebSocket } = await import('../components/socket');
|
||||
|
||||
const sock = new SockJs('/manage/check-conn');
|
||||
const sock = new WebSocket('/manage/check-conn');
|
||||
sock.onopen = () => {
|
||||
$('<blockquote class="blue"><p>Connection opened.</p></blockquote>').appendTo('#messages');
|
||||
};
|
||||
|
||||
@@ -129,14 +129,14 @@ const page = new NamedPage(['problem_detail', 'contest_detail_problem', 'homewor
|
||||
if (reactLoaded) return;
|
||||
$('.loader-container').show();
|
||||
|
||||
const { default: SockJs } = await import('../components/socket');
|
||||
const { default: WebSocket } = await import('../components/socket');
|
||||
const { default: ScratchpadApp } = await import('../components/scratchpad');
|
||||
const { default: ScratchpadReducer } = await import('../components/scratchpad/reducers');
|
||||
const {
|
||||
React, render, unmountComponentAtNode, Provider, store,
|
||||
} = await loadReactRedux(ScratchpadReducer);
|
||||
|
||||
const sock = new SockJs(UiContext.pretestConnUrl);
|
||||
const sock = new WebSocket(UiContext.pretestConnUrl);
|
||||
sock.onmessage = (message) => {
|
||||
const msg = JSON.parse(message.data);
|
||||
store.dispatch({
|
||||
|
||||
@@ -2,12 +2,12 @@ import { NamedPage } from 'vj/misc/Page';
|
||||
|
||||
const page = new NamedPage('record_detail', async () => {
|
||||
if (!UiContext.socketUrl) return;
|
||||
const [{ default: SockJs }, { DiffDOM }] = await Promise.all([
|
||||
const [{ default: WebSocket }, { DiffDOM }] = await Promise.all([
|
||||
import('../components/socket'),
|
||||
import('diff-dom'),
|
||||
]);
|
||||
|
||||
const sock = new SockJs(UiContext.socketUrl);
|
||||
const sock = new WebSocket(UiContext.socketUrl);
|
||||
const dd = new DiffDOM();
|
||||
sock.onmessage = (message) => {
|
||||
const msg = JSON.parse(message.data);
|
||||
|
||||
@@ -5,12 +5,12 @@ import tpl from 'vj/utils/tpl';
|
||||
import getAvailableLangs from 'vj/utils/availableLangs';
|
||||
|
||||
const page = new NamedPage('record_main', async () => {
|
||||
const [{ default: SockJs }, { DiffDOM }] = await Promise.all([
|
||||
const [{ default: WebSocket }, { DiffDOM }] = await Promise.all([
|
||||
import('../components/socket'),
|
||||
import('diff-dom'),
|
||||
]);
|
||||
|
||||
const sock = new SockJs(UiContext.socketUrl);
|
||||
const sock = new WebSocket(UiContext.socketUrl);
|
||||
const dd = new DiffDOM();
|
||||
|
||||
let firstLoad = true;
|
||||
|
||||
@@ -1,63 +1,54 @@
|
||||
import ReconnectingWebsocket from 'reconnecting-websocket';
|
||||
|
||||
console.log('SharedWorker init');
|
||||
const ports: MessagePort[] = [];
|
||||
let conn: ReconnectingWebsocket;
|
||||
|
||||
const sharedConnections: Record<string, [ReconnectingWebsocket, MessagePort[]]> = {};
|
||||
interface RequestInitSharedConnPayload {
|
||||
type: 'sharedConn';
|
||||
cookie: any;
|
||||
path: string;
|
||||
}
|
||||
interface RequestSendMessagePayload {
|
||||
type: 'message';
|
||||
path: string;
|
||||
payload: string;
|
||||
}
|
||||
type RequestPayload = RequestInitSharedConnPayload | RequestSendMessagePayload;
|
||||
|
||||
function broadcastMsg(ports: MessagePort[], message: any) {
|
||||
for (const p of ports) p.postMessage(message);
|
||||
}
|
||||
function closeSharedConn(path: string) {
|
||||
sharedConnections[path][0].close();
|
||||
delete sharedConnections[path];
|
||||
}
|
||||
function initSharedConn(path: string, port: MessagePort, cookie: any) {
|
||||
if (!sharedConnections[path]) {
|
||||
console.log('Init connection for', path);
|
||||
const sock = new ReconnectingWebsocket(path);
|
||||
const ports: MessagePort[] = [];
|
||||
sharedConnections[path] = [sock, ports];
|
||||
|
||||
sock.onopen = () => sock.send(cookie);
|
||||
sock.onmessage = (message) => {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('SharedWorker.port.onmessage: ', message);
|
||||
const payload = JSON.parse(message.data);
|
||||
if (payload.event === 'auth') {
|
||||
if (['PermissionError', 'PrivilegeError'].includes(payload.error)) {
|
||||
broadcastMsg(ports, { type: 'close', path, error: payload.error });
|
||||
closeSharedConn(path);
|
||||
} else {
|
||||
console.log('Connected to', path);
|
||||
broadcastMsg(ports, { type: 'open', path });
|
||||
}
|
||||
} else broadcastMsg(ports, { type: 'message', path, payload: message.data });
|
||||
};
|
||||
sock.onerror = () => broadcastMsg(ports, { type: 'error', path });
|
||||
sock.onclose = (ev) => broadcastMsg(ports, { type: 'close', path, error: ev.reason });
|
||||
}
|
||||
|
||||
sharedConnections[path][1].push(port);
|
||||
function initSharedConn(path: string, sid: string) {
|
||||
if (conn) return;
|
||||
console.log('Init connection for', path);
|
||||
const url = new URL(path);
|
||||
url.protocol = url.protocol.replace('http', 'ws');
|
||||
url.searchParams.append('sid', sid);
|
||||
const sock = new ReconnectingWebsocket(url.toString());
|
||||
conn = sock;
|
||||
sock.onopen = () => console.log('Connected');
|
||||
sock.onerror = console.error;
|
||||
sock.onclose = (...args) => console.log('Closed', ...args);
|
||||
sock.onmessage = async (message) => {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('SharedWorker.port.onmessage: ', message);
|
||||
const payload = JSON.parse(message.data);
|
||||
if (payload.event === 'auth') {
|
||||
if (['PermissionError', 'PrivilegeError'].includes(payload.error)) {
|
||||
conn?.close();
|
||||
conn = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const port of ports) {
|
||||
port.postMessage({ type: 'message', payload: message.data });
|
||||
const success = await new Promise((resolve) => {
|
||||
const handle = (msg) => {
|
||||
if (['success', 'fail'].includes(msg.data.type)) {
|
||||
port.removeEventListener('message', handle);
|
||||
resolve(msg.data.type === 'success');
|
||||
}
|
||||
};
|
||||
port.addEventListener('message', handle);
|
||||
});
|
||||
if (success) return;
|
||||
}
|
||||
console.log('Failed to push notification');
|
||||
// TODO try system notification
|
||||
};
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
onconnect = function (e) { // eslint-disable-line no-undef
|
||||
const port: MessagePort = e.ports[0];
|
||||
|
||||
port.addEventListener('message', (msg: { data: RequestPayload }) => {
|
||||
if (msg.data.type === 'sharedConn') initSharedConn(msg.data.path, port, msg.data.cookie);
|
||||
if (msg.data.type === 'message') sharedConnections[msg.data.path]?.[0].send(msg.data.payload);
|
||||
port.addEventListener('message', (msg) => {
|
||||
initSharedConn(msg.data.path, msg.data.cookie);
|
||||
});
|
||||
|
||||
port.start();
|
||||
ports.push(port);
|
||||
};
|
||||
@@ -2,11 +2,11 @@
|
||||
{%- if not invalid -%}
|
||||
{% set _linkArgs = { pid:pdoc.pid|default(pdoc.docId) } %}
|
||||
{% if tdoc %}{{ set(_linkArgs, 'query', {tid:tdoc.docId}) }}{% endif %}
|
||||
{% if pdoc.domainId !== handler.domainId %}{{ set(_linkArgs, 'domainId', pdoc.domainId) }}{% endif %}
|
||||
{% if pdoc.domainId !== handler.args.domainId %}{{ set(_linkArgs, 'domainId', pdoc.domainId) }}{% endif %}
|
||||
<a href="{{ url('problem_detail', _linkArgs) }}"{% if small %} data-tooltip="{{ pdoc.title }}"{% endif %}>
|
||||
{%- endif -%}
|
||||
{%- if pdoc.domainId !== handler.domainId -%}<b>{{ pdoc.domainId }}#</b>{%- endif -%}
|
||||
<b>{{ pdoc.pid or 'P'+pdoc.docId if pdoc.domainId === handler.domainId else pdoc.docId }}</b>
|
||||
{%- if pdoc.domainId !== handler.args.domainId -%}<b>{{ pdoc.domainId }}#</b>{%- endif -%}
|
||||
<b>{{ pdoc.pid or 'P'+pdoc.docId if pdoc.domainId === handler.args.domainId else pdoc.docId }}</b>
|
||||
{%- if not small -%} {{ pdoc.title }}{%- endif -%}
|
||||
{%- if not invalid -%}
|
||||
</a>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<td class="col--pid display-mode-hide">{{ pdoc.docId }}</td>
|
||||
{% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
|
||||
{% if psdoc['rid'] %}
|
||||
{{ record.render_status_td(psdoc, rid_key='rid', class="edit-mode-hide", allDomain=pdoc.domainId!==handler.domainId) }}
|
||||
{{ record.render_status_td(psdoc, rid_key='rid', class="edit-mode-hide", allDomain=pdoc.domainId!==handler.args.domainId) }}
|
||||
{% else %}
|
||||
<td class="col--status record-status--border edit-mode-hide"></td>
|
||||
{% endif %}
|
||||
|
||||
@@ -13,14 +13,14 @@
|
||||
{{ set(UiContext, 'postSubmitUrl', url('problem_submit', pid=pdoc.docId, query={tid:tdoc.docId})) }}
|
||||
{{ set(UiContext, 'getSubmissionsUrl', url('record_main', query={fullStatus:'true', pid:pdoc.docId, tid:tdoc.docId})) }}
|
||||
{{ set(UiContext, 'getRecordDetailUrl', url('record_detail', rid='{rid}', query={tid:tdoc.docId})) }}
|
||||
{{ set(UiContext, 'pretestConnUrl', "/record-conn?pretest=1&uidOrName=" + handler.user._id + "&pid=" + pdoc.docId + "&domainId=" + handler.domainId + "&tid=" + tdoc.docId) }}
|
||||
{{ set(UiContext, 'pretestConnUrl', "/record-conn?pretest=1&uidOrName=" + handler.user._id + "&pid=" + pdoc.docId + "&domainId=" + handler.args.domainId + "&tid=" + tdoc.docId) }}
|
||||
{{ set(UiContext, 'tdoc', tdoc) }}
|
||||
{% else %}
|
||||
{{ set(UiContext, 'canViewRecord', true) }}
|
||||
{{ set(UiContext, 'postSubmitUrl', url('problem_submit', pid=pdoc.docId)) }}
|
||||
{{ set(UiContext, 'getSubmissionsUrl', url('record_main', query={fullStatus:'true', pid:pdoc.docId})) }}
|
||||
{{ set(UiContext, 'getRecordDetailUrl', url('record_detail', rid='{rid}')) }}
|
||||
{{ set(UiContext, 'pretestConnUrl', "/record-conn?pretest=1&uidOrName=" + handler.user._id + "&pid=" + pdoc.docId + "&domainId=" + handler.domainId) }}
|
||||
{{ set(UiContext, 'pretestConnUrl', "/record-conn?pretest=1&uidOrName=" + handler.user._id + "&pid=" + pdoc.docId + "&domainId=" + handler.args.domainId) }}
|
||||
{% endif %}
|
||||
</script>
|
||||
<div class="row">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% block content %}
|
||||
{{ set(
|
||||
UiContext, 'socketUrl',
|
||||
"/record-conn?domainId=" + handler.domainId
|
||||
"/record-conn?domainId=" + handler.args.domainId
|
||||
+ (("&tid=" + filterTid) if filterTid else "")
|
||||
+ (("&uidOrName=" + filterUidOrName) if filterUidOrName else "")
|
||||
+ (("&pid=" + filterPid) if filterPid else "")
|
||||
|
||||
@@ -3,8 +3,8 @@ import path from 'path';
|
||||
import { Duplex } from 'stream';
|
||||
import { inspect } from 'util';
|
||||
import fs from 'fs-extra';
|
||||
import { isMoment } from 'moment';
|
||||
import type { Moment } from 'moment-timezone';
|
||||
import { isMoment } from 'moment-timezone';
|
||||
import { ObjectID } from 'mongodb';
|
||||
|
||||
declare global {
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ describe('App', () => {
|
||||
let timeout;
|
||||
const resolve = () => setTimeout(() => {
|
||||
clearTimeout(timeout);
|
||||
agent = supertest.agent(require('hydrooj/src/service/server').server);
|
||||
agent = supertest.agent(require('hydrooj/src/service/server').httpServer);
|
||||
done();
|
||||
}, 2000);
|
||||
bus.on('app/started', resolve);
|
||||
|
||||
Reference in new issue
Block a user