diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index 34fcb96da..d69334a9d 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -534,7 +534,7 @@ export class ContestManagementHandler extends ContestManagementBaseHandler { } @param('tid', Types.ObjectID) - @post('filename', Types.Name, true) + @post('filename', Types.Filename, true) async postUploadFile(domainId: string, tid: ObjectID, filename: string) { if ((this.tdoc.files?.length || 0) >= system.get('limit.contest_files')) { throw new FileLimitExceededError('count'); @@ -557,7 +557,7 @@ export class ContestManagementHandler extends ContestManagementBaseHandler { } @param('tid', Types.ObjectID) - @post('files', Types.ArrayOf(Types.Name)) + @post('files', Types.ArrayOf(Types.Filename)) async postDeleteFiles(domainId: string, tid: ObjectID, files: string[]) { await Promise.all([ storage.del(files.map((t) => `contest/${domainId}/${tid}/${t}`), this.user._id), @@ -569,7 +569,7 @@ export class ContestManagementHandler extends ContestManagementBaseHandler { export class ContestFileDownloadHandler extends ContestDetailBaseHandler { @param('tid', Types.ObjectID) - @param('filename', Types.Name) + @param('filename', Types.Filename) @param('noDisposition', Types.Boolean) async get(domainId: string, tid: ObjectID, filename: string, noDisposition = false) { this.response.addHeader('Cache-Control', 'public'); diff --git a/packages/hydrooj/src/handler/discussion.ts b/packages/hydrooj/src/handler/discussion.ts index 919e81113..7f5bc451f 100644 --- a/packages/hydrooj/src/handler/discussion.ts +++ b/packages/hydrooj/src/handler/discussion.ts @@ -29,7 +29,7 @@ class DiscussionHandler extends Handler { drrdoc?: DiscussionTailReplyDoc; vnode?: any; - @param('type', Types.Name, true) + @param('type', Types.Range(Object.keys(typeMapper)), true) @param('name', Types.Name, true) @param('did', Types.ObjectID, true) @param('drid', Types.ObjectID, true) @@ -94,7 +94,7 @@ class DiscussionMainHandler extends Handler { } class DiscussionNodeHandler extends DiscussionHandler { - @param('type', Types.Name) + @param('type', Types.Range(Object.keys(typeMapper))) @param('name', Types.Name) @param('page', Types.PositiveInt, true) async get(domainId: string, type: string, _name: string, page = 1) { @@ -140,7 +140,7 @@ class DiscussionCreateHandler extends DiscussionHandler { this.response.body = { path, vnode: this.vnode }; } - @param('type', Types.Name) + @param('type', Types.Range(Object.keys(typeMapper))) @param('title', Types.Title) @param('content', Types.Content) @param('highlight', Types.Boolean) diff --git a/packages/hydrooj/src/handler/domain.ts b/packages/hydrooj/src/handler/domain.ts index e4ad78cfc..ec86aa318 100644 --- a/packages/hydrooj/src/handler/domain.ts +++ b/packages/hydrooj/src/handler/domain.ts @@ -159,7 +159,7 @@ class DomainUserHandler extends ManageHandler { @requireSudo @post('uid', Types.Int) - @post('role', Types.Name) + @post('role', Types.Role) async postSetUser(domainId: string, uid: number, role: string) { await Promise.all([ domain.setUserRole(domainId, uid, role), @@ -170,7 +170,7 @@ class DomainUserHandler extends ManageHandler { @requireSudo @param('uid', Types.NumericArray) - @param('role', Types.Name) + @param('role', Types.Role) async postSetUsers(domainId: string, uid: number[], role: string) { await Promise.all([ domain.setUserRole(domainId, uid, role), @@ -213,7 +213,7 @@ class DomainRoleHandler extends ManageHandler { this.response.body = { roles, domain: this.domain }; } - @param('role', Types.Name) + @param('role', Types.Role) async postAdd(domainId: string, role: string) { const roles = await domain.getRoles(this.domain); const rdict: Dictionary = {}; @@ -224,7 +224,7 @@ class DomainRoleHandler extends ManageHandler { } @requireSudo - @param('roles', Types.ArrayOf(Types.Name)) + @param('roles', Types.ArrayOf(Types.Role)) async postDelete(domainId: string, roles: string[]) { if (Set.intersection(roles, ['root', 'default', 'guest']).size > 0) { throw new ValidationError('role', null, 'You cannot delete root, default or guest roles'); @@ -251,7 +251,7 @@ class DomainJoinApplicationsHandler extends ManageHandler { @requireSudo @post('method', Types.Range([domain.JOIN_METHOD_NONE, domain.JOIN_METHOD_ALL, domain.JOIN_METHOD_CODE])) - @post('role', Types.Name, true) + @post('role', Types.Role, true) @post('expire', Types.Int, true) @post('invitationCode', Types.Content, true) async post(domainId: string, method: number, role: string, expire: number, invitationCode = '') { diff --git a/packages/hydrooj/src/handler/home.ts b/packages/hydrooj/src/handler/home.ts index 8dfe59fc7..6ddb05555 100644 --- a/packages/hydrooj/src/handler/home.ts +++ b/packages/hydrooj/src/handler/home.ts @@ -13,7 +13,6 @@ import { DomainDoc, MessageDoc, Setting } from '../interface'; import avatar from '../lib/avatar'; import * as mail from '../lib/mail'; import * as useragent from '../lib/useragent'; -import { isDomainId, isEmail, isPassword } from '../lib/validator'; import { verifyTFA } from '../lib/verifyTFA'; import BlackListModel from '../model/blacklist'; import { PERM, PRIV } from '../model/builtin'; @@ -180,8 +179,8 @@ class HomeSecurityHandler extends Handler { @requireSudo @param('current', Types.String) - @param('password', Types.String, isPassword) - @param('verifyPassword', Types.String) + @param('password', Types.Password) + @param('verifyPassword', Types.Password) async postChangePassword(_: string, current: string, password: string, verify: string) { if (password !== verify) throw new VerifyPasswordError(); this.user.checkPassword(current); @@ -191,8 +190,8 @@ class HomeSecurityHandler extends Handler { } @requireSudo - @param('password', Types.String) - @param('mail', Types.Name, isEmail) + @param('password', Types.Password) + @param('mail', Types.Email) async postChangeMail(domainId: string, current: string, email: string) { const mailDomain = email.split('@')[1]; if (await BlackListModel.get(`mail::${mailDomain}`)) throw new BlacklistedError(mailDomain); @@ -335,7 +334,7 @@ function set(s: Setting, key: string, value: any) { } class HomeSettingsHandler extends Handler { - @param('category', Types.Name) + @param('category', Types.Range(['preference', 'account', 'domain'])) async get(domainId: string, category: string) { this.response.template = 'home_settings.html'; this.response.body = { @@ -445,7 +444,7 @@ class HomeDomainCreateHandler extends Handler { this.response.template = 'domain_create.html'; } - @param('id', Types.Name, isDomainId) + @param('id', Types.DomainId) @param('name', Types.Title) @param('bulletin', Types.Content) @param('avatar', Types.Content, true) diff --git a/packages/hydrooj/src/handler/manage.ts b/packages/hydrooj/src/handler/manage.ts index 188a886bc..7ac3e6325 100644 --- a/packages/hydrooj/src/handler/manage.ts +++ b/packages/hydrooj/src/handler/manage.ts @@ -6,7 +6,6 @@ import * as check from '../check'; import { CannotEditSuperAdminError, NotLaunchedByPM2Error, UserNotFoundError, ValidationError, } from '../error'; -import { isEmail, isPassword } from '../lib/validator'; import { Logger } from '../logger'; import { PRIV, STATUS } from '../model/builtin'; import domain from '../model/domain'; @@ -230,9 +229,9 @@ class SystemUserImportHandler extends SystemHandler { let [email, username, password, displayName, extra] = u.split(',').map((t) => t.trim()); if (!email || !username || !password) [email, username, password, displayName, extra] = u.split('\t').map((t) => t.trim()); if (email && username && password) { - if (!isEmail(email)) messages.push(`Line ${+i + 1}: Invalid email.`); + if (!Types.Email[1](email)) messages.push(`Line ${+i + 1}: Invalid email.`); else if (!Types.Username[1](username)) messages.push(`Line ${+i + 1}: Invalid username`); - else if (!isPassword(password)) messages.push(`Line ${+i + 1}: Invalid password`); + else if (!Types.Password[1](password)) messages.push(`Line ${+i + 1}: Invalid password`); else if (await user.getByEmail('system', email)) { messages.push(`Line ${+i + 1}: Email ${email} already exists.`); } else if (await user.getByUname('system', username)) { diff --git a/packages/hydrooj/src/handler/misc.ts b/packages/hydrooj/src/handler/misc.ts index a703593c7..f276a31fb 100644 --- a/packages/hydrooj/src/handler/misc.ts +++ b/packages/hydrooj/src/handler/misc.ts @@ -44,7 +44,7 @@ export class FilesHandler extends Handler { this.response.template = 'home_files.html'; } - @post('filename', Types.Name, true) + @post('filename', Types.Filename) async postUploadFile(domainId: string, filename: string) { this.checkPriv(PRIV.PRIV_CREATE_FILE); if ((this.user._files?.length || 0) >= system.get('limit.user_files')) { @@ -57,9 +57,7 @@ export class FilesHandler extends Handler { if (size >= system.get('limit.user_files_size')) { if (!this.user.hasPriv(PRIV.PRIV_UNLIMITED_QUOTA)) throw new FileLimitExceededError('size'); } - 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 FileExistsError(filename); + if (this.user._files.find((i) => i.name === filename)) 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']) }; @@ -69,7 +67,7 @@ export class FilesHandler extends Handler { this.back(); } - @post('files', Types.ArrayOf(Types.Name)) + @post('files', Types.ArrayOf(Types.Filename)) async postDeleteFiles(domainId: string, files: string[]) { await Promise.all([ storage.del(files.map((t) => `user/${this.user._id}/${t}`), this.user._id), @@ -83,7 +81,7 @@ export class FSDownloadHandler extends Handler { noCheckPermView = true; @param('uid', Types.Int) - @param('filename', Types.Name) + @param('filename', Types.Filename) @param('noDisposition', Types.Boolean) async get(domainId: string, uid: number, filename: string, noDisposition = false) { const targetUser = await user.getById('system', uid); @@ -111,7 +109,7 @@ export class StorageHandler extends Handler { noCheckPermView = true; @param('target', Types.Name) - @param('filename', Types.Name, true) + @param('filename', Types.Filename, true) @param('expire', Types.UnsignedInt) @param('secret', Types.String) async get(domainId: string, target: string, filename = '', expire: number, secret: string) { diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index 9b3cd74e7..70bc9cc91 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -2,7 +2,7 @@ import AdmZip from 'adm-zip'; import { readFile, statSync } from 'fs-extra'; import { isBinaryFile } from 'isbinaryfile'; import { - escapeRegExp, flattenDeep, intersection, isSafeInteger, pick, uniqBy, + escapeRegExp, flattenDeep, intersection, pick, uniqBy, } from 'lodash'; import { FilterQuery, ObjectID } from 'mongodb'; import { nanoid } from 'nanoid'; @@ -18,7 +18,6 @@ import { ProblemDoc, ProblemSearchOptions, ProblemStatusDoc, RecordDoc, User, } from '../interface'; import paginate from '../lib/paginate'; -import { isPid, parsePid as convertPid } from '../lib/validator'; import { PERM, PRIV, STATUS } from '../model/builtin'; import * as contest from '../model/contest'; import * as discussion from '../model/discussion'; @@ -40,7 +39,6 @@ import { registerResolver, registerValue } from './api'; import { ContestDetailBaseHandler } from './contest'; export const parseCategory = (value: string) => value.replace(/,/g, ',').split(',').map((e) => e.trim()); -export const parsePid = (value: string) => (isSafeInteger(value) ? +value : value); registerValue('FileInfo', [ ['_id', 'String!'], @@ -312,7 +310,7 @@ export class ProblemDetailHandler extends ContestDetailBaseHandler { udoc: User; psdoc: ProblemStatusDoc; - @route('pid', Types.Name, true, null, parsePid) + @route('pid', Types.ProblemId, true) @query('tid', Types.ObjectID, true) async _prepare(domainId: string, pid: number | string, tid?: ObjectID) { this.pdoc = await problem.get(domainId, pid); @@ -628,17 +626,18 @@ export class ProblemEditHandler extends ProblemManageHandler { this.response.template = 'problem_edit.html'; } - @route('pid', Types.Name, null, parsePid) + @route('pid', Types.ProblemId) @post('title', Types.Title) @post('content', Types.Content) - @post('pid', Types.Name, isPid, convertPid, true) + @post('pid', Types.ProblemId, true) @post('hidden', Types.Boolean) @post('tag', Types.Content, true, null, parseCategory) @post('difficulty', Types.PositiveInt, (i) => +i <= 10, true) async post( domainId: string, pid: string | number, title: string, content: string, - newPid: string = '', hidden = false, tag: string[] = [], difficulty = 0, + newPid: string | number = '', hidden = false, tag: string[] = [], difficulty = 0, ) { + if (typeof newPid !== 'string') newPid = `P${newPid}`; 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, @@ -722,7 +721,7 @@ export class ProblemFilesHandler extends ProblemDetailHandler { this.response.body.links = links; } - @post('filename', Types.Name, true) + @post('filename', Types.Filename, true) @post('type', Types.Range(['testdata', 'additional_file']), true) async postUploadFile(domainId: string, filename: string, type = 'testdata') { if (this.pdoc.reference) throw new ProblemIsReferencedError('edit files'); @@ -784,7 +783,7 @@ export class ProblemFilesHandler extends ProblemDetailHandler { this.back(); } - @post('files', Types.ArrayOf(Types.Name)) + @post('files', Types.ArrayOf(Types.Filename)) @post('type', Types.Range(['testdata', 'additional_file']), true) async postDeleteFiles(domainId: string, files: string[], type = 'testdata') { if (this.pdoc.reference) throw new ProblemIsReferencedError('delete files'); @@ -797,7 +796,7 @@ export class ProblemFilesHandler extends ProblemDetailHandler { export class ProblemFileDownloadHandler extends ProblemDetailHandler { @query('type', Types.Range(['additional_file', 'testdata']), true) - @param('filename', Types.Name) + @param('filename', Types.Filename) @param('noDisposition', Types.Boolean) async get(domainId: string, type = 'additional_file', filename: string, noDisposition = false) { if (this.pdoc.reference) { @@ -966,14 +965,15 @@ export class ProblemCreateHandler extends Handler { @post('title', Types.Title) @post('content', Types.Content) - @post('pid', Types.Name, true, isPid, convertPid) + @post('pid', Types.ProblemId, true) @post('hidden', Types.Boolean) @post('difficulty', Types.PositiveInt, (i) => +i <= 10, true) @post('tag', Types.Content, true, null, parseCategory) async post( - domainId: string, title: string, content: string, pid: string, + domainId: string, title: string, content: string, pid: string | number = '', hidden = false, difficulty = 0, tag: string[] = [], ) { + if (typeof pid !== 'string') pid = `P${pid}`; 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])); diff --git a/packages/hydrooj/src/handler/record.ts b/packages/hydrooj/src/handler/record.ts index 09fec61ef..7440bf0c8 100644 --- a/packages/hydrooj/src/handler/record.ts +++ b/packages/hydrooj/src/handler/record.ts @@ -25,15 +25,15 @@ class RecordListHandler extends ContestDetailBaseHandler { tdoc?: Tdoc<30>; @param('page', Types.PositiveInt, true) - @param('pid', Types.Name, true) + @param('pid', Types.ProblemId, true) @param('tid', Types.ObjectID, true) - @param('uidOrName', Types.Name, true) + @param('uidOrName', Types.UidOrName, true) @param('lang', Types.String, true) @param('status', Types.Int, true) @param('fullStatus', Types.Boolean) @param('allDomain', Types.Boolean, true) async get( - domainId: string, page = 1, pid?: string, tid?: ObjectID, + domainId: string, page = 1, pid?: string | number, tid?: ObjectID, uidOrName?: string, lang?: string, status?: number, full = false, all = false, ) { @@ -64,10 +64,10 @@ class RecordListHandler extends ContestDetailBaseHandler { if (udoc) q.uid = udoc._id; else invalid = true; } - if (pid && tdoc && /^[A-Z]$/.test(pid)) { - pid = tdoc.pids[parseInt(pid, 36) - 10]; - } if (pid) { + if (typeof pid === 'string' && tdoc && /^[A-Z]$/.test(pid)) { + pid = tdoc.pids[parseInt(pid, 36) - 10]; + } const pdoc = await problem.get(domainId, pid); if (pdoc) q.pid = pdoc.docId; else invalid = true; @@ -236,13 +236,13 @@ class RecordMainConnectionHandler extends ConnectionHandler { tdoc: Tdoc<30>; @param('tid', Types.ObjectID, true) - @param('pid', Types.Name, true) - @param('uidOrName', Types.Name, true) + @param('pid', Types.ProblemId, true) + @param('uidOrName', Types.UidOrName, true) @param('status', Types.Int, true) @param('pretest', Types.Boolean) @param('allDomain', Types.Boolean) async prepare( - domainId: string, tid?: ObjectID, pid?: string, uidOrName?: string, + domainId: string, tid?: ObjectID, pid?: string | number, uidOrName?: string, status?: number, pretest = false, all = false, ) { if (tid) { diff --git a/packages/hydrooj/src/handler/training.ts b/packages/hydrooj/src/handler/training.ts index 0b5ee44d2..b3c589d7d 100644 --- a/packages/hydrooj/src/handler/training.ts +++ b/packages/hydrooj/src/handler/training.ts @@ -256,7 +256,7 @@ export class TrainingFilesHandler extends Handler { } @param('tid', Types.ObjectID) - @post('filename', Types.Name, true) + @post('filename', Types.Filename, true) async postUploadFile(domainId: string, tid: ObjectID, filename: string) { if ((this.tdoc.files?.length || 0) >= system.get('limit.contest_files')) { throw new FileLimitExceededError('count'); @@ -268,8 +268,6 @@ export class TrainingFilesHandler extends Handler { if (size >= system.get('limit.contest_files_size')) { throw new FileLimitExceededError('size'); } - filename ||= file.originalFilename || String.random(16); - if (filename.includes('/') || filename.includes('..')) throw new ValidationError('filename', null, 'Bad filename'); await storage.put(`training/${domainId}/${tid}/${filename}`, file.filepath, this.user._id); const meta = await storage.getMeta(`training/${domainId}/${tid}/${filename}`); const payload = { _id: filename, name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) }; @@ -279,7 +277,7 @@ export class TrainingFilesHandler extends Handler { } @param('tid', Types.ObjectID) - @post('files', Types.ArrayOf(Types.Name)) + @post('files', Types.ArrayOf(Types.Filename)) async postDeleteFiles(domainId: string, tid: ObjectID, files: string[]) { await Promise.all([ storage.del(files.map((t) => `contest/${domainId}/${tid}/${t}`), this.user._id), @@ -290,7 +288,7 @@ export class TrainingFilesHandler extends Handler { } export class TrainingFileDownloadHandler extends Handler { @param('tid', Types.ObjectID) - @param('filename', Types.Name) + @param('filename', Types.Filename) @param('noDisposition', Types.Boolean) async get(domainId: string, tid: ObjectID, filename: string, noDisposition = false) { this.response.addHeader('Cache-Control', 'public'); diff --git a/packages/hydrooj/src/handler/user.ts b/packages/hydrooj/src/handler/user.ts index 9baf70ade..7f6d7bd18 100644 --- a/packages/hydrooj/src/handler/user.ts +++ b/packages/hydrooj/src/handler/user.ts @@ -8,7 +8,6 @@ import { import { Udoc, User } from '../interface'; import avatar from '../lib/avatar'; import { sendMail } from '../lib/mail'; -import { isEmail, isPassword } from '../lib/validator'; import { verifyTFA } from '../lib/verifyTFA'; import BlackListModel from '../model/blacklist'; import { PERM, PRIV, STATUS } from '../model/builtin'; @@ -81,7 +80,7 @@ class UserLoginHandler extends Handler { } @param('uname', Types.Username) - @param('password', Types.String) + @param('password', Types.Password) @param('rememberme', Types.Boolean) @param('redirect', Types.String, true) @param('tfa', Types.String, true) @@ -225,7 +224,7 @@ export class UserRegisterHandler extends Handler { this.response.template = 'user_register.html'; } - @post('mail', Types.String, true, isEmail) + @post('mail', Types.Email, true) @post('phone', Types.String, true, (s) => /^\d{11}$/.test(s)) async post(domainId: string, mail: string, phoneNumber: string) { if (mail) { @@ -279,8 +278,8 @@ class UserRegisterWithCodeHandler extends Handler { this.response.body = tdoc; } - @param('password', Types.String, isPassword) - @param('verifyPassword', Types.String) + @param('password', Types.Password) + @param('verifyPassword', Types.Password) @param('uname', Types.Username) @param('code', Types.String) async post( @@ -312,7 +311,7 @@ class UserLostPassHandler extends Handler { this.response.template = 'user_lostpass.html'; } - @param('mail', Types.String, isEmail) + @param('mail', Types.Email) async post(domainId: string, mail: string) { if (!system.get('smtp.user')) throw new SystemError('Cannot send mail'); const udoc = await user.getByEmail('system', mail); @@ -347,8 +346,8 @@ class UserLostPassWithCodeHandler extends Handler { } @param('code', Types.String) - @param('password', Types.String, isPassword) - @param('verifyPassword', Types.String) + @param('password', Types.Password) + @param('verifyPassword', Types.Password) 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_TEXTS[token.TYPE_LOSTPASS], code); @@ -429,7 +428,7 @@ class UserDeleteHandler extends Handler { class OauthHandler extends Handler { noCheckPermView = true; - @param('type', Types.String) + @param('type', Types.Key) async get(domainId: string, type: string) { await global.Hydro.module.oauth[type]?.get.call(this); } diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index f3274a6e8..8c2a3e245 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -733,7 +733,6 @@ export interface Lib extends Record { rank: typeof import('./lib/rank'); rating: typeof import('./lib/rating'); testdataConfig: typeof import('./lib/testdataConfig'); - validator: typeof import('./lib/validator'); template?: any; problemSearch: ProblemSearch; } diff --git a/packages/hydrooj/src/lib/index.ts b/packages/hydrooj/src/lib/index.ts index b4d985141..752f9329b 100644 --- a/packages/hydrooj/src/lib/index.ts +++ b/packages/hydrooj/src/lib/index.ts @@ -3,7 +3,6 @@ import './mail'; import './paginate'; import './hash.hydro'; import './rank'; -import './validator'; import './ui'; import './testdataConfig'; import './difficulty'; diff --git a/packages/hydrooj/src/lib/validator.ts b/packages/hydrooj/src/lib/validator.ts deleted file mode 100644 index 9a62fa9b3..000000000 --- a/packages/hydrooj/src/lib/validator.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ValidationError } from '../error'; - -const RE_UID = /^-?\d+$/i; -const RE_DOMAINID = /^[a-zA-Z][a-zA-Z0-9_]{3,31}$/i; -const RE_PID = /^[a-zA-Z]+[a-zA-Z0-9]*$/i; -const RE_ROLE = /^[_0-9A-Za-z]{1,31}$/i; -const RE_MAIL = /^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+$/i; - -export const isTitle = (s) => typeof s === 'string' && s.trim().length && s.trim().length < 64; -export const checkTitle = (s) => { if (!(s?.trim()?.length && s.trim().length < 64)) throw new ValidationError('title'); else return s; }; -export const isDomainId = (s) => RE_DOMAINID.test(s); -export const checkDomainId = (s) => { if (!isDomainId(s)) throw new ValidationError('domainId'); else return s; }; -export const isUid = (s) => RE_UID.test(s); -export const checkUid = (s) => { if (!isUid(s)) throw new ValidationError('uid'); else return s; }; -export const isRole = (s) => RE_ROLE.test(s); -export const checkRole = (s) => { if (!isRole(s)) throw new ValidationError('role'); else return s; }; -export const isPassword = (s) => s.length >= 5; -export const checkPassword = (s) => { if (!(s && s.length >= 5)) throw new ValidationError('password'); else return s; }; -export const isEmail = (s) => RE_MAIL.test(s); -export const checkEmail = (s) => { if (!RE_MAIL.test(s)) throw new ValidationError('mail'); else return s; }; -export const isContent = (s: any) => s && s.toString().trim().length < 65536; -export const checkContent = (s) => { if (!(s && s.length < 65536)) throw new ValidationError('content'); else return s; }; -export const isPid = (s) => RE_PID.test(s.toString()); -export const checkPid = (s) => { if (!RE_PID.test(s)) throw new ValidationError('pid'); else return s; }; -export const isIntro = () => true; -export const checkIntro = (s) => { if (!isIntro()) throw new ValidationError('intro'); else return s; }; - -export const parsePid = (s) => (Number.isNaN(+s) ? s : `P${s}`); - -global.Hydro.lib.validator = { - isTitle, - checkTitle, - isDomainId, - checkDomainId, - isUid, - checkUid, - isRole, - checkRole, - isPassword, - checkPassword, - isEmail, - checkEmail, - isContent, - checkContent, - isPid, - checkPid, - isIntro, - checkIntro, - - parsePid, -}; diff --git a/packages/hydrooj/src/model/domain.ts b/packages/hydrooj/src/model/domain.ts index 9ebab356b..fd293ab15 100644 --- a/packages/hydrooj/src/model/domain.ts +++ b/packages/hydrooj/src/model/domain.ts @@ -96,7 +96,7 @@ class DomainModel { static async edit(domainId: string, $set: Partial) { domainId = domainId.toLowerCase(); await bus.parallel('domain/before-update', domainId, $set); - const result = await coll.findOneAndUpdate({ _id: domainId }, { $set }, { returnDocument: 'after' }); + const result = await coll.findOneAndUpdate({ lower: domainId }, { $set }, { returnDocument: 'after' }); if (result.value) { await bus.parallel('domain/update', domainId, $set, result.value); cache.delete(`id::${domainId}`); @@ -199,7 +199,7 @@ class DomainModel { static async getDomainUser(domainId: string, udoc: DomainUserArg) { let dudoc = await collUser.findOne({ domainId, uid: udoc._id }); - dudoc ||= {}; + dudoc ||= { domainId, uid: udoc._id }; if (!(udoc.priv & PRIV.PRIV_USER_PROFILE)) dudoc.role = 'guest'; if (udoc.priv & PRIV.PRIV_MANAGE_ALL_DOMAIN) dudoc.role = 'root'; dudoc.role ||= 'default'; diff --git a/packages/hydrooj/src/model/user.ts b/packages/hydrooj/src/model/user.ts index ac260158e..78b9fca71 100644 --- a/packages/hydrooj/src/model/user.ts +++ b/packages/hydrooj/src/model/user.ts @@ -161,9 +161,18 @@ function handleMailLower(mail: string) { return data; } +async function initAndCache(udoc: Udoc, dudoc, scope: bigint = PERM.PERM_ALL) { + const res = await new User(udoc, dudoc, scope).init(); + cache.set(`id/${udoc._id}/${dudoc.domainId}`, res); + cache.set(`name/${udoc.unameLower}/${dudoc.domainId}`, res); + cache.set(`mail/${udoc.mailLower}/${dudoc.domainId}`, res); + return res; +} + class UserModel { static coll = coll; static User = User; + static cache = cache; static defaultUser: Udoc = { _id: 0, uname: 'Unknown User', @@ -183,7 +192,7 @@ class UserModel { }; @ArgMethod - static async getById(domainId: string, _id: number, scope: bigint | string = PERM.PERM_ALL): Promise { + static async getById(domainId: string, _id: number, scope: bigint | string = PERM.PERM_ALL): Promise { if (cache.has(`id/${_id}/${domainId}`)) return cache.get(`id/${_id}/${domainId}`) || null; const udoc = await (_id < -999 ? collV : coll).findOne({ _id }); if (!udoc) return null; @@ -193,11 +202,7 @@ class UserModel { ]); dudoc.group = groups.map((i) => i.name); if (typeof scope === 'string') scope = BigInt(scope); - const res = await new User(udoc, dudoc, scope).init(); - cache.set(`id/${res._id}/${domainId}`, res); - cache.set(`name/${res.uname.toLowerCase()}/${domainId}`, res); - cache.set(`mail/${res.mail.toLowerCase()}/${domainId}`, res); - return res; + return initAndCache(udoc, dudoc, scope); } static async getList(domainId: string, uids: number[]): Promise { @@ -211,30 +216,21 @@ class UserModel { @ArgMethod static async getByUname(domainId: string, uname: string): Promise { const unameLower = uname.trim().toLowerCase(); - if (cache.has(`name/${unameLower}/${domainId}`)) return cache.get(`name/${unameLower}/${domainId}`) || null; - let udoc = await coll.findOne({ unameLower }); - udoc ||= await collV.findOne({ unameLower }); + if (cache.has(`name/${unameLower}/${domainId}`)) return cache.get(`name/${unameLower}/${domainId}`); + const udoc = (await coll.findOne({ unameLower })) || await collV.findOne({ unameLower }); if (!udoc) return null; const dudoc = await domain.getDomainUser(domainId, udoc); - const res = await new UserModel.User(udoc, dudoc).init(); - cache.set(`id/${res._id}/${domainId}`, res); - cache.set(`name/${res.uname.toLowerCase()}/${domainId}`, res); - cache.set(`mail/${handleMailLower(res.mail)}/${domainId}`, res); - return res; + return initAndCache(udoc, dudoc); } @ArgMethod - static async getByEmail(domainId: string, mail: string): Promise { + static async getByEmail(domainId: string, mail: string): Promise { const mailLower = handleMailLower(mail); - if (cache.has(`mail/${mailLower}/${domainId}`)) return cache.get(`mail/${mailLower}/${domainId}`) || null; + if (cache.has(`mail/${mailLower}/${domainId}`)) return cache.get(`mail/${mailLower}/${domainId}`); const udoc = await coll.findOne({ mailLower }); if (!udoc) return null; const dudoc = await domain.getDomainUser(domainId, udoc); - const res = await new UserModel.User(udoc, dudoc).init(); - cache.set(`id/${res._id}/${domainId}`, res); - cache.set(`name/${res.uname.toLowerCase()}/${domainId}`, res); - cache.set(`mail/${handleMailLower(res.mail)}/${domainId}`, res); - return res; + return initAndCache(udoc, dudoc); } @ArgMethod @@ -260,7 +256,7 @@ class UserModel { } @ArgMethod - static async setPassword(uid: number, password: string): Promise { + static async setPassword(uid: number, password: string): Promise { const salt = String.random(); const res = await coll.findOneAndUpdate( { _id: uid }, @@ -268,7 +264,7 @@ class UserModel { { returnDocument: 'after' }, ); deleteUserCache(res.value); - return res.value || null; + return res.value; } @ArgMethod @@ -373,20 +369,18 @@ class UserModel { const $regex = `^${escapeRegExp(prefix.toLowerCase())}`; const udocs = await coll.find({ unameLower: { $regex } }) .limit(limit).project({ _id: 1 }).toArray(); - const users = []; - for (const { _id } of udocs) users.push(UserModel.getById(domainId, _id)); - return await Promise.all(users); + return await Promise.all(udocs.map(({ _id }) => UserModel.getById(domainId, _id))); } @ArgMethod - static async setPriv(uid: number, priv: number): Promise { + static async setPriv(uid: number, priv: number): Promise { const res = await coll.findOneAndUpdate( { _id: uid }, { $set: { priv } }, { returnDocument: 'after' }, ); deleteUserCache(res.value); - return res.value || null; + return res.value; } @ArgMethod diff --git a/packages/hydrooj/src/plugin-api.ts b/packages/hydrooj/src/plugin-api.ts index ee998a989..7ce689563 100644 --- a/packages/hydrooj/src/plugin-api.ts +++ b/packages/hydrooj/src/plugin-api.ts @@ -38,7 +38,6 @@ export { Collections } from './service/db'; // export { Collections } from './interface'; export { Service, Context } from './context'; export { buildContent } from './lib/content'; -export * as validator from './lib/validator'; export { default as avatar } from './lib/avatar'; export { default as rank } from './lib/rank'; export { default as paginate } from './lib/paginate'; diff --git a/packages/hydrooj/src/service/decorators.ts b/packages/hydrooj/src/service/decorators.ts index fc0570e6f..8fcbecf17 100644 --- a/packages/hydrooj/src/service/decorators.ts +++ b/packages/hydrooj/src/service/decorators.ts @@ -6,7 +6,6 @@ import { ObjectID } from 'mongodb'; import saslprep from 'saslprep'; import { Time } from '@hydrooj/utils'; import { ValidationError } from '../error'; -import { isContent, isTitle } from '../lib/validator'; import type { Handler } from './server'; type MethodDecorator = (target: any, name: string, obj: any) => any; @@ -21,51 +20,83 @@ export interface ParamOption { validate?: Validator, } -type Type = [Converter, Validator?, boolean?]; +type Type = readonly [Converter, Validator?, boolean?]; export interface Types { + // String outputs Content: Type; + Key: Type; + /** @deprecated */ Name: Type; Username: Type; + Password: Type; + UidOrName: Type; + Email: Type; + Filename: Type; + DomainId: Type; + ProblemId: Type; + Role: Type; Title: Type; + Emoji: Type; String: Type; + + // Number outputs Int: Type; UnsignedInt: Type; PositiveInt: Type; Float: Type; + + // Other ObjectID: Type; Boolean: Type; Date: Type; Time: Type; Range: (range: Array | Record) => Type; + /** @deprecated */ Array: Type; NumericArray: Type; CommaSeperatedArray: Type; Set: Type>; - Emoji: Type; Any: Type; ArrayOf: >(type: T) => (T extends Type ? Type : never); + AnyOf: >(...type: T[]) => (T extends Type ? Type : never); } -const safeSaslprep = (v) => { - try { - return saslprep(v.toString().trim()); - } catch (e) { - return ''; - } -}; +const saslprepString = (regex?: RegExp, cb?: (i: string) => boolean, convert?: (i: string) => T) => [ + convert || ((v) => saslprep(v.toString().trim())), + (v) => { + try { + const res = saslprep(v.toString().trim()); + if (regex && !regex.test(res)) return false; + if (cb && !cb(res)) return false; + return !!res.length; + } catch (e) { + return false; + } + }, +] as [(v) => string, (v) => boolean]; export const Types: Types = { - Content: [(v) => v.toString().trim(), isContent], - Name: [(v) => saslprep(v.toString().trim()), (v) => /^.{1,255}$/.test(safeSaslprep(v))], - Username: [(v) => saslprep(v.toString().trim()), (v) => /^(.{3,31}|[\u4e00-\u9fa5]{2})$/.test(safeSaslprep(v))], - Title: [(v) => v.toString().trim(), isTitle], + Content: [(v) => v.toString().trim(), (v) => v && v.toString().trim().length < 65536], + Key: saslprepString(/^[a-zA-Z0-9-_]+$/), + /** @deprecated */ + Name: saslprepString(/^.{1,255}$/), + Filename: saslprepString(/^[^.\\/?#~]{1,255}$/, (i) => !['con', '.', '..'].includes(i)), + UidOrName: saslprepString(/^(.{3,31}|[\u4e00-\u9fa5]{2}|-?[0-9]+)$/), + Username: saslprepString(/^(.{3,31}|[\u4e00-\u9fa5]{2})$/), + Password: saslprepString(/^.{6,255}$/), + ProblemId: saslprepString(/^[a-zA-Z]+[a-zA-Z0-9]*$/i, () => true, (s) => (Number.isSafeInteger(+s) ? +s : s)), + Email: saslprepString(/^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+$/i), + DomainId: saslprepString(/^[a-zA-Z][a-zA-Z0-9_]{3,31}$/), + Role: saslprepString(/^[_0-9A-Za-z\u4e00-\u9fa5]{1,31}$/i), + Title: saslprepString(/^.{1,64}$/), String: [(v) => v.toString(), null], + Int: [(v) => +v, (v) => /^[+-]?[0-9]+$/.test(v.toString().trim()) && isSafeInteger(+v)], UnsignedInt: [(v) => +v, (v) => /^(-0|\+?[0-9]+)$/.test(v.toString().trim()) && isSafeInteger(+v)], PositiveInt: [(v) => +v, (v) => /^\+?[1-9][0-9]*$/.test(v.toString().trim()) && isSafeInteger(+v)], Float: [(v) => +v, (v) => Number.isFinite(+v)], - // eslint-disable-next-line no-shadow + ObjectID: [(v) => new ObjectID(v), ObjectID.isValid], Boolean: [(v) => v && !['false', 'off'].includes(v), null, true], Date: [ @@ -153,6 +184,10 @@ export const Types: Types = { return arr.every((i) => type[1](i)); }, ] as any, + AnyOf: (...types) => [ + (v) => types.find((type) => type[1](v))[0](v), + (v) => types.some((type) => type[1](v)), + ] as any, }; function _buildParam(name: string, source: 'get' | 'post' | 'all' | 'route', ...args: Array | boolean | Validator | Converter>) { diff --git a/packages/ui-default/backendlib/builder.ts b/packages/ui-default/backendlib/builder.ts index 6dd21ddfd..72a5386f7 100644 --- a/packages/ui-default/backendlib/builder.ts +++ b/packages/ui-default/backendlib/builder.ts @@ -118,7 +118,7 @@ export async function buildUI() { class UiConstantsHandler extends Handler { noCheckPermView = true; - @param('name', Types.String, true) + @param('name', Types.Filename, true) async all(domainId: string, name: string) { this.response.type = 'application/javascript'; name ||= 'entry.js'; diff --git a/test/main.ts b/test/main.ts index ecaa8766c..99095de82 100644 --- a/test/main.ts +++ b/test/main.ts @@ -15,17 +15,13 @@ const Root = { describe('App', () => { let agent: supertest.SuperAgentTest; before((done) => { - let timeout; - const resolve = () => setTimeout(() => { - clearTimeout(timeout); - agent = supertest.agent(require('hydrooj').httpServer); - done(); - }, 2000); process.send = ((send) => (data) => { - if (data === 'ready') resolve(); + if (data === 'ready') { + agent = supertest.agent(require('hydrooj').httpServer); + done(); + } return send?.(data) || false; })(process.send); - timeout = setTimeout(resolve, 20000); }, { timeout: 30000 }); const routes = ['/', '/api', '/p', '/contest', '/homework', '/user/1', '/training'];