diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..d8ca12f40 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +## Changes for hydrooj@3: + +- 重构了 server 结构: +- - 移除了 `Handler.init` `Handler.finish` 方法。 +- - 移除了 `handler/finish` 钩子。 +- - 移除了 `requireCsrfToken` 。 +- - 移除了 `sockjs`,转为使用原生 `WebSocket` 。 diff --git a/packages/hydrojudge/package.json b/packages/hydrojudge/package.json index 6dc12184e..44e7803fe 100644 --- a/packages/hydrojudge/package.json +++ b/packages/hydrojudge/package.json @@ -1,7 +1,7 @@ { "name": "@hydrooj/hydrojudge", "bin": "bin/hydrojudge.js", - "version": "2.17.4", + "version": "2.18.0", "main": "package.json", "author": "undefined ", "repository": "https://github.com/hydro-dev/Hydro.git", diff --git a/packages/hydrojudge/src/hosts/hydro.ts b/packages/hydrojudge/src/hosts/hydro.ts index f90156f0a..5667492a7 100644 --- a/packages/hydrojudge/src/hosts/hydro.ts +++ b/packages/hydrojudge/src/hosts/hydro.ts @@ -278,16 +278,16 @@ export default class Hydro { async consume(queue: Queue) { 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; diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index dee4942c7..d3eaa1585 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -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", diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index 7f7e1409e..24357f073 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -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', diff --git a/packages/hydrooj/src/handler/home.ts b/packages/hydrooj/src/handler/home.ts index 363ae856c..338907b74 100644 --- a/packages/hydrooj/src/handler/home.ts +++ b/packages/hydrooj/src/handler/home.ts @@ -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 }); } diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index 282ed40c3..36b747ba3 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -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), diff --git a/packages/hydrooj/src/handler/record.ts b/packages/hydrooj/src/handler/record.ts index f766aa428..6a90f5411 100644 --- a/packages/hydrooj/src/handler/record.ts +++ b/packages/hydrooj/src/handler/record.ts @@ -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; diff --git a/packages/hydrooj/src/handler/user.ts b/packages/hydrooj/src/handler/user.ts index c55459ef4..c44536720 100644 --- a/packages/hydrooj/src/handler/user.ts +++ b/packages/hydrooj/src/handler/user.ts @@ -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; } } diff --git a/packages/hydrooj/src/model/blacklist.ts b/packages/hydrooj/src/model/blacklist.ts index 4fe4a338b..7c94f1364 100644 --- a/packages/hydrooj/src/model/blacklist.ts +++ b/packages/hydrooj/src/model/blacklist.ts @@ -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'; diff --git a/packages/hydrooj/src/model/oplog.ts b/packages/hydrooj/src/model/oplog.ts index eda870715..ec0d8227d 100644 --- a/packages/hydrooj/src/model/oplog.ts +++ b/packages/hydrooj/src/model/oplog.ts @@ -25,7 +25,7 @@ export async function log(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, diff --git a/packages/hydrooj/src/service/decorators.ts b/packages/hydrooj/src/service/decorators.ts new file mode 100644 index 000000000..7c0071f57 --- /dev/null +++ b/packages/hydrooj/src/service/decorators.ts @@ -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 | Record) => 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) { + 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) => 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)); diff --git a/packages/hydrooj/src/service/layers/base.ts b/packages/hydrooj/src/service/layers/base.ts new file mode 100644 index 000000000..0c1e937b8 --- /dev/null +++ b/packages/hydrooj/src/service/layers/base.ts @@ -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, + }); + } +}; diff --git a/packages/hydrooj/src/service/layers/domain.ts b/packages/hydrooj/src/service/layers/domain.ts new file mode 100644 index 000000000..1ad3f1ad5 --- /dev/null +++ b/packages/hydrooj/src/service/layers/domain.ts @@ -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(); +}; diff --git a/packages/hydrooj/src/service/layers/renderer.ts b/packages/hydrooj/src/service/layers/renderer.ts new file mode 100644 index 000000000..566fab208 --- /dev/null +++ b/packages/hydrooj/src/service/layers/renderer.ts @@ -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(); +}; diff --git a/packages/hydrooj/src/service/layers/response.ts b/packages/hydrooj/src/service/layers/response.ts new file mode 100644 index 000000000..f75c94a0c --- /dev/null +++ b/packages/hydrooj/src/service/layers/response.ts @@ -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); + } + } +}; diff --git a/packages/hydrooj/src/service/layers/user.ts b/packages/hydrooj/src/service/layers/user.ts new file mode 100644 index 000000000..6997269ae --- /dev/null +++ b/packages/hydrooj/src/service/layers/user.ts @@ -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(); +}; diff --git a/packages/hydrooj/src/service/router.ts b/packages/hydrooj/src/service/router.ts new file mode 100644 index 000000000..b257fd4fd --- /dev/null +++ b/packages/hydrooj/src/service/router.ts @@ -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(); + regexp: RegExp; + + constructor(private router: Router, path: Parameters[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) { + const layer = super.register(...args); + // TODO handle unregister + return layer; + } + + ws(path: Parameters[0], callback?: WebSocketCallback) { + const layer = new WebSocketLayer(this, path, callback); + this.wsStack.push(layer); + // TODO handle unregistry + return layer; + } +} diff --git a/packages/hydrooj/src/service/server.ts b/packages/hydrooj/src/service/server.ts index f175ff810..b5d97f409 100644 --- a/packages/hydrooj/src/service/server.ts +++ b/packages/hydrooj/src/service/server.ts @@ -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; + query: any; + path: string; + params: any; + referer: string; + json: boolean; + websocket: boolean; +} +export interface HydroResponse { + body: any, + type: string, + status: number, + template?: string, + redirect?: string, + disposition?: string, + etag?: string, + attachment: (name: string, stream?: any) => void, + addHeader: (name: string, value: string) => void, +} +interface HydroContext { + request: HydroRequest; + response: HydroResponse; + user: User; + domain?: DomainDoc; + args: Record; + UiContext: Record; +} +export interface KoaContext extends Koa.DefaultContext { + HydroContext: HydroContext; + handler: any; + session: Record; + render: (name: string, args: any) => Promise; + renderHTML: (name: string, args: any) => Promise; + getUrl: (name: string, args: any) => string; + translate: (key: string) => string; +} const argv = cac().parse(); const logger = new Logger('server'); @@ -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({ + 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 | Dictionary) => 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) { - 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) => 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; + renderHTML: (name: string, args?: any) => Promise; + url: (name: string, args?: any) => string; + translate: (key: string) => string; session: Record; - request: Record; - extraTitleContent?: string; + /** @deprecated */ + domainId: string; + + constructor( + public ctx: KoaContext, public args: Record, + public request: HydroRequest, public response: HydroResponse, + public user: User, public domain: DomainDoc, public UiContext: Record, + ) { + this.render = ctx.render.bind(ctx); + this.renderHTML = ctx.renderHTML.bind(ctx); + this.url = ctx.getUrl.bind(ctx); + this.translate = ctx.translate.bind(ctx); + this.session = ctx.session; + this.domainId = args.domainId; + } async limitRate(op: string, periodSecs: number, maxOperations: number) { if (ignoredLimit.includes(op)) return; @@ -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 { - 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, - 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; - - 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; 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; - declare request: { - params: Record; - headers: Record; - 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 ) { - 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 = 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, diff --git a/packages/onlyoffice/handler.ts b/packages/onlyoffice/handler.ts index 0c9dffef6..db05dfe4a 100644 --- a/packages/onlyoffice/handler.ts +++ b/packages/onlyoffice/handler.ts @@ -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; } diff --git a/packages/onlyoffice/package.json b/packages/onlyoffice/package.json index 466fc5ad5..f4edb4316 100644 --- a/packages/onlyoffice/package.json +++ b/packages/onlyoffice/package.json @@ -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 ", diff --git a/packages/ui-default/components/message/index.page.js b/packages/ui-default/components/message/index.page.js index ed84b1de9..a396f23a5 100644 --- a/packages/ui-default/components/message/index.page.js +++ b/packages/ui-default/components/message/index.page.js @@ -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` -
- ${{ templateRaw: true, html: i18n('Message from {0}:', `
${msg.mdoc.from}
`) }} -

