From 534a634028db501682720bd3df7f627f052d836f Mon Sep 17 00:00:00 2001 From: panda Date: Sat, 26 Nov 2022 20:30:18 +0800 Subject: [PATCH] core&ui: add error & fix error notification (#464) --- packages/hydrooj/src/error.ts | 41 ++++++++++++++++++- packages/hydrooj/src/handler/blog.ts | 4 +- packages/hydrooj/src/handler/contest.ts | 15 ++++--- packages/hydrooj/src/handler/domain.ts | 8 ++-- packages/hydrooj/src/handler/home.ts | 4 +- packages/hydrooj/src/handler/homework.ts | 5 +-- packages/hydrooj/src/handler/manage.ts | 6 +-- packages/hydrooj/src/handler/misc.ts | 17 ++++---- packages/hydrooj/src/handler/problem.ts | 52 ++++++++++++------------ packages/hydrooj/src/handler/record.ts | 4 +- packages/hydrooj/src/handler/user.ts | 28 ++++++------- packages/hydrooj/src/model/problem.ts | 4 +- packages/hydrooj/src/model/token.ts | 9 ++++ packages/ui-default/utils/request.js | 2 +- 14 files changed, 123 insertions(+), 76 deletions(-) diff --git a/packages/hydrooj/src/error.ts b/packages/hydrooj/src/error.ts index 4c6e50d50..9e8e12c31 100644 --- a/packages/hydrooj/src/error.ts +++ b/packages/hydrooj/src/error.ts @@ -54,9 +54,11 @@ export const RemoteOnlineJudgeError = Err('RemoteOnlineJudgeError', UserFacingEr export const SendMailError = Err('SendMailError', UserFacingError, 'Failed to send mail to {0}. (1)', 500); export const AlreadyVotedError = Err('AlreadyVotedError', ForbiddenError, "You've already voted."); +export const BuiltinLoginError = Err('BuiltinLoginError', ForbiddenError, 'Builtin login is disabled.'); export const LoginError = Err('LoginError', ForbiddenError, 'Invalid password for user {0}.'); +export const AccessDeniedError = Err('AccessDeniedError', ForbiddenError, 'Access denied.'); export const UserAlreadyExistError = Err('UserAlreadyExistError', ForbiddenError, 'User {0} already exists.'); -export const InvalidTokenError = Err('InvalidTokenError', ForbiddenError); +export const InvalidTokenError = Err('InvalidTokenError', ForbiddenError, 'The {0} Token is invalid.'); export const BlacklistedError = Err('BlacklistedError', ForbiddenError, 'Address or user {0} is blacklisted.'); export const VerifyPasswordError = Err('VerifyPasswordError', ForbiddenError, "Passwords don't match."); export const OpcountExceededError = Err('OpcountExceededError', ForbiddenError, 'Too frequent operations of {0} (limit: {2} operations in {1} seconds).'); @@ -99,6 +101,23 @@ export const DomainJoinAlreadyMemberError = Err('DomainJoinAlreadyMemberError', export const InvalidJoinInvitationCodeError = Err('InvalidJoinInvitationCodeError', ForbiddenError, 'The invitation code you provided is invalid.'); export const CurrentPasswordError = Err('CurrentPasswordError', ForbiddenError, "Current password doesn't match."); export const DiscussionLockedError = Err('DiscussionLockedError', ForbiddenError, 'The discussion is locked, you can not reply anymore.'); +export const NotAssignedError = Err('NotAssignedError', ForbiddenError, 'You are not assigned to this {0}.'); +export const FileLimitExceededError = Err('FileLimitExceededError', ForbiddenError, 'File {0} limit exceeded.'); +export const FileUploadError = Err('FileUploadError', ForbiddenError, 'File upload failed.'); +export const FileExistsError = Err('FileExistsError', ForbiddenError, 'File {0} already exists.'); +export const HackFailedError = Err('HackFailedError', ForbiddenError, 'Hack failed: {0}'); +export const ProblemAlreadyExistError = Err('ProblemAlreadyExistError', ForbiddenError, 'Problem {0} already exists.'); +export const ProblemAlreadyUsedByContestError = Err('ProblemAlreadyUsedByContestError', ForbiddenError, 'Problem {0} is already used by contest {1}.'); +export const ProblemNotAllowPretestError = Err('ProblemNotAllowPretestError', ForbiddenError, 'This {0} is not allow run pretest.'); +export const ProblemNotAllowLanguageError = Err('ProblemNotAllowSubmitError', ForbiddenError, 'This language is not allow to submit.'); + +export const HackRejudgeFailedError = Err('HackRejudgeFailedError', BadRequestError, 'Cannot rejudge a hack record.'); +export const CannotDeleteSystemDomainError = Err('CannotDeleteSystemDomainError', BadRequestError, 'You are not allowed to delete system domain.'); +export const OnlyOwnerCanDeleteDomainError = Err('OnlyOwnerCanDeleteDomainError', BadRequestError, 'You are not the owner of this domain.'); +export const CannotEditSuperAdminError = Err('CannotEditSuperAdminError', BadRequestError, 'You are not allowed to edit super admin in web.'); +export const ProblemConfigError = Err('ProblemConfigError', BadRequestError, 'Invalid problem config.'); +export const ProblemIsReferencedError = Err('ProblemIsReferencedError', BadRequestError, 'Cannot {0} of a referenced problem.'); +export const TFAOperationError = Err('TFAOperationError', BadRequestError, '2FA is already {0}.'); export const UserNotFoundError = Err('UserNotFoundError', NotFoundError, 'User {0} not found.'); export const NoProblemError = Err('NoProblemError', NotFoundError, 'No problem.'); @@ -115,6 +134,7 @@ export const DiscussionNotFoundError = Err('DiscussionNotFoundError', DocumentNo export const DiscussionNodeNotFoundError = Err('DiscussionNodeNotFoundError', DocumentNotFoundError, 'Discussion node {1} not found.'); export const InvalidOperationError = Err('InvalidOperationError', MethodNotAllowedError); +export const NotLaunchedByPM2Error = Err('NotLaunchedByPM2Error', BadRequestError, 'Not launched by PM2.'); export const FileTooLargeError = Err('FileTooLargeError', ValidationError, 'The uploaded file is too long.'); global.Hydro.error = { @@ -168,6 +188,25 @@ global.Hydro.error = { FileTooLargeError, CurrentPasswordError, DiscussionLockedError, + FileLimitExceededError, + FileUploadError, + FileExistsError, + HackFailedError, + HackRejudgeFailedError, + CannotDeleteSystemDomainError, + OnlyOwnerCanDeleteDomainError, + NotAssignedError, + BuiltinLoginError, + ProblemAlreadyExistError, + ProblemAlreadyUsedByContestError, + ProblemConfigError, + ProblemIsReferencedError, + ProblemNotAllowPretestError, + ProblemNotAllowLanguageError, + TFAOperationError, + CannotEditSuperAdminError, + NotLaunchedByPM2Error, + AccessDeniedError, }; /* diff --git a/packages/hydrooj/src/handler/blog.ts b/packages/hydrooj/src/handler/blog.ts index 19d2823f4..8afe4e2fe 100644 --- a/packages/hydrooj/src/handler/blog.ts +++ b/packages/hydrooj/src/handler/blog.ts @@ -1,5 +1,5 @@ import { ObjectID } from 'mongodb'; -import { DiscussionNotFoundError, ForbiddenError } from '../error'; +import { DiscussionNotFoundError } from '../error'; import { BlogDoc } from '../interface'; import paginate from '../lib/paginate'; import * as blog from '../model/blog'; @@ -14,7 +14,6 @@ class BlogHandler extends Handler { @param('did', Types.ObjectID, true) async _prepare(domainId: string, did: ObjectID) { - if (!system.get('server.blog')) throw new ForbiddenError('Blog is disabled'); if (did) { this.ddoc = await blog.get(did); if (!this.ddoc) throw new DiscussionNotFoundError(domainId, did); @@ -118,6 +117,7 @@ class BlogEditHandler extends BlogHandler { } export async function apply(ctx) { + if (!system.get('server.blog')) return; ctx.Route('blog_main', '/blog/:uid', BlogUserHandler); ctx.Route('blog_create', '/blog/:uid/create', BlogEditHandler, PRIV.PRIV_USER_PROFILE); ctx.Route('blog_detail', '/blog/:uid/:did', BlogDetailHandler); diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index 4b2e4037d..b3e82bc6a 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -6,9 +6,8 @@ import { ObjectID } from 'mongodb'; import { Counter, sortFiles, Time } from '@hydrooj/utils/lib/utils'; import { BadRequestError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError, - ContestScoreboardHiddenError, - ForbiddenError, InvalidTokenError, PermissionError, - ValidationError, + ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError, InvalidTokenError, + NotAssignedError, PermissionError, ValidationError, } from '../error'; import { Tdoc } from '../interface'; import paginate from '../lib/paginate'; @@ -101,7 +100,7 @@ export class ContestDetailBaseHandler extends Handler { if (this.tdoc.assign?.length && !this.user.own(this.tdoc)) { const groups = await user.listGroup(domainId, this.user._id); if (!Set.intersection(this.tdoc.assign, groups.map((i) => i.name)).size) { - throw new ForbiddenError('You are not assigned.'); + throw new NotAssignedError('contest', tid); } } if (this.tdoc.duration && this.tsdoc?.startAt) { @@ -191,7 +190,7 @@ export class ContestDetailHandler extends ContestDetailBaseHandler { @param('code', Types.String, true) async postAttend(domainId: string, tid: ObjectID, code = '') { if (contest.isDone(this.tdoc)) throw new ContestNotLiveError(tid); - if (this.tdoc._code && code !== this.tdoc._code) throw new InvalidTokenError(code); + if (this.tdoc._code && code !== this.tdoc._code) throw new InvalidTokenError('Contest Invitation', code); await contest.attend(domainId, tid, this.user._id); this.back(); } @@ -485,21 +484,21 @@ export class ContestFilesHandler extends ContestDetailBaseHandler { @post('filename', Types.Name, true) async postUploadFile(domainId: string, tid: ObjectID, filename: string) { if ((this.tdoc.files?.length || 0) >= system.get('limit.contest_files')) { - throw new ForbiddenError('File limit exceeded.'); + throw new FileLimitExceededError('count'); } const file = this.request.files?.file; if (!file) throw new ValidationError('file'); const f = statSync(file.filepath); const size = Math.sum((this.tdoc.files || []).map((i) => i.size)) + f.size; if (size >= system.get('limit.contest_files_size')) { - throw new ForbiddenError('File size limit exceeded.'); + throw new FileLimitExceededError('size'); } if (!filename) filename = file.originalFilename || String.random(16); if (filename.includes('/') || filename.includes('..')) throw new ValidationError('filename', null, 'Bad filename'); await storage.put(`contest/${domainId}/${tid}/${filename}`, file.filepath, this.user._id); const meta = await storage.getMeta(`contest/${domainId}/${tid}/${filename}`); const payload = { name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) }; - if (!meta) throw new Error('Upload failed'); + if (!meta) throw new FileUploadError(); await contest.edit(domainId, tid, { files: [...(this.tdoc.files || []), payload] }); this.back(); } diff --git a/packages/hydrooj/src/handler/domain.ts b/packages/hydrooj/src/handler/domain.ts index 0a84467f6..cc75b7bc7 100644 --- a/packages/hydrooj/src/handler/domain.ts +++ b/packages/hydrooj/src/handler/domain.ts @@ -3,8 +3,8 @@ import { Dictionary } from 'lodash'; import moment from 'moment-timezone'; import { Context } from '../context'; import { - DomainJoinAlreadyMemberError, DomainJoinForbiddenError, ForbiddenError, - InvalidJoinInvitationCodeError, PermissionError, RoleAlreadyExistError, ValidationError, + CannotDeleteSystemDomainError, DomainJoinAlreadyMemberError, DomainJoinForbiddenError, + InvalidJoinInvitationCodeError, OnlyOwnerCanDeleteDomainError, PermissionError, RoleAlreadyExistError, ValidationError, } from '../error'; import type { DomainDoc } from '../interface'; import avatar from '../lib/avatar'; @@ -102,8 +102,8 @@ class DomainEditHandler extends ManageHandler { @requireSudo async postDelete({ domainId }) { - if (domainId === 'system') throw new ForbiddenError('You are not allowed to delete system domain'); - if (this.domain.owner !== this.user._id) throw new ForbiddenError('You are not the owner of this domain.'); + if (domainId === 'system') throw new CannotDeleteSystemDomainError(); + if (this.domain.owner !== this.user._id) throw new OnlyOwnerCanDeleteDomainError(); await domain.del(domainId); this.response.redirect = this.url('home_domain', { domainId: 'system' }); } diff --git a/packages/hydrooj/src/handler/home.ts b/packages/hydrooj/src/handler/home.ts index a44c8035e..1db411bd2 100644 --- a/packages/hydrooj/src/handler/home.ts +++ b/packages/hydrooj/src/handler/home.ts @@ -217,7 +217,7 @@ class HomeSecurityHandler extends Handler { return this.back(); } } - throw new InvalidTokenError(tokenDigest); + throw new InvalidTokenError(token.TYPE_TEXTS[token.TYPE_SESSION], tokenDigest); } async postDeleteAllTokens() { @@ -293,7 +293,7 @@ class UserChangemailWithCodeHandler extends Handler { async get(domainId: string, code: string) { const tdoc = await token.get(code, token.TYPE_CHANGEMAIL); if (!tdoc || tdoc.uid !== this.user._id) { - throw new InvalidTokenError(code); + throw new InvalidTokenError(token.TYPE_TEXTS[token.TYPE_CHANGEMAIL], code); } const udoc = await user.getByEmail(domainId, tdoc.email); if (udoc) throw new UserAlreadyExistError(tdoc.email); diff --git a/packages/hydrooj/src/handler/homework.ts b/packages/hydrooj/src/handler/homework.ts index 7f0e67678..9a064581b 100644 --- a/packages/hydrooj/src/handler/homework.ts +++ b/packages/hydrooj/src/handler/homework.ts @@ -3,8 +3,7 @@ import moment from 'moment-timezone'; import { ObjectID } from 'mongodb'; import { Time } from '@hydrooj/utils/lib/utils'; import { - ContestNotFoundError, ForbiddenError, HomeworkNotLiveError, - ValidationError, + ContestNotFoundError, HomeworkNotLiveError, NotAssignedError, ValidationError, } from '../error'; import { PenaltyRules, Tdoc } from '../interface'; import paginate from '../lib/paginate'; @@ -49,7 +48,7 @@ class HomeworkDetailHandler extends Handler { if (tdoc.rule !== 'homework') throw new ContestNotFoundError(domainId, tid); if (tdoc.assign?.length && !this.user.own(tdoc)) { if (!Set.intersection(tdoc.assign, this.user.group).size) { - throw new ForbiddenError('You are not assigned.'); + throw new NotAssignedError('homework', tdoc.docId); } } } diff --git a/packages/hydrooj/src/handler/manage.ts b/packages/hydrooj/src/handler/manage.ts index 555bd433b..18e226267 100644 --- a/packages/hydrooj/src/handler/manage.ts +++ b/packages/hydrooj/src/handler/manage.ts @@ -4,7 +4,7 @@ import * as yaml from 'js-yaml'; import Schema from 'schemastery'; import * as check from '../check'; import { - BadRequestError, ForbiddenError, UserNotFoundError, ValidationError, + CannotEditSuperAdminError, NotLaunchedByPM2Error, UserNotFoundError, ValidationError, } from '../error'; import { isEmail, isPassword } from '../lib/validator'; import { Logger } from '../logger'; @@ -86,7 +86,7 @@ class SystemDashboardHandler extends SystemHandler { } async postRestart() { - if (!process.env.pm_cwd) throw new BadRequestError('Not launched by pm2'); + if (!process.env.pm_cwd) throw new NotLaunchedByPM2Error(); exec(`pm2 reload "${process.env.name}"`); this.back(); } @@ -293,7 +293,7 @@ class SystemUserPrivHandler extends SystemHandler { if (!editSystem) { const udoc = await user.getById(domainId, uid); if (!udoc) throw new UserNotFoundError(uid); - if (udoc.priv === -1 || priv === -1 || priv === allPriv) throw new ForbiddenError('you can not edit user as SU in web.'); + if (udoc.priv === -1 || priv === -1 || priv === allPriv) throw new CannotEditSuperAdminError(); await user.setPriv(uid, priv); } else { const defaultPriv = system.get('default.priv'); diff --git a/packages/hydrooj/src/handler/misc.ts b/packages/hydrooj/src/handler/misc.ts index a909931ce..bafb2fe35 100644 --- a/packages/hydrooj/src/handler/misc.ts +++ b/packages/hydrooj/src/handler/misc.ts @@ -3,7 +3,8 @@ import { statSync } from 'fs'; import { pick } from 'lodash'; import { lookup } from 'mime-types'; import { - BadRequestError, ForbiddenError, NotFoundError, ValidationError, + AccessDeniedError, FileExistsError, FileLimitExceededError, FileUploadError, NotFoundError, + ValidationError, } from '../error'; import { PRIV } from '../model/builtin'; import * as oplog from '../model/oplog'; @@ -57,22 +58,22 @@ export class FilesHandler extends Handler { async postUploadFile(domainId: string, filename: string) { this.checkPriv(PRIV.PRIV_CREATE_FILE); if ((this.user._files?.length || 0) >= system.get('limit.user_files')) { - if (!this.user.hasPriv(PRIV.PRIV_UNLIMITED_QUOTA)) throw new ForbiddenError('File limit exceeded.'); + if (!this.user.hasPriv(PRIV.PRIV_UNLIMITED_QUOTA)) throw new FileLimitExceededError('count'); } const file = this.request.files?.file; if (!file) throw new ValidationError('file'); const f = statSync(file.filepath); const size = Math.sum((this.user._files || []).map((i) => i.size)) + f.size; if (size >= system.get('limit.user_files_size')) { - if (!this.user.hasPriv(PRIV.PRIV_UNLIMITED_QUOTA)) throw new ForbiddenError('File size limit exceeded.'); + if (!this.user.hasPriv(PRIV.PRIV_UNLIMITED_QUOTA)) throw new FileLimitExceededError('size'); } if (!filename) filename = file.originalFilename || String.random(16); if (filename.includes('/') || filename.includes('..')) throw new ValidationError('filename', null, 'Bad filename'); - if (this.user._files.filter((i) => i.name === filename).length) throw new BadRequestError('file exists'); + if (this.user._files.filter((i) => i.name === filename).length) throw new FileExistsError(filename); await storage.put(`user/${this.user._id}/${filename}`, file.filepath, this.user._id); const meta = await storage.getMeta(`user/${this.user._id}/${filename}`); const payload = { name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) }; - if (!meta) throw new Error('Upload failed'); + if (!meta) throw new FileUploadError(); this.user._files.push({ _id: filename, ...payload }); await user.setById(this.user._id, { _files: this.user._files }); this.back(); @@ -97,7 +98,7 @@ export class FSDownloadHandler extends Handler { async get(domainId: string, uid: number, filename: string, noDisposition = false) { const targetUser = await user.getById('system', uid); if (!targetUser) throw new NotFoundError(uid); - if (this.user._id !== uid && !targetUser.hasPriv(PRIV.PRIV_CREATE_FILE)) throw new ForbiddenError('Access denied'); + if (this.user._id !== uid && !targetUser.hasPriv(PRIV.PRIV_CREATE_FILE)) throw new AccessDeniedError(); this.response.addHeader('Cache-Control', 'public'); const target = `user/${uid}/${filename}`; const file = await storage.getMeta(target); @@ -120,8 +121,8 @@ export class StorageHandler extends Handler { @param('secret', Types.String) async get(domainId: string, target: string, filename = '', expire: number, secret: string) { const expected = md5(`${target}/${expire}/${builtinConfig.file.secret}`); - if (expire < Date.now()) throw new ForbiddenError('Link expired'); - if (secret !== expected) throw new ForbiddenError('Invalid secret'); + if (expire < Date.now()) throw new AccessDeniedError(); + if (secret !== expected) throw new AccessDeniedError(); this.response.body = await storage.get(target); this.response.type = (target.endsWith('.out') || target.endsWith('.ans')) ? 'text/plain' diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index 29581f3dc..ea342332e 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -8,11 +8,11 @@ import { FilterQuery, ObjectID } from 'mongodb'; import { nanoid } from 'nanoid'; import { sortFiles, streamToBuffer } from '@hydrooj/utils/lib/utils'; import { - BadRequestError, ContestNotAttendedError, ContestNotEndedError, - ContestNotFoundError, ContestNotLiveError, - ForbiddenError, NoProblemError, NotFoundError, - PermissionError, ProblemNotFoundError, RecordNotFoundError, SolutionNotFoundError, - ValidationError, + ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError, + FileLimitExceededError, HackFailedError, NoProblemError, NotFoundError, + PermissionError, ProblemAlreadyExistError, ProblemAlreadyUsedByContestError, ProblemConfigError, + ProblemIsReferencedError, ProblemNotAllowLanguageError, ProblemNotAllowPretestError, ProblemNotFoundError, + RecordNotFoundError, SolutionNotFoundError, ValidationError, } from '../error'; import { ProblemDoc, ProblemSearchOptions, ProblemStatusDoc, RecordDoc, User, @@ -94,7 +94,7 @@ registerResolver( 'ProblemManage', 'delete', 'Boolean!', async (arg, ctx) => { 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); + if (tdocs.length) throw new ProblemAlreadyUsedByContestError(ctx.pdoc.docId, tdocs[0]._id); return problem.del(ctx.pdoc.domainId, ctx.pdoc.docId); }, ); @@ -467,7 +467,7 @@ export class ProblemDetailHandler extends ContestDetailBaseHandler { 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.args.domainId, this.pdoc.docId); - if (tdocs.length) throw new BadRequestError('Problem already used by contest {0}', tdocs[0]._id); + if (tdocs.length) throw new ProblemAlreadyUsedByContestError(this.pdoc.docId, tdocs[0]._id); await problem.del(this.pdoc.domainId, this.pdoc.docId); this.response.redirect = this.url('problem_main'); } @@ -477,7 +477,7 @@ export class ProblemSubmitHandler extends ProblemDetailHandler { @param('tid', Types.ObjectID, true) async prepare(domainId: string, tid?: ObjectID) { if (tid && !contest.isOngoing(this.tdoc, this.tsdoc)) throw new ContestNotLiveError(this.tdoc.docId); - if (typeof this.pdoc.config === 'string') throw new BadRequestError('Invalid problem config'); + if (typeof this.pdoc.config === 'string') throw new ProblemConfigError(); } async get() { @@ -505,17 +505,17 @@ export class ProblemSubmitHandler extends ProblemDetailHandler { @param('tid', Types.ObjectID, true) async post(domainId: string, lang: string, code: string, pretest = false, input = '', tid?: ObjectID) { const config = this.pdoc.config; - if (typeof config === 'string' || config === null) throw new BadRequestError('Problem configuration incorrect'); + if (typeof config === 'string' || config === null) throw new ProblemConfigError(); if (config.type === 'objective') { lang = '_'; } else if (config.langs && !config.langs.includes(lang)) { - throw new BadRequestError('Language not allowed.'); + throw new ProblemNotAllowLanguageError(); } if (pretest) { if (setting.langs[lang]?.pretest) lang = setting.langs[lang].pretest as string; - if (setting.langs[lang]?.pretest === false) throw new BadRequestError('Cannot run pretest for this language.'); + if (setting.langs[lang]?.pretest === false) throw new ProblemNotAllowPretestError('language'); if (!['default', 'fileio', 'remote_judge'].includes(this.response.body.pdoc.config?.type)) { - throw new BadRequestError('unable to run pretest'); + throw new ProblemNotAllowPretestError('type'); } } await this.limitRate('add_record', 60, system.get('limit.submission_user'), true); @@ -565,17 +565,17 @@ export class ProblemHackHandler extends ProblemDetailHandler { @param('rid', Types.ObjectID) @param('tid', Types.ObjectID, true) async prepare(domainId: string, rid: ObjectID, tid?: ObjectID) { - if (typeof this.pdoc.config !== 'object' || !this.pdoc.config.hackable) throw new ForbiddenError('This problem is not hackable.'); + if (typeof this.pdoc.config !== 'object' || !this.pdoc.config.hackable) throw new HackFailedError('This problem is not hackable.'); this.rdoc = await record.get(domainId, rid); if (!this.rdoc || this.rdoc.pid !== this.pdoc.docId || this.rdoc.contest?.toString() !== tid?.toString()) throw new RecordNotFoundError(domainId, rid); if (tid) { - if (this.tdoc.rule !== 'codeforces') throw new ForbiddenError('This contest is not hackable.'); + if (this.tdoc.rule !== 'codeforces') throw new HackFailedError('This contest is not hackable.'); if (!contest.isOngoing(this.tdoc, this.tsdoc)) throw new ContestNotLiveError(this.tdoc.docId); } - if (this.rdoc.uid === this.user._id) throw new BadRequestError('You cannot hack your own submission'); - if (this.psdoc?.status !== STATUS.STATUS_ACCEPTED) throw new ForbiddenError('You must accept this problem before hacking.'); - if (this.rdoc.status !== STATUS.STATUS_ACCEPTED) throw new ForbiddenError('You cannot hack a unsuccessful submission.'); + if (this.rdoc.uid === this.user._id) throw new HackFailedError('You cannot hack your own submission'); + if (this.psdoc?.status !== STATUS.STATUS_ACCEPTED) throw new HackFailedError('You must accept this problem before hacking.'); + if (this.rdoc.status !== STATUS.STATUS_ACCEPTED) throw new HackFailedError('You cannot hack a unsuccessful submission.'); } async get() { @@ -638,7 +638,7 @@ export class ProblemEditHandler extends ProblemManageHandler { domainId: string, pid: string | number, title: string, content: string, newPid: string = '', hidden = false, tag: string[] = [], difficulty = 0, ) { - if (newPid !== this.pdoc.pid && await problem.get(domainId, newPid)) throw new BadRequestError('new pid exists'); + if (newPid !== this.pdoc.pid && await problem.get(domainId, newPid)) throw new ProblemAlreadyExistError(pid); const $update: Partial = { title, content, pid: newPid, hidden, tag: tag ?? [], difficulty, html: false, }; @@ -649,7 +649,7 @@ export class ProblemEditHandler extends ProblemManageHandler { export class ProblemConfigHandler extends ProblemManageHandler { async get() { - if (this.pdoc.reference) throw new ForbiddenError('Cannot edit config of a referenced problem.'); + if (this.pdoc.reference) throw new ProblemIsReferencedError('edit config'); this.response.body.testdata = sortFiles(this.pdoc.data || []); const configFile = (this.pdoc.data || []).filter((i) => i.name.toLowerCase() === 'config.yaml'); this.response.body.config = ''; @@ -692,7 +692,7 @@ export class ProblemFilesHandler extends ProblemDetailHandler { @post('type', Types.Range(['testdata', 'additional_file']), true) async postGetLinks(domainId: string, files: Set, type = 'testdata') { if (type === 'testdata' && !this.user.own(this.pdoc)) { - if (this.pdoc.reference) throw new BadRequestError('Cannot download testdata.'); + if (this.pdoc.reference) throw new ProblemIsReferencedError('download testdata.'); if (!this.user.hasPriv(PRIV.PRIV_READ_PROBLEM_DATA)) this.checkPerm(PERM.PERM_READ_PROBLEM_DATA); if (this.tdoc && !contest.isDone(this.tdoc)) throw new ContestNotEndedError(this.tdoc.domainId, this.tdoc.docId); } @@ -720,7 +720,7 @@ export class ProblemFilesHandler extends ProblemDetailHandler { @post('filename', Types.Name, true) @post('type', Types.Range(['testdata', 'additional_file']), true) async postUploadFile(domainId: string, filename: string, type = 'testdata') { - if (this.pdoc.reference) throw new ForbiddenError('Cannot edit files of a referenced problem.'); + if (this.pdoc.reference) throw new ProblemIsReferencedError('edit files'); if (!this.request.files.file) throw new ValidationError('file'); if (!filename) filename = this.request.files.file.originalFilename || String.random(16); if (filename.includes('/') || filename.includes('..')) throw new ValidationError('filename', null, 'Bad filename'); @@ -756,7 +756,7 @@ export class ProblemFilesHandler extends ProblemDetailHandler { + (this.pdoc.additional_file?.length || 0) + files.length >= system.get('limit.problem_files_max')) { - throw new ForbiddenError('File limit exceeded.'); + throw new FileLimitExceededError('count'); } const size = Math.sum( (this.pdoc.data || []).map((i) => i.size), @@ -764,7 +764,7 @@ export class ProblemFilesHandler extends ProblemDetailHandler { files.map((i) => i.size), ); if (size >= system.get('limit.problem_files_max_size')) { - throw new ForbiddenError('File size limit exceeded.'); + throw new FileLimitExceededError('size'); } } for (const entry of files) { @@ -782,7 +782,7 @@ export class ProblemFilesHandler extends ProblemDetailHandler { @post('files', Types.Array) @post('type', Types.Range(['testdata', 'additional_file']), true) async postDeleteFiles(domainId: string, files: string[], type = 'testdata') { - if (this.pdoc.reference) throw new ForbiddenError('Cannot delete files of a referenced problem.'); + if (this.pdoc.reference) throw new ProblemIsReferencedError('delete files'); if (!this.user.own(this.pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) this.checkPerm(PERM.PERM_EDIT_PROBLEM); if (type === 'testdata') await problem.delTestdata(domainId, this.pdoc.docId, files, this.user._id); else await problem.delAdditionalFile(domainId, this.pdoc.docId, files, this.user._id); @@ -796,7 +796,7 @@ export class ProblemFileDownloadHandler extends ProblemDetailHandler { @param('noDisposition', Types.Boolean) async get(domainId: string, type = 'additional_file', filename: string, noDisposition = false) { if (this.pdoc.reference) { - if (type === 'testdata') throw new ForbiddenError('Cannot download testdata'); + if (type === 'testdata') throw new ProblemIsReferencedError('download testdata'); this.pdoc = await problem.get(this.pdoc.reference.domainId, this.pdoc.reference.pid); if (!this.pdoc) throw new ProblemNotFoundError(); } @@ -969,7 +969,7 @@ export class ProblemCreateHandler extends Handler { domainId: string, title: string, content: string, pid: string, hidden = false, difficulty = 0, tag: string[] = [], ) { - if (pid && await problem.get(domainId, pid)) throw new BadRequestError('ProblemID already exists'); + if (pid && await problem.get(domainId, pid)) throw new ProblemAlreadyExistError(pid); const docId = await problem.add(domainId, pid, title, content, this.user._id, tag ?? [], hidden); const files = new Set(Array.from(content.matchAll(/file:\/\/([a-zA-Z0-9_-]+\.[a-zA-Z0-9]+)/g)).map((i) => i[1])); const tasks = []; diff --git a/packages/hydrooj/src/handler/record.ts b/packages/hydrooj/src/handler/record.ts index 815cc4b8e..e34d63b5e 100644 --- a/packages/hydrooj/src/handler/record.ts +++ b/packages/hydrooj/src/handler/record.ts @@ -1,7 +1,7 @@ import { omit, pick, throttle } from 'lodash'; import { FilterQuery, ObjectID } from 'mongodb'; import { - ContestNotAttendedError, ContestNotFoundError, ForbiddenError, PermissionError, + ContestNotAttendedError, ContestNotFoundError, HackRejudgeFailedError, PermissionError, ProblemNotFoundError, RecordNotFoundError, UserNotFoundError, } from '../error'; import { RecordDoc, Tdoc } from '../interface'; @@ -186,7 +186,7 @@ class RecordDetailHandler extends ContestDetailBaseHandler { @param('rid', Types.ObjectID) async post() { this.checkPerm(PERM.PERM_REJUDGE); - if (this.rdoc.files?.hack) throw new ForbiddenError('Cannot rejudge a hack record.'); + if (this.rdoc.files?.hack) throw new HackRejudgeFailedError(); } @param('rid', Types.ObjectID) diff --git a/packages/hydrooj/src/handler/user.ts b/packages/hydrooj/src/handler/user.ts index 4ca094e6f..b71c2f5c2 100644 --- a/packages/hydrooj/src/handler/user.ts +++ b/packages/hydrooj/src/handler/user.ts @@ -2,8 +2,8 @@ import moment from 'moment-timezone'; import notp from 'notp'; import b32 from 'thirty-two'; import { - BlacklistedError, ForbiddenError, InvalidTokenError, LoginError, - SystemError, UserAlreadyExistError, UserFacingError, + BlacklistedError, BuiltinLoginError, ForbiddenError, InvalidTokenError, + SystemError, TFAOperationError, UserAlreadyExistError, UserFacingError, UserNotFoundError, ValidationError, VerifyPasswordError, } from '../error'; import { Udoc, User } from '../interface'; @@ -53,8 +53,8 @@ registerResolver( registerResolver( 'TFAContext', 'enable(secret: String!, code: String!)', 'Boolean!', async (arg, ctx) => { - if (ctx.user._tfa) throw new Error('2FA is already enabled'); - if (!verifyToken(arg.secret, arg.code)) throw new Error('Invalid 2FA code'); + if (ctx.user._tfa) throw new TFAOperationError('enabled'); + if (!verifyToken(arg.secret, arg.code)) throw new InvalidTokenError('2FA'); await user.setById(ctx.user._id, { tfa: arg.secret }); // TODO: return backup codes return true; @@ -64,8 +64,8 @@ registerResolver( registerResolver( 'TFAContext', 'disable(code: String!)', 'Boolean!', async (arg, ctx) => { - if (!ctx.user._tfa) throw new Error('2FA is already disabled'); - if (!verifyToken(ctx.user._tfa, arg.code)) throw new Error('Invalid 2FA code'); + if (!ctx.user._tfa) throw new TFAOperationError('disabled'); + if (!verifyToken(ctx.user._tfa, arg.code)) throw new InvalidTokenError('2FA'); await user.setById(ctx.user._id, undefined, { tfa: '' }); return true; }, @@ -106,7 +106,7 @@ class UserLoginHandler extends Handler { noCheckPermView = true; async get() { - if (!system.get('server.login')) throw new LoginError('Builtin login disabled.'); + if (!system.get('server.login')) throw new BuiltinLoginError(); this.response.template = 'user_login.html'; } @@ -116,7 +116,7 @@ class UserLoginHandler extends Handler { @param('redirect', Types.String, true) @param('tfa', Types.String, true) async post(domainId: string, uname: string, password: string, rememberme = false, redirect = '', tfa = '') { - if (!system.get('server.login')) throw new LoginError('Builtin login disabled.'); + if (!system.get('server.login')) throw new BuiltinLoginError(); let udoc = await user.getByEmail(domainId, uname); if (!udoc) udoc = await user.getByUname(domainId, uname); if (!udoc) throw new UserNotFoundError(uname); @@ -125,7 +125,7 @@ class UserLoginHandler extends Handler { this.limitRate(`user_login_${uname}`, 60, 5, false), oplog.log(this, 'user.login', { redirect }), ]); - if (udoc._tfa && !verifyToken(udoc._tfa, tfa)) throw new InvalidTokenError('2FA token invalid.'); + if (udoc._tfa && !verifyToken(udoc._tfa, tfa)) throw new InvalidTokenError('2FA'); udoc.checkPassword(password); await user.setById(udoc._id, { loginat: new Date(), loginip: this.request.ip }); if (!udoc.hasPriv(PRIV.PRIV_USER_PROFILE)) throw new BlacklistedError(uname); @@ -156,7 +156,7 @@ class UserSudoHandler extends Handler { oplog.log(this, 'user.sudo', {}), ]); if (tfa) { - if (!this.user._tfa || !verifyToken(this.user._tfa, tfa)) throw new InvalidTokenError('2FA token invalid.'); + if (!this.user._tfa || !verifyToken(this.user._tfa, tfa)) throw new InvalidTokenError('2FA'); } else { this.user.checkPassword(password); } @@ -241,7 +241,7 @@ class UserRegisterWithCodeHandler extends Handler { async get(domainId: string, code: string) { this.response.template = 'user_register_with_code.html'; const tdoc = await token.get(code, token.TYPE_REGISTRATION); - if (!tdoc) throw new InvalidTokenError(token.TYPE_REGISTRATION, code); + if (!tdoc) throw new InvalidTokenError(token.TYPE_TEXTS[token.TYPE_REGISTRATION], code); this.response.body = tdoc; } @@ -254,7 +254,7 @@ class UserRegisterWithCodeHandler extends Handler { uname: string, code: string, ) { const tdoc = await token.get(code, token.TYPE_REGISTRATION); - if (!tdoc || (!tdoc.mail && !tdoc.phone)) throw new InvalidTokenError(token.TYPE_REGISTRATION, code); + if (!tdoc || (!tdoc.mail && !tdoc.phone)) throw new InvalidTokenError(token.TYPE_TEXTS[token.TYPE_REGISTRATION], code); if (password !== verify) throw new VerifyPasswordError(); if (tdoc.phone) tdoc.mail = `${tdoc.phone}@hydro.local`; const uid = await user.create(tdoc.mail, uname, password, undefined, this.request.ip); @@ -305,7 +305,7 @@ class UserLostPassWithCodeHandler extends Handler { async get({ domainId, code }) { const tdoc = await token.get(code, token.TYPE_LOSTPASS); - if (!tdoc) throw new InvalidTokenError(token.TYPE_LOSTPASS, code); + if (!tdoc) throw new InvalidTokenError(token.TYPE_TEXTS[token.TYPE_LOSTPASS], code); const udoc = await user.getById(domainId, tdoc.uid); this.response.body = { uname: udoc.uname }; this.response.template = 'user_lostpass_with_code.html'; @@ -316,7 +316,7 @@ class UserLostPassWithCodeHandler extends Handler { @param('verifyPassword', Types.String) async post(domainId: string, code: string, password: string, verifyPassword: string) { const tdoc = await token.get(code, token.TYPE_LOSTPASS); - if (!tdoc) throw new InvalidTokenError(token.TYPE_LOSTPASS, code); + if (!tdoc) throw new InvalidTokenError(token.TYPE_TEXTS[token.TYPE_LOSTPASS], code); if (password !== verifyPassword) throw new VerifyPasswordError(); await user.setPassword(tdoc.uid, password); await token.del(code, token.TYPE_LOSTPASS); diff --git a/packages/hydrooj/src/model/problem.ts b/packages/hydrooj/src/model/problem.ts index 61a5c6bde..5ff38a5a9 100644 --- a/packages/hydrooj/src/model/problem.ts +++ b/packages/hydrooj/src/model/problem.ts @@ -2,7 +2,7 @@ import { escapeRegExp, pick } from 'lodash'; import { FilterQuery, ObjectID } from 'mongodb'; import type { Readable } from 'stream'; import { streamToBuffer } from '@hydrooj/utils/lib/utils'; -import { ProblemNotFoundError, ValidationError } from '../error'; +import { FileUploadError, ProblemNotFoundError, ValidationError } from '../error'; import type { Document, ProblemDict, ProblemStatusDoc, User, } from '../interface'; @@ -245,7 +245,7 @@ export class ProblemModel { storage.put(`problem/${domainId}/${pid}/testdata/${name}`, f, operator), ]); const meta = await storage.getMeta(`problem/${domainId}/${pid}/testdata/${name}`); - if (!meta) throw new Error('Upload failed'); + if (!meta) throw new FileUploadError(); const payload = { name, ...pick(meta, ['size', 'lastModified', 'etag']) }; payload.lastModified ||= new Date(); if (!fileinfo) await ProblemModel.push(domainId, pid, 'data', { _id: name, ...payload }); diff --git a/packages/hydrooj/src/model/token.ts b/packages/hydrooj/src/model/token.ts index aa349aeef..0a5be4fcf 100644 --- a/packages/hydrooj/src/model/token.ts +++ b/packages/hydrooj/src/model/token.ts @@ -13,6 +13,15 @@ class TokenModel { static TYPE_LOSTPASS = 5; static TYPE_EXPORT = 6; static TYPE_IMPORT = 7; + static TYPE_TEXTS = { + [TokenModel.TYPE_SESSION]: 'Session', + [TokenModel.TYPE_REGISTRATION]: 'Registration', + [TokenModel.TYPE_CHANGEMAIL]: 'Change Email', + [TokenModel.TYPE_OAUTH]: 'OAuth', + [TokenModel.TYPE_LOSTPASS]: 'Lost Password', + [TokenModel.TYPE_EXPORT]: 'Export', + [TokenModel.TYPE_IMPORT]: 'Import', + }; static async add( tokenType: number, expireSeconds: number, data: any, id = String.random(32), diff --git a/packages/ui-default/utils/request.js b/packages/ui-default/utils/request.js index bfba69b23..4335928f6 100644 --- a/packages/ui-default/utils/request.js +++ b/packages/ui-default/utils/request.js @@ -26,7 +26,7 @@ const request = { const { error } = jqXHR.responseJSON; if (error.params) { const message = i18n(error.message, ...error.params); - const err = new Error(message); + const err = new Error(message === error.message && error.params.length ? `${error.message}: ${error.params.join(' ')}` : message); err.rawMessage = error.message; err.params = error.params; reject(err);