diff --git a/package.json b/package.json index 485e95a4e..5c0390786 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "lint": "eslint packages --ext ts --fix", "pub": "node build/publish", "jest": "node build/jest", - "start": "node_modules/.bin/hydrooj" + "start": "node --async-stack-traces --trace-deprecation --enable-source-maps node_modules/.bin/hydrooj" }, "version": "1.0.0", "license": "AGPL-3.0-only", diff --git a/packages/hydrooj/TODO b/packages/hydrooj/TODO index c448fc491..e3efbb589 100644 --- a/packages/hydrooj/TODO +++ b/packages/hydrooj/TODO @@ -3,5 +3,4 @@ SETTINGS_PRIVACY.allowRegisteredUsers model.setting.ui.name pdoc.secretConfig? -message.markAsRead -domain_join_application \ No newline at end of file +message.markAsRead \ No newline at end of file diff --git a/packages/hydrooj/locales/zh_CN.yaml b/packages/hydrooj/locales/zh_CN.yaml index 0df07cb59..8690bb40e 100644 --- a/packages/hydrooj/locales/zh_CN.yaml +++ b/packages/hydrooj/locales/zh_CN.yaml @@ -237,6 +237,7 @@ Domain ID: 域 ID domain_dashboard: 管理域 domain_discussion: 讨论节点 domain_edit: 编辑域资料 +domain_join_applications: 加域申请 domain_join: 加入域 domain_main: 首页 domain_permission: 管理权限 @@ -408,7 +409,6 @@ Lucky: 手气不错 manage_dashboard: 控制面板 manage_join_applications: 加域申请 manage_module: 管理模块 -manage_script: 脚本管理 manage_setting: 系统设置 manage: 管理 Manage: 管理 diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index 87cd17cac..1fdca7824 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "2.12.6", + "version": "2.12.7", "bin": "bin/hydrooj.js", "main": "dist/loader.js", "typings": "dist/loader.d.ts", diff --git a/packages/hydrooj/src/entry/master.ts b/packages/hydrooj/src/entry/master.ts index cb29ebff2..950c42738 100644 --- a/packages/hydrooj/src/entry/master.ts +++ b/packages/hydrooj/src/entry/master.ts @@ -13,11 +13,15 @@ const lockfile = path.resolve(tmpdir, 'lock.json'); export async function load(call: Entry) { if (fs.existsSync(lockfile) && !argv.ignorelock) { - const file = require(lockfile); - const plist = await pslist(); - if (file.pid && plist.map((i) => i.pid).includes(file.pid)) { - console.error(`Lockfile exists, pid=${file.pid}`); - process.exit(1); + try { + const file = require(lockfile); + const plist = await pslist(); + if (file.pid && plist.map((i) => i.pid).includes(file.pid)) { + console.error(`Lockfile exists, pid=${file.pid}`); + process.exit(1); + } + } catch { + // Invalid lockfile. ignore. } } const context = { diff --git a/packages/hydrooj/src/error.ts b/packages/hydrooj/src/error.ts index cccb4ae3f..01d82c9f7 100644 --- a/packages/hydrooj/src/error.ts +++ b/packages/hydrooj/src/error.ts @@ -84,6 +84,9 @@ export const HomeworkNotLiveError = Err('HomeworkNotLiveError', ForbiddenError, export const HomeworkNotAttendedError = Err('HomeworkNotAttendedError', ForbiddenError, "You haven't claimed this homework yet."); export const RoleAlreadyExistError = Err('RoleAlreadyExistError', ForbiddenError, 'This role already exists.'); export const CsrfTokenError = Err('CsrfTokenError', ForbiddenError, 'CsrfTokenError'); +export const DomainJoinForbiddenError = Err('DomainJoinForbiddenError', ForbiddenError, 'You are not allowed to join the domain. The link is either invalid or expired.'); +export const DomainJoinAlreadyMemberError = Err('DomainJoinAlreadyMemberError', ForbiddenError, 'Failed to join the domain. You are already a member.'); +export const InvalidJoinInvitationCodeError = Err('InvalidJoinInvitationCodeError', ForbiddenError, 'The invitation code you provided is invalid.'); export const UserNotFoundError = Err('UserNotFoundError', NotFoundError, 'User {0} not found.'); export const NoProblemError = Err('NoProblemError', NotFoundError, 'No problem.'); @@ -132,6 +135,9 @@ global.Hydro.error = { ContestNotLiveError, ContestScoreboardHiddenError, ContestAlreadyAttendedError, + DomainJoinAlreadyMemberError, + DomainJoinForbiddenError, + InvalidJoinInvitationCodeError, UserFacingError, SystemError, TrainingAlreadyEnrollError, diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index 27fd08ee9..4fc132a78 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -24,7 +24,7 @@ const ContestHandler = contest.ContestHandlerMixin(Handler); class ContestListHandler extends ContestHandler { @param('rule', Types.String, true) - @param('page', Types.UnsignedInt, true) + @param('page', Types.PositiveInt, true) async get(domainId: string, rule = '', page = 1) { this.response.template = 'contest_main.html'; let tdocs; @@ -55,7 +55,7 @@ class ContestListHandler extends ContestHandler { class ContestDetailHandler extends ContestHandler { @param('tid', Types.ObjectID) - @param('page', Types.UnsignedInt, true) + @param('page', Types.PositiveInt, true) async get(domainId: string, tid: ObjectID, page = 1) { const tdoc = await contest.get(domainId, tid); this.response.template = 'contest_detail.html'; diff --git a/packages/hydrooj/src/handler/discussion.ts b/packages/hydrooj/src/handler/discussion.ts index 055d5d4b9..ca871eea9 100644 --- a/packages/hydrooj/src/handler/discussion.ts +++ b/packages/hydrooj/src/handler/discussion.ts @@ -71,7 +71,7 @@ class DiscussionHandler extends Handler { } class DiscussionMainHandler extends Handler { - @param('page', Types.UnsignedInt, true) + @param('page', Types.PositiveInt, true) async get(domainId: string, page = 1) { const [ddocs, dpcount] = await paginate( discussion.getMulti(domainId), @@ -99,7 +99,7 @@ class DiscussionMainHandler extends Handler { class DiscussionNodeHandler extends DiscussionHandler { @param('type', Types.String) @param('name', Types.String) - @param('page', Types.UnsignedInt, true) + @param('page', Types.PositiveInt, true) async get(domainId: string, type: string, _name: string, page = 1) { let name: ObjectID | string | number; if (ObjectID.isValid(_name)) name = new ObjectID(_name); @@ -177,7 +177,7 @@ class DiscussionCreateHandler extends DiscussionHandler { class DiscussionDetailHandler extends DiscussionHandler { @param('did', Types.ObjectID) - @param('page', Types.UnsignedInt, true) + @param('page', Types.PositiveInt, true) async get(domainId: string, did: ObjectID, page = 1) { const dsdoc = this.user.hasPriv(PRIV.PRIV_USER_PROFILE) ? await discussion.getStatus(domainId, did, this.user._id) diff --git a/packages/hydrooj/src/handler/domain.ts b/packages/hydrooj/src/handler/domain.ts index 01ef32788..ba2d08fe4 100644 --- a/packages/hydrooj/src/handler/domain.ts +++ b/packages/hydrooj/src/handler/domain.ts @@ -1,4 +1,8 @@ -import { RoleAlreadyExistError, ValidationError } from '../error'; +import moment from 'moment-timezone'; +import { + RoleAlreadyExistError, ValidationError, DomainJoinForbiddenError, + DomainJoinAlreadyMemberError, InvalidJoinInvitationCodeError, +} from '../error'; import * as user from '../model/user'; import * as domain from '../model/domain'; import { DOMAIN_SETTINGS, DOMAIN_SETTINGS_BY_KEY } from '../model/setting'; @@ -10,7 +14,7 @@ import { } from '../service/server'; class DomainRankHandler extends Handler { - @param('page', Types.UnsignedInt, true) + @param('page', Types.PositiveInt, true) async get(domainId: string, page = 1) { const [dudocs, upcount, ucount] = await paginate( domain.getMultiInDomain(domainId).sort({ rp: -1 }), @@ -184,6 +188,75 @@ class DomainRoleHandler extends ManageHandler { } } +class DomainJoinApplicationsHandler extends ManageHandler { + async get() { + const r = await domain.getRoles(this.domain); + const roles = r.map((role) => role._id).sort(); + const rolesWithText = roles.map((role) => [role, role]); + const joinSettings = domain.getJoinSettings(this.domain, roles); + const expirations = { ...domain.JOIN_EXPIRATION_RANGE }; + if (!joinSettings) delete expirations[domain.JOIN_EXPIRATION_KEEP_CURRENT]; + this.response.template = 'domain_join_applications.html'; + this.response.body = { rolesWithText, joinSettings, expirations }; + } + + @param('method', Types.UnsignedInt) + @param('role', Types.String, true) + @param('expire', Types.UnsignedInt, true) + @param('invitationCode', Types.String, true) + async post(domainId: string, method: number, role: string, expire: number, invitationCode = '') { + const r = await domain.getRoles(this.domain); + const roles = r.map((rl) => rl._id); + const current = domain.getJoinSettings(this.domain, roles); + if (!domain.JOIN_METHOD_RANGE[method]) throw new ValidationError('method'); + let joinSettings; + if (method === domain.JOIN_METHOD_NONE) joinSettings = null; + else { + if (!roles.includes(role)) throw new ValidationError('role'); + if (!domain.JOIN_EXPIRATION_RANGE[expire]) throw new ValidationError('expire'); + if (!current && expire === domain.JOIN_EXPIRATION_KEEP_CURRENT) throw new ValidationError('expire'); + joinSettings = { method, role }; + if (method === domain.JOIN_METHOD_CODE) joinSettings.code = invitationCode; + if (expire === domain.JOIN_EXPIRATION_KEEP_CURRENT) { + joinSettings.expire = current.expire; + } else if (expire === domain.JOIN_EXPIRATION_UNLIMITED) joinSettings.expire = null; + else joinSettings.expire = moment().add(expire, 'hours').toDate(); + } + await domain.edit(this.domain._id, { join: joinSettings }); + this.back(); + } +} + +class DomainJoinHandler extends Handler { + joinSettings: any; + + async prepare() { + const r = await domain.getRoles(this.domain); + const roles = r.map((role) => role._id); + this.joinSettings = domain.getJoinSettings(this.domain, roles); + if (!this.joinSettings) throw new DomainJoinForbiddenError(this.domain._id); + if (this.user.role !== 'default') throw new DomainJoinAlreadyMemberError(this.domain._id, this.user._id); + } + + // @base.require_priv(builtin.PRIV_USER_PROFILE) + @param('code', Types.String, true) + async get(domainId: string, code: string) { + this.response.template = 'domain_join.html'; + this.response.body = { joinSettings: this.joinSettings, code }; + } + + @param('code', Types.String, true) + async post(domainId: string, code: string) { + if (this.joinSettings.method === domain.JOIN_METHOD_CODE) { + if (this.joinSettings.code !== code) { + throw new InvalidJoinInvitationCodeError(this.domain._id); + } + } + await domain.setUserRole(this.domain._id, this.user._id, this.joinSettings.role); + this.response.redirect = this.url('domain_main'); + } +} + class DomainSearchHandler extends Handler { @param('q', Types.String) async get(domainId: string, q: string) { @@ -202,6 +275,8 @@ export async function apply() { Route('domain_user', '/domain/user', DomainUserHandler); Route('domain_permission', '/domain/permission', DomainPermissionHandler); Route('domain_role', '/domain/role', DomainRoleHandler); + Route('domain_join_applications', '/domain/join_applications', DomainJoinApplicationsHandler); + Route('domain_join', '/domain/join', DomainJoinHandler, PRIV.PRIV_USER_PROFILE); Route('domain_search', '/domain/search', DomainSearchHandler, PRIV.PRIV_USER_PROFILE); } diff --git a/packages/hydrooj/src/handler/homework.ts b/packages/hydrooj/src/handler/homework.ts index 312ec032e..b02bab638 100644 --- a/packages/hydrooj/src/handler/homework.ts +++ b/packages/hydrooj/src/handler/homework.ts @@ -55,7 +55,7 @@ class HomeworkMainHandler extends HomeworkHandler { class HomeworkDetailHandler extends HomeworkHandler { @param('tid', Types.ObjectID) - @param('page', Types.UnsignedInt, true) + @param('page', Types.PositiveInt, true) async get(domainId: string, tid: ObjectID, page = 1) { const tdoc = await contest.get(domainId, tid, document.TYPE_HOMEWORK); const [tsdoc, pdict] = await Promise.all([ diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index 0ae21d130..2e8feeb28 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -57,7 +57,7 @@ class ProblemHandler extends Handler { } class ProblemMainHandler extends ProblemHandler { - @param('page', Types.UnsignedInt, true) + @param('page', Types.PositiveInt, true) @param('q', Types.String, true) async get(domainId: string, page = 1, q = '') { this.response.template = 'problem_main.html'; @@ -103,7 +103,7 @@ class ProblemMainHandler extends ProblemHandler { } class ProblemCategoryHandler extends ProblemHandler { - @param('page', Types.UnsignedInt, true) + @param('page', Types.PositiveInt, true) @param('category', Types.String, null, parseCategory) async get(domainId: string, page = 1, category: string[]) { this.response.template = 'problem_main.html'; @@ -469,7 +469,7 @@ class ProblemDataDownloadHandler extends ProblemDetailHandler { } class ProblemSolutionHandler extends ProblemDetailHandler { - @param('page', Types.UnsignedInt, true) + @param('page', Types.PositiveInt, true) async get(domainId: string, page = 1) { this.response.template = 'problem_solution.html'; this.checkPerm(PERM.PERM_VIEW_PROBLEM_SOLUTION); diff --git a/packages/hydrooj/src/handler/record.ts b/packages/hydrooj/src/handler/record.ts index 1e91d65f1..57d6074c3 100644 --- a/packages/hydrooj/src/handler/record.ts +++ b/packages/hydrooj/src/handler/record.ts @@ -15,7 +15,7 @@ import { Rdoc } from '../interface'; const RecordHandler = contest.ContestHandlerMixin(Handler); class RecordListHandler extends RecordHandler { - @param('page', Types.UnsignedInt, true) + @param('page', Types.PositiveInt, true) @param('pid', Types.String, true) @param('tid', Types.ObjectID, true) @param('uidOrName', Types.String, true) diff --git a/packages/hydrooj/src/handler/training.ts b/packages/hydrooj/src/handler/training.ts index f0426e1ba..1ddb8b2a5 100644 --- a/packages/hydrooj/src/handler/training.ts +++ b/packages/hydrooj/src/handler/training.ts @@ -54,7 +54,7 @@ async function _parseDagJson(domainId, dag) { } class TrainingMainHandler extends Handler { - @param('page', Types.UnsignedInt, true) + @param('page', Types.PositiveInt, true) async get(domainId: string, page = 1) { const [tdocs, tpcount] = await paginate( training.getMulti(domainId), diff --git a/packages/hydrooj/src/loader.ts b/packages/hydrooj/src/loader.ts index 24af4ad63..f6525a827 100644 --- a/packages/hydrooj/src/loader.ts +++ b/packages/hydrooj/src/loader.ts @@ -106,6 +106,13 @@ async function executeCommand(input: string) { } } +async function reload(count = 1) { + console.log('Reloading'); + await stopWorker(); + console.log('Worker stopped'); + await startWorker(count); +} + async function messageHandler(worker: cluster.Worker, msg: any) { if (!msg) msg = worker; if (msg.event) { @@ -120,10 +127,7 @@ async function messageHandler(worker: cluster.Worker, msg: any) { } else if (msg.event === 'stat') { global.Hydro.stat.reqCount += msg.count; } else if (msg.event === 'restart') { - console.log('Restarting'); - await stopWorker(); - console.log('Worker stopped'); - await startWorker(msg.count); + await reload(msg.count); } else if (msg.event === 'run') { await executeCommand(msg.command); } diff --git a/packages/hydrooj/src/model/domain.ts b/packages/hydrooj/src/model/domain.ts index 147d01e64..fca611319 100644 --- a/packages/hydrooj/src/model/domain.ts +++ b/packages/hydrooj/src/model/domain.ts @@ -1,11 +1,34 @@ import { Dictionary } from 'lodash'; +import { Collection } from 'mongodb'; import { BUILTIN_ROLES, PRIV } from './builtin'; import { DomainDoc } from '../interface'; import * as db from '../service/db'; -const coll = db.collection('domain'); +const coll: Collection = db.collection('domain'); const collUser = db.collection('domain.user'); +export const JOIN_METHOD_NONE = 0; +export const JOIN_METHOD_ALL = 1; +export const JOIN_METHOD_CODE = 2; +export const JOIN_METHOD_RANGE = { + [JOIN_METHOD_NONE]: 'No user is allowed to join this domain', + [JOIN_METHOD_ALL]: 'Any user is allowed to join this domain', + [JOIN_METHOD_CODE]: 'Any user is allowed to join this domain with an invitation code', +}; + +export const JOIN_EXPIRATION_KEEP_CURRENT = 0; +export const JOIN_EXPIRATION_UNLIMITED = -1; + +export const JOIN_EXPIRATION_RANGE = { + [JOIN_EXPIRATION_KEEP_CURRENT]: 'Keep current expiration', + 3: 'In 3 hours', + 24: 'In 1 day', + [24 * 3]: 'In 3 days', + [24 * 7]: 'In 1 week', + [24 * 30]: 'In 1 month', + [JOIN_EXPIRATION_UNLIMITED]: 'Never expire', +}; + export async function add(domainId: string, owner: number, name: string, bulletin: string) { const ddoc: DomainDoc = { _id: domainId, @@ -32,9 +55,11 @@ export function edit(domainId: string, $set: any) { return coll.updateOne({ _id: domainId }, { $set }); } -export async function inc(domainId: string, field: string, n: number): Promise { +export async function inc(domainId: string, field: any, n: number): Promise { const res = await coll.findOneAndUpdate( { _id: domainId }, + // FIXME + // @ts-ignore { $inc: { [field]: n } }, { returnOriginal: false }, ); @@ -52,6 +77,8 @@ export function setUserRole(domainId: string, uid: number, role: string) { return collUser.updateOne({ uid, domainId }, { role }, { upsert: true }); } +export async function getRoles(domainId: string): Promise +export async function getRoles(domain: DomainDoc): Promise export async function getRoles(arg: string | DomainDoc) { let ddoc: DomainDoc; if (typeof arg === 'string') ddoc = await get(arg); @@ -144,6 +171,15 @@ export async function getDictUserByDomainId(uid: number) { return dudict; } +export function getJoinSettings(ddoc: DomainDoc, roles: string[]) { + if (!ddoc.join) return null; + const joinSettings = ddoc.join; + if (joinSettings.method === JOIN_METHOD_NONE) return null; + if (!roles.includes(joinSettings.role)) return null; + if (joinSettings.expire && joinSettings.expire < new Date()) return null; + return joinSettings; +} + export async function getPrefixSearch(prefix: string, limit = 50) { const $regex = new RegExp(prefix, 'mi'); const ddocs = await coll.find({ @@ -153,6 +189,14 @@ export async function getPrefixSearch(prefix: string, limit = 50) { } global.Hydro.model.domain = { + JOIN_METHOD_NONE, + JOIN_METHOD_ALL, + JOIN_METHOD_CODE, + JOIN_METHOD_RANGE, + JOIN_EXPIRATION_KEEP_CURRENT, + JOIN_EXPIRATION_UNLIMITED, + JOIN_EXPIRATION_RANGE, + getRoles, add, inc, @@ -170,5 +214,6 @@ global.Hydro.model.domain = { incUserInDomain, getMultiInDomain, getDictUserByDomainId, + getJoinSettings, getPrefixSearch, }; diff --git a/packages/hydrooj/src/service/server.ts b/packages/hydrooj/src/service/server.ts index 1aaeeecd7..20c87e0fb 100644 --- a/packages/hydrooj/src/service/server.ts +++ b/packages/hydrooj/src/service/server.ts @@ -47,15 +47,15 @@ interface ParamOption { } // eslint-disable-next-line no-shadow -export enum Types { String, Int, UnsignedInt, Float, ObjectID, Boolean, Date, Time } +export enum Types { String, Int, UnsignedInt, PositiveInt, Float, ObjectID, Boolean, Date, Time } const Tools: Array<[Converter, Validator, boolean?]> = [ [(v) => v.toString(), null], [(v) => parseInt(v, 10), (v) => isSafeInteger(parseInt(v, 10))], + [(v) => parseInt(v, 10), (v) => parseInt(v, 10) >= 0], [(v) => parseInt(v, 10), (v) => parseInt(v, 10) > 0], [(v) => parseFloat(v), (v) => { const t = parseFloat(v); - console.log(t); return t && !Number.isNaN(t) && Number.isFinite(t); }], [(v) => new ObjectID(v), ObjectID.isValid], @@ -117,8 +117,7 @@ export function param(name: string, ...args: any): MethodDecorator { if (args[cursor] !== null) v.validate = args[cursor]; isValidate = false; } else { - const I = args[cursor]; - v.convert = I; + v.convert = args[cursor]; } cursor++; }