${i18n(msg.mdoc.content)}

-
`, - }).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` +
+ ${{ templateRaw: true, html: i18n('Message from {0}:', `
${msg.mdoc.from}
`) }} +

${i18n(msg.mdoc.content)}

+
`, + }).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; diff --git a/packages/ui-default/components/navigation/navigation.page.js b/packages/ui-default/components/navigation/navigation.page.js index 92a4b3803..5ab5ec624 100644 --- a/packages/ui-default/components/navigation/navigation.page.js +++ b/packages/ui-default/components/navigation/navigation.page.js @@ -35,7 +35,6 @@ function handlerSwitchAccount(ev) { `, onDispatch(action) { - console.log(action, userSelector.value()); if (action === 'ok' && userSelector.value() === null) { userSelector.focus(); return false; diff --git a/packages/ui-default/components/socket/index.js b/packages/ui-default/components/socket/index.js index b3fb2ec2f..0435d062b 100644 --- a/packages/ui-default/components/socket/index.js +++ b/packages/ui-default/components/socket/index.js @@ -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`
${i18n('Disconnected from the server, trying to reconnect...')}
`; - -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?.(); } } diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 5d25dc098..70504d0e6 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.34.40", + "version": "4.35.0", "author": "undefined ", "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", diff --git a/packages/ui-default/pages/home_messages.page.jsx b/packages/ui-default/pages/home_messages.page.jsx index f5a4f565b..698492aea 100644 --- a/packages/ui-default/pages/home_messages.page.jsx +++ b/packages/ui-default/pages/home_messages.page.jsx @@ -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({ diff --git a/packages/ui-default/pages/manage_dashboard.page.js b/packages/ui-default/pages/manage_dashboard.page.js index fad131d5b..9186a1ab0 100644 --- a/packages/ui-default/pages/manage_dashboard.page.js +++ b/packages/ui-default/pages/manage_dashboard.page.js @@ -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 = () => { $('

