diff --git a/packages/hydrooj/src/lib/avatar.ts b/packages/hydrooj/src/lib/avatar.ts index 55fd76ff1..a493a5404 100644 --- a/packages/hydrooj/src/lib/avatar.ts +++ b/packages/hydrooj/src/lib/avatar.ts @@ -1,5 +1,5 @@ -import { Types } from '../service/decorators'; import { md5 } from '../utils'; +import { Types } from './validator'; type AvatarProvider = (src: string, size: number) => string; diff --git a/packages/hydrooj/src/lib/validator.ts b/packages/hydrooj/src/lib/validator.ts new file mode 100644 index 000000000..1e7d79878 --- /dev/null +++ b/packages/hydrooj/src/lib/validator.ts @@ -0,0 +1,179 @@ +import assert from 'assert'; +import emojiRegex from 'emoji-regex'; +import { isSafeInteger } from 'lodash'; +import moment from 'moment-timezone'; +import { ObjectID } from 'mongodb'; +import saslprep from 'saslprep'; + +type InputType = string | number | Record | any[]; +export type Converter = (value: any) => T; +export type Validator = (value: Loose extends true ? any : InputType) => boolean; +export 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>; + Any: Type; + ArrayOf: >(type: T) => (T extends Type ? Type : never); + AnyOf: >(...type: T[]) => (T extends Type ? Type : never); +} + +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(), (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-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)], + + ObjectID: [(v) => new ObjectID(v), ObjectID.isValid], + Boolean: [(v) => v && !['false', 'off'].includes(v), null, true], + Date: [ + (v) => { + const d = v.split('-'); + assert(d.length === 3); + return `${d[0]}-${d[1].length === 1 ? '0' : ''}${d[1]}-${d[2].length === 1 ? '0' : ''}${d[2]}`; + }, + (v) => { + const d = v.toString().split('-'); + if (d.length !== 3) return false; + const st = `${d[0]}-${d[1].length === 1 ? '0' : ''}${d[1]}-${d[2].length === 1 ? '0' : ''}${d[2]}`; + return moment(st).isValid(); + }, + ], + Time: [ + (v) => { + const t = v.split(':'); + assert(t.length === 2); + return `${(t[0].length === 1 ? '0' : '') + t[0]}:${t[1].length === 1 ? '0' : ''}${t[1]}`; + }, + (v) => { + const t = v.toString().split(':'); + if (t.length !== 2) return false; + return moment(`2020-01-01 ${(t[0].length === 1 ? '0' : '') + t[0]}:${t[1].length === 1 ? '0' : ''}${t[1]}`).isValid(); + }, + ], + Range: (range) => [ + (v) => { + if (range instanceof Array) { + for (const item of range) { + if (typeof item === 'number' && item === +v) return +v; + if (item === v) return v; + } + } + return v; + }, + (v) => { + if (range instanceof Array) { + for (const item of range) { + if (typeof item === 'string' && item === v) return true; + if (typeof item === 'number' && item === +v) return true; + } + } else { + for (const key in range) { + if (key === v) return true; + } + } + return false; + }, + ], + /** @deprecated suggested to use Types.ArrayOf instead. */ + Array: [(v) => { + if (v instanceof Array) return v; + return v ? [v] : []; + }, null], + NumericArray: [(v) => { + if (v instanceof Array) return v.map(Number); + return v.split(',').map(Number); + }, (v) => { + if (v instanceof Array) return v.map(Number).every(Number.isSafeInteger); + return v.toString().split(',').map(Number).every(Number.isSafeInteger); + }], + CommaSeperatedArray: [ + (v) => v.toString().replace(/,/g, ',').split(',').map((e) => e.trim()).filter((i) => i), + (v) => !!v.toString(), + ], + Set: [(v) => { + if (v instanceof Array) return new Set(v); + return v ? new Set([v]) : new Set(); + }, null], + Emoji: [ + (v: string) => v.matchAll(emojiRegex()).next().value[0], + (v) => emojiRegex().test(v.toString()), + ], + Any: [(v) => v, null], + ArrayOf: (type) => [ + (v) => { + const arr = v instanceof Array ? v : [v]; + return arr.map((i) => type[0](i)); + }, + (v) => { + if (!type[1]) return true; + const arr = v instanceof Array ? v : [v]; + 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, +}; diff --git a/packages/hydrooj/src/plugin-api.ts b/packages/hydrooj/src/plugin-api.ts index 7ce689563..cefc15f8f 100644 --- a/packages/hydrooj/src/plugin-api.ts +++ b/packages/hydrooj/src/plugin-api.ts @@ -41,6 +41,7 @@ export { buildContent } from './lib/content'; export { default as avatar } from './lib/avatar'; export { default as rank } from './lib/rank'; export { default as paginate } from './lib/paginate'; +export * from './lib/validator'; export * from './service/decorators'; export { Handler, ConnectionHandler, captureAllRoutes, diff --git a/packages/hydrooj/src/service/decorators.ts b/packages/hydrooj/src/service/decorators.ts index 69f9791ea..4ad2c283a 100644 --- a/packages/hydrooj/src/service/decorators.ts +++ b/packages/hydrooj/src/service/decorators.ts @@ -1,17 +1,9 @@ -import assert from 'assert'; -import emojiRegex from 'emoji-regex'; -import { isSafeInteger } from 'lodash'; -import moment from 'moment-timezone'; -import { ObjectID } from 'mongodb'; -import saslprep from 'saslprep'; import { Time } from '@hydrooj/utils'; import { ValidationError } from '../error'; +import { Converter, Type, Validator } from '../lib/validator'; import type { Handler } from './server'; type MethodDecorator = (target: any, name: string, obj: any) => any; -type InputType = string | number | Record | any[]; -type Converter = (value: any) => T; -type Validator = (value: Loose extends true ? any : InputType) => boolean; export interface ParamOption { name: string, source: 'all' | 'get' | 'post' | 'route', @@ -20,176 +12,6 @@ export interface ParamOption { validate?: Validator, } -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>; - Any: Type; - ArrayOf: >(type: T) => (T extends Type ? Type : never); - AnyOf: >(...type: T[]) => (T extends Type ? Type : never); -} - -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(), (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-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)], - - ObjectID: [(v) => new ObjectID(v), ObjectID.isValid], - Boolean: [(v) => v && !['false', 'off'].includes(v), null, true], - Date: [ - (v) => { - const d = v.split('-'); - assert(d.length === 3); - return `${d[0]}-${d[1].length === 1 ? '0' : ''}${d[1]}-${d[2].length === 1 ? '0' : ''}${d[2]}`; - }, - (v) => { - const d = v.toString().split('-'); - if (d.length !== 3) return false; - const st = `${d[0]}-${d[1].length === 1 ? '0' : ''}${d[1]}-${d[2].length === 1 ? '0' : ''}${d[2]}`; - return moment(st).isValid(); - }, - ], - Time: [ - (v) => { - const t = v.split(':'); - assert(t.length === 2); - return `${(t[0].length === 1 ? '0' : '') + t[0]}:${t[1].length === 1 ? '0' : ''}${t[1]}`; - }, - (v) => { - const t = v.toString().split(':'); - if (t.length !== 2) return false; - return moment(`2020-01-01 ${(t[0].length === 1 ? '0' : '') + t[0]}:${t[1].length === 1 ? '0' : ''}${t[1]}`).isValid(); - }, - ], - Range: (range) => [ - (v) => { - if (range instanceof Array) { - for (const item of range) { - if (typeof item === 'number' && item === +v) return +v; - if (item === v) return v; - } - } - return v; - }, - (v) => { - if (range instanceof Array) { - for (const item of range) { - if (typeof item === 'string' && item === v) return true; - if (typeof item === 'number' && item === +v) return true; - } - } else { - for (const key in range) { - if (key === v) return true; - } - } - return false; - }, - ], - /** @deprecated suggested to use Types.ArrayOf instead. */ - Array: [(v) => { - if (v instanceof Array) return v; - return v ? [v] : []; - }, null], - NumericArray: [(v) => { - if (v instanceof Array) return v.map(Number); - return v.split(',').map(Number); - }, (v) => { - if (v instanceof Array) return v.map(Number).every(Number.isSafeInteger); - return v.toString().split(',').map(Number).every(Number.isSafeInteger); - }], - CommaSeperatedArray: [ - (v) => v.toString().replace(/,/g, ',').split(',').map((e) => e.trim()).filter((i) => i), - (v) => !!v.toString(), - ], - Set: [(v) => { - if (v instanceof Array) return new Set(v); - return v ? new Set([v]) : new Set(); - }, null], - Emoji: [ - (v: string) => v.matchAll(emojiRegex()).next().value[0], - (v) => emojiRegex().test(v.toString()), - ], - Any: [(v) => v, null], - ArrayOf: (type) => [ - (v) => { - const arr = v instanceof Array ? v : [v]; - return arr.map((i) => type[0](i)); - }, - (v) => { - if (!type[1]) return true; - const arr = v instanceof Array ? v : [v]; - 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>) { let cursor = 0; const v: ParamOption = { name, source }; diff --git a/packages/hydrooj/src/service/server.ts b/packages/hydrooj/src/service/server.ts index fec3d59a2..21f3cd122 100644 --- a/packages/hydrooj/src/service/server.ts +++ b/packages/hydrooj/src/service/server.ts @@ -17,6 +17,7 @@ import { UserFacingError, } from '../error'; import { DomainDoc } from '../interface'; +import { Types } from '../lib/validator'; import { Logger } from '../logger'; import { PERM, PRIV } from '../model/builtin'; import * as opcount from '../model/opcount'; @@ -35,6 +36,7 @@ import { Router } from './router'; import { encodeRFC5987ValueChars } from './storage'; export * from './decorators'; +export * from '../lib/validator'; export interface HydroRequest { method: string; @@ -556,6 +558,7 @@ ${ctx.response.status} ${endTime - startTime}ms ${ctx.response.length}`); global.Hydro.service.server = { ...decorators, + Types, app, httpServer, wsServer,