Connection opened.

').appendTo('#messages'); }; diff --git a/packages/ui-default/pages/problem_detail.page.jsx b/packages/ui-default/pages/problem_detail.page.jsx index f0eadd5d1..4d2890a8d 100644 --- a/packages/ui-default/pages/problem_detail.page.jsx +++ b/packages/ui-default/pages/problem_detail.page.jsx @@ -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({ diff --git a/packages/ui-default/pages/record_detail.page.ts b/packages/ui-default/pages/record_detail.page.ts index defeb6f8f..4969ab709 100644 --- a/packages/ui-default/pages/record_detail.page.ts +++ b/packages/ui-default/pages/record_detail.page.ts @@ -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); diff --git a/packages/ui-default/pages/record_main.page.ts b/packages/ui-default/pages/record_main.page.ts index 9bf33b323..8f0bd0f24 100644 --- a/packages/ui-default/pages/record_main.page.ts +++ b/packages/ui-default/pages/record_main.page.ts @@ -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; diff --git a/packages/ui-default/sharedworker.ts b/packages/ui-default/sharedworker.ts index 2edfc2ab4..798ed7045 100644 --- a/packages/ui-default/sharedworker.ts +++ b/packages/ui-default/sharedworker.ts @@ -1,63 +1,54 @@ import ReconnectingWebsocket from 'reconnecting-websocket'; console.log('SharedWorker init'); +const ports: MessagePort[] = []; +let conn: ReconnectingWebsocket; -const sharedConnections: Record = {}; -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); }; diff --git a/packages/ui-default/templates/components/problem.html b/packages/ui-default/templates/components/problem.html index ff39c7d7f..fda06daca 100644 --- a/packages/ui-default/templates/components/problem.html +++ b/packages/ui-default/templates/components/problem.html @@ -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 %} {%- endif -%} -{%- if pdoc.domainId !== handler.domainId -%}{{ pdoc.domainId }}#{%- endif -%} -{{ pdoc.pid or 'P'+pdoc.docId if pdoc.domainId === handler.domainId else pdoc.docId }} +{%- if pdoc.domainId !== handler.args.domainId -%}{{ pdoc.domainId }}#{%- endif -%} +{{ pdoc.pid or 'P'+pdoc.docId if pdoc.domainId === handler.args.domainId else pdoc.docId }} {%- if not small -%}  {{ pdoc.title }}{%- endif -%} {%- if not invalid -%} diff --git a/packages/ui-default/templates/partials/problem_list.html b/packages/ui-default/templates/partials/problem_list.html index c6877ca2f..5796bd017 100644 --- a/packages/ui-default/templates/partials/problem_list.html +++ b/packages/ui-default/templates/partials/problem_list.html @@ -49,7 +49,7 @@ {{ pdoc.docId }} {% 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 %} {% endif %} diff --git a/packages/ui-default/templates/problem_detail.html b/packages/ui-default/templates/problem_detail.html index 5b6fd3f49..69b0fdb26 100644 --- a/packages/ui-default/templates/problem_detail.html +++ b/packages/ui-default/templates/problem_detail.html @@ -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 %}
diff --git a/packages/ui-default/templates/record_main.html b/packages/ui-default/templates/record_main.html index 09f96ee03..df72c12b2 100644 --- a/packages/ui-default/templates/record_main.html +++ b/packages/ui-default/templates/record_main.html @@ -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 "") diff --git a/packages/utils/lib/utils.ts b/packages/utils/lib/utils.ts index 37dee8d33..bec176ca4 100644 --- a/packages/utils/lib/utils.ts +++ b/packages/utils/lib/utils.ts @@ -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 { diff --git a/test/main.ts b/test/main.ts index 1ff918226..668733d5c 100644 --- a/test/main.ts +++ b/test/main.ts @@ -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);