Merge branch 'master' into problem-content

This commit is contained in:
undefined authored and GitHub committed 2025-07-21 10:23:57 +01:00
commit 5d667f4ebb
418 files changed
+11249 -7675

No files matched your search

+12 -6
View File
@@ -6,13 +6,19 @@ const { homedir } = require('os');
const { default: hook } = require('@undefined-moe/require-resolve-hook');
const { bypass } = hook(/^(hydrooj|@hydrooj|cordis|lodash|js-yaml)($|\/)/, (id) => {
if (id.startsWith('hydrooj/src')) {
console.log('module require via %s is deprecated.', id);
if (process.env.DEV) {
console.log(
new Error().stack.split('\n')
.filter((i) => !i.includes('node:internal') && i.startsWith(' '))
.join('\n'),
);
const error = new Error(`module require via ${id} is deprecated.`);
const filter = [
'node:diagnostics', 'node:internal',
'_resolveFilename', 'ignoreModuleNotFoundError',
'framework/register', '@hydrooj/register',
];
error.stack = error.stack.split('\n')
.filter((i) => !filter.some((f) => i.includes(f)))
.join('\n');
console.error(error);
} else {
console.warn('Module require via %s is deprecated.', id);
}
}
if (id.startsWith('hydrooj')) {
+2
View File
@@ -1,4 +1,6 @@
__flag: 🇬🇧
__id: en
__interface: yes
__langname: English
Can be Extended For: Extension
contest_create: Contest Create
+3 -1
View File
@@ -1,4 +1,6 @@
__flag: 🇰🇷
__id: ko
__interface: yes
__langname: 한국어
'"{data}" copied to clipboard!': “{data}”복사 완료
'{0} comments': '{0} 댓글'
@@ -686,7 +688,6 @@ The role to assign when user joining the domain.: 사용자가 그룹에 참여
The uploaded file is too long.: 업로드한 파일이 너무 깁니다.
The user is already a member of the domain.: 이미 그룹에 속한 사용자입니다.
The user is too lazy to leave something here...: 설명이 없습니다... (´・ω・`)
Their account will not be deleted and they will be with the default role.: 계정은 삭제되지 않을 것이고, default 역할을 가질 것입니다.
There are no contests...: 대회 없음
There is no homework so far ╰(*°▽°*)╯: 과제 없음 ╰(*°▽°*)╯
This contest is not live.: 대회가 실시간이 아닙니다.
@@ -808,6 +809,7 @@ You cannot visit this domain.: 이 그룹을 방문할 수 없습니다.
You don't have the required permission ({0}) in this domain.: 그룹 권한이 없습니다. ({0})。
You don't have the required privilege.: 권한이 없습니다.
You don't own any domain and don't have privilege to create domain.: 그룹을 만들 권한이 없습니다.
You have been kicked from domain {0} by {1}.: 당신은 {1}에 의해 {0}에서 추방되었습니다.
You have not submitted any solutions for this problem: 아직 답안을 제출하지 않았습니다.
You have permission to create problems for this domain.: 이 그룹에서 문제를 만들기 위한 권한이 있습니다.
You have uncompleted domain creation operation. You can click "Continue" to finish the creation.: 그룹 생성이 끝나지 않았습니다. "계속"을 눌러 생성을 완료하십시오.
+6 -2
View File
@@ -1,4 +1,6 @@
__flag: 🇨🇳
__id: zh
__interface: yes
__langname: 简体中文
'"{data}" copied to clipboard!': “{data}”已复制到剪贴板!
'{0} {1} delete your discussion reply {2} in "{3}"({4}).' : '{0} {1} 从讨论 {3}({4}) 删除了你的回复 {2}。'
@@ -785,12 +787,12 @@ The discussion is locked, you can not reply anymore.: 此讨论已经锁定,
The homework's deadline is due but in extension. You can still submit for this problem but your score will be penalized.: 作业已超过截止时间,但仍在延期时间内。您递交题目将无法获得全部分数。
The invitation code to enter to successfully join the domain. You can only use letters and numbers in the code and it should not be longer than 64 characters.: 加入此域的邀请码。您只能使用字母和数字,并且不能长于 64 个字符。
The invitation code you provided is invalid.: 您提供的邀请码无效。
The link is either invalid or expired.: 加域链接无效或已过期。
The link will be expired at {0}: 链接将于 {0} 过期
The role to assign when user joining the domain.: 用户加入此域时分配的角色。
The uploaded file is too long.: 上传的文件太长。
The user is already a member of the domain.: 该用户已是域的成员。
The user is too lazy to leave something here...: 该用户太懒,这里啥也没写 (´・ω・`)
Their account will not be deleted and they will be with the default role.: 他们的账号不会被删除,并且之后将以 default 角色访问。
theme: 主题
There are no contests...: 没有比赛…
There is no homework so far ╰(*°▽°*)╯: 目前还没有作业 ╰(*°▽°*)╯
@@ -924,7 +926,8 @@ Write Your Comment: 发表您的评论
Write Your Solution: 发表您的题解
WYSIWYG: 所见即所得
Year: 年
You are not allowed to join the domain. The link is either invalid or expired.: 您无法加入该域,链接无效或已过期。
You are banned by the domain moderator.: 您被该域的管理员封禁。
You are not allowed to join domain {0}. {1}: 您无法加入 {0}。{1}
You can create your own training plans and share them with others.: 您可以创建您自己的训练计划并且与他人分享。
You cannot submit for this problem because the contest is ended. You can click "Open in Problem Set" to view this problem in normal mode.: 该比赛已结束,您无法在比赛模式下递交该题目。您可以点击“在题库中打开”以普通模式查看和递交本题。
You cannot submit for this problem because the homework's deadline is due.: 作业已超过截止时间,您无法递交本题目。
@@ -932,6 +935,7 @@ You cannot visit this domain.: 您不能访问此域。
You don't have the required permission ({0}) in this domain.: 您在该域中无相应权限 ({0})。
You don't have the required privilege.: 您没有相应的特权。
You don't own any domain and don't have privilege to create domain.: 您不拥有任何域也没有创建域的特权。
You have been kicked from domain {0} by {1}.: 您已被 {1} 从域 {0} 中移除。
You have not submitted any solutions for this problem: 您在该题下没有递交过任何解答
You have permission to create problems for this domain.: 您有权限为此域创建题目。
You have uncompleted domain creation operation. You can click "Continue" to finish the creation.: 您有未完成的创建域操作。您可以点击“继续”来完成创建。
+5 -1
View File
@@ -1,4 +1,5 @@
__id: zh_TW
__interface: yes
__langname: 正體中文
'{0} comments': '{0} 條評論'
'{0} discussions': '{0} 條討論'
@@ -462,7 +463,7 @@ Technical Information: 技術資訊
Temporary session: 臨時會話
Text: 文字
The homework's deadline is due but in extension. You can still submit for this problem but your score will be penalized.: 功課已經截止,但處於延期時間内。您此時遞交的題目無法獲得全部分數。
Their account will not be deleted and they will be with the default role.: 他們的賬號不會被刪除,並且之後將以 default 角色訪問。
The link is either invalid or expired.: 加域鏈接無效或已過期。
There are no contests...: 沒有比賽…
There is no homework so far ╰(*°▽°*)╯: 目前沒有功課 ╰(*°▽°*)╯
This contest is not live.: 比賽沒有開始。
@@ -545,11 +546,14 @@ Will be used as the domain icon.: 將被用作圖示。
Without slashes or backslashes: 不含斜槓和反斜槓
Write Your Comment: 發表您的評論
Write Your Solution: 發表您的題解
You are banned by the domain moderator.: 您被該域的管理員封禁。
You are not allowed to join domain {0}. {1}: 您無法加入 {0}。{1}
You can create your own training plans and share them with others.: 您可以創建您自己的訓練計劃並且與他人分享。
You cannot submit for this problem because the homework's deadline is due.: 功課已到達截止時間,您無法遞交了。
You don't have the required permission ({0}) in this domain.: 您在該域中無相應許可權 ({0})。
You don't have the required privilege.: 您沒有相應的特權。
You don't own any domain and don't have privilege to create domain.: 您不擁有任何域也沒有創建域的特權。
You have been kicked from domain {0} by {1}.: 您已被 {1} 從域 {0} 中移除。
You have not submitted any solutions for this problem: 您在該題下沒有遞交過任何解答
You have permission to create problems for this domain.: 您有許可權為此域創建題目。
You haven't attended this contest yet.: 您還沒有參加本次比賽。
+33 -32
View File
@@ -1,73 +1,74 @@
{
"name": "hydrooj",
"version": "4.17.4",
"version": "5.0.0-beta.5",
"bin": "bin/hydrooj.js",
"main": "src/plugin-api",
"module": "src/plugin-api",
"repository": "https://github.com/hydro-dev/Hydro.git",
"author": "undefined <i@undefined.moe>",
"license": "AGPL-3.0-or-later",
"license": "AGPL-3.0-or-later OR Proprietary",
"engines": {
"node": ">=18"
},
"preferUnplugged": true,
"dependencies": {
"@aws-sdk/client-s3": "3.677.0",
"@aws-sdk/lib-storage": "3.677.0",
"@aws-sdk/s3-presigned-post": "3.677.0",
"@aws-sdk/s3-request-presigner": "3.677.0",
"@graphql-tools/schema": "^10.0.7",
"@aws-sdk/client-s3": "3.726.1",
"@aws-sdk/lib-storage": "3.726.1",
"@aws-sdk/s3-presigned-post": "3.726.1",
"@aws-sdk/s3-request-presigner": "3.726.1",
"@babel/code-frame": "^7.27.1",
"@cordisjs/plugin-timer": "1.1.1",
"@hydrooj/common": "workspace:^",
"@hydrooj/framework": "workspace:^",
"@hydrooj/utils": "workspace:^",
"@simplewebauthn/server": "9.0.3",
"@simplewebauthn/server": "13.1.1",
"@undefined-moe/require-resolve-hook": "^1.1.0",
"@yarnpkg/libzip": "^3.1.0",
"adm-zip": "0.5.14",
"@zip.js/zip.js": "^2.7.62",
"adm-zip": "0.5.16",
"cac": "^6.7.14",
"cordis": "3.18.1",
"csv-stringify": "^6.5.1",
"cordis": "4.0.0-beta.5",
"csv-stringify": "^6.5.2",
"emoji-regex": "^10.4.0",
"emojis-list": "2.1.0",
"fs-extra": "^11.2.0",
"graphql": "^16.9.0",
"graphql-scalars": "^1.23.0",
"fs-extra": "^11.3.0",
"js-yaml": "^4.1.0",
"koa-proxies": "^0.12.4",
"koa-static-cache": "^5.1.4",
"lodash": "^4.17.21",
"lru-cache": "^10.4.3",
"mime-types": "^2.1.35",
"moment-timezone": "^0.5.46",
"mongodb": "^5.9.2",
"lru-cache": "^11.1.0",
"mime-types": "^3.0.1",
"moment-timezone": "^0.6.0",
"mongodb": "^6.17.0",
"mongodb-uri": "^0.9.7",
"nanoid": "^5.0.7",
"nodemailer": "^6.9.15",
"nanoid": "^5.1.5",
"nodemailer": "^7.0.4",
"notp": "^2.0.3",
"p-queue": "^8.0.1",
"p-queue": "^8.1.0",
"sanitize-filename": "^1.6.3",
"saslprep": "^1.0.3",
"schemastery": "^3.14.6",
"semver": "^7.6.3",
"schemastery": "^3.16.1",
"semver": "^7.7.2",
"shorty.js": "^1.0.1",
"superagent": "^10.1.1",
"superagent": "^10.2.1",
"tar": "6.2.1",
"thirty-two": "^1.0.2",
"ua-parser-js": "^1.0.39"
"ua-parser-js": "^2.0.4"
},
"devDependencies": {
"@types/adm-zip": "^0.4.34",
"@types/adm-zip": "^0.5.7",
"@types/babel__code-frame": "^7.0.6",
"@types/fs-extra": "^11.0.4",
"@types/js-yaml": "^4.0.9",
"@types/koa-static-cache": "^5.1.4",
"@types/lodash": "^4.17.12",
"@types/mime-types": "^2.1.4",
"@types/lodash": "^4.17.19",
"@types/mime-types": "^3.0.1",
"@types/mongodb-uri": "^0.9.4",
"@types/nodemailer": "^6.4.16",
"@types/nodemailer": "^6.4.17",
"@types/notp": "^2.0.5",
"@types/saslprep": "^1.0.3",
"@types/semver": "^7.5.8",
"@types/semver": "^7.7.0",
"@types/superagent": "^8.1.9",
"chokidar": "^4.0.1",
"chokidar": "^4.0.3",
"moment": "^2.30.1"
}
}
+2
View File
@@ -33,6 +33,7 @@ langs:
- ~ Key marked with a ? means the option is optional
- (name):
disabled?: whether to disable this language
hidden?: won't show in the language selection
compile?: Compile Command
code_file?: Where to write code file when compiling, defaults to `foo.[name]`
execute: Command to execute the program
@@ -42,6 +43,7 @@ langs:
target?: Compiler output file, defaults to `foo`
display: Display name
compile_time_limit?: Compile time limit, in milliseconds
compile_memory_limit?: Compile memory limit, in MB
address_space_limit?: Enable address space memory limit as CCF did
process_limit?: Limit the number of thread
bash:
+1 -1
View File
@@ -5,6 +5,7 @@ import readline from 'readline/promises';
import cac, { CAC } from 'cac';
import fs from 'fs-extra';
import { Logger, size } from '@hydrooj/utils';
import { hydroPath } from '../options';
const argv = cac().parse();
const logger = new Logger('db');
@@ -15,7 +16,6 @@ const exec = (...args: Parameters<typeof child.spawnSync>) => {
if (res.status) throw new Error(`Error: Exited with code ${res.status}`);
return res;
};
const hydroPath = path.resolve(os.homedir(), '.hydro');
const dir = `${os.tmpdir()}/${Math.random().toString(36).substring(2)}`;
function getUrl() {
const dbConfig = fs.readFileSync(path.resolve(hydroPath, 'config.json'), 'utf-8');
+6 -6
View File
@@ -1,30 +1,30 @@
import child from 'child_process';
import os from 'os';
import path from 'path';
import AdmZip from 'adm-zip';
import { BlobReader, ZipReader } from '@zip.js/zip.js';
import { CAC } from 'cac';
import fs from 'fs-extra';
import superagent from 'superagent';
import tar from 'tar';
import { extractZip, Logger } from '@hydrooj/utils';
import { version } from 'hydrooj/package.json';
import { hydroPath } from '../options';
const logger = new Logger('install');
let yarnVersion = 0;
try {
// eslint-disable-next-line no-unsafe-optional-chaining
yarnVersion = +child.execSync('yarn --version', { cwd: os.tmpdir() }).toString().split('v').pop()!.split('.')[0];
} catch (e) {
// yarn 2 does not support global dir
}
const hydroPath = path.resolve(os.homedir(), '.hydro');
const addonDir = path.join(hydroPath, 'addons');
const userAgent = `Hydro/${version} Node.js/${process.version.split('v').pop()}`;
function downloadAndExtractTgz(url: string, dest: string) {
return new Promise((resolve, reject) => {
superagent.get(url)
.set('User-Agent', `Hydro/${version} Node.js/${process.version.split('v').pop()}`)
.set('User-Agent', userAgent)
.pipe(tar.x({
C: dest,
strip: 1,
@@ -34,8 +34,8 @@ function downloadAndExtractTgz(url: string, dest: string) {
});
}
async function downloadAndExtractZip(url: string, dest: string) {
const res = await superagent.get(url).responseType('arraybuffer');
await extractZip(new AdmZip(res.body), dest, true, true);
const res = await superagent.get(url).set('User-Agent', userAgent).responseType('arraybuffer');
await extractZip(new ZipReader(new BlobReader(new Blob([res.body]))), dest, { strip: true, overwrite: true });
}
const types = {
'.tgz': downloadAndExtractTgz,
+9 -3
View File
@@ -15,15 +15,21 @@ export function register(cli: CAC) {
return;
}
logger.info('Patching %s', mod);
const res = await superagent.get(patch).responseType('arraybuffer');
logger.info('Downloaded patch');
let content = '';
if (patch.startsWith('http')) {
const res = await superagent.get(patch).responseType('arraybuffer');
logger.info('Downloaded patch');
content = res.body;
} else {
content = await fs.readFile(patch, 'utf-8');
}
for (let i = 0; i <= 100; i++) {
const fp = path.join(path.dirname(mod), `${path.basename(mod)}.${i}.patch`);
if (fs.existsSync(fp)) continue;
patch = fp;
break;
}
await fs.writeFile(patch, res.body);
await fs.writeFile(patch, content);
child.execSync(`patch ${mod} -o ${mod}.tmp < ${patch}`);
await fs.move(`${mod}.tmp`, mod, { overwrite: true });
logger.info('Patched %s', mod);
+29 -30
View File
@@ -1,15 +1,15 @@
import LoggerService from '@cordisjs/plugin-logger';
import { TimerService } from '@cordisjs/plugin-timer';
import * as cordis from 'cordis';
import Schema from 'schemastery';
import type { ServerEvents, WebService } from '@hydrooj/framework';
import type { DomainDoc, GeoIP, ModuleInterfaces } from './interface';
import { inject } from './lib/ui';
import { Loader } from './loader';
import type { EventMap } from './service/bus';
import type { CheckService } from './service/check';
import type CheckService from './service/check';
import type { } from './service/migration';
import type { ConnectionHandler, Handler } from './service/server';
export interface Events<C extends Context = Context> extends cordis.Events<C>, EventMap, ServerEvents<Handler, ConnectionHandler> { }
export { EventMap as Events };
function addScript<K>(name: string, description: string, validate: Schema<K>, run: (args: K, report: any) => boolean | Promise<boolean>) {
if (global.Hydro.script[name]) throw new Error(`duplicate script ${name} registered.`);
@@ -18,20 +18,17 @@ function addScript<K>(name: string, description: string, validate: Schema<K>, ru
}
function provideModule<T extends keyof ModuleInterfaces>(type: T, id: string, module: ModuleInterfaces[T]) {
if (global.Hydro.module[type][id]) throw new Error(`duplicate script ${type}/${id} registered.`);
if (global.Hydro.module[type][id]) throw new Error(`duplicate module ${type}/${id} registered.`);
global.Hydro.module[type as any][id] = module;
return () => delete global.Hydro.module[type][id];
}
export type EffectScope = cordis.EffectScope<Context>;
export type ForkScope = cordis.ForkScope<Context>;
export type MainScope = cordis.MainScope<Context>;
export type Fiber = cordis.Fiber<Context>;
export type { Disposable, ScopeStatus, Plugin } from 'cordis';
export { Disposable, Plugin, FiberState } from 'cordis';
export interface Context extends cordis.Context, Pick<WebService, 'Route' | 'Connection' | 'withHandlerClass'> {
// @ts-ignore
[Context.events]: Events<this>;
export interface Context extends cordis.Context {
[Context.events]: EventMap & cordis.Events<Context>;
loader: Loader;
check: CheckService;
setImmediate: typeof setImmediate;
@@ -42,10 +39,7 @@ export interface Context extends cordis.Context, Pick<WebService, 'Route' | 'Con
geoip?: GeoIP;
}
export abstract class Service<T = any, C extends Context = Context> extends cordis.Service<T, C> {
[cordis.Service.setup]() {
this.ctx = new Context() as C;
}
export abstract class Service<T = never> extends cordis.Service<T, Context> {
}
const T = <F extends (...args: any[]) => any>(origFunc: F, disposeFunc?) =>
@@ -61,9 +55,11 @@ export class ApiMixin extends Service {
setImmediate = T(setImmediate, clearImmediate);
provideModule = T(provideModule);
injectUI = T(inject);
broadcast = (event: keyof EventMap, ...payload) => this.ctx.emit('bus/broadcast', event, payload);
broadcast = (event: keyof EventMap, ...payload) =>
this.ctx.emit('bus/broadcast', event, payload, process.env.TRACE_BROADCAST ? new Error().stack : null);
constructor(ctx) {
super(ctx, '$api', true);
super(ctx, '$api');
ctx.mixin('$api', ['addScript', 'setImmediate', 'provideModule', 'injectUI', 'broadcast']);
}
}
@@ -71,18 +67,21 @@ export class ApiMixin extends Service {
export class Context extends cordis.Context {
domain?: DomainDoc;
constructor(config: {} = {}) {
super(config);
constructor() {
super();
this.plugin(ApiMixin);
this.plugin(TimerService);
this.plugin(LoggerService, {
console: {
showDiff: false,
showTime: 'dd hh:mm:ss',
label: {
align: 'right',
width: 9,
margin: 1,
},
levels: { default: process.env.DEV ? 3 : 2 },
},
});
}
}
const old = cordis.Registry.prototype.inject;
// cordis.Registry.prototype.using = old;
cordis.Registry.prototype.inject = function wrapper(...args) {
if (typeof args[0] === 'string') {
console.warn('old functionality of ctx.inject is deprecated. please use ctx.injectUI instead.');
return T(inject).call(this, ...args as any) as any;
}
return old.call(this, ...args);
};
+23 -21
View File
@@ -1,14 +1,14 @@
/* eslint-disable import/no-dynamic-require */
/* eslint-disable no-await-in-loop */
import os from 'os';
import path from 'path';
import cac from 'cac';
import fs from 'fs-extra';
import { ObjectId } from 'mongodb';
import { Context } from '../context';
import db from '../service/db';
import { load as loadOptions } from '../options';
import { MongoService } from '../service/db';
import { SettingService } from '../settings';
import {
addon, builtinModel, lib, model, script, service, setting,
addon, builtinModel, model, service,
} from './common';
const argv = cac().parse();
@@ -104,34 +104,36 @@ async function cli() {
export async function load(ctx: Context) {
fs.ensureDirSync(tmpdir);
require('../lib/i18n');
require('../utils');
require('../error');
require('../service/bus').apply(ctx);
const pending = global.addons;
const fail = [];
await db.start();
await require('../settings').loadConfig();
await ctx.plugin(MongoService, loadOptions() || {});
await ctx.plugin(SettingService);
await require('../model/system').runConfig();
await require('../service/storage').loadStorageService();
await ctx.root.start();
await ctx.lifecycle.flush();
require('../lib/index');
ctx = await new Promise((resolve) => {
ctx.inject(['loader', 'setting', 'db'], (c) => {
resolve(c);
});
});
await Promise.all([
lib(pending, fail, ctx),
service(pending, fail, ctx),
ctx.loader.reloadPlugin(require.resolve('../service/storage'), 'file'),
ctx.loader.reloadPlugin(require.resolve('../service/worker'), 'worker'),
ctx.loader.reloadPlugin(require.resolve('../service/server'), 'server'),
]);
ctx.plugin(require('../service/worker'));
require('../lib/index');
await service(pending, fail, ctx);
await builtinModel(ctx);
await model(pending, fail, ctx);
await setting(pending, fail, require('../model/setting'));
ctx.plugin(require('../service/server'));
ctx = await new Promise((resolve) => {
ctx.inject(['server', 'worker'], (c) => {
resolve(c);
});
});
await addon(pending, fail, ctx);
const scriptDir = path.resolve(__dirname, '..', 'script');
for (const h of await fs.readdir(scriptDir)) {
ctx.loader.reloadPlugin(ctx, path.resolve(scriptDir, h), {}, `hydrooj/script/${h.split('.')[0]}`);
}
await script(pending, fail, ctx);
await ctx.lifecycle.flush();
await Promise.all((await fs.readdir(scriptDir))
.map((h) => ctx.loader.reloadPlugin(path.resolve(scriptDir, h), '')));
await cli();
}
+13 -93
View File
@@ -1,30 +1,17 @@
/* eslint-disable import/no-dynamic-require */
/* eslint-disable no-await-in-loop */
/* eslint-disable no-eval */
import '../lib/index';
import os from 'os';
import path from 'path';
import fs from 'fs-extra';
import yaml from 'js-yaml';
import { Context } from '../context';
import { Logger } from '../logger';
import { PRIV } from '../model/builtin';
import { unwrapExports } from '../utils';
import { isClass, unwrapExports } from '../utils';
const logger = new Logger('common');
async function getFiles(folder: string, base = ''): Promise<string[]> {
const files = [];
const f = await fs.readdir(folder);
for (const i of f) {
if ((await fs.stat(path.join(folder, i))).isDirectory()) {
files.push(...await getFiles(path.join(folder, i), path.join(base, i)));
} else files.push(path.join(base, i));
}
return files.map((item) => item.replace(/\\/gmi, '/'));
}
function locateFile(basePath: string, filenames: string[]) {
for (const i of filenames) {
const p = path.resolve(basePath, i);
@@ -33,35 +20,31 @@ function locateFile(basePath: string, filenames: string[]) {
return null;
}
type LoadTask = 'handler' | 'model' | 'addon' | 'lib' | 'script' | 'service';
const getLoader = (type: LoadTask, filename: string, dontLoad = false) => async function loader(pending: string[], fail: string[], ctx: Context) {
for (const i of pending) {
type LoadTask = 'model' | 'addon' | 'service';
const getLoader = (type: LoadTask, filename: string) => async function loader(pending: Record<string, string>, fail: string[], ctx: Context) {
for (const [name, i] of Object.entries(pending)) {
const p = locateFile(i, [`${filename}.ts`, `${filename}.js`]);
if (p && !fail.includes(i)) {
const name = type.replace(/^(.)/, (t) => t.toUpperCase());
const loadType = type.replace(/^(.)/, (t) => t.toUpperCase());
try {
if (dontLoad) throw new Error(`deprecated ${name} in %s will not be load: ${i}`);
const m = unwrapExports(require(p));
if (m.apply) ctx.loader.reloadPlugin(ctx, p, {});
else logger.info(`${name} init: %s`, i);
if (m.apply) ctx.loader.reloadPlugin(p, name);
else logger.info(`${loadType} init: %s`, i);
} catch (e) {
fail.push(i);
app.injectUI(
'Notification', `${name} load fail: {0}`,
'Notification', `${loadType} load fail: {0}`,
{ args: [i], type: 'warn' }, PRIV.PRIV_VIEW_SYSTEM_NOTIFICATION,
);
logger.info(`${name} load fail: %s`, i);
logger.info(`${loadType} load fail: %s`, i);
logger.error(e);
}
}
}
};
export const handler = getLoader('handler', 'handler', true);
export const addon = getLoader('addon', 'index');
export const model = getLoader('model', 'model');
export const lib = getLoader('lib', 'lib', true);
export const script = getLoader('script', 'script', true);
export const service = getLoader('service', 'service');
export async function builtinModel(ctx: Context) {
@@ -69,12 +52,13 @@ export async function builtinModel(ctx: Context) {
const models = await fs.readdir(modelDir);
for (const t of models.filter((i) => i.endsWith('.ts'))) {
const q = path.resolve(modelDir, t);
if ('apply' in require(q)) ctx.loader.reloadPlugin(ctx, q, {}, `hydrooj/model/${t.split('.')[0]}`);
const module = require(q);
if ('apply' in module || isClass(unwrapExports(module))) ctx.loader.reloadPlugin(q, '');
}
}
export async function locale(pending: string[], fail: string[]) {
for (const i of pending) {
export async function locale(pending: Record<string, string>, fail: string[]) {
for (const i of Object.values(pending)) {
const p = locateFile(i, ['locale', 'locales']);
if (p && (await fs.stat(p)).isDirectory() && !fail.includes(i)) {
try {
@@ -95,67 +79,3 @@ export async function locale(pending: string[], fail: string[]) {
}
}
}
export async function setting(pending: string[], fail: string[], modelSetting: typeof import('../model/setting')) {
const map = {
system: modelSetting.SystemSetting,
account: modelSetting.AccountSetting,
preference: modelSetting.PreferenceSetting,
domain: modelSetting.DomainSetting,
};
for (const i of pending) {
let p = path.resolve(i, 'setting.yaml');
const t = i.split(path.sep);
// TODO: change this name setting to package name
const name = t[t.length - 1];
if (!fs.existsSync(p)) p = path.resolve(i, 'settings.yaml');
if (fs.existsSync(p) && !fail.includes(i)) {
try {
const cfg: any = yaml.load(await fs.readFile(p, 'utf-8'));
for (const key in cfg) {
let val = cfg[key].default || cfg[key].value;
if (typeof val === 'string') {
val = val
.replace(/\$TEMP/g, os.tmpdir())
.replace(/\$HOME/g, os.homedir());
}
const category = cfg[key].category || 'system';
map[category](
modelSetting.Setting(
cfg[key].family || name, category === 'system' ? `${name}.${key}` : key, val, cfg[key].type || 'text',
cfg[key].name || key, cfg[key].desc || '',
),
);
}
logger.info('Config load: %s', i);
} catch (e) {
app.injectUI('Notification', 'Config load fail: {0}', { args: [i], type: 'warn' }, PRIV.PRIV_VIEW_SYSTEM_NOTIFICATION);
logger.error('Config Load Fail: %s', i);
logger.error(e);
}
}
}
}
export async function template(pending: string[], fail: string[]) {
for (const i of pending) {
const p = locateFile(i, ['template', 'templates']);
if (p && (await fs.stat(p)).isDirectory() && !fail.includes(i)) {
try {
const files = await getFiles(p);
for (const file of files) {
const l = path.resolve(p, file);
if (file.endsWith('.tsx')) global.Hydro.ui.template[file] = require(l);
global.Hydro.ui.template[file] = await fs.readFile(l, 'utf-8');
if (process.env.DEV) global.Hydro.ui.template[`${file}.source`] = l;
}
logger.info('Template init: %s', i);
} catch (e) {
fail.push(i);
app.injectUI('Notification', 'Template load fail: {0}', { args: [i], type: 'warn' }, PRIV.PRIV_VIEW_SYSTEM_NOTIFICATION);
logger.error('Template Load Fail: %s', i);
logger.error(e);
}
}
}
}
-1
View File
@@ -13,7 +13,6 @@ export async function load() {
console.error('{"url":"mongodb://localhost:27017/hydro"}');
process.exit(1);
}
// eslint-disable-next-line no-constant-condition
while (true) {
console.error('Please type your MongoDB connection string below:');
console.error('Example: mongodb://localhost:27017/hydro');
+50 -54
View File
@@ -1,5 +1,3 @@
/* eslint-disable import/no-dynamic-require */
/* eslint-disable no-await-in-loop */
import os from 'os';
import path from 'path';
import cac from 'cac';
@@ -7,10 +5,10 @@ import fs from 'fs-extra';
import { Context } from '../context';
import { Logger } from '../logger';
import { load } from '../options';
import db from '../service/db';
import { MongoService } from '../service/db';
import { SettingService } from '../settings';
import {
addon, builtinModel, handler, lib, locale, model,
script, service, setting, template,
addon, builtinModel, locale, model, service,
} from './common';
const argv = cac().parse();
@@ -19,65 +17,65 @@ const tmpdir = path.resolve(os.tmpdir(), 'hydro');
export async function apply(ctx: Context) {
fs.ensureDirSync(tmpdir);
require('../lib/i18n');
require('../utils');
require('../error');
require('../service/bus').apply(ctx);
const config = load();
if (!process.env.CI && !config) {
const url = await MongoService.getUrl();
if (!url) {
logger.info('Starting setup');
await require('./setup').load(ctx);
}
const pending = global.addons;
const fail = [];
await Promise.all([
locale(pending, fail),
template(pending, fail),
]);
await db.start();
await require('../settings').loadConfig();
await locale(pending, fail);
await ctx.plugin(MongoService, load() || {});
await ctx.plugin(SettingService);
const modelSystem = require('../model/system');
await modelSystem.runConfig();
const storage = require('../service/storage');
await storage.loadStorageService();
// Make sure everything is ready and then start main entry
if (argv.options.watch) ctx.plugin(require('../service/watcher').default, {});
await ctx.root.start();
await ctx.lifecycle.flush();
await require('../service/worker').apply(ctx);
await require('../service/server').apply(ctx);
await require('../service/api').apply(ctx);
await ctx.lifecycle.flush();
ctx = await new Promise((resolve) => {
ctx.inject(['loader', 'setting', 'db'], (c) => {
resolve(c);
});
});
await ctx.plugin(require('../service/hmr').default, { watch: argv.options.watch });
await Promise.all([
ctx.loader.reloadPlugin(require.resolve('../service/storage'), 'file'),
ctx.loader.reloadPlugin(require.resolve('../service/worker'), 'worker'),
ctx.loader.reloadPlugin(require.resolve('../service/server'), 'server'),
]);
ctx = await new Promise((resolve) => {
ctx.inject(['server'], (c) => {
resolve(c);
});
});
require('../lib/index');
await lib(pending, fail, ctx);
await ctx.lifecycle.flush();
await setting(pending, fail, require('../model/setting'));
ctx.plugin(require('../service/monitor'));
ctx.plugin(require('../service/check'));
ctx.plugin(require('../service/check').default);
await service(pending, fail, ctx);
await builtinModel(ctx);
await model(pending, fail, ctx);
await ctx.lifecycle.flush();
const handlerDir = path.resolve(__dirname, '..', 'handler');
const handlers = await fs.readdir(handlerDir);
for (const h of handlers.filter((i) => i.endsWith('.ts'))) {
ctx.loader.reloadPlugin(ctx, path.resolve(handlerDir, h), {}, `hydrooj/handler/${h.split('.')[0]}`);
}
ctx.plugin(require('../service/migration').default);
await handler(pending, fail, ctx);
ctx = await new Promise((resolve) => {
ctx.inject(['worker', 'setting'], (c) => {
resolve(c);
});
});
const loadDir = async (dir: string) => Promise.all((await fs.readdir(dir)).filter((i) => i.endsWith('.ts'))
.map((h) => ctx.loader.reloadPlugin(path.resolve(dir, h), '')));
await loadDir(path.resolve(__dirname, '..', 'handler'));
await ctx.plugin(require('../service/migration').default);
await addon(pending, fail, ctx);
await ctx.lifecycle.flush();
const scriptDir = path.resolve(__dirname, '..', 'script');
for (const h of await fs.readdir(scriptDir)) {
ctx.loader.reloadPlugin(ctx, path.resolve(scriptDir, h), {}, `hydrooj/script/${h.split('.')[0]}`);
}
await ctx.lifecycle.flush();
await script(pending, fail, ctx);
await ctx.lifecycle.flush();
await loadDir(path.resolve(__dirname, '..', 'script'));
await ctx.parallel('app/started');
if (process.env.NODE_APP_INSTANCE === '0') {
const staticDir = path.join(os.homedir(), '.hydro/static');
await fs.emptyDir(staticDir);
// Use ordered copy to allow resource override
for (const f of Object.values(global.addons)) {
const dir = path.join(f, 'public');
// eslint-disable-next-line no-await-in-loop
if (await fs.pathExists(dir)) await fs.copy(dir, staticDir);
}
await new Promise((resolve, reject) => {
ctx.inject(['migration'], async (c) => {
c.migration.registerChannel('hydrooj', require('../upgrade').coreScripts);
@@ -91,14 +89,12 @@ export async function apply(ctx: Context) {
});
});
}
for (const f of global.addons) {
const dir = path.join(f, 'public');
// eslint-disable-next-line no-await-in-loop
if (await fs.pathExists(dir)) await fs.copy(dir, path.join(os.homedir(), '.hydro/static'));
}
await ctx.parallel('app/listen');
logger.success('Server started');
process.send?.('ready');
await ctx.parallel('app/ready');
ctx.inject(['server'], async ({ server }) => {
await server.listen();
await ctx.parallel('app/listen');
logger.success('Server started');
process.send?.('ready');
await ctx.parallel('app/ready');
});
return { fail };
}
+1 -1
View File
@@ -42,7 +42,7 @@ 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 DomainAlreadyExistsError = Err('DomainAlreadyExistsError', ForbiddenError, 'The domain {0} already exists.');
export const DomainJoinForbiddenError = Err('DomainJoinForbiddenError', ForbiddenError, 'You are not allowed to join the domain. The link is either invalid or expired.');
export const DomainJoinForbiddenError = Err('DomainJoinForbiddenError', ForbiddenError, 'You are not allowed to join domain {0}. {1}');
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 CurrentPasswordError = Err('CurrentPasswordError', ForbiddenError, "Current password doesn't match.");
+113
View File
@@ -0,0 +1,113 @@
import { Context } from '../context';
import { ForbiddenError } from '../error';
import * as SystemModel from '../model/system';
import TokenModel from '../model/token';
import UserModel from '../model/user';
import { ConnectionHandler } from '../service/server';
import { Logger } from '../utils';
const logger = new Logger('connection');
class WebsocketEventsConnectionManagerHandler extends ConnectionHandler {
noCheckPermView = true;
category = '#gateway';
private id = Math.random().toString(16).substring(2);
private privileged = false;
private channels: Set<string> = new Set();
private disposables: Map<string, () => void> = new Map();
async prepare() {
const secret = SystemModel.get('websocket.secret');
const token = this.request.headers['x-hydro-websocket-gateway'];
if (token) {
if (!secret || token !== secret) throw new ForbiddenError('Invalid token');
logger.info('Gateway websocket connection from %s, id=%s', this.request.ip, this.id);
this.privileged = true;
}
await this.ctx.parallel('subscription/init', this, this.privileged);
}
async accept(channel: string) {
if (this.channels.has(channel)) return;
this.channels.add(channel);
const d: (() => void)[] = [];
const disposeAll = () => {
for (const dispose of d) dispose();
};
this.disposables.set(channel, disposeAll);
await this.ctx.parallel('subscription/enable', channel, this, this.privileged, (disposable) => {
d.push(disposable);
});
}
async message(payload: any) {
try {
if (['resume', 'subscribe'].includes(payload.operation)) await this.subscribe(payload);
if (payload.operation === 'unsubscribe') await this.unsubscribe(payload);
} catch (e) {
logger.error('Failed to process message %o: %o', payload, e);
}
}
async unsubscribe(payload: any) {
for (const channel of payload.channels || []) {
try {
if (!this.channels.has(channel)) continue;
this.channels.delete(channel);
this.disposables.get(channel)?.();
this.disposables.delete(channel);
} catch (e) {
logger.error('Error unsubscribing from channel %s: %s', channel, e);
}
}
}
async subscribe(payload: any) {
const accept = [];
const reject = [];
const session = payload.credential
? await TokenModel.get(payload.credential, TokenModel.TYPE_SESSION)
: null;
const op = payload.operation || '';
if (op === 'resume' && !this.privileged) {
this.send({ operation: 'resume_failed' });
return;
}
const user = op === 'resume' ? null : await UserModel.getById('system', session?.uid || 0);
for (const channel of payload.channels || []) {
try {
const result = op === 'resume'
? { ok: true, channel }
// eslint-disable-next-line no-await-in-loop
: await this.ctx.bail('subscription/subscribe', channel, user, this.privileged ? payload.metadata || {} : {});
if (result?.ok) {
// eslint-disable-next-line no-await-in-loop
await this.accept(result.channel);
accept.push(result.channel);
} else reject.push(channel);
} catch (e) {
logger.error('Error subscribing to channel %s for user %s: %s', channel, user?._id, e);
reject.push(channel);
}
}
if (op !== 'resume') {
this.send({
operation: 'verify',
accept,
reject,
request_id: payload.request_id,
subscription_id: payload.subscription_id,
});
}
}
async cleanup() {
if (this.privileged) {
logger.info('Gateway websocket disconnected from %s, id=%s', this.request.ip, this.id);
}
}
}
export function apply(ctx: Context) {
ctx.Connection('websocket_gateway', '/websocket', WebsocketEventsConnectionManagerHandler);
}
+54 -67
View File
@@ -1,11 +1,11 @@
import AdmZip from 'adm-zip';
import { Readable } from 'stream';
import { BlobWriter, TextReader, ZipWriter } from '@zip.js/zip.js';
import { stringify as toCSV } from 'csv-stringify/sync';
import { statSync } from 'fs-extra';
import { pick } from 'lodash';
import { escapeRegExp, pick } from 'lodash';
import moment from 'moment-timezone';
import { ObjectId } from 'mongodb';
import {
Counter, sortFiles, streamToBuffer, Time, yaml,
Counter, diffArray, getAlphabeticId, randomstring, sortFiles, Time, yaml,
} from '@hydrooj/utils/lib/utils';
import { Context, Service } from '../context';
import {
@@ -34,14 +34,17 @@ export class ContestListHandler extends Handler {
@param('rule', Types.Range(contest.RULES), true)
@param('group', Types.Name, true)
@param('page', Types.PositiveInt, true)
async get(domainId: string, rule = '', group = '', page = 1) {
@param('q', Types.String, true)
async get(domainId: string, rule = '', group = '', page = 1, q = '') {
if (rule && contest.RULES[rule].hidden) throw new BadRequestError();
const groups = (await user.listGroup(domainId, this.user.hasPerm(PERM.PERM_VIEW_HIDDEN_CONTEST) ? undefined : this.user._id))
.map((i) => i.name);
if (group && !groups.includes(group)) throw new NotAssignedError(group);
const rules = Object.keys(contest.RULES).filter((i) => !contest.RULES[i].hidden);
const q = {
...this.user.hasPerm(PERM.PERM_VIEW_HIDDEN_CONTEST) && !group
const escaped = escapeRegExp(q.toLowerCase());
const $regex = new RegExp(q.length >= 2 ? escaped : `\\A${escaped}`, 'gmi');
const filter = {
...(this.user.hasPerm(PERM.PERM_VIEW_HIDDEN_CONTEST) && !group)
? {}
: {
$or: [
@@ -53,11 +56,13 @@ export class ContestListHandler extends Handler {
},
...rule ? { rule } : { rule: { $in: rules } },
...group ? { assign: { $in: [group] } } : {},
...q ? { title: { $regex } } : {},
};
await this.ctx.parallel('contest/list', q, this);
const cursor = contest.getMulti(domainId, q).sort({ endAt: -1, beginAt: -1, _id: -1 });
await this.ctx.parallel('contest/list', filter, this);
const cursor = contest.getMulti(domainId, filter).sort({ endAt: -1, beginAt: -1, _id: -1 });
let qs = rule ? `rule=${rule}` : '';
if (group) qs += qs ? `&group=${group}` : `group=${group}`;
if (q) qs += `${qs ? '&' : ''}q=${encodeURIComponent(q)}`;
const [tdocs, tpcount] = await this.paginate(cursor, page, 'contest');
const tids = [];
for (const tdoc of tdocs) tids.push(tdoc.docId);
@@ -65,7 +70,7 @@ export class ContestListHandler extends Handler {
const groupsFilter = groups.filter((i) => !Number.isSafeInteger(+i));
this.response.template = 'contest_main.html';
this.response.body = {
page, tpcount, qs, rule, tdocs, tsdict, groups: groupsFilter, group,
page, tpcount, qs, rule, tdocs, tsdict, groups: groupsFilter, group, q,
};
}
}
@@ -118,7 +123,7 @@ export class ContestDetailBaseHandler extends Handler {
{
name: 'contest_problemlist',
args: { tid, prefix: 'contest_problemlist' },
checker: () => true,
checker: () => this.tsdoc?.attend || contest.isDone(this.tdoc),
},
{
name: 'contest_scoreboard',
@@ -127,7 +132,7 @@ export class ContestDetailBaseHandler extends Handler {
},
{
name: 'problem_detail',
displayName: `${String.fromCharCode(65 + this.tdoc.pids.indexOf(pdoc.docId))}. ${pdoc.title}`,
displayName: `${getAlphabeticId(this.tdoc.pids.indexOf(pdoc.docId))}. ${pdoc.title}`,
args: { query: { tid }, pid: pdoc.docId, prefix: 'contest_detail_problem' },
checker: () => 'pdoc' in this,
},
@@ -202,17 +207,26 @@ export class ContestProblemListHandler extends ContestDetailBaseHandler {
const psdocs: any[] = Object.values(this.response.body.psdict);
const canViewRecord = contest.canShowSelfRecord.call(this, this.tdoc);
this.response.body.canViewRecord = canViewRecord;
const rids = psdocs.map((i) => i.rid);
if (contest.isDone(this.tdoc) && canViewRecord) {
const correction = await problem.getListStatus(domainId, this.user._id, this.tdoc.pids);
for (const pid in correction) {
if (this.tsdoc.detail?.[pid]?.rid === correction[pid].rid) delete correction[pid];
}
rids.push(...Object.values(correction).map((i) => i.rid));
this.response.body.correction = correction;
}
[this.response.body.rdict, this.response.body.rdocs] = canViewRecord
? await Promise.all([
record.getList(domainId, psdocs.map((i: any) => i.rid)),
record.getList(domainId, rids),
record.getMulti(domainId, { contest: tid, uid: this.user._id })
.sort({ _id: -1 }).toArray(),
])
: [Object.fromEntries(psdocs.map((i) => [i.rid, { _id: i.rid }])), []];
if (!this.user.own(this.tdoc) && !this.user.hasPerm(PERM.PERM_EDIT_CONTEST)) {
this.response.body.rdocs = this.response.body.rdocs.map((rdoc) => contest.applyProjection(this.tdoc, rdoc, this.user));
for (const psdoc of psdocs) {
this.response.body.rdict[psdoc.rid] = contest.applyProjection(this.tdoc, this.response.body.rdict[psdoc.rid], this.user);
for (const key in this.response.body.rdict) {
this.response.body.rdict[key] = contest.applyProjection(this.tdoc, this.response.body.rdict[key], this.user);
}
for (const key in this.response.body.psdict) {
this.response.body.psdict[key] = contest.applyProjection(this.tdoc, this.response.body.psdict[key], this.user);
@@ -229,11 +243,11 @@ export class ContestProblemListHandler extends ContestDetailBaseHandler {
await this.limitRate('add_discussion', 3600, 60);
await contest.addClarification(domainId, tid, this.user._id, content, this.request.ip, subject);
if (!this.user.own(this.tdoc)) {
await Promise.all([this.tdoc.owner, ...this.tdoc.maintainer].map((uid) => message.send(1, uid, JSON.stringify({
await message.send(1, this.tdoc.maintainer.concat(this.tdoc.owner), JSON.stringify({
message: 'Contest {0} has a new clarification about {1}, please go to contest management to reply.',
params: [this.tdoc.title, subject > 0 ? `#${this.tdoc.pids.indexOf(subject) + 1}` : 'the contest'],
url: this.url('contest_manage', { tid }),
}), message.FLAG_I18N | message.FLAG_UNREAD)));
}), message.FLAG_I18N | message.FLAG_UNREAD);
}
this.back();
}
@@ -291,11 +305,12 @@ export class ContestEditHandler extends Handler {
@param('contestDuration', Types.Float, true)
@param('maintainer', Types.NumericArray, true)
@param('allowViewCode', Types.Boolean)
@param('langs', Types.CommaSeperatedArray, true)
async postUpdate(
domainId: string, tid: ObjectId, beginAtDate: string, beginAtTime: string, duration: number,
title: string, content: string, rule: string, _pids: string, rated = false,
_code = '', autoHide = false, assign: string[] = [], lock: number = null,
contestDuration: number = null, maintainer: number[] = [], allowViewCode = false,
contestDuration: number = null, maintainer: number[] = [], allowViewCode = false, langs: string[] = [],
) {
if (autoHide) this.checkPerm(PERM.PERM_EDIT_PROBLEM);
const pids = _pids.replace(/,/g, ',').split(',').map((i) => +i).filter((i) => i);
@@ -312,7 +327,7 @@ export class ContestEditHandler extends Handler {
title, content, rule, beginAt, endAt, pids, rated, duration: contestDuration,
});
if (this.tdoc.beginAt !== beginAt || this.tdoc.endAt !== endAt
|| Array.isDiff(this.tdoc.pids, pids) || this.tdoc.rule !== rule
|| diffArray(this.tdoc.pids, pids) || this.tdoc.rule !== rule
|| lockAt !== this.tdoc.lockAt) {
await contest.recalcStatus(domainId, this.tdoc.docId);
}
@@ -325,7 +340,6 @@ export class ContestEditHandler extends Handler {
await ScheduleModel.deleteMany(task);
const operation = [];
if (Date.now() <= endAt.getTime() && autoHide) {
// eslint-disable-next-line no-await-in-loop
await Promise.all(pids.map((pid) => problem.edit(domainId, pid, { hidden: true })));
operation.push('unhide');
}
@@ -337,7 +351,7 @@ export class ContestEditHandler extends Handler {
});
}
await contest.edit(domainId, tid, {
assign, _code, autoHide, lockAt, maintainer, allowViewCode,
assign, _code, autoHide, lockAt, maintainer, allowViewCode, langs,
});
this.response.body = { tid };
this.response.redirect = this.url('contest_detail', { tid });
@@ -396,7 +410,7 @@ export class ContestCodeHandler extends Handler {
}
}
}
const zip = new AdmZip();
const zip = new ZipWriter(new BlobWriter('application/zip'), { bufferedWrite: true });
const rdocs = await record.getMulti(domainId, {
_id: { $in: Array.from(Object.keys(rnames)).map((id) => new ObjectId(id)) },
}).toArray();
@@ -404,15 +418,15 @@ export class ContestCodeHandler extends Handler {
if (rdoc.files?.code) {
const [id, filename] = rdoc.files?.code?.split('#') || [];
if (!id) return;
zip.addFile(
await zip.add(
`${rnames[rdoc._id.toHexString()]}.${filename || 'txt'}`,
await streamToBuffer(await storage.get(`submission/${id}`)),
Readable.toWeb(await storage.get(`submission/${id}`)),
);
} else if (rdoc.code) {
zip.addFile(`${rnames[rdoc._id.toHexString()]}.${rdoc.lang}`, Buffer.from(rdoc.code));
await zip.add(`${rnames[rdoc._id.toHexString()]}.${rdoc.lang}`, new TextReader(rdoc.code));
}
}));
this.binary(zip.toBuffer(), `${tdoc.title}.zip`);
this.binary(await zip.close(), `${tdoc.title}.zip`);
}
}
@@ -458,11 +472,11 @@ export class ContestManagementHandler extends ContestManagementBaseHandler {
const flag = contest.isOngoing(this.tdoc) ? message.FLAG_ALERT : message.FLAG_UNREAD;
await Promise.all([
contest.addClarification(domainId, tid, 0, content, this.request.ip, subject),
...uids.map((uid) => message.send(1, uid, JSON.stringify({
message.send(1, uids, JSON.stringify({
message: 'Broadcast message from contest {0}:\n{1}',
params: [this.tdoc.title, content],
url: this.url('contest_problemlist', { tid }),
}), flag | message.FLAG_I18N)),
}), flag | message.FLAG_I18N),
]);
}
this.back();
@@ -476,12 +490,11 @@ export class ContestManagementHandler extends ContestManagementBaseHandler {
}
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;
const size = Math.sum((this.tdoc.files || []).map((i) => i.size)) + file.size;
if (size >= system.get('limit.contest_files_size')) {
throw new FileLimitExceededError('size');
}
filename ||= file.originalFilename || String.random(16);
filename ||= file.originalFilename || randomstring(16);
await storage.put(`contest/${domainId}/${tid}/${filename}`, file.filepath, this.user._id);
const meta = await storage.getMeta(`contest/${domainId}/${tid}/${filename}`);
const payload = { _id: filename, name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) };
@@ -573,7 +586,7 @@ export class ContestBalloonHandler extends ContestManagementBaseHandler {
const bdocs = await contest.getMultiBalloon(domainId, tid, {
...todo ? { sent: { $exists: false } } : {},
...(!this.tdoc.lockAt || this.user.hasPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD))
? {} : { _id: { $lt: this.tdoc.lockAt } },
? {} : { _id: { $lt: Time.getObjectID(this.tdoc.lockAt) } },
}).sort({ _id: -1 }).toArray();
const uids = bdocs.map((i) => i.uid).concat(bdocs.filter((i) => i.sent).map((i) => i.sent));
this.response.body = {
@@ -677,8 +690,7 @@ export class ContestScoreboardHandler extends ContestDetailBaseHandler {
class ScoreboardService extends Service {
views: Record<string, ScoreboardView<any>> = {};
constructor(ctx: Context) {
super(ctx, 'scoreboard', true);
ctx.set('scoreboard', this);
super(ctx, 'scoreboard');
}
addView<T extends { [key: string]: keyof BuiltinInput | AnyFunction | Type<any> }>(
@@ -740,35 +752,8 @@ export async function apply(ctx: Context) {
}
await Promise.all(tasks);
});
ctx.inject(['api'], ({ api }) => {
api.value('Contest', [
['_id', 'ObjectID!'],
['domainId', 'String!'],
['docId', 'ObjectID!'],
['owner', 'Int!'],
['beginAt', 'Date!'],
['title', 'String!'],
['content', 'String!'],
['beginAt', 'Date!'],
['endAt', 'Date!'],
['attend', 'Int!'],
['pids', '[Int]!'],
['rated', 'Boolean!'],
]);
api.resolver(
'Query', 'contest(id: ObjectID!)', 'Contest',
async (arg, c) => {
c.checkPerm(PERM.PERM_VIEW);
arg.id = new ObjectId(arg.id);
c.tdoc = await contest.get(c.args.domainId, new ObjectId(arg.id));
if (!c.tdoc) throw new ContestNotFoundError(c.args.domainId, arg.id);
return c.tdoc;
},
'Get a contest by ID',
);
});
ctx.plugin(ScoreboardService);
ctx.inject(['scoreboard'], ({ Route, scoreboard }) => {
await ctx.inject(['scoreboard'], ({ Route, scoreboard }) => {
Route('contest_scoreboard', '/contest/:tid/scoreboard', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST_SCOREBOARD);
Route('contest_scoreboard_view', '/contest/:tid/scoreboard/:view', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST_SCOREBOARD);
scoreboard.addView('default', 'Default', { tdoc: 'tdoc', groups: 'groups', realtime: Types.Boolean }, {
@@ -807,11 +792,11 @@ export async function apply(ctx: Context) {
const teamIds: Record<number, number> = {};
for (let i = 1; i <= teams.length; i++) teamIds[teams[i - 1].uid] = i;
const time = (t: ObjectId) => Math.floor((t.getTimestamp().getTime() - tdoc.beginAt.getTime()) / Time.second);
const pid = (i: number) => String.fromCharCode(65 + i);
const pid = (i: number) => getAlphabeticId(i);
const escape = (i: string) => i.replace(/[",]/g, '');
const unknownSchool = this.translate('Unknown School');
const statusMap = {
[STATUS.STATUS_ACCEPTED]: 'AC',
[STATUS.STATUS_ACCEPTED]: 'OK',
[STATUS.STATUS_WRONG_ANSWER]: 'WA',
[STATUS.STATUS_COMPILE_ERROR]: 'CE',
[STATUS.STATUS_TIME_LIMIT_EXCEEDED]: 'TL',
@@ -836,8 +821,10 @@ export async function apply(ctx: Context) {
].concat(
tdoc.pids.map((i, idx) => `@p ${pid(idx)},${escape(pdict[i]?.title || 'Unknown Problem')},20,0`),
teams.map((i, idx) => {
const teamName = `${i.rank ? '*' : ''}${escape(udict[i.uid].school || unknownSchool)}-${escape(udict[i.uid].uname)}`;
return `@t ${idx + 1},0,1,${teamName}`;
const showName = this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME) && udict[i.uid].displayName
? udict[i.uid].displayName : udict[i.uid].uname;
const teamName = `${i.rank ? '*' : ''}${escape(udict[i.uid].school || unknownSchool)}-${escape(showName)}`;
return `@t ${idx + 1},0,1,"${teamName}"`;
}),
submissions,
);
@@ -861,7 +848,7 @@ export async function apply(ctx: Context) {
const [, rows] = await contest.getScoreboard.call(this, tdoc.domainId, tdoc._id, {
isExport: true, lockAt: this.tdoc.lockAt, showDisplayName: this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME),
});
this.binary(toCSV(rows.map((r) => r.map((c) => c.value.toString()))), `${this.tdoc.title}.csv`);
this.binary(toCSV(rows.map((r) => r.map((c) => c.value.toString())), { bom: true }), `${this.tdoc.title}.csv`);
},
supportedRules: ['*'],
});
+6 -15
View File
@@ -72,7 +72,7 @@ class DiscussionMainHandler extends Handler {
const parentType = { $in: Object.keys(typeMapper).map((i) => typeMapper[i]) };
all &&= this.user.hasPerm(PERM.PERM_MOD_BADGE);
const [ddocs, dpcount] = await this.paginate(
discussion.getMulti(domainId, { parentType, ...all ? {} : { hidden: false } }),
discussion.getMulti(domainId, { parentType, ...all ? {} : { hidden: false } }).hint('discussionSort'),
page,
'discussion',
);
@@ -238,7 +238,7 @@ class DiscussionDetailHandler extends DiscussionHandler {
message: 'User {0} mentioned you in {1:link}',
params: [this.user.uname, `/d/${domainId}${this.request.path}`],
});
await Promise.all(uids.map((i) => message.send(1, i, msg, message.FLAG_RICHTEXT | message.FLAG_UNREAD)));
await message.send(1, uids, msg, message.FLAG_RICHTEXT | message.FLAG_UNREAD);
const drid = await discussion.addReply(domainId, did, this.user._id, content, this.request.ip);
this.back({ drid });
}
@@ -255,7 +255,7 @@ class DiscussionDetailHandler extends DiscussionHandler {
message: 'User {0} mentioned you in {1:link}',
params: [this.user.uname, `/d/${domainId}${this.request.path}`],
});
await Promise.all(uids.map((i) => message.send(1, i, msg, message.FLAG_RICHTEXT | message.FLAG_UNREAD)));
await message.send(1, uids, msg, message.FLAG_RICHTEXT | message.FLAG_UNREAD);
await discussion.addTailReply(domainId, drid, this.user._id, content, this.request.ip);
this.back();
}
@@ -275,8 +275,7 @@ class DiscussionDetailHandler extends DiscussionHandler {
@param('drid', Types.ObjectId)
async postDeleteReply(domainId: string, drid: ObjectId) {
const deleteBy = this.user.own(this.drdoc) ? 'self' : this.user.own(this.ddoc) ? 'DiscussionOwner' : 'Admin';
if (!(this.user.own(this.ddoc)
&& this.user.hasPerm(PERM.PERM_DELETE_DISCUSSION_REPLY_SELF_DISCUSSION))) {
if (!this.user.own(this.ddoc) || !this.user.hasPerm(PERM.PERM_DELETE_DISCUSSION_REPLY_SELF_DISCUSSION)) {
if (!this.user.own(this.drdoc)) {
this.checkPerm(PERM.PERM_DELETE_DISCUSSION_REPLY);
} else this.checkPerm(PERM.PERM_DELETE_DISCUSSION_REPLY_SELF);
@@ -316,8 +315,7 @@ class DiscussionDetailHandler extends DiscussionHandler {
@param('drrid', Types.ObjectId)
async postDeleteTailReply(domainId: string, drid: ObjectId, drrid: ObjectId) {
const deleteBy = this.user.own(this.drrdoc) ? 'self' : 'Admin';
if (!(this.user.own(this.drrdoc)
&& this.user.hasPerm(PERM.PERM_DELETE_DISCUSSION_REPLY_SELF))) {
if (!this.user.own(this.drrdoc) || !this.user.hasPerm(PERM.PERM_DELETE_DISCUSSION_REPLY_SELF)) {
this.checkPerm(PERM.PERM_DELETE_DISCUSSION_REPLY);
}
const msg = JSON.stringify({
@@ -370,15 +368,8 @@ class DiscussionRawHandler extends DiscussionHandler {
class DiscussionEditHandler extends DiscussionHandler {
async get() {
const path = [
['Hydro', 'homepage'],
['discussion_main', 'discussion_main'],
[this.vnode.title, 'discussion_node', { type: discussion.typeDisplay[this.ddoc.parentType], name: this.ddoc.parentId }, true],
[this.ddoc.title, 'discussion_detail', { did: this.ddoc.docId }, true],
['discussion_edit', null],
];
this.response.template = 'discussion_edit.html';
this.response.body = { ddoc: this.ddoc, path };
this.response.body = { ddoc: this.ddoc };
}
@param('did', Types.ObjectId)
+174 -75
View File
@@ -1,6 +1,7 @@
import { load } from 'js-yaml';
import { Dictionary } from 'lodash';
import moment from 'moment-timezone';
import Schema from 'schemastery';
import type { Context } from '../context';
import {
CannotDeleteSystemDomainError, DomainJoinAlreadyMemberError, DomainJoinForbiddenError, ForbiddenError,
@@ -11,12 +12,13 @@ import avatar from '../lib/avatar';
import { PERM, PERMS_BY_FAMILY, PRIV } from '../model/builtin';
import * as discussion from '../model/discussion';
import domain from '../model/domain';
import MessageModel from '../model/message';
import * as oplog from '../model/oplog';
import { DOMAIN_SETTINGS, DOMAIN_SETTINGS_BY_KEY } from '../model/setting';
import * as system from '../model/system';
import user from '../model/user';
import {
Handler, param, post, query, requireSudo, Types,
Handler, Mutation, param, post, Query, query, requireSudo, Types,
} from '../service/server';
import { log2 } from '../utils';
@@ -53,6 +55,9 @@ class DomainEditHandler extends ManageHandler {
async post(args) {
if (args.operation) return;
const $set = {};
const booleanKeys = args.booleanKeys || {};
delete args.booleanKeys;
for (const key in booleanKeys) if (!args[key]) $set[key] = false;
for (const key in args) {
if (DOMAIN_SETTINGS_BY_KEY[key]) $set[key] = args[key];
}
@@ -96,36 +101,79 @@ class DomainDashboardHandler extends ManageHandler {
class DomainUserHandler extends ManageHandler {
@requireSudo
async get({ domainId }) {
const rudocs = {};
@param('format', Types.Range(['default', 'raw']), true)
async get({ domainId }, format = 'default') {
const [dudocs, roles] = await Promise.all([
domain.getMultiUserInDomain(domainId, {
$and: [
{ role: { $nin: ['default', 'guest'] } },
{ role: { $ne: null } },
],
}).toArray(),
domain.collUser.aggregate([
{
$match: {
// TODO: add a page to display users who joined but with default role
role: {
$nin: ['default', 'guest'],
$ne: null,
},
domainId,
},
},
{
$lookup: {
from: 'user',
let: { uid: '$uid' },
pipeline: [
{
$match: {
$expr: { $eq: ['$_id', '$$uid'] },
priv: { $bitsAllSet: PRIV.PRIV_USER_PROFILE },
},
},
{
$project: {
_id: 1,
uname: 1,
avatar: 1,
},
},
],
as: 'user',
},
},
{ $unwind: '$user' },
{
$project: {
user: 1,
role: 1,
join: 1,
...(this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME) ? { displayName: 1 } : {}),
},
},
]).toArray(),
domain.getRoles(domainId),
]);
const uids = dudocs.map((dudoc) => dudoc.uid);
const udict = await user.getList(domainId, uids);
for (const role of roles) rudocs[role._id] = [];
for (const dudoc of dudocs) {
const udoc = udict[dudoc.uid];
if (!(udoc.priv & PRIV.PRIV_USER_PROFILE)) continue;
rudocs[udoc.role || 'default'].push(udoc);
}
this.response.template = 'domain_user.html';
const users = dudocs.map((dudoc) => {
const u = {
...dudoc,
...dudoc.user,
};
delete u.user;
return u;
});
const rudocs = {};
for (const role of roles) rudocs[role._id] = users.filter((udoc) => udoc.role === role._id);
this.response.template = format === 'raw' ? 'domain_user_raw.html' : 'domain_user.html';
this.response.body = {
roles, rudocs, udict, domain: this.domain,
roles, rudocs, domain: this.domain,
};
}
@param('uids', Types.NumericArray)
async post({ }, uids: number[]) {
if (uids.includes(this.domain.owner)) throw new ForbiddenError();
}
@requireSudo
@post('uid', Types.Int)
@post('role', Types.Role)
async postSetUser(domainId: string, uid: number, role: string) {
if (uid === this.domain.owner) throw new ForbiddenError();
@param('uids', Types.NumericArray)
@param('role', Types.Role)
async postSetUsers(domainId: string, uid: number[], role: string) {
await Promise.all([
domain.setUserRole(domainId, uid, role),
oplog.log(this, 'domain.setRole', { uid, role }),
@@ -134,14 +182,20 @@ class DomainUserHandler extends ManageHandler {
}
@requireSudo
@param('uid', Types.NumericArray)
@param('role', Types.Role)
async postSetUsers(domainId: string, uid: number[], role: string) {
if (uid.includes(this.domain.owner)) throw new ForbiddenError();
@param('uids', Types.NumericArray)
async postKick({ domainId }, uids: number[]) {
const original = await domain.getMultiUserInDomain(domainId, { uid: { $in: uids } }).toArray();
const needUpdate = uids.filter((uid) => original.find((i) => i.uid === uid)?.join);
if (!needUpdate.length) return;
await Promise.all([
domain.setUserRole(domainId, uid, role),
oplog.log(this, 'domain.setRole', { uid, role }),
domain.setJoin(domainId, needUpdate.length > 1 ? needUpdate : needUpdate[0], false),
oplog.log(this, 'domain.kick', { uids: needUpdate }),
]);
const msg = JSON.stringify({
message: 'You have been kicked from domain {0} by {1}.',
params: [this.domain.name, this.user.uname],
});
await Promise.all(needUpdate.map((i) => MessageModel.send(1, i, msg, MessageModel.FLAG_RICHTEXT | MessageModel.FLAG_UNREAD)));
this.back();
}
}
@@ -160,6 +214,7 @@ class DomainPermissionHandler extends ManageHandler {
async post({ domainId }) {
const roles = {};
for (const role in this.request.body) {
if (role === 'root') continue; // root role is not editable
const perms = this.request.body[role] instanceof Array
? this.request.body[role]
: [this.request.body[role]];
@@ -213,7 +268,7 @@ class DomainJoinApplicationsHandler extends ManageHandler {
async get() {
const r = await domain.getRoles(this.domain);
const roles = r.map((role) => role._id).sort();
this.response.body.rolesWithText = roles.filter((i) => !['default', 'guest'].includes(i)).map((role) => [role, role]);
this.response.body.rolesWithText = roles.filter((i) => i !== 'guest').map((role) => [role, role]);
this.response.body.joinSettings = domain.getJoinSettings(this.domain, roles);
this.response.body.expirations = { ...domain.JOIN_EXPIRATION_RANGE };
if (!this.response.body.joinSettings) {
@@ -227,9 +282,10 @@ class DomainJoinApplicationsHandler extends ManageHandler {
@requireSudo
@post('method', Types.Range([domain.JOIN_METHOD_NONE, domain.JOIN_METHOD_ALL, domain.JOIN_METHOD_CODE]))
@post('role', Types.Role, true)
@post('group', Types.Name, true)
@post('expire', Types.Int, true)
@post('invitationCode', Types.Content, true)
async post(domainId: string, method: number, role: string, expire: number, invitationCode = '') {
async post(domainId: string, method: number, role: string, group = '', expire: number, invitationCode = '') {
const r = await domain.getRoles(this.domain);
const roles = r.map((rl) => rl._id);
const current = domain.getJoinSettings(this.domain, roles);
@@ -238,7 +294,7 @@ class DomainJoinApplicationsHandler extends ManageHandler {
else {
if (!roles.includes(role)) throw new ValidationError('role');
if (!current && expire === domain.JOIN_EXPIRATION_KEEP_CURRENT) throw new ValidationError('expire');
joinSettings = { method, role };
joinSettings = { method, role, group };
if (expire === domain.JOIN_EXPIRATION_KEEP_CURRENT) joinSettings.expire = current.expire;
else if (expire === domain.JOIN_EXPIRATION_UNLIMITED) joinSettings.expire = null;
else if (!domain.JOIN_EXPIRATION_RANGE[expire]) throw new ValidationError('expire');
@@ -277,33 +333,68 @@ class DomainJoinHandler extends Handler {
joinSettings: any;
noCheckPermView = true;
async prepare() {
const r = await domain.getRoles(this.domain);
@param('target', Types.DomainId, true)
async prepare({ domainId }, target: string = domainId) {
const [ddoc, dudoc] = await Promise.all([
domain.get(target),
domain.collUser.findOne({ domainId: target, uid: this.user._id }),
]);
const assignedRole = this.user.hasPriv(PRIV.PRIV_MANAGE_ALL_DOMAIN)
? 'root'
: dudoc?.role || 'default';
if (dudoc?.join) throw new DomainJoinAlreadyMemberError(target, this.user._id);
const r = await domain.getRoles(ddoc);
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);
this.joinSettings = domain.getJoinSettings(ddoc, roles);
if (assignedRole !== 'default') delete this.joinSettings;
else if (!this.joinSettings) throw new DomainJoinForbiddenError(target, 'The link is either invalid or expired.');
if (assignedRole === 'guest') throw new DomainJoinForbiddenError(target, 'You are banned by the domain moderator.');
}
@param('code', Types.Content, true)
async get(domainId: string, code: string) {
@param('target', Types.DomainId, true)
@param('redirect', Types.Content, true)
async get({ domainId }, code: string, target: string = domainId, redirect: string = '') {
this.response.template = 'domain_join.html';
this.response.body.joinSettings = this.joinSettings;
this.response.body.code = code;
const ddoc = await domain.get(target);
const domainInfo = {
name: ddoc.name,
owner: await user.getById(domainId, ddoc.owner),
avatar: ddoc.avatar,
bulletin: ddoc.showBulletin ? ddoc.bulletin : '',
};
this.response.body = {
joinSettings: this.joinSettings,
code,
redirect,
target,
domainInfo,
};
}
@param('code', Types.Content, true)
async post(domainId: string, code: string) {
if (this.joinSettings.method === domain.JOIN_METHOD_CODE) {
@param('target', Types.DomainId, true)
@param('redirect', Types.Content, true)
async post({ domainId }, code: string, target: string = domainId, redirect: string = '') {
if (this.joinSettings?.method === domain.JOIN_METHOD_CODE) {
if (this.joinSettings.code !== code) {
throw new InvalidJoinInvitationCodeError(this.domain._id);
throw new InvalidJoinInvitationCodeError(target);
}
}
if (this.joinSettings?.group) {
const groups = await user.listGroup(target);
const entry = groups.find((i) => i.name === this.joinSettings.group);
if (!entry) throw new ValidationError('group');
await user.updateGroup(target, entry.name, entry.uids.concat(this.user._id));
}
await Promise.all([
domain.setUserRole(this.domain._id, this.user._id, this.joinSettings.role),
domain.setUserInDomain(target, this.user._id, {
join: true,
...(this.joinSettings ? { role: this.joinSettings.role } : {}),
}),
oplog.log(this, 'domain.join', {}),
]);
this.response.redirect = this.url('homepage', { query: { notification: 'Successfully joined domain.' } });
this.response.redirect = redirect || this.url('homepage', { domainId: target, query: { notification: 'Successfully joined domain.' } });
}
}
@@ -323,6 +414,42 @@ class DomainSearchHandler extends Handler {
}
}
export const DomainApi = {
domain: Query(
Schema.object({
id: Schema.string(),
}),
async (ctx, args) => {
const ddoc = args.id ? await domain.get(args.id) : ctx.domain;
if (!ddoc) return null;
const udoc = await user.getById(ddoc._id, ctx.user._id);
if (!udoc.hasPerm(PERM.PERM_VIEW) && !udoc.hasPriv(PRIV.PRIV_VIEW_ALL_DOMAIN)) return null;
return ddoc;
},
),
'domain.group': Mutation(
Schema.object({
name: Schema.string().required(),
uids: Schema.array(Schema.number()),
}),
async (ctx, args) => {
if (!ctx.user.hasPerm(PERM.PERM_EDIT_DOMAIN)) throw new PermissionError(PERM.PERM_EDIT_DOMAIN);
if (args.uids?.length) {
const res = await user.updateGroup(ctx.domain._id, args.name, args.uids);
return res.upsertedCount > 0;
}
const res = await user.delGroup(ctx.domain._id, args.name);
return res.deletedCount > 0;
},
),
} as const;
declare module '@hydrooj/framework' {
interface Apis {
domain: typeof DomainApi;
}
}
export async function apply(ctx: Context) {
ctx.Route('ranking', '/ranking', DomainRankHandler, PERM.PERM_VIEW_RANKING);
ctx.Route('domain_dashboard', '/domain/dashboard', DomainDashboardHandler);
@@ -334,35 +461,7 @@ export async function apply(ctx: Context) {
ctx.Route('domain_join_applications', '/domain/join_applications', DomainJoinApplicationsHandler);
ctx.Route('domain_join', '/domain/join', DomainJoinHandler, PRIV.PRIV_USER_PROFILE);
ctx.Route('domain_search', '/domain/search', DomainSearchHandler, PRIV.PRIV_USER_PROFILE);
ctx.inject(['api'], ({ api }) => {
api.value('GroupInfo', [
['name', 'String!'],
['uids', '[Int]!'],
]);
api.resolver('Query', 'domain(id: String)', 'Domain', async (args, c) => {
const ddoc = args.id ? await domain.get(args.id) : c.domain;
if (!ddoc) return null;
const udoc = await user.getById(ddoc._id, c.user._id);
if (!udoc.hasPerm(PERM.PERM_VIEW) && !udoc.hasPriv(PRIV.PRIV_VIEW_ALL_DOMAIN)) return null;
c.udoc = udoc;
return ddoc;
});
api.resolver('Domain', 'manage', 'DomainManage', async (args, c) => {
if (!c.udoc.hasPerm(PERM.PERM_EDIT_DOMAIN)) throw new PermissionError(PERM.PERM_EDIT_DOMAIN);
return c.parent;
});
api.resolver('DomainManage', 'group', 'DomainGroup', (args, c) => c.parent);
api.resolver(
'DomainGroup', 'list(uid: Int)', '[GroupInfo]',
(args, c) => user.listGroup(c.parent._id, args.uid),
);
api.resolver(
'DomainGroup', 'update(name: String!, uids: [Int]!)', 'Boolean',
async (args, c) => !!(await user.updateGroup(c.parent._id, args.name, args.uids)).upsertedCount,
);
api.resolver(
'DomainGroup', 'del(name: String!)', 'Boolean',
async (args, c) => !!(await user.delGroup(c.parent._id, args.name)).deletedCount,
);
await ctx.inject(['api'], ({ api }) => {
api.provide(DomainApi);
});
}
+96 -59
View File
@@ -1,16 +1,17 @@
import path from 'path';
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers';
import yaml from 'js-yaml';
import { pick } from 'lodash';
import { Binary, ObjectId } from 'mongodb';
import Parser from 'ua-parser-js';
import { UAParser } from 'ua-parser-js';
import { Context } from '../context';
import {
AuthOperationError, BlacklistedError, DomainAlreadyExistsError, InvalidTokenError,
AuthOperationError, BadRequestError, BlacklistedError, DomainAlreadyExistsError, InvalidTokenError,
NotFoundError, PermissionError, UserAlreadyExistError,
UserNotFoundError, ValidationError, VerifyPasswordError,
} from '../error';
import { DomainDoc, MessageDoc, Setting } from '../interface';
import { DomainDoc, Setting } from '../interface';
import avatar, { validate } from '../lib/avatar';
import * as mail from '../lib/mail';
import { verifyTFA } from '../lib/verifyTFA';
@@ -28,7 +29,7 @@ import token from '../model/token';
import * as training from '../model/training';
import user from '../model/user';
import {
ConnectionHandler, Handler, param, query, requireSudo, subscribe, Types,
Handler, param, query, requireSudo, Types,
} from '../service/server';
import { camelCase, md5 } from '../utils';
@@ -186,15 +187,13 @@ class HomeSecurityHandler extends Handler {
session.isCurrent = session._id === this.session._id;
session._id = md5(session._id);
const ua = session.updateUa || session.createUa;
if (ua) {
const parser = new Parser(ua);
session.updateUaInfo = parser.getResult();
}
if (ua) session.updateUaInfo = UAParser(ua);
session.updateGeoip = this.ctx.geoip?.lookup?.(
session.updateIp || session.createIp,
this.translate('geoip_locale'),
);
}
const relations = await this.ctx.oauth.list(this.user._id);
this.response.template = 'home_security.html';
this.response.body = {
sudoUid: this.session.sudoUid || null,
@@ -203,7 +202,8 @@ class HomeSecurityHandler extends Handler {
'credentialID', 'name', 'credentialType', 'credentialDeviceType',
'authenticatorAttachment', 'regat', 'fmt',
])),
geoipProvider: this.ctx?.geoip?.provider,
geoipProvider: this.ctx.geoip?.provider,
relations,
};
}
@@ -248,12 +248,19 @@ class HomeSecurityHandler extends Handler {
uname: this.user.uname,
url_prefix: prefix.endsWith('/') ? prefix.slice(0, -1) : prefix,
});
await mail.sendMail(email, 'Change Email', 'user_changemail_mail', m);
await mail.sendMail(email, 'Change Email', 'user_changemail_mail', m.toString());
this.response.template = 'user_changemail_mail_sent.html';
}
@param('platform', Types.String)
async postLinkAccount({ }, platform: string) {
if (!this.ctx.oauth.providers[platform]) throw new ValidationError('platform');
this.session.oauthBind = platform;
await this.ctx.oauth.providers[platform].get.call(this);
}
@param('tokenDigest', Types.String)
async postDeleteToken(domainId: string, tokenDigest: string) {
async postDeleteToken({ }, tokenDigest: string) {
const sessions = await token.getSessionListByUid(this.user._id);
for (const session of sessions) {
if (tokenDigest === md5(session._id)) {
@@ -273,7 +280,7 @@ class HomeSecurityHandler extends Handler {
@requireSudo
@param('code', Types.String)
@param('secret', Types.String)
async postEnableTfa(domainId: string, code: string, secret: string) {
async postEnableTfa({ }, code: string, secret: string) {
if (this.user._tfa) throw new AuthOperationError('2FA', 'enabled');
if (!verifyTFA(secret, code)) throw new InvalidTokenError('2FA');
await user.setById(this.user._id, { tfa: secret });
@@ -287,19 +294,21 @@ class HomeSecurityHandler extends Handler {
@requireSudo
@param('type', Types.Range(['cross-platform', 'platform']))
async postRegister(domainId: string, type: 'cross-platform' | 'platform') {
async postRegister({ }, type: 'cross-platform' | 'platform') {
const options = await generateRegistrationOptions({
rpName: system.get('server.name'),
rpID: this.getAuthnHost(),
userID: this.user._id.toString(),
userID: isoUint8Array.fromUTF8String(this.user._id.toString()),
userDisplayName: this.user.uname,
userName: `${this.user.uname}(${this.user.mail})`,
attestationType: 'direct',
excludeCredentials: this.user._authenticators.map((c) => ({
id: c.credentialID.buffer,
id: isoBase64URL.fromBuffer(c.credentialID.buffer),
type: 'public-key',
})),
authenticatorSelection: {
residentKey: 'required',
userVerification: 'preferred',
authenticatorAttachment: type,
},
});
@@ -309,7 +318,7 @@ class HomeSecurityHandler extends Handler {
@requireSudo
@param('name', Types.String)
async postEnableAuthn(domainId: string, name: string) {
async postEnableAuthn({ }, name: string) {
if (!this.session.webauthnVerify) throw new InvalidTokenError(token.TYPE_TEXTS[token.TYPE_WEBAUTHN]);
const verification = await verifyRegistrationResponse({
response: this.args.result,
@@ -319,12 +328,13 @@ class HomeSecurityHandler extends Handler {
}).catch(() => { throw new ValidationError('verify'); });
if (!verification.verified) throw new ValidationError('verify');
const info = verification.registrationInfo;
const id = Buffer.from(info.credentialID);
const id = isoBase64URL.toBuffer(info.credential.id);
if (this.user._authenticators.find((c) => c.credentialID.buffer.toString() === id.toString())) throw new ValidationError('authenticator');
this.user._authenticators.push({
...info,
counter: info.credential.counter,
credentialID: new Binary(id),
credentialPublicKey: new Binary(Buffer.from(info.credentialPublicKey)),
credentialPublicKey: new Binary(Buffer.from(info.credential.publicKey)),
attestationObject: new Binary(Buffer.from(info.attestationObject)),
name,
regat: Date.now(),
@@ -336,7 +346,7 @@ class HomeSecurityHandler extends Handler {
@requireSudo
@param('id', Types.String)
async postDisableAuthn(domainId: string, id: string) {
async postDisableAuthn({ }, id: string) {
const authenticators = this.user._authenticators?.filter((c) => Buffer.from(c.credentialID.buffer).toString('base64') !== id);
if (this.user._authenticators?.length === authenticators?.length) throw new ValidationError('authenticator');
await user.setById(this.user._id, { authenticators });
@@ -395,7 +405,7 @@ function set(s: Setting, key: string, value: any) {
class HomeSettingsHandler extends Handler {
@param('category', Types.Range(['preference', 'account', 'domain']))
async get(domainId: string, category: string) {
async get({ }, category: string) {
this.response.template = 'home_settings.html';
this.response.body = {
category,
@@ -432,7 +442,7 @@ class HomeSettingsHandler extends Handler {
class HomeAvatarHandler extends Handler {
@param('avatar', Types.String, true)
async post(domainId: string, input: string) {
async post({ }, input: string) {
if (input) {
if (!validate(input)) throw new ValidationError('avatar');
await user.setById(this.user._id, { avatar: input });
@@ -468,36 +478,33 @@ class UserChangemailWithCodeHandler extends Handler {
class HomeDomainHandler extends Handler {
@query('all', Types.Boolean)
async get(domainId: string, all: boolean) {
let res: DomainDoc[] = [];
let dudict: Record<string, any> = {};
async get({ }, all: boolean) {
let ddocs: DomainDoc[] = [];
const role: Record<string, string> = {};
if (!all) {
dudict = await domain.getDictUserByDomainId(this.user._id);
const dudict = await domain.getDictUserByDomainId(this.user._id);
const dids = Object.keys(dudict);
res = await domain.getMulti({ _id: { $in: dids } }).toArray();
ddocs = await domain.getMulti({ _id: { $in: dids } }).toArray();
} else {
this.checkPriv(PRIV.PRIV_VIEW_ALL_DOMAIN);
res = await domain.getMulti().toArray();
await Promise.all(res.map(async (ddoc) => {
dudict[ddoc._id] = await user.getById(domainId, this.user._id);
}));
ddocs = await domain.getMulti().toArray();
}
const canManage = {};
const ddocs = [];
for (const ddoc of res) {
// eslint-disable-next-line no-await-in-loop
const udoc = (await user.getById(ddoc._id, this.user._id))!;
const dudoc = dudict[ddoc._id];
if (['default', 'guest'].includes(dudoc.role)) {
delete dudict[ddoc._id];
continue;
if (this.user.hasPriv(PRIV.PRIV_MANAGE_ALL_DOMAIN)) {
for (const ddoc of ddocs) {
canManage[ddoc._id] = true;
role[ddoc._id] = 'root';
}
} else {
for (const ddoc of ddocs) {
// eslint-disable-next-line no-await-in-loop
const udoc = await user.getById(ddoc._id, this.user._id);
canManage[ddoc._id] = udoc.hasPerm(PERM.PERM_EDIT_DOMAIN);
role[ddoc._id] = udoc.role;
}
ddocs.push(ddoc);
canManage[ddoc._id] = udoc.hasPerm(PERM.PERM_EDIT_DOMAIN)
|| udoc.hasPriv(PRIV.PRIV_MANAGE_ALL_DOMAIN);
}
this.response.template = 'home_domain.html';
this.response.body = { ddocs, dudict, canManage };
this.response.body = { ddocs, canManage, role };
}
@param('id', Types.String)
@@ -507,6 +514,15 @@ class HomeDomainHandler extends Handler {
else user.setById(this.user._id, { pinnedDomains: this.user.pinnedDomains.filter((i) => i !== id) });
this.back({ star });
}
@param('id', Types.String)
async postLeave({ }, id: string) {
if (id === 'system') throw new BadRequestError();
const ddoc = await domain.get(id);
if (!ddoc) throw new NotFoundError(id);
await domain.setJoin(id, this.user._id, false);
this.back();
}
}
class HomeDomainCreateHandler extends Handler {
@@ -565,7 +581,7 @@ class HomeMessagesHandler extends Handler {
@param('uid', Types.Int)
@param('content', Types.Content)
async postSend(domainId: string, uid: number, content: string) {
async postSend({ }, uid: number, content: string) {
this.checkPriv(PRIV.PRIV_SEND_MESSAGE);
const udoc = await user.getById('system', uid);
if (!udoc) throw new UserNotFoundError(uid);
@@ -575,7 +591,7 @@ class HomeMessagesHandler extends Handler {
}
@param('messageId', Types.ObjectId)
async postDeleteMessage(domainId: string, messageId: ObjectId) {
async postDeleteMessage({ }, messageId: ObjectId) {
const msg = await message.get(messageId);
if ([msg.from, msg.to].includes(this.user._id)) await message.del(messageId);
else throw new PermissionError();
@@ -583,7 +599,7 @@ class HomeMessagesHandler extends Handler {
}
@param('messageId', Types.ObjectId)
async postRead(domainId: string, messageId: ObjectId) {
async postRead({ }, messageId: ObjectId) {
const msg = await message.get(messageId);
if ([msg.from, msg.to].includes(this.user._id)) {
await message.setFlag(messageId, message.FLAG_UNREAD);
@@ -592,19 +608,7 @@ class HomeMessagesHandler extends Handler {
}
}
class HomeMessagesConnectionHandler extends ConnectionHandler {
category = '#message';
@subscribe('user/message')
async onMessageReceived(uid: number, mdoc: MessageDoc) {
if (uid !== this.user._id) return;
const udoc = (await user.getById(this.args.domainId, mdoc.from))!;
udoc.avatarUrl = avatar(udoc.avatar, 64);
this.send({ udoc, mdoc });
}
}
export const inject = { geoip: { required: false } };
export const inject = { geoip: { required: false }, oauth: {} };
export function apply(ctx: Context) {
ctx.Route('homepage', '/', HomeHandler);
ctx.Route('home_security', '/home/security', HomeSecurityHandler, PRIV.PRIV_USER_PROFILE);
@@ -614,5 +618,38 @@ export function apply(ctx: Context) {
ctx.Route('home_domain', '/home/domain', HomeDomainHandler, PRIV.PRIV_USER_PROFILE);
ctx.Route('home_domain_create', '/home/domain/create', HomeDomainCreateHandler, PRIV.PRIV_CREATE_DOMAIN);
ctx.Route('home_messages', '/home/messages', HomeMessagesHandler, PRIV.PRIV_USER_PROFILE);
ctx.Connection('home_messages_conn', '/home/messages-conn', HomeMessagesConnectionHandler, PRIV.PRIV_USER_PROFILE);
async function notifyMessage(uid: number[], mdoc: any, h) {
const udoc = (await user.getById('system', mdoc.from))!;
return {
operation: 'event',
channels: uid.map((u) => `message:${u}`),
payload: { udoc: { ...udoc.serialize(h) as any, avatarUrl: avatar(udoc.avatar, 128) }, mdoc },
};
}
ctx.on('subscription/init', (h, privileged) => {
if (!privileged) return;
h.ctx.on('user/message', async (uid, mdoc) => {
h.send(await notifyMessage(uid, mdoc, h));
});
});
ctx.on('subscription/enable', (channel, h, privileged) => {
if (!channel.startsWith('message:') || privileged) return;
const uid = +channel.split(':')[1];
h.ctx.on('user/message', async (uids, mdoc) => {
if (!uids.includes(uid)) return;
h.send(await notifyMessage([uid], mdoc, h));
});
});
ctx.on('subscription/subscribe', (channel, udoc) => { // eslint-disable-line consistent-return
if (channel === 'message' && udoc.hasPriv(PRIV.PRIV_USER_PROFILE)) {
return {
ok: true,
channel: `message:${udoc._id}`,
};
}
});
}
+25 -15
View File
@@ -1,6 +1,5 @@
import { statSync } from 'fs-extra';
import yaml from 'js-yaml';
import { pick } from 'lodash';
import { escapeRegExp, pick } from 'lodash';
import moment from 'moment-timezone';
import { ObjectId } from 'mongodb';
import { sortFiles, Time } from '@hydrooj/utils/lib/utils';
@@ -21,16 +20,25 @@ import {
} from '../service/server';
import { ContestCodeHandler, ContestFileDownloadHandler, ContestScoreboardHandler } from './contest';
const validatePenaltyRules = (input: string) => yaml.load(input);
const convertPenaltyRules = validatePenaltyRules;
const validatePenaltyRules = (input: string) => {
try {
yaml.load(input);
return true;
} catch (e) {
return false;
}
};
const convertPenaltyRules = (input: string) => yaml.load(input);
class HomeworkMainHandler extends Handler {
@param('group', Types.Name, true)
@param('page', Types.PositiveInt, true)
async get(domainId: string, group = '', page = 1) {
@param('q', Types.String, true)
async get(domainId: string, group = '', page = 1, q = '') {
const groups = (await user.listGroup(domainId, this.user.hasPerm(PERM.PERM_VIEW_HIDDEN_HOMEWORK) ? undefined : this.user._id))
.map((i) => i.name);
if (group && !groups.includes(group)) throw new NotAssignedError(group);
const escaped = escapeRegExp(q.toLowerCase());
const cursor = contest.getMulti(domainId, {
rule: 'homework',
...this.user.hasPerm(PERM.PERM_VIEW_HIDDEN_HOMEWORK) && !group
@@ -44,6 +52,7 @@ class HomeworkMainHandler extends Handler {
],
},
...group ? { assign: { $in: [group] } } : {},
...q ? { title: { $regex: new RegExp(q.length >= 2 ? escaped : `\\A${escaped}`, 'gmi') } } : {},
}).sort({
penaltySince: -1, endAt: -1, beginAt: -1, _id: -1,
});
@@ -57,10 +66,11 @@ class HomeworkMainHandler extends Handler {
} else cal.endAt = tdoc.penaltySince;
calendar.push(cal);
}
const qs = group ? `group=${group}` : '';
let qs = group ? `group=${group}` : '';
if (q) qs += `${qs ? '&' : ''}q=${encodeURIComponent(q)}`;
const groupsFilter = groups.filter((i) => !Number.isSafeInteger(+i));
this.response.body = {
tdocs, calendar, tpcount, page, qs, groups: groupsFilter, group,
tdocs, calendar, tpcount, page, qs, groups: groupsFilter, group, q,
};
this.response.template = 'homework_main.html';
}
@@ -181,11 +191,12 @@ class HomeworkEditHandler extends Handler {
@param('rated', Types.Boolean)
@param('maintainer', Types.NumericArray, true)
@param('assign', Types.CommaSeperatedArray, true)
@param('langs', Types.CommaSeperatedArray, true)
async postUpdate(
domainId: string, tid: ObjectId, beginAtDate: string, beginAtTime: string,
penaltySinceDate: string, penaltySinceTime: string, extensionDays: number,
penaltyRules: PenaltyRules, title: string, content: string, _pids: string, rated = false,
maintainer: number[] = [], assign: string[] = [],
maintainer: number[] = [], assign: string[] = [], langs: string[] = [],
) {
const pids = _pids.replace(/,/g, ',').split(',').map((i) => +i).filter((i) => i);
const tdoc = tid ? await contest.get(domainId, tid) : null;
@@ -216,6 +227,7 @@ class HomeworkEditHandler extends Handler {
rated,
maintainer,
assign,
langs,
});
if (tdoc.beginAt !== beginAt.toDate()
|| tdoc.endAt !== endAt.toDate()
@@ -273,8 +285,7 @@ export class HomeworkFilesHandler extends Handler {
}
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;
const size = Math.sum((this.tdoc.files || []).map((i) => i.size)) + file.size;
if (size >= system.get('limit.contest_files_size')) {
throw new FileLimitExceededError('size');
}
@@ -301,13 +312,12 @@ export async function apply(ctx) {
ctx.Route('homework_main', '/homework', HomeworkMainHandler, PERM.PERM_VIEW_HOMEWORK);
ctx.Route('homework_create', '/homework/create', HomeworkEditHandler);
ctx.Route('homework_detail', '/homework/:tid', HomeworkDetailHandler, PERM.PERM_VIEW_HOMEWORK);
ctx.Route('homework_scoreboard', '/homework/:tid/scoreboard', ContestScoreboardHandler, PERM.PERM_VIEW_HOMEWORK_SCOREBOARD);
ctx.Route(
'homework_scoreboard_download', '/homework/:tid/scoreboard/download/:ext',
ContestScoreboardHandler, PERM.PERM_VIEW_HOMEWORK_SCOREBOARD,
);
ctx.Route('homework_code', '/homework/:tid/code', ContestCodeHandler, PERM.PERM_VIEW_HOMEWORK);
ctx.Route('homework_edit', '/homework/:tid/edit', HomeworkEditHandler);
ctx.Route('homework_files', '/homework/:tid/file', HomeworkFilesHandler, PERM.PERM_VIEW_HOMEWORK);
ctx.Route('homework_file_download', '/homework/:tid/file/:filename', ContestFileDownloadHandler, PERM.PERM_VIEW_HOMEWORK);
await ctx.inject(['scoreboard'], ({ Route }) => {
Route('homework_scoreboard', '/homework/:tid/scoreboard', ContestScoreboardHandler, PERM.PERM_VIEW_HOMEWORK_SCOREBOARD);
Route('homework_scoreboard_view', '/homework/:tid/scoreboard/:view', ContestScoreboardHandler, PERM.PERM_VIEW_HOMEWORK_SCOREBOARD);
});
}
+3 -1
View File
@@ -13,7 +13,8 @@ class ProblemImportHydroHandler extends Handler {
@param('keepUser', Types.Boolean)
@param('preferredPrefix', Types.String, true)
async post(domainId: string, keepUser: boolean, preferredPrefix?: string) {
@param('hidden', Types.Boolean)
async post(domainId: string, keepUser: boolean, preferredPrefix?: string, hidden?: boolean) {
if (keepUser) this.checkPriv(PRIV.PRIV_EDIT_SYSTEM);
if (!this.request.files.file) throw new ValidationError('file');
if (preferredPrefix && !/^[a-zA-Z]+$/.test(preferredPrefix)) throw new ValidationError('preferredPrefix');
@@ -24,6 +25,7 @@ class ProblemImportHydroHandler extends Handler {
progress: this.progress.bind(this),
operator: keepUser ? null : this.user._id,
delSource: true,
hidden,
},
).catch((e) => MessageModel.send(1, this.user._id, `Import failed: ${e.message}\n${e.stack}`));
let resolved = false;
+169 -123
View File
@@ -3,15 +3,15 @@ import fs from 'fs-extra';
import yaml from 'js-yaml';
import { omit } from 'lodash';
import { ObjectId } from 'mongodb';
import PQueue from 'p-queue';
import sanitize from 'sanitize-filename';
import {
JudgeMeta, JudgeResultBody, ProblemConfigFile, TestCase,
} from '@hydrooj/common';
import { Context } from '../context';
import {
FileLimitExceededError, ForbiddenError, ProblemIsReferencedError, ValidationError,
BadRequestError, FileLimitExceededError, ForbiddenError, ProblemIsReferencedError, ValidationError,
} from '../error';
import {
JudgeResultBody, ProblemConfigFile, RecordDoc, Task, TestCase,
} from '../interface';
import { RecordDoc, Task } from '../interface';
import { Logger } from '../logger';
import * as builtin from '../model/builtin';
import { PERM, STATUS } from '../model/builtin';
@@ -24,7 +24,7 @@ import storage from '../model/storage';
import * as system from '../model/system';
import task, { Consumer } from '../model/task';
import user from '../model/user';
import * as bus from '../service/bus';
import bus from '../service/bus';
import { updateJudge } from '../service/monitor';
import {
ConnectionHandler, Handler, post, subscribe, Types,
@@ -72,85 +72,125 @@ function processPayload(body: Partial<JudgeResultBody>) {
};
}
export async function next(body: Partial<JudgeResultBody>) {
body.rid = new ObjectId(body.rid); // TODO remove this
const {
$set, $push, $unset, $inc,
} = processPayload(body);
const rdoc = await record.update(body.domainId, body.rid, $set, $push, $unset, $inc);
bus.broadcast('record/change', rdoc, $set, $push, body);
return rdoc;
}
export class JudgeResultCallbackContext {
private resolve: (_: any) => void;
private finishPromise: Promise<any>;
private operationPromise = Promise.resolve(null);
private relatedId = new ObjectId();
private meta: { rejudge?: JudgeMeta['rejudge'] };
export async function postJudge(rdoc: RecordDoc) {
if (rdoc.contest?.toString().startsWith('0'.repeat(23))) return;
const accept = rdoc.status === builtin.STATUS.STATUS_ACCEPTED;
const updated = await problem.updateStatus(rdoc.domainId, rdoc.pid, rdoc.uid, rdoc._id, rdoc.status, rdoc.score);
if (rdoc.contest) await contest.updateStatus(rdoc.domainId, rdoc.contest, rdoc.uid, rdoc._id, rdoc.pid, rdoc);
else if (accept && updated) await domain.incUserInDomain(rdoc.domainId, rdoc.uid, 'nAccept', 1);
const isNormalSubmission = ![
STATUS.STATUS_ETC, STATUS.STATUS_HACK_SUCCESSFUL, STATUS.STATUS_HACK_UNSUCCESSFUL,
STATUS.STATUS_FORMAT_ERROR, STATUS.STATUS_SYSTEM_ERROR, STATUS.STATUS_CANCELED,
].includes(rdoc.status);
const pdoc = (accept && updated)
? await problem.inc(rdoc.domainId, rdoc.pid, 'nAccept', 1)
: await problem.get(rdoc.domainId, rdoc.pid, undefined, true);
if (pdoc && isNormalSubmission) {
await Promise.all([
problem.inc(pdoc.domainId, pdoc.docId, `stats.${builtin.STATUS_SHORT_TEXTS[rdoc.status]}`, 1),
problem.inc(pdoc.domainId, pdoc.docId, `stats.s${Math.floor(rdoc.score)}`, 1),
]);
}
await bus.parallel('record/judge', rdoc, updated, pdoc);
}
bus.on('record/judge', async (rdoc, updated, pdoc) => {
if (!pdoc || rdoc.status !== STATUS.STATUS_HACK_SUCCESSFUL) return;
if (rdoc.contest) return;
try {
const config = yaml.load(pdoc.config as string) as ProblemConfigFile;
assert(config.subtasks instanceof Array);
const file = await storage.get(`submission/${rdoc.files.hack.split('#')[0]}`);
assert(file);
const hackSubtask = config.subtasks[config.subtasks.length - 1];
hackSubtask.cases ||= [];
const input = `hack-${rdoc._id}-${hackSubtask.cases.length + 1}.in`;
hackSubtask.cases.push({ input, output: '/dev/null' });
await Promise.all([
problem.addTestdata(rdoc.domainId, rdoc.pid, input, file),
problem.addTestdata(rdoc.domainId, rdoc.pid, 'config.yaml', Buffer.from(yaml.dump(config))),
]);
// trigger rejudge
const rdocs = await record.getMulti(rdoc.domainId, {
pid: rdoc.pid,
status: STATUS.STATUS_ACCEPTED,
contest: { $nin: [record.RECORD_GENERATE, record.RECORD_PRETEST] },
}).project({ _id: 1, contest: 1 }).toArray();
const priority = await record.submissionPriority(rdoc.uid, -5000 - rdocs.length * 5 - 50);
await record.judge(rdoc.domainId, rdocs.map((r) => r._id), priority, {}, { hackRejudge: input });
} catch (e) {
next({
rid: rdoc._id,
domainId: rdoc.domainId,
key: 'next',
message: { message: 'Unable to apply hack: {0}', params: [e.message] },
constructor(public ctx: Context, public readonly task: Omit<Task, '_id'> & { type: string }) { // eslint-disable-line @typescript-eslint/no-shadow
this.meta = task.meta as JudgeMeta || {};
this.finishPromise = new Promise((resolve) => {
this.resolve = resolve;
});
}
});
export async function end(body: Partial<JudgeResultBody>) {
body.rid = new ObjectId(body.rid); // TODO remove this
const { $set, $push } = processPayload(body);
const $unset: any = { progress: '' };
$set.judgeAt = new Date();
$set.judger = body.judger ?? 1;
let rdoc = await record.update(body.domainId, body.rid, $set, $push, $unset);
await postJudge(rdoc);
rdoc = await record.get(body.rid);
bus.broadcast('record/change', rdoc, null, null, body); // trigger a full update
return rdoc;
async _next(body: Partial<JudgeResultBody>) {
const {
$set, $push, $unset, $inc,
} = processPayload(body);
if (this.meta?.rejudge === 'controlled') {
await record.collHistory.updateOne({
_id: this.relatedId,
}, {
$set, $push, $unset, $inc,
}, { upsert: true });
} else {
const rdoc = await record.update(this.task.domainId, new ObjectId(this.task.rid as string), $set, $push, $unset, $inc);
if (rdoc) this.ctx.broadcast('record/change', rdoc, $set, $push, body);
}
}
static async next(domainId: string, rid: ObjectId, body: Partial<JudgeResultBody>) {
const {
$set, $push, $unset, $inc,
} = processPayload(body);
const rdoc = await record.update(domainId, rid, $set, $push, $unset, $inc);
if (rdoc) app.broadcast('record/change', rdoc, $set, $push, body);
}
next(body: Partial<JudgeResultBody>) {
this.operationPromise = this.operationPromise.then(() => this._next(body));
return this.operationPromise;
}
static async postJudge(rdoc: RecordDoc, context?: JudgeResultCallbackContext) {
if (rdoc.contest?.toString().startsWith('0'.repeat(23))) return;
const accept = rdoc.status === builtin.STATUS.STATUS_ACCEPTED;
const updated = await problem.updateStatus(rdoc.domainId, rdoc.pid, rdoc.uid, rdoc._id, rdoc.status, rdoc.score);
if (rdoc.contest) await contest.updateStatus(rdoc.domainId, rdoc.contest, rdoc.uid, rdoc._id, rdoc.pid, rdoc);
else if (accept && updated) await domain.incUserInDomain(rdoc.domainId, rdoc.uid, 'nAccept', 1);
const isNormalSubmission = ![
STATUS.STATUS_ETC, STATUS.STATUS_HACK_SUCCESSFUL, STATUS.STATUS_HACK_UNSUCCESSFUL,
STATUS.STATUS_FORMAT_ERROR, STATUS.STATUS_SYSTEM_ERROR, STATUS.STATUS_CANCELED,
].includes(rdoc.status);
const pdoc = (accept && updated)
? await problem.inc(rdoc.domainId, rdoc.pid, 'nAccept', 1)
: await problem.get(rdoc.domainId, rdoc.pid, undefined, true);
if (pdoc && isNormalSubmission) {
await Promise.all([
problem.inc(pdoc.domainId, pdoc.docId, `stats.${builtin.STATUS_SHORT_TEXTS[rdoc.status]}`, 1),
problem.inc(pdoc.domainId, pdoc.docId, `stats.s${Math.floor(rdoc.score)}`, 1),
]);
}
await app.parallel('record/judge', rdoc, updated, pdoc, context);
}
async _end(body: Partial<JudgeResultBody>) {
const { $set, $push } = processPayload(body);
const $unset: any = { progress: '' };
$set.judgeAt = new Date();
$set.judger = body.judger ?? 1;
if (this.meta?.rejudge === 'controlled') {
await record.collHistory.updateOne({
_id: this.relatedId,
}, {
$set, $push, $unset,
}, { upsert: true });
this.resolve(null);
return;
}
const rdoc = await record.update(this.task.domainId, new ObjectId(this.task.rid as string), $set, $push, $unset);
if (rdoc) bus.broadcast('record/change', rdoc, null, null, body); // trigger a full update
await JudgeResultCallbackContext.postJudge(rdoc, this);
this.resolve(rdoc);
}
static async end(domainId: string, rid: ObjectId, body: Partial<JudgeResultBody>) {
const { $set, $push } = processPayload(body);
const $unset: any = { progress: '' };
$set.judgeAt = new Date();
$set.judger = body.judger ?? 1;
const rdoc = await record.update(domainId, rid, $set, $push, $unset);
if (rdoc) app.broadcast('record/change', rdoc, null, null, body); // trigger a full update
await JudgeResultCallbackContext.postJudge(rdoc);
}
end(body?: Partial<JudgeResultBody>) {
if (!body) this.resolve(null);
else this.operationPromise = this.operationPromise.then(() => this._end(body));
return this.operationPromise;
}
reset() {
return this.operationPromise.then(async () => {
const rdoc = await record.reset(this.task.domainId, this.task.rid, false);
this.ctx.broadcast('record/change', rdoc);
return task.add(this.task);
});
}
then(onfulfilled?: (value: any) => void, onrejected?: (reason: any) => void) {
return this.finishPromise.then(onfulfilled, onrejected);
}
}
/** @deprecated use JudgeResultCallbackContext.postJudge instead */
export const postJudge = (rdoc: RecordDoc) => JudgeResultCallbackContext.postJudge(rdoc);
export class JudgeFilesDownloadHandler extends Handler {
noCheckPermView = true;
notUsage = true;
@@ -223,21 +263,11 @@ export class JudgeConnectionHandler extends ConnectionHandler {
query: any = { type: { $in: ['judge', 'generate'] } };
concurrency = 1;
consumer: Consumer = null;
startTimeout: NodeJS.Timeout = null;
tasks: Record<string, {
resolve: (_: any) => void,
queue: PQueue,
domainId: string,
t: Task,
}> = {};
tasks: Record<string, JudgeResultCallbackContext> = {};
async prepare() {
logger.info('Judge daemon connected from ', this.request.ip);
this.sendLanguageConfig();
// TODO deprecated, just for compatibility
this.startTimeout = setTimeout(() => {
this.consumer ||= task.consume(this.query, this.newTask.bind(this), true, this.concurrency);
}, 15000);
}
@subscribe('system/setting')
@@ -246,21 +276,11 @@ export class JudgeConnectionHandler extends ConnectionHandler {
}
async newTask(t: Task) {
const rdoc = await record.get(t.domainId, t.rid);
if (!rdoc) return;
const rid = rdoc._id.toHexString();
let resolve: (_: any) => void;
const p = new Promise((r) => { resolve = r; });
this.tasks[rid] = {
queue: new PQueue({ concurrency: 1 }),
domainId: rdoc.domainId,
resolve,
t,
};
this.send({ task: { ...rdoc, ...t } });
this.tasks[rid].queue.add(() => next({ status: STATUS.STATUS_FETCHED, domainId: rdoc.domainId, rid: rdoc._id }));
await p;
const rid = t.rid.toHexString();
this.tasks[rid] = new JudgeResultCallbackContext(this.ctx, t);
this.send({ task: t });
this.tasks[rid].next({ status: STATUS.STATUS_FETCHED });
await this.tasks[rid];
delete this.tasks[rid];
}
@@ -273,18 +293,10 @@ export class JudgeConnectionHandler extends ConnectionHandler {
if (['next', 'end'].includes(msg.key)) {
const t = this.tasks[msg.rid];
if (!t) return;
msg.domainId = t.domainId;
if (msg.key === 'next') t.queue.add(() => next(msg));
if (msg.key === 'end') {
if (!msg.nop) t.queue.add(() => end({ judger: this.user._id, ...msg }));
t.resolve(null);
}
if (msg.key === 'next') t.next(msg);
if (msg.key === 'end') t.end(msg.nop ? undefined : { judger: this.user._id, ...msg });
} else if (msg.key === 'status') {
await updateJudge(msg.info);
} else if (msg.key === 'prio' && typeof msg.prio === 'number') {
// TODO deprecated, use config instead
this.query.priority = { $gt: msg.prio };
this.consumer?.setQuery(this.query);
} else if (msg.key === 'config') {
if (Number.isSafeInteger(msg.prio)) {
this.query.priority = { $gt: msg.prio };
@@ -303,21 +315,16 @@ export class JudgeConnectionHandler extends ConnectionHandler {
this.consumer?.setQuery(this.query);
}
} else if (msg.key === 'start') {
clearTimeout(this.startTimeout);
this.consumer ||= task.consume(this.query, this.newTask.bind(this), true, this.concurrency);
if (this.consumer) throw new BadRequestError('Judge daemon already started');
this.consumer = task.consume(this.query, this.newTask.bind(this), true, this.concurrency);
logger.info('Judge daemon started');
}
}
async cleanup() {
clearTimeout(this.startTimeout);
this.consumer?.destroy();
logger.info('Judge daemon disconnected from ', this.request.ip);
await Promise.all(Object.values(this.tasks).map(async ({ t }) => {
const rdoc = await record.reset(t.domainId, t.rid, false);
bus.broadcast('record/change', rdoc);
return task.add(t);
}));
await Promise.all(Object.values(this.tasks).map((cb) => cb.reset()));
}
}
@@ -325,7 +332,46 @@ export async function apply(ctx: Context) {
ctx.Route('judge_files_download', '/judge/files', JudgeFilesDownloadHandler, builtin.PRIV.PRIV_JUDGE);
ctx.Route('judge_files_upload', '/judge/upload', JudgeFileUpdateHandler, builtin.PRIV.PRIV_JUDGE);
ctx.Connection('judge_conn', '/judge/conn', JudgeConnectionHandler, builtin.PRIV.PRIV_JUDGE);
ctx.on('record/judge', async (rdoc, updated, pdoc, t) => {
if (!pdoc || rdoc.status !== STATUS.STATUS_HACK_SUCCESSFUL) return;
if (rdoc.contest) return;
try {
const config = yaml.load(pdoc.config as string) as ProblemConfigFile;
assert(Array.isArray(config.subtasks));
const file = await storage.get(`submission/${rdoc.files.hack.split('#')[0]}`);
assert(file);
const hackSubtask = config.subtasks[config.subtasks.length - 1];
hackSubtask.cases ||= [];
const input = `hack-${rdoc._id}-${hackSubtask.cases.length + 1}.in`;
hackSubtask.cases.push({ input, output: '/dev/null' });
await Promise.all([
problem.addTestdata(rdoc.domainId, rdoc.pid, input, file),
problem.addTestdata(rdoc.domainId, rdoc.pid, 'config.yaml', Buffer.from(yaml.dump(config))),
]);
// trigger rejudge
const rdocs = await record.getMulti(rdoc.domainId, {
pid: rdoc.pid,
status: STATUS.STATUS_ACCEPTED,
contest: { $nin: [record.RECORD_GENERATE, record.RECORD_PRETEST] },
}).project({ _id: 1, contest: 1 }).toArray();
const priority = await record.submissionPriority(rdoc.uid, -5000 - rdocs.length * 5 - 50);
await record.judge(rdoc.domainId, rdocs.map((r) => r._id), priority, {}, { hackRejudge: input });
} catch (e) {
t?.next?.({
rid: rdoc._id.toString(),
domainId: rdoc.domainId,
key: 'next',
message: { message: 'Unable to apply hack: {0}', params: [e.message] },
});
}
});
}
/** @deprecated use JudgeResultCallbackContext.next instead */
export const next = (payload: any) => JudgeResultCallbackContext.next(payload.domainId, payload.rid, payload);
/** @deprecated use JudgeResultCallbackContext.end instead */
export const end = (payload: any) => JudgeResultCallbackContext.end(payload.domainId, payload.rid, payload);
/** @deprecated use JudgeResultCallbackContext.next instead */
apply.next = next;
/** @deprecated use JudgeResultCallbackContext.end instead */
apply.end = end;
+58 -34
View File
@@ -13,12 +13,10 @@ import record from '../model/record';
import * as setting from '../model/setting';
import * as system from '../model/system';
import user from '../model/user';
import { check } from '../service/check';
import {
ConnectionHandler, Handler, param, requireSudo, Types,
} from '../service/server';
import { configSource, saveConfig, SystemSettings } from '../settings';
import * as judge from './judge';
import { JudgeResultCallbackContext } from './judge';
const logger = new Logger('manage');
@@ -71,11 +69,11 @@ class SystemCheckConnHandler extends ConnectionHandler {
const log = (payload: any) => this.send({ type: 'log', payload });
const warn = (payload: any) => this.send({ type: 'warn', payload });
const error = (payload: any) => this.send({ type: 'error', payload });
await check.run(this, log, warn, error, (id) => { this.id = id; });
await this.ctx.check.run(this, log, warn, error, (id) => { this.id = id; });
}
async cleanup() {
check.cancel(this.id);
this.ctx.check.cancel(this.id);
}
}
@@ -106,33 +104,25 @@ class SystemScriptHandler extends SystemHandler {
args = global.Hydro.script[id].validate(args);
}
const rid = await record.add(domainId, -1, this.user._id, '-', id, false, { input: raw, type: 'pretest' });
const report = (data) => judge.next({ domainId, rid, ...data });
report({ message: `Running script: ${id} `, status: STATUS.STATUS_JUDGING });
const c = new JudgeResultCallbackContext(this.ctx, { type: 'judge', domainId, rid });
c.next({ message: `Running script: ${id} `, status: STATUS.STATUS_JUDGING });
const start = Date.now();
// Maybe async?
global.Hydro.script[id].run(args, report)
.then((ret: any) => {
const time = new Date().getTime() - start;
judge.end({
domainId,
rid,
status: STATUS.STATUS_ACCEPTED,
message: inspect(ret, false, 10, true),
judger: 1,
time,
memory: 0,
});
})
global.Hydro.script[id].run(args, (data) => c.next(data))
.then((ret: any) => c.end({
status: STATUS.STATUS_ACCEPTED,
message: inspect(ret, false, 10, true),
judger: 1,
time: Date.now() - start,
memory: 0,
}))
.catch((err: Error) => {
const time = new Date().getTime() - start;
logger.error(err);
judge.end({
domainId,
rid,
c.end({
status: STATUS.STATUS_SYSTEM_ERROR,
message: `${err.message} \n${(err as any).params || []} \n${err.stack} `,
judger: 1,
time,
time: Date.now() - start,
memory: 0,
});
});
@@ -184,27 +174,57 @@ class SystemConfigHandler extends SystemHandler {
@requireSudo
async get() {
this.response.template = 'manage_config.html';
let value = configSource;
let value = this.ctx.setting.configSource;
const processNode = (node: any, schema: Schema<any, any>, parent?: any, accessKey?: string) => {
if (['union', 'intersect'].includes(schema.type)) {
for (const item of schema.list) processNode(node, item, parent, accessKey);
}
if (parent && (schema.meta.secret === true || schema.meta.role === 'secret')) {
if (schema.type === 'string') parent[accessKey] = '[hidden]';
// TODO support more types
}
if (schema.type === 'object') {
for (const key in schema.dict) processNode(node[key], schema.dict[key], node, key);
}
};
try {
value = yaml.dump(Schema.intersect(SystemSettings)(yaml.load(configSource)));
const temp = yaml.load(this.ctx.setting.configSource);
for (const schema of this.ctx.setting.settings) processNode(temp, schema);
value = yaml.dump(temp);
} catch (e) { }
this.response.body = {
schema: Schema.intersect(SystemSettings).toJSON(),
schema: Schema.intersect(this.ctx.setting.settings).toJSON(),
value,
};
}
@requireSudo
@param('value', Types.String)
async post(domainId: string, value: string) {
async post({ }, value: string) {
const oldConfig = yaml.load(this.ctx.setting.configSource);
let config;
const processNode = (node: any, old: any, schema: Schema<any, any>, parent?: any, accessKey?: string) => {
if (['union', 'intersect'].includes(schema.type)) {
for (const item of schema.list) processNode(node, old, item, parent, accessKey);
}
if (parent && (schema.meta.secret === true || schema.meta.role === 'secret')) {
if (node === '[hidden]') parent[accessKey] = old;
// TODO support more types
}
if (schema.type === 'object') {
for (const key in schema.dict) processNode(node[key] || {}, old[key] || {}, schema.dict[key], node, key);
}
};
try {
config = yaml.load(value);
Schema.intersect(SystemSettings)(config);
for (const schema of this.ctx.setting.settings) processNode(config, oldConfig, schema, null, '');
} catch (e) {
throw new ValidationError('value');
throw new ValidationError('value', '', e.message);
}
await saveConfig(config);
await this.ctx.setting.saveConfig(config);
}
}
@@ -290,9 +310,12 @@ const allPriv = Math.sum(Object.values(Priv));
class SystemUserPrivHandler extends SystemHandler {
@requireSudo
async get() {
@param('extraIgnore', Types.NumericArray, true)
async get({ }, extraIgnore: number[] = []) {
const defaultPriv = system.get('default.priv');
const udocs = await user.getMulti({ _id: { $gte: -1000, $ne: 1 }, priv: { $nin: [0, defaultPriv] } }).limit(1000).sort({ _id: 1 }).toArray();
const udocs = await user.getMulti({
_id: { $gte: -1000, $ne: 1 }, priv: { $nin: [0, defaultPriv, ...extraIgnore] },
}).limit(1000).sort({ _id: 1 }).toArray();
const banudocs = await user.getMulti({ _id: { $gte: -1000, $ne: 1 }, priv: 0 }).limit(1000).sort({ _id: 1 }).toArray();
this.response.body = {
udocs: [...udocs, ...banudocs],
@@ -323,6 +346,7 @@ class SystemUserPrivHandler extends SystemHandler {
}
}
export const inject = ['setting', 'check'];
export async function apply(ctx) {
ctx.Route('manage', '/manage', SystemMainHandler);
ctx.Route('manage_dashboard', '/manage/dashboard', SystemDashboardHandler);
+25 -19
View File
@@ -1,5 +1,3 @@
/* eslint-disable camelcase */
import { statSync } from 'fs';
import { pick } from 'lodash';
import { lookup } from 'mime-types';
import { Context } from '../context';
@@ -11,13 +9,12 @@ import { PRIV } from '../model/builtin';
import * as oplog from '../model/oplog';
import storage from '../model/storage';
import * as system from '../model/system';
import user from '../model/user';
import user, { User } from '../model/user';
import {
Handler, param, post, requireSudo, Types,
} from '../service/server';
import { encodeRFC5987ValueChars } from '../service/storage';
import { builtinConfig } from '../settings';
import { md5, sortFiles } from '../utils';
import { sortFiles } from '../utils';
class SwitchLanguageHandler extends Handler {
noCheckPermView = true;
@@ -34,27 +31,37 @@ class SwitchLanguageHandler extends Handler {
export class FilesHandler extends Handler {
noCheckPermView = true;
udoc: User;
async get() {
if (!this.user._files?.length) this.checkPriv(PRIV.PRIV_CREATE_FILE);
@param('uid', Types.Int, true)
async prepare({ domainId }, uid: number) {
if (uid) {
this.checkPriv(PRIV.PRIV_EDIT_SYSTEM);
this.udoc = await (await user.getById(domainId, uid)).private();
} else {
this.udoc = this.user;
}
}
async get({ }) {
if (!this.udoc._files?.length) this.checkPriv(PRIV.PRIV_CREATE_FILE);
this.response.body = {
files: sortFiles(this.user._files),
urlForFile: (filename: string) => this.url('fs_download', { uid: this.user._id, filename }),
files: sortFiles(this.udoc._files),
urlForFile: (filename: string) => this.url('fs_download', { uid: this.udoc._id, filename }),
};
this.response.pjax = 'partials/files.html';
this.response.template = 'home_files.html';
}
@post('filename', Types.Filename)
async postUploadFile(domainId: string, filename: string) {
async postUploadFile({ }, 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 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;
const size = Math.sum((this.user._files || []).map((i) => i.size)) + file.size;
if (size >= system.get('limit.user_files_size')) {
if (!this.user.hasPriv(PRIV.PRIV_UNLIMITED_QUOTA)) throw new FileLimitExceededError('size');
}
@@ -69,10 +76,10 @@ export class FilesHandler extends Handler {
}
@post('files', Types.ArrayOf(Types.Filename))
async postDeleteFiles(domainId: string, files: string[]) {
async postDeleteFiles({ }, files: string[]) {
await Promise.all([
storage.del(files.map((t) => `user/${this.user._id}/${t}`), this.user._id),
user.setById(this.user._id, { _files: this.user._files.filter((i) => !files.includes(i.name)) }),
storage.del(files.map((t) => `user/${this.udoc._id}/${t}`), this.user._id),
user.setById(this.udoc._id, { _files: this.udoc._files.filter((i) => !files.includes(i.name)) }),
]);
this.back();
}
@@ -111,10 +118,9 @@ export class StorageHandler extends Handler {
@param('filename', Types.Filename, true)
@param('expire', Types.UnsignedInt)
@param('secret', Types.String)
async get(domainId: string, target: string, filename = '', expire: number, secret: string) {
const expected = md5(`${target}/${expire}/${builtinConfig.file.secret}`);
async get({ }, target: string, filename = '', expire: number, secret: string) {
if (expire < Date.now()) throw new AccessDeniedError();
if (secret !== expected) throw new AccessDeniedError();
if (!(await this.ctx.get('storage')?.isLinkValid?.(`${target}/${expire}/${secret}`))) throw new AccessDeniedError();
this.response.body = await storage.get(target);
this.response.type = (target.endsWith('.out') || target.endsWith('.ans'))
? 'text/plain'
@@ -126,7 +132,7 @@ export class StorageHandler extends Handler {
export class SwitchAccountHandler extends Handler {
@requireSudo
@param('uid', Types.Int)
async get(domainId: string, uid: number) {
async get({ }, uid: number) {
this.session.sudoUid = this.user._id;
this.session.uid = uid;
this.back();
+121 -149
View File
@@ -1,17 +1,20 @@
import AdmZip from 'adm-zip';
import { readFile, statSync } from 'fs-extra';
import { createReadStream } from 'fs';
import { PassThrough, Readable, Writable } from 'stream';
import { Entry, ZipReader } from '@zip.js/zip.js';
import { readFile } from 'fs-extra';
import {
escapeRegExp, flattenDeep, intersection, pick,
} from 'lodash';
import { Filter, ObjectId } from 'mongodb';
import { nanoid } from 'nanoid';
import sanitize from 'sanitize-filename';
import Schema from 'schemastery';
import parser from '@hydrooj/utils/lib/search';
import { sortFiles, streamToBuffer } from '@hydrooj/utils/lib/utils';
import { randomstring, sortFiles, streamToBuffer } from '@hydrooj/utils/lib/utils';
import type { Context } from '../context';
import {
BadRequestError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError,
FileLimitExceededError, HackFailedError, NoProblemError, NotFoundError,
FileLimitExceededError, FileTooLargeError, HackFailedError, NoProblemError, NotFoundError,
PermissionError, ProblemAlreadyExistError, ProblemAlreadyUsedByContestError, ProblemConfigError,
ProblemIsReferencedError, ProblemNotAllowCopyError, ProblemNotAllowLanguageError, ProblemNotAllowPretestError,
ProblemNotFoundError, RecordNotFoundError, SolutionNotFoundError, ValidationError,
@@ -32,7 +35,7 @@ import storage from '../model/storage';
import * as system from '../model/system';
import user from '../model/user';
import {
Handler, param, post, query, route, Types,
Handler, param, post, Query, query, route, Types,
} from '../service/server';
import { ContestDetailBaseHandler } from './contest';
@@ -81,6 +84,7 @@ export interface QueryContext {
text: string;
total: number;
fail: boolean;
hint: string;
}
export class ProblemMainHandler extends Handler {
@@ -93,6 +97,7 @@ export class ProblemMainHandler extends Handler {
text: '',
total: 0,
fail: false,
hint: 'sort',
};
@param('page', Types.PositiveInt, true)
@@ -107,7 +112,7 @@ export class ProblemMainHandler extends Handler {
// eslint-disable-next-line @typescript-eslint/no-shadow
const query = this.queryContext.query;
const psdict = {};
const search = global.Hydro.lib.problemSearch || defaultSearch;
const search = Object.values(global.Hydro.module.problemSearch)[0] || defaultSearch;
const parsed = parser.parse(q, {
keywords: ['category', 'difficulty', 'namespace'],
offsets: false,
@@ -134,23 +139,17 @@ export class ProblemMainHandler extends Handler {
total = result.total;
this.queryContext.pcountRelation = result.countRelation;
if (!result.hits.length) this.queryContext.fail = true;
query.$and ||= [];
query.$and.push({
$or: result.hits.map((i) => {
const [did, docId] = i.split('/');
return { domainId: did, docId: +docId };
}),
});
query.docId = { $in: result.hits.map((t) => +t.split('/')[1]) };
this.queryContext.hint = 'basic';
this.queryContext.sort = result.hits;
}
const sort = this.queryContext.sort;
await this.ctx.parallel('problem/list', query, this, sort);
// eslint-disable-next-line prefer-const
let [pdocs, ppcount, pcount] = this.queryContext.fail
? [[], 0, 0]
: await this.paginate(
problem.getMulti(domainId, query, quick ? ['title', 'pid', 'domainId', 'docId'] : undefined)
.sort({ sort: 1, docId: 1 }).hint('sort'),
.sort({ sort: 1, docId: 1 }).hint(this.queryContext.hint),
sort.length ? 1 : page, limit,
);
if (total) {
@@ -160,14 +159,10 @@ export class ProblemMainHandler extends Handler {
if (sort.length) pdocs = pdocs.sort((a, b) => sort.indexOf(`${a.domainId}/${a.docId}`) - sort.indexOf(`${b.domainId}/${b.docId}`));
if (text && pcount > pdocs.length) pcount = pdocs.length;
if (this.user.hasPriv(PRIV.PRIV_USER_PROFILE)) {
const domainIds = Array.from(new Set(pdocs.map((i) => i.domainId)));
await Promise.all(
domainIds.map((did) =>
problem.getListStatus(
did, this.user._id,
pdocs.filter((i) => i.domainId === did).map((i) => i.docId),
).then((res) => Object.assign(psdict, res))),
);
Object.assign(psdict, await problem.getListStatus(
domainId, this.user._id,
pdocs.map((i) => i.docId),
));
}
if (pjax) {
this.response.body = {
@@ -195,24 +190,37 @@ export class ProblemMainHandler extends Handler {
@param('pids', Types.NumericArray)
@param('target', Types.String)
async postCopy(domainId: string, pids: number[], target: string) {
const t = `,${this.domain.share || ''},`;
if (t !== ',*,' && !t.includes(`,${target},`)) throw new PermissionError(target);
const ddoc = await domain.get(target);
@param('hidden', Types.Boolean)
async postCopy(domainId: string, pids: number[], target: string, hidden?: boolean) {
let t = `,${this.domain.share || ''},`;
if (t !== ',*,' && !t.includes(`,${target},`)) throw new ProblemNotAllowCopyError(this.domain._id, target);
let ddoc = await domain.get(target);
if (!ddoc) throw new NotFoundError(target);
const dudoc = await user.getById(target, this.user._id);
if (!dudoc.hasPerm(PERM.PERM_CREATE_PROBLEM)) throw new PermissionError(PERM.PERM_CREATE_PROBLEM);
// Check if user can access all those problems
await problem.getList(
const pdict = await problem.getList(
domainId, pids, this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN) || this.user._id,
true, ['docId'], true,
);
const ids = [];
for (const pid of pids) {
let pdoc = pdict[pid];
if (pdoc.reference) {
// eslint-disable-next-line no-await-in-loop
[pdoc, ddoc] = await Promise.all([
problem.get(pdoc.reference.domainId, pdoc.reference.pid),
domain.get(pdoc.reference.domainId),
]);
if (!pdoc) throw new ProblemNotFoundError(pdoc.reference.domainId, pdoc.reference.pid);
t = `,${ddoc.share || ''},`;
if (t !== ',*,' && !t.includes(`,${target},`)) throw new ProblemNotAllowCopyError(ddoc._id, target);
}
// eslint-disable-next-line no-await-in-loop
ids.push(await problem.copy(domainId, pid, target));
ids.push(await problem.copy(pdoc.domainId, pdoc.docId, target, undefined, hidden));
}
this.response.body = ids;
if (ids.length === 1) this.response.redirect = this.url('problem_detail', { domainId: target, pid: ids[0] });
}
@param('pids', Types.NumericArray)
@@ -305,18 +313,21 @@ export class ProblemDetailHandler extends ContestDetailBaseHandler {
}
if (typeof this.pdoc.config !== 'string') {
let baseLangs;
const t = [];
if (this.pdoc.config.langs) t.push(this.pdoc.config.langs);
if (ddoc.langs) t.push(ddoc.langs.split(',').map((i) => i.trim()).filter((i) => i));
if (this.domain.langs) t.push(this.domain.langs.split(',').map((i) => i.trim()).filter((i) => i));
if (this.tdoc?.langs) t.push(this.tdoc.langs);
if (this.pdoc.config.type === 'remote_judge') {
const p = this.pdoc.config.subType;
const dl = Object.keys(setting.langs).filter((i) => i.startsWith(`${p}.`) || setting.langs[i].validAs[p]);
if (setting.langs[p]) dl.push(p);
baseLangs = dl;
} else {
baseLangs = Object.keys(setting.langs).filter((i) => !setting.langs[i].remote);
const needHiddenLangs = flattenDeep(t).length;
baseLangs = Object.keys(setting.langs).filter((i) =>
(needHiddenLangs ? !setting.langs[i].remote : !setting.langs[i].remote && !setting.langs[i].hidden));
}
const t = [];
if (this.pdoc.config.langs) t.push(this.pdoc.config.langs);
if (ddoc.langs) t.push(ddoc.langs.split(',').map((i) => i.trim()).filter((i) => i));
if (this.domain.langs) t.push(this.domain.langs.split(',').map((i) => i.trim()).filter((i) => i));
this.pdoc.config.langs = ['objective', 'submit_answer'].includes(this.pdoc.config.type) ? ['_'] : intersection(baseLangs, ...t);
}
await this.ctx.parallel('problem/get', this.pdoc, this);
@@ -337,6 +348,10 @@ export class ProblemDetailHandler extends ContestDetailBaseHandler {
discussionCount: dcnt,
tdoc: this.tdoc,
owner_udoc: (tid && this.tdoc.owner !== this.pdoc.owner) ? await user.getById(domainId, this.tdoc.owner) : null,
mode: !tid ? 'normal'
: !this.tsdoc?.attend ? 'view'
: !contest.isDone(this.tdoc) ? 'contest'
: problem.canViewBy(this.pdoc, this.user) ? 'correction' : 'none',
};
if (this.tdoc && this.tsdoc) {
const fields = ['attend', 'startAt'];
@@ -387,52 +402,34 @@ export class ProblemDetailHandler extends ContestDetailBaseHandler {
if (this.psdoc?.rid) {
this.response.body.rdoc = await record.get(this.args.domainId, this.psdoc.rid);
}
[this.response.body.ctdocs, this.response.body.htdocs] = await Promise.all([
[this.response.body.ctdocs, this.response.body.htdocs] = (await Promise.all([
contest.getRelated(this.args.domainId, this.pdoc.docId),
contest.getRelated(this.args.domainId, this.pdoc.docId, 'homework'),
]);
])).map((tdocs) => tdocs.filter((tdoc) =>
this.user.hasPerm(PERM.PERM_VIEW_HIDDEN_CONTEST) || !tdoc.assign?.length || Set.intersection(tdoc.assign, this.user.group).size,
));
}
}
@param('pid', Types.UnsignedInt)
async postRejudge(domainId: string, pid: number) {
this.checkPerm(PERM.PERM_REJUDGE_PROBLEM);
if (!this.pdoc.config || typeof this.pdoc.config === 'string') throw new ProblemConfigError();
const rdocs = await record.getMulti(domainId, {
pid,
contest: { $nin: [record.RECORD_GENERATE, record.RECORD_PRETEST] },
status: { $ne: STATUS.STATUS_CANCELED },
'files.hack': { $exists: false },
}).project({ _id: 1, contest: 1 }).toArray();
if (!this.pdoc.config || typeof this.pdoc.config === 'string') throw new ProblemConfigError();
const priority = await record.submissionPriority(this.user._id, -10000 - rdocs.length * 5 - 50);
await record.reset(domainId, rdocs.map((rdoc) => rdoc._id), true);
await Promise.all([
record.judge(domainId, rdocs.filter((i) => i.contest).map((i) => i._id), priority, { detail: false }, { rejudge: true }),
record.judge(domainId, rdocs.filter((i) => !i.contest).map((i) => i._id), priority, {}, { rejudge: true }),
]);
this.back();
}
@param('target', Types.String)
async postCopy({ }, target: string) {
let t = `,${this.domain.share || ''},`;
if (t !== ',*,' && !t.includes(`,${target},`)) throw new ProblemNotAllowCopyError(this.domain._id, target);
let ddoc = await domain.get(target);
if (!ddoc) throw new NotFoundError(target);
const dudoc = await user.getById(target, this.user._id);
let pdoc = this.pdoc;
if (this.pdoc.reference) {
[pdoc, ddoc] = await Promise.all([
problem.get(this.pdoc.reference.domainId, this.pdoc.reference.pid),
domain.get(this.pdoc.reference.domainId),
if (rdocs.length) {
const priority = await record.submissionPriority(this.user._id, -10000 - rdocs.length * 5 - 50);
await record.reset(domainId, rdocs.map((rdoc) => rdoc._id), true);
await Promise.all([
record.judge(domainId, rdocs.filter((i) => i.contest).map((i) => i._id), priority, { detail: false }, { rejudge: true }),
record.judge(domainId, rdocs.filter((i) => !i.contest).map((i) => i._id), priority, {}, { rejudge: true }),
]);
if (!pdoc) throw new ProblemNotFoundError(this.pdoc.reference.domainId, this.pdoc.reference.pid);
t = `,${ddoc.domain.share || ''},`;
if (t !== ',*,' && !t.includes(`,${target},`)) throw new ProblemNotAllowCopyError(ddoc._id, target);
}
if (!dudoc.hasPerm(PERM.PERM_CREATE_PROBLEM)) throw new PermissionError(PERM.PERM_CREATE_PROBLEM);
const docId = await problem.copy(pdoc.domainId, pdoc.docId, target);
this.response.redirect = this.url('problem_detail', { domainId: target, pid: docId });
this.back();
}
async postDelete() {
@@ -498,16 +495,14 @@ export class ProblemSubmitHandler extends ProblemDetailHandler {
const file = this.request.files?.file;
if (!file || file.size === 0) throw new ValidationError('code');
const sizeLimit = config.type === 'submit_answer' ? 128 * 1024 * 1024 : lengthLimit;
if (file.size > sizeLimit) throw new ValidationError('file');
const showReadFile = () => {
if (file.size > sizeLimit) throw new FileTooLargeError('file');
const shouldReadFile = () => {
if (config.type === 'objective') return true;
if (lang === '_') return true;
if (lang === '_') return false;
return file.size < lengthLimit && !file.filepath.endsWith('.zip') && !setting.langs[lang].isBinary;
};
if (showReadFile()) {
// TODO submission file shape
code = await readFile(file.filepath, 'utf-8');
} else {
if (shouldReadFile()) code = await readFile(file.filepath, 'utf-8');
else {
const id = nanoid();
await storage.put(`submission/${this.user._id}/${id}`, file.filepath, this.user._id);
files.code = `${this.user._id}/${id}#${file.originalFilename}`;
@@ -710,32 +705,37 @@ export class ProblemFilesHandler extends ProblemDetailHandler {
@post('filename', Types.Filename, true)
@post('type', Types.Range(['testdata', 'additional_file']), true)
async postUploadFile(domainId: string, filename: string, type = 'testdata') {
if (!this.request.files.file) throw new ValidationError('file');
filename ||= this.request.files.file.originalFilename || String.random(16);
const file = this.request.files.file;
if (!file) throw new ValidationError('file');
filename ||= file.originalFilename || randomstring(16);
const files = [];
if (filename.endsWith('.zip') && type === 'testdata') {
let zip: AdmZip;
const zip = new ZipReader(Readable.toWeb(createReadStream(file.filepath)));
let entries: Entry[];
try {
zip = new AdmZip(this.request.files.file.filepath);
entries = await zip.getEntries();
} catch (e) {
throw new ValidationError('zip', null, e.message);
}
const entries = zip.getEntries();
for (const entry of entries) {
if (!entry.name || entry.isDirectory) continue;
if (!entry.filename || entry.directory) continue;
files.push({
type,
name: sanitize(entry.name),
size: entry.header.size,
data: () => entry.getData(),
name: sanitize(entry.filename),
size: entry.uncompressedSize,
data: () => {
const pass = new PassThrough();
entry.getData(Writable.toWeb(pass));
return pass;
},
});
}
} else {
files.push({
type,
name: filename,
size: statSync(this.request.files.file.filepath).size,
data: () => this.request.files.file.filepath,
size: file.size,
data: () => file.filepath,
});
}
if (!this.user.hasPriv(PRIV.PRIV_EDIT_SYSTEM)) {
@@ -800,7 +800,7 @@ export class ProblemFileDownloadHandler extends ProblemDetailHandler {
@param('filename', Types.Filename)
@param('noDisposition', Types.Boolean)
@query('tid', Types.ObjectId, true)
async get(domainId: string, type = 'additional_file', filename: string, noDisposition = false, tid: ObjectId) {
async get({ }, type = 'additional_file', filename: string, noDisposition = false, tid: ObjectId) {
if (!tid) this.checkPerm(PERM.PERM_VIEW_PROBLEM);
if (this.pdoc.reference) {
if (type === 'testdata') throw new ProblemIsReferencedError('download testdata');
@@ -900,9 +900,8 @@ export class ProblemSolutionHandler extends ProblemDetailHandler {
@param('content', Types.Content)
async postEditReply(domainId: string, psid: ObjectId, psrid: ObjectId, content: string) {
const [psdoc, psrdoc] = await solution.getReply(domainId, psid, psrid);
if ((!psdoc) || psdoc.parentId !== this.pdoc.docId) throw new SolutionNotFoundError(domainId, psid);
if (!(this.user.own(psrdoc)
&& this.user.hasPerm(PERM.PERM_EDIT_PROBLEM_SOLUTION_REPLY_SELF))) {
if (!psdoc || psdoc.parentId !== this.pdoc.docId) throw new SolutionNotFoundError(domainId, psid);
if (!this.user.own(psrdoc) || !this.user.hasPerm(PERM.PERM_EDIT_PROBLEM_SOLUTION_REPLY_SELF)) {
throw new PermissionError(PERM.PERM_EDIT_PROBLEM_SOLUTION_REPLY_SELF);
}
await solution.editReply(domainId, psid, psrid, content);
@@ -913,9 +912,8 @@ export class ProblemSolutionHandler extends ProblemDetailHandler {
@param('psrid', Types.ObjectId)
async postDeleteReply(domainId: string, psid: ObjectId, psrid: ObjectId) {
const [psdoc, psrdoc] = await solution.getReply(domainId, psid, psrid);
if ((!psdoc) || psdoc.parentId !== this.pdoc.docId) throw new SolutionNotFoundError(psid);
if (!(this.user.own(psrdoc)
&& this.user.hasPerm(PERM.PERM_DELETE_PROBLEM_SOLUTION_REPLY_SELF))) {
if (!psdoc || psdoc.parentId !== this.pdoc.docId) throw new SolutionNotFoundError(psid);
if (!this.user.own(psrdoc) || !this.user.hasPerm(PERM.PERM_DELETE_PROBLEM_SOLUTION_REPLY_SELF)) {
this.checkPerm(PERM.PERM_DELETE_PROBLEM_SOLUTION_REPLY);
}
await solution.delReply(domainId, psid, psrid);
@@ -1022,6 +1020,38 @@ export class ProblemCreateHandler extends Handler {
}
}
export const ProblemApi = {
problem: Query(
Schema.object({
id: Schema.union([Schema.number().step(1), Schema.string()]).required(),
domainId: Schema.string().required(),
}),
async (ctx, args) => {
const pdoc = await problem.get(args.domainId, args.id);
if (!pdoc) return null;
if (pdoc.hidden) ctx.checkPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN);
return pdoc;
},
),
problems: Query(
Schema.object({
ids: Schema.array(Schema.number().step(1)).required(),
domainId: Schema.string().required(),
}),
async (ctx, args) => {
const pdocs = await problem.getList(args.domainId, args.ids, ctx.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN) || ctx.user._id,
undefined, undefined, true);
return Object.keys(pdocs).map((id) => pdocs[+id]);
},
),
} as const;
declare module '@hydrooj/framework' {
interface Apis {
problem: typeof ProblemApi;
}
}
export async function apply(ctx: Context) {
ctx.Route('problem_main', '/p', ProblemMainHandler, PERM.PERM_VIEW_PROBLEM);
ctx.Route('problem_random', '/problem/random', ProblemRandomHandler, PERM.PERM_VIEW_PROBLEM);
@@ -1038,65 +1068,7 @@ export async function apply(ctx: Context) {
ctx.Route('problem_solution_reply_raw', '/p/:pid/solution/:psid/:psrid/raw', ProblemSolutionRawHandler, PERM.PERM_VIEW_PROBLEM);
ctx.Route('problem_statistics', '/p/:pid/stat', ProblemStatisticsHandler, PERM.PERM_VIEW_PROBLEM);
ctx.Route('problem_create', '/problem/create', ProblemCreateHandler, PERM.PERM_CREATE_PROBLEM);
ctx.inject(['api'], ({ api }) => {
api.value('FileInfo', [
['_id', 'String!'],
['name', 'String!'],
['size', 'Int'],
['lastModified', 'Date'],
]);
api.value('Problem', [
['_id', 'ObjectID!'],
['owner', 'Int!'],
['domainId', 'String!'],
['docId', 'Int!'],
['docType', 'Int!'],
['pid', 'String'],
['title', 'String!'],
['content', 'String!'],
['data', '[FileInfo]'],
['additional_file', '[FileInfo]'],
['nSubmit', 'Int'],
['nAccept', 'Int'],
['difficulty', 'Int'],
['tag', '[String]'],
['hidden', 'Boolean'],
]);
api.resolver(
'Query', 'problem(id: Int, pid: String)', 'Problem',
async (arg, c) => {
c.checkPerm(PERM.PERM_VIEW);
const pdoc = await problem.get(c.args.domainId, arg.pid || arg.id);
if (!pdoc) return null;
if (pdoc.hidden) c.checkPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN);
c.pdoc = pdoc;
return pdoc;
},
);
api.resolver('Query', 'problems(ids: [Int])', '[Problem]', async (arg, c) => {
c.checkPerm(PERM.PERM_VIEW);
const res = await problem.getList(c.args.domainId, arg.ids, c.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN) || c.user._id,
undefined, undefined, true);
return Object.keys(res).map((id) => res[+id]);
}, 'Get a list of problem by ids');
api.resolver(
'Problem', 'manage', 'ProblemManage',
(arg, c) => {
if (!c.user.own(c.pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) c.checkPerm(PERM.PERM_EDIT_PROBLEM);
return {};
},
);
api.resolver(
'ProblemManage', 'delete', 'Boolean!',
async (arg, c) => {
const tdocs = await contest.getRelated(c.args.domainId, c.pdoc.docId);
if (tdocs.length) throw new ProblemAlreadyUsedByContestError(c.pdoc.docId, tdocs[0]._id);
return problem.del(c.pdoc.domainId, c.pdoc.docId);
},
);
api.resolver(
'ProblemManage', 'edit(title: String, content: String, tag: [String], hidden: Boolean)', 'Problem!',
(arg, c) => problem.edit(c.args.domainId, c.pdoc.docId, arg),
);
await ctx.inject(['api'], ({ api }) => {
api.provide(ProblemApi);
});
}
+32 -16
View File
@@ -25,8 +25,6 @@ import { ContestDetailBaseHandler } from './contest';
import { postJudge } from './judge';
class RecordListHandler extends ContestDetailBaseHandler {
tdoc?: Tdoc;
@param('page', Types.PositiveInt, true)
@param('pid', Types.ProblemId, true)
@param('tid', Types.ObjectId, true)
@@ -131,7 +129,6 @@ class RecordListHandler extends ContestDetailBaseHandler {
class RecordDetailHandler extends ContestDetailBaseHandler {
rdoc: RecordDoc;
tdoc?: Tdoc;
@param('rid', Types.ObjectId)
async prepare(domainId: string, rid: ObjectId) {
@@ -156,9 +153,15 @@ class RecordDetailHandler extends ContestDetailBaseHandler {
@param('rid', Types.ObjectId)
@param('download', Types.Boolean)
@param('rev', Types.ObjectId, true)
// eslint-disable-next-line consistent-return
async get(domainId: string, rid: ObjectId, download = false) {
const rdoc = this.rdoc;
async get(domainId: string, rid: ObjectId, download = false, rev?: ObjectId) {
let rdoc = this.rdoc;
const allRev = await record.collHistory.find({ rid }).project({ _id: 1, judgeAt: 1 }).sort({ _id: -1 }).toArray();
const allRevs: Record<string, Date> = Object.fromEntries(allRev.map((i) => [i._id.toString(), i.judgeAt]));
if (rev && allRevs[rev.toString()]) {
rdoc = { ...rdoc, ...omit(await record.collHistory.findOne({ _id: rev }), ['_id']), progress: null };
}
let canViewDetail = true;
if (rdoc.contest?.toString().startsWith('0'.repeat(23))) {
if (rdoc.uid !== this.user._id) throw new PermissionError(PERM.PERM_READ_RECORD_CODE);
@@ -201,7 +204,7 @@ class RecordDetailHandler extends ContestDetailBaseHandler {
} else if (download) return await this.download();
this.response.template = 'record_detail.html';
this.response.body = {
udoc, rdoc: canViewDetail ? rdoc : pick(rdoc, ['_id', 'lang', 'code']), pdoc, tdoc: this.tdoc,
udoc, rdoc: canViewDetail ? rdoc : pick(rdoc, ['_id', 'lang', 'code']), pdoc, tdoc: this.tdoc, rev, allRevs,
};
}
@@ -257,6 +260,7 @@ class RecordMainConnectionHandler extends ConnectionHandler {
pretest = false;
tdoc: Tdoc;
applyProjection = false;
noTemplate = false;
queue: Map<string, () => Promise<any>> = new Map();
throttleQueueClear: () => void;
@@ -267,9 +271,10 @@ class RecordMainConnectionHandler extends ConnectionHandler {
@param('pretest', Types.Boolean)
@param('all', Types.Boolean)
@param('allDomain', Types.Boolean)
@param('noTemplate', Types.Boolean, true)
async prepare(
domainId: string, tid?: ObjectId, pid?: string | number, uidOrName?: string,
status?: number, pretest = false, all = false, allDomain = false,
status?: number, pretest = false, all = false, allDomain = false, noTemplate = false,
) {
if (tid) {
this.tdoc = await contest.get(domainId, tid);
@@ -308,6 +313,7 @@ class RecordMainConnectionHandler extends ConnectionHandler {
this.checkPriv(PRIV.PRIV_MANAGE_ALL_DOMAIN);
this.allDomain = true;
}
this.noTemplate = noTemplate;
this.throttleQueueClear = throttle(this.queueClear, 100, { trailing: true });
}
@@ -346,8 +352,11 @@ class RecordMainConnectionHandler extends ConnectionHandler {
if (!this.user.hasPerm(PERM.PERM_VIEW_PROBLEM)) pdoc = null;
}
if (this.applyProjection && typeof rdoc.input !== 'string') rdoc = contest.applyProjection(tdoc, rdoc, this.user);
if (this.pretest) this.queueSend(rdoc._id.toHexString(), async () => ({ rdoc: omit(rdoc, ['code', 'input']) }));
else {
if (this.pretest) {
this.queueSend(rdoc._id.toHexString(), async () => ({ rdoc: omit(rdoc, ['code', 'input']) }));
} else if (this.noTemplate) {
this.queueSend(rdoc._id.toHexString(), async () => ({ rdoc }));
} else {
this.queueSend(rdoc._id.toHexString(), async () => ({
html: await this.renderHTML('record_main_tr.html', {
rdoc, udoc, pdoc, tdoc, allDomain: this.allDomain,
@@ -374,9 +383,11 @@ class RecordDetailConnectionHandler extends ConnectionHandler {
disconnectTimeout: NodeJS.Timeout;
throttleSend: any;
applyProjection = false;
noTemplate = false;
@param('rid', Types.ObjectId)
async prepare(domainId: string, rid: ObjectId) {
@param('noTemplate', Types.Boolean, true)
async prepare(domainId: string, rid: ObjectId, noTemplate = false) {
const rdoc = await record.get(domainId, rid);
if (!rdoc) return;
if (rdoc.contest && ![record.RECORD_GENERATE, record.RECORD_PRETEST].some((i) => i.toHexString() === rdoc.contest.toHexString())) {
@@ -403,22 +414,27 @@ class RecordDetailConnectionHandler extends ConnectionHandler {
rdoc.compilerTexts = [];
}
if (!(rdoc.contest && this.user._id === rdoc.uid)) {
if (!rdoc.contest || this.user._id !== rdoc.uid) {
if (!problem.canViewBy(pdoc, this.user)) throw new PermissionError(PERM.PERM_VIEW_PROBLEM_HIDDEN);
}
this.pdoc = pdoc;
this.noTemplate = noTemplate;
this.throttleSend = throttle(this.sendUpdate, 1000, { trailing: true });
this.rid = rid.toString();
this.onRecordChange(rdoc);
}
async sendUpdate(rdoc: RecordDoc) {
this.send({
status: rdoc.status,
status_html: await this.renderHTML('record_detail_status.html', { rdoc, pdoc: this.pdoc }),
summary_html: await this.renderHTML('record_detail_summary.html', { rdoc, pdoc: this.pdoc }),
});
if (this.noTemplate) {
this.send({ rdoc });
} else {
this.send({
status: rdoc.status,
status_html: await this.renderHTML('record_detail_status.html', { rdoc, pdoc: this.pdoc }),
summary_html: await this.renderHTML('record_detail_summary.html', { rdoc, pdoc: this.pdoc }),
});
}
}
@subscribe('record/change')
+1 -3
View File
@@ -1,5 +1,4 @@
import assert from 'assert';
import { statSync } from 'fs-extra';
import { pick } from 'lodash';
import { Filter, ObjectId } from 'mongodb';
import { sortFiles } from '@hydrooj/utils/lib/utils';
@@ -261,8 +260,7 @@ export class TrainingFilesHandler extends Handler {
}
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;
const size = Math.sum((this.tdoc.files || []).map((i) => i.size)) + file.size;
if (size >= system.get('limit.contest_files_size')) {
throw new FileLimitExceededError('size');
}
+208 -143
View File
@@ -1,9 +1,13 @@
import { generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import moment from 'moment-timezone';
import type { Binary } from 'mongodb';
import { Binary } from 'mongodb';
import Schema from 'schemastery';
import { randomstring } from '@hydrooj/utils';
import type { Context } from '../context';
import {
AuthOperationError, BlacklistedError, BuiltinLoginError, ForbiddenError, InvalidTokenError,
AuthOperationError, BadRequestError, BlacklistedError, BuiltinLoginError,
ForbiddenError, InvalidTokenError, NotFoundError,
SystemError, UserAlreadyExistError, UserFacingError,
UserNotFoundError, ValidationError, VerifyPasswordError,
} from '../error';
@@ -15,7 +19,6 @@ import BlackListModel from '../model/blacklist';
import { PERM, PRIV, STATUS } from '../model/builtin';
import * as ContestModel from '../model/contest';
import domain from '../model/domain';
import oauth from '../model/oauth';
import * as oplog from '../model/oplog';
import problem, { ProblemDoc } from '../model/problem';
import ScheduleModel from '../model/schedule';
@@ -24,9 +27,21 @@ import * as system from '../model/system';
import token from '../model/token';
import user, { deleteUserCache } from '../model/user';
import {
Handler, param, post, Types,
Handler, param, post, Query, Types,
} from '../service/server';
async function successfulAuth(this: Handler, udoc: User) {
await user.setById(udoc._id, { loginat: new Date(), loginip: this.request.ip });
this.context.HydroContext.user = udoc;
this.session.viewLang = '';
this.session.uid = udoc._id;
this.session.sudo = null;
this.session.sudoUid = null;
this.session.scope = PERM.PERM_ALL.toString();
this.session.oauthBind = null;
this.session.recreate = true;
}
class UserLoginHandler extends Handler {
noCheckPermView = true;
async prepare() {
@@ -73,15 +88,9 @@ class UserLoginHandler extends Handler {
} else throw new ValidationError('2FA', 'Authn');
}
await 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, udoc.banReason);
this.context.HydroContext.user = udoc;
this.session.viewLang = '';
this.session.uid = udoc._id;
this.session.sudo = null;
this.session.scope = PERM.PERM_ALL.toString();
await successfulAuth.call(this, udoc);
this.session.save = rememberme;
this.session.recreate = true;
this.response.redirect = redirect || ((this.request.referer || '/login').endsWith('/login')
? this.url('homepage') : this.request.referer);
}
@@ -120,6 +129,8 @@ class UserSudoHandler extends Handler {
}
class UserTFAHandler extends Handler {
noCheckPermView = true;
@param('q', Types.String)
async get({ }, q: string) {
let udoc = await user.getByUname('system', q);
@@ -138,29 +149,41 @@ class UserWebauthnHandler extends Handler {
}
@param('uname', Types.Username, true)
async get(domainId: string, uname: string) {
const udoc = this.user._id ? this.user : ((await user.getByEmail(domainId, uname)) || await user.getByUname(domainId, uname));
if (!udoc._id) throw new UserNotFoundError(uname || 'user');
if (!udoc.authn) throw new AuthOperationError('authn', 'disabled');
@param('login', Types.Boolean)
async get(domainId: string, uname: string, login: boolean) {
let allowCredentials = [];
let uid = 0;
if (!login) {
const udoc = this.user._id ? this.user : ((await user.getByEmail(domainId, uname)) || await user.getByUname(domainId, uname));
if (!udoc._id) throw new UserNotFoundError(uname || 'user');
if (!udoc.authn) throw new AuthOperationError('authn', 'disabled');
allowCredentials = udoc._authenticators.map((authenticator) => ({
id: isoBase64URL.fromBuffer(authenticator.credentialID.buffer),
}));
uid = udoc._id;
}
const options = await generateAuthenticationOptions({
allowCredentials: udoc._authenticators.map((authenticator) => ({
id: authenticator.credentialID.buffer,
type: 'public-key',
})),
allowCredentials,
rpID: this.getAuthnHost(),
userVerification: 'preferred',
});
await token.add(token.TYPE_WEBAUTHN, 60, { uid: udoc._id }, options.challenge);
await token.add(token.TYPE_WEBAUTHN, 60, { uid: login ? 'login' : uid }, options.challenge);
this.session.challenge = options.challenge;
this.response.body.authOptions = options;
}
async post({ domainId, result }) {
async post({ domainId, result, redirect }) {
const challenge = this.session.challenge;
if (!challenge) throw new ForbiddenError();
const tdoc = await token.get(challenge, token.TYPE_WEBAUTHN);
if (!tdoc) throw new InvalidTokenError(token.TYPE_TEXTS[token.TYPE_WEBAUTHN]);
const udoc = await user.getById(domainId, tdoc.uid);
const udoc = await (tdoc.uid === 'login'
? (async () => {
const u = await user.coll.findOne({ 'authenticators.credentialID': Binary.createFromBase64(result.id) });
return u ? await user.getById(domainId, u._id) : null;
})()
: user.getById(domainId, tdoc.uid));
if (!udoc) throw new NotFoundError();
const parseId = (id: Binary) => Buffer.from(id.toString('hex'), 'hex').toString('base64url');
const authenticator = udoc._authenticators?.find((c) => parseId(c.credentialID) === result.id);
if (!authenticator) throw new ValidationError('authenticator');
@@ -169,17 +192,24 @@ class UserWebauthnHandler extends Handler {
expectedChallenge: challenge,
expectedOrigin: this.request.headers.origin,
expectedRPID: this.getAuthnHost(),
authenticator: {
credential: {
...authenticator,
credentialID: authenticator.credentialID.buffer,
credentialPublicKey: authenticator.credentialPublicKey.buffer,
id: isoBase64URL.fromBuffer(authenticator.credentialID.buffer),
publicKey: authenticator.credentialPublicKey.buffer,
},
}).catch(() => null);
if (!verification?.verified) throw new ValidationError('authenticator');
authenticator.counter = verification.authenticationInfo.newCounter;
await user.setById(udoc._id, { authenticators: udoc._authenticators });
await token.update(challenge, token.TYPE_WEBAUTHN, 60, { verified: true });
this.back();
if (tdoc.uid === 'login') {
await successfulAuth.call(this, await user.getById(domainId, udoc._id));
await token.del(challenge, token.TYPE_WEBAUTHN);
this.response.redirect = redirect || ((this.request.referer || '/login').endsWith('/login')
? this.url('homepage') : this.request.referer);
} else {
await token.update(challenge, token.TYPE_WEBAUTHN, 60, { verified: true });
this.back();
}
}
}
@@ -191,15 +221,12 @@ class UserLogoutHandler extends Handler {
}
async post({ domainId }) {
this.context.HydroContext.user = await user.getById(domainId, 0);
this.session.uid = 0;
this.session.sudo = null;
this.session.sudoUid = null;
this.session.scope = PERM.PERM_ALL.toString();
await successfulAuth.call(this, await user.getById(domainId, 0));
this.response.redirect = '/';
}
}
// rename to RegisterSendMailHandler
export class UserRegisterHandler extends Handler {
noCheckPermView = true;
async prepare() {
@@ -223,7 +250,15 @@ export class UserRegisterHandler extends Handler {
const t = await token.add(
token.TYPE_REGISTRATION,
system.get('session.unsaved_expire_seconds'),
{ mail, redirect: this.domain.registerRedirect },
{
mail,
redirect: this.domain.registerRedirect,
identity: {
provider: 'mail',
platform: 'mail',
id: mail,
},
},
);
const prefix = this.domain.host
? `${this.domain.host instanceof Array ? this.domain.host[0] : this.domain.host}`
@@ -233,7 +268,7 @@ export class UserRegisterHandler extends Handler {
path: `/register/${t[0]}`,
url_prefix: prefix.endsWith('/') ? prefix.slice(0, -1) : prefix,
});
await sendMail(mail, 'Sign Up', 'user_register_mail', m);
await sendMail(mail, 'Sign Up', 'user_register_mail', m.toString());
this.response.template = 'user_register_mail_sent.html';
this.response.body = { mail };
} else this.response.redirect = this.url('user_register_with_code', { code: t[0] });
@@ -247,7 +282,7 @@ class UserRegisterWithCodeHandler extends Handler {
@param('code', Types.String)
async prepare({ }, code: string) {
this.tdoc = await token.get(code, token.TYPE_REGISTRATION);
if (!this.tdoc || (!this.tdoc.mail && !this.tdoc.phone)) {
if (!this.tdoc?.identity) {
// prevent brute forcing tokens
await this.limitRate('user_register_with_code', 60, 5);
throw new InvalidTokenError(token.TYPE_TEXTS[token.TYPE_REGISTRATION], code);
@@ -267,28 +302,22 @@ class UserRegisterWithCodeHandler extends Handler {
domainId: string, password: string, verify: string,
uname = '', code: string,
) {
if (this.tdoc.oauth?.[0] && global.Hydro.module.oauth[this.tdoc.oauth[0]].lockUsername) {
uname = this.tdoc.username;
}
const provider = this.ctx.oauth.providers[this.tdoc.identity.provider];
if (!provider) throw new SystemError(`OAuth provider ${this.tdoc.identity.provider} not found`);
if (provider.lockUsername) uname = this.tdoc.identity.username;
if (!Types.Username[1](uname)) throw new ValidationError('uname');
if (password !== verify) throw new VerifyPasswordError();
if (this.tdoc.phone) this.tdoc.mail = `${String.random(12)}@hydro.local`;
const uid = await user.create(this.tdoc.mail, uname, password, undefined, this.request.ip);
const randomEmail = `${randomstring(12)}@invalid.local`; // some random email to remove in the future
const uid = await user.create(this.tdoc.mail || randomEmail, uname, password, undefined, this.request.ip);
await token.del(code, token.TYPE_REGISTRATION);
const [id, mailDomain] = this.tdoc.mail.split('@');
const $set: any = this.tdoc.set || {};
if (this.tdoc.phone) $set.phone = this.tdoc.phone;
if (mailDomain === 'qq.com' && !Number.isNaN(+id)) $set.avatar = `qq:${id}`;
if (this.session.viewLang) $set.viewLang = this.session.viewLang;
if (Object.keys($set).length) await user.setById(uid, $set);
if (Object.keys(this.tdoc.setInDomain || {}).length) await domain.setUserInDomain(domainId, uid, this.tdoc.setInDomain);
if (this.tdoc.oauth) await oauth.set(this.tdoc.oauth[1], uid);
this.context.HydroContext.user = await user.getById(domainId, uid);
this.session.viewLang = '';
this.session.uid = uid;
this.session.sudoUid = null;
this.session.scope = PERM.PERM_ALL.toString();
this.session.recreate = true;
await this.ctx.oauth.set(this.tdoc.identity.platform, this.tdoc.identity.id, uid);
await successfulAuth.call(this, await user.getById(domainId, uid));
this.response.redirect = this.tdoc.redirect || this.url('home_settings', { category: 'preference' });
}
}
@@ -323,7 +352,7 @@ class UserLostPassHandler extends Handler {
url_prefix: prefix.endsWith('/') ? prefix.slice(0, -1) : prefix,
uname: udoc.uname,
});
await sendMail(mail, 'Lost Password', 'user_lostpass_mail', m);
await sendMail(mail, 'Lost Password', 'user_lostpass_mail', m.toString());
this.response.template = 'user_lostpass_mail_sent.html';
}
}
@@ -422,7 +451,7 @@ class OauthHandler extends Handler {
@param('type', Types.Key)
async get(domainId: string, type: string) {
await global.Hydro.module.oauth[type]?.get.call(this);
await this.ctx.oauth.providers[type]?.get.call(this);
}
}
@@ -430,58 +459,61 @@ class OauthCallbackHandler extends Handler {
noCheckPermView = true;
async get(args: any) {
if (!global.Hydro.module.oauth[args.type]) throw new UserFacingError('Oauth type');
const r = await global.Hydro.module.oauth[args.type].callback.call(this, args);
const uid = await oauth.get(r._id);
if (uid) {
await user.setById(uid, { loginat: new Date(), loginip: this.request.ip });
this.session.uid = uid;
this.session.scope = PERM.PERM_ALL.toString();
this.response.redirect = '/';
} else {
if (r.email) {
const udoc = await user.getByEmail('system', r.email);
if (udoc) {
await oauth.set(r._id, udoc._id);
await user.setById(udoc._id, { loginat: new Date(), loginip: this.request.ip });
this.session.uid = udoc._id;
this.session.scope = PERM.PERM_ALL.toString();
this.session.recreate = true;
this.response.redirect = '/';
return;
}
const provider = this.ctx.oauth.providers[args.type];
if (!provider) throw new UserFacingError('Oauth type');
const r = await provider.callback.call(this, args);
if (this.session.oauthBind === args.type) {
delete this.session.oauthBind;
const existing = await this.ctx.oauth.get(args.type, r._id);
if (existing && existing !== this.user._id) {
throw new BadRequestError('Already binded to another account');
}
this.checkPriv(PRIV.PRIV_REGISTER_USER);
let username = '';
r.uname ||= [];
const mailDomain = r.email.split('@')[1];
if (await BlackListModel.get(`mail::${mailDomain}`)) throw new BlacklistedError(mailDomain);
for (const uname of r.uname) {
// eslint-disable-next-line no-await-in-loop
const nudoc = await user.getByUname('system', uname);
if (!nudoc) {
username = uname;
break;
}
}
const set: Partial<Udoc> = { ...(r.set || {}), oauth: args.type };
if (r.bio) set.bio = r.bio;
if (r.viewLang) set.viewLang = r.viewLang;
if (r.avatar) set.avatar = r.avatar;
const [t] = await token.add(
token.TYPE_REGISTRATION,
system.get('session.unsaved_expire_seconds'),
{
mail: r.email,
username,
redirect: this.domain.registerRedirect,
set,
setInDomain: r.setInDomain,
oauth: [args.type, r._id],
},
);
this.response.redirect = this.url('user_register_with_code', { code: t });
this.response.redirect = '/home/security';
if (existing !== this.user._id) await this.ctx.oauth.set(args.type, r._id, this.user._id);
return;
}
const uid = await this.ctx.oauth.get(args.type, r._id) || await this.ctx.oauth.get('mail', r.email);
if (uid) {
await successfulAuth.call(this, await user.getById('system', uid));
this.response.redirect = '/';
return;
}
if (!provider.canRegister) throw new ForbiddenError('No binded account found');
this.checkPriv(PRIV.PRIV_REGISTER_USER);
let username = '';
r.uname ||= [];
const mailDomain = r.email.split('@')[1];
if (await BlackListModel.get(`mail::${mailDomain}`)) throw new BlacklistedError(mailDomain);
for (const uname of r.uname) {
// eslint-disable-next-line no-await-in-loop
const nudoc = await user.getByUname('system', uname);
if (!nudoc) {
username = uname;
break;
}
}
const set: Partial<Udoc> = { ...r.set };
if (r.bio) set.bio = r.bio;
if (r.viewLang) set.viewLang = r.viewLang;
if (r.avatar) set.avatar = r.avatar;
const [t] = await token.add(
token.TYPE_REGISTRATION,
system.get('session.unsaved_expire_seconds'),
{
mail: r.email,
username,
redirect: this.domain.registerRedirect,
set,
setInDomain: r.setInDomain,
identity: {
provider: args.type,
platform: args.type,
id: r._id,
},
},
);
this.response.redirect = this.url('user_register_with_code', { code: t });
}
}
@@ -503,6 +535,69 @@ class ContestModeHandler extends Handler {
}
}
export const inject = ['oauth'];
const UserApi = {
user: Query(Schema.object({
id: Schema.number().step(1),
uname: Schema.string(),
mail: Schema.string(),
domainId: Schema.string().required(),
}), (c, arg) => {
if (arg.id) return user.getById(arg.domainId, arg.id);
if (arg.mail) return user.getByEmail(arg.domainId, arg.mail);
if (arg.uname) return user.getByUname(arg.domainId, arg.uname);
return user.getById(arg.domainId, c.user._id);
}),
users: Query(Schema.object({
ids: Schema.array(Schema.number().step(1)),
auto: Schema.array(Schema.string()),
search: Schema.string(),
limit: Schema.number().step(1),
exact: Schema.boolean(),
}), async (c, arg) => {
const auto = (arg.ids?.length && arg.ids) || arg.auto || [];
if (auto.length) {
const maybeId = auto.filter((i) => !Number.isNaN(+i));
const result = [];
if (maybeId.length) {
const udocs = await user.getList(arg.domainId, maybeId.map((i) => +i));
for (const i in udocs) udocs[i].avatarUrl = avatar(udocs[i].avatar);
result.push(...Object.values(udocs));
}
const notFound = auto.filter((i) => !result.find((j) => j._id === +i));
if (notFound.length > 50) return result; // reject if too many
for (const i of notFound) {
// eslint-disable-next-line no-await-in-loop
const udoc = await user.getByUname(arg.domainId, i.toString()) || await user.getByEmail(arg.domainId, i.toString());
if (udoc) result.push(udoc);
}
return result;
}
if (!arg.search) return [];
const udoc = await user.getById(arg.domainId, +arg.search)
|| await user.getByUname(arg.domainId, arg.search)
|| await user.getByEmail(arg.domainId, arg.search);
const udocs: User[] = arg.exact
? []
: await user.getPrefixList(arg.domainId, arg.search, Math.min(arg.limit || 10, 10));
if (udoc && !udocs.find((i) => i._id === udoc._id)) {
udocs.pop();
udocs.unshift(udoc);
}
for (const i in udocs) {
udocs[i].avatarUrl = avatar(udocs[i].avatar);
}
return udocs;
}),
} as const;
declare module '@hydrooj/framework' {
interface Apis {
user: typeof UserApi;
}
}
export async function apply(ctx: Context) {
ctx.Route('user_login', '/login', UserLoginHandler);
ctx.Route('user_oauth', '/oauth/:type', OauthHandler);
@@ -520,48 +615,18 @@ export async function apply(ctx: Context) {
if (system.get('server.contestmode')) {
ctx.Route('contest_mode', '/contestmode', ContestModeHandler, PRIV.PRIV_EDIT_SYSTEM);
}
ctx.inject(['api'], ({ api }) => {
api.value('User', [
['_id', 'Int!'],
['uname', 'String!'],
['mail', 'String!'],
['perm', 'String'],
['role', 'String'],
['loginat', 'Date'],
['regat', 'Date!'],
['priv', 'Int!', 'User Privilege'],
['avatarUrl', 'String'],
['displayName', 'String @if(perm: "PERM_VIEW_DISPLAYNAME")'],
['rpInfo', 'JSONObject'],
]);
api.resolver('Query', 'user(id: Int, uname: String, mail: String)', 'User', (arg, c) => {
if (arg.id) return user.getById(c.args.domainId, arg.id);
if (arg.mail) return user.getByEmail(c.args.domainId, arg.mail);
if (arg.uname) return user.getByUname(c.args.domainId, arg.uname);
return c.user;
}, `Get a user by id, uname, or mail.
Returns current user if no argument is provided.`);
api.resolver('Query', 'users(ids: [Int], search: String, limit: Int, exact: Boolean)', '[User]', async (arg, c) => {
if (arg.ids?.length) {
const res = await user.getList(c.args.domainId, arg.ids);
for (const i in res) res[i].avatarUrl = avatar(res[i].avatar);
return Object.keys(res).map((id) => res[+id]);
}
if (!arg.search) return [];
const udoc = await user.getById(c.args.domainId, +arg.search)
|| await user.getByUname(c.args.domainId, arg.search)
|| await user.getByEmail(c.args.domainId, arg.search);
const udocs: User[] = arg.exact
? []
: await user.getPrefixList(c.args.domainId, arg.search, Math.min(arg.limit || 10, 10));
if (udoc && !udocs.find((i) => i._id === udoc._id)) {
udocs.pop();
udocs.unshift(udoc);
}
for (const i in udocs) {
udocs[i].avatarUrl = avatar(udocs[i].avatar);
}
return udocs;
}, 'Get a list of user by ids, or search users with the prefix.');
ctx.oauth.provide('mail', {
text: 'Mail',
name: 'mail',
hidden: true,
async get() {
throw new NotFoundError();
},
async callback() {
throw new NotFoundError();
},
});
await ctx.inject(['api'], ({ api }) => {
api.provide(UserApi);
});
}
+4 -10
View File
@@ -1,5 +1,5 @@
const versionNum = +process.version.replace(/v/gim, '').split('.')[0];
if (versionNum < 18) throw new Error('NodeJS >=18 required');
if (versionNum < 22) throw new Error('NodeJS >=22 required');
console.log('Process', process.pid, 'running as', process.env.NODE_APP_INSTANCE === '0' ? 'master' : 'worker');
if (!global.Hydro) {
@@ -9,12 +9,8 @@ if (!global.Hydro) {
hydrooj: require('hydrooj/package.json').version,
},
// @ts-ignore
service: {},
// @ts-ignore
model: {},
script: {},
// @ts-ignore
lib: {},
module: new Proxy({} as any, {
get(self, key) {
self[key] ||= {};
@@ -22,16 +18,14 @@ if (!global.Hydro) {
},
}),
// @ts-ignore
ui: {
template: {},
},
ui: {},
// @ts-ignore
error: {},
locales: {},
};
global.addons = [];
global.addons = {};
}
global.app = new (require('./context').Context)();
process.on('exit', () => {
app.stop();
});
+30 -207
View File
@@ -1,13 +1,17 @@
import type { AuthenticationExtensionsAuthenticatorOutputs } from '@simplewebauthn/server/esm/helpers/decodeAuthenticatorExtensions';
import type { AttestationFormat } from '@simplewebauthn/server/helpers';
import { CredentialDeviceType } from '@simplewebauthn/types';
import type { AttestationFormat, CredentialDeviceType } from '@simplewebauthn/server';
import type { ParsedAuthenticatorData } from '@simplewebauthn/server/helpers';
import type fs from 'fs';
import type { Dictionary, NumericDictionary } from 'lodash';
import type { Binary, FindCursor, ObjectId } from 'mongodb';
import type {
FileInfo, RecordJudgeInfo, RecordPayload,
} from '@hydrooj/common/types';
import type { Context } from './context';
import type { DocStatusType } from './model/document';
import type { OauthMap } from './model/oauth';
import type { ProblemDoc } from './model/problem';
import type { Handler } from './service/server';
export * from '@hydrooj/common/types';
type document = typeof import('./model/document');
@@ -51,17 +55,6 @@ export interface Setting {
flag: number,
}
export interface OAuthUserResponse {
_id: string;
email: string;
avatar?: string;
bio?: string;
uname?: string[];
viewLang?: string;
set?: Record<string, any>;
setInDomain?: Record<string, any>;
}
export interface Authenticator {
name: string;
regat: number;
@@ -76,7 +69,7 @@ export interface Authenticator {
userVerified: boolean;
credentialDeviceType: CredentialDeviceType;
credentialBackedUp: boolean;
authenticatorExtensionResults?: AuthenticationExtensionsAuthenticatorOutputs;
authenticatorExtensionResults?: ParsedAuthenticatorData['extensionsData'];
authenticatorAttachment: 'platform' | 'cross-platform';
}
@@ -142,73 +135,6 @@ export interface BaseUser {
}
export type BaseUserDict = Record<number, BaseUser>;
export interface FileInfo {
/** storage path */
_id: string,
/** filename */
name: string,
/** file size (in bytes) */
size: number,
etag: string,
lastModified: Date,
}
export interface TestCaseConfig {
input: string;
output: string;
time?: string;
memory?: string;
score?: number;
}
export enum ProblemType {
Default = 'default',
SubmitAnswer = 'submit_answer',
Interactive = 'interactive',
Objective = 'objective',
Remote = 'remote_judge',
}
export enum SubtaskType {
min = 'min',
max = 'max',
sum = 'sum',
}
export interface SubtaskConfig {
time?: string;
memory?: string;
score?: number;
if?: number[];
id?: number;
type?: SubtaskType;
cases?: TestCaseConfig[];
}
export interface ProblemConfigFile {
type?: ProblemType;
subType?: string;
target?: string;
score?: number;
time?: string;
memory?: string;
filename?: string;
checker_type?: string;
checker?: string;
interactor?: string;
user_extra_files?: string[];
judge_extra_files?: string[];
detail?: boolean;
answers?: Record<string, [string | string[], number]>;
redirect?: string;
cases?: TestCaseConfig[];
subtasks?: SubtaskConfig[];
langs?: string[];
validator?: string;
time_limit_rate?: Record<string, number>;
memory_limit_rate?: Record<string, number>;
}
export interface ProblemConfig {
redirect?: [string, string];
count: number;
@@ -236,7 +162,6 @@ export interface Document {
}
declare module './model/problem' {
// eslint-disable-next-line @typescript-eslint/no-shadow
interface ProblemDoc {
docType: document['TYPE_PROBLEM'];
docId: number;
@@ -282,45 +207,15 @@ export interface ProblemStatusDoc extends StatusDocBase {
star?: boolean;
}
export interface TestCase {
id?: number;
subtaskId?: number;
score?: number;
time: number;
memory: number;
status: number;
message: string;
}
export interface RecordDoc {
export type RecordDoc = {
[K in keyof RecordPayload]: K extends 'hackTarget' | 'contest' ? ObjectId : RecordPayload[K];
} & {
_id: ObjectId;
domainId: string;
pid: number;
uid: number;
lang: string;
code: string;
score: number;
memory: number;
time: number;
judgeTexts: (string | JudgeMessage)[];
compilerTexts: string[];
testCases: Required<TestCase>[];
rejudged: boolean;
source?: string;
/** judge uid */
judger: number;
judgeAt: Date;
status: number;
progress?: number;
/** pretest */
input?: string;
/** hack target rid */
hackTarget?: ObjectId;
/** 0 if pretest&script */
contest?: ObjectId;
};
files?: Record<string, string>
subtasks?: Record<number, SubtaskResult>;
export interface RecordHistoryDoc extends RecordJudgeInfo {
_id: ObjectId;
rid: ObjectId;
}
export interface RecordStatDoc {
@@ -333,24 +228,6 @@ export interface RecordStatDoc {
length: number;
lang: string;
}
export interface JudgeMeta {
problemOwner: number;
hackRejudge?: string;
rejudge?: boolean;
// FIXME stricter types
type?: string;
}
export interface JudgeRequest extends Omit<RecordDoc, '_id' | 'testCases'> {
priority: number;
type: 'judge' | 'generate';
rid: ObjectId;
config: ProblemConfigFile;
meta: JudgeMeta;
data: FileInfo[];
source: string;
trusted: boolean;
}
export interface ScoreboardNode {
type: 'string' | 'rank' | 'user' | 'email' | 'record' | 'records' | 'problem' | 'solved' | 'time' | 'total_score';
@@ -393,6 +270,7 @@ export interface Tdoc extends Document {
autoHide?: boolean;
balloon?: Record<number, string | { color: string, name: string }>;
score?: Record<number, number>;
langs?: string[];
/**
* In hours
@@ -428,7 +306,6 @@ export interface DomainDoc extends Record<string, any> {
// Message
export interface MessageDoc {
_id: ObjectId,
from: number,
to: number,
content: string,
@@ -567,39 +444,6 @@ export interface Script {
validate: any,
}
export interface JudgeMessage {
message: string;
params?: string[];
stack?: string;
}
export interface SubtaskResult {
type: SubtaskType;
score: number;
status: number;
}
export interface JudgeResultBody {
key: string;
domainId: string;
rid: ObjectId;
judger?: number;
progress?: number;
addProgress?: number;
case?: TestCase;
cases?: TestCase[];
status?: number;
score?: number;
/** in miliseconds */
time?: number;
/** in kilobytes */
memory?: number;
message?: string | JudgeMessage;
compilerText?: string;
nop?: boolean;
subtasks?: Record<number, SubtaskResult>;
}
export interface Task {
_id: ObjectId;
type: string;
@@ -640,6 +484,7 @@ export interface EventDoc {
event: number | string;
payload: string;
expire: Date;
trace?: string;
}
export interface OpCountDoc {
@@ -650,12 +495,7 @@ export interface OpCountDoc {
opcount: number;
}
export interface OauthMap {
/** source openId */
_id: string;
/** target uid */
uid: number;
}
export type { OauthMap, OAuthProvider, OAuthUserResponse } from './model/oauth';
export interface DiscussionHistoryDoc {
title?: string;
@@ -680,6 +520,13 @@ export interface ContestBalloonDoc {
sentAt?: Date;
}
export interface LockDoc {
_id: ObjectId;
key: string;
lockAt: Date;
daemonId: string;
}
declare module './service/db' {
interface Collections {
'blacklist': BlacklistDoc;
@@ -687,6 +534,7 @@ declare module './service/db' {
'domain.user': any;
'record': RecordDoc;
'record.stat': RecordStatDoc;
'record.history': RecordHistoryDoc;
'document': any;
'document.status': StatusDocBase & {
[K in keyof DocStatusType]: { docType: K } & DocStatusType[K];
@@ -709,6 +557,7 @@ declare module './service/db' {
'opcount': OpCountDoc;
'schedule': Schedule;
'contest.balloon': ContestBalloonDoc;
'lock': LockDoc;
}
}
@@ -737,14 +586,6 @@ export interface Model {
rp: typeof import('./script/rating').RpTypes,
}
export interface HydroService {
/** @deprecated */
bus: Context,
db: typeof import('./service/db').default,
server: typeof import('./service/server'),
storage: typeof import('./service/storage').default,
}
export interface GeoIP {
provider: string,
lookup: (ip: string, locale?: string) => any,
@@ -762,40 +603,22 @@ export interface ProblemSearchOptions {
export type ProblemSearch = (domainId: string, q: string, options?: ProblemSearchOptions) => Promise<ProblemSearchResponse>;
export interface Lib extends Record<string, any> {
difficulty: typeof import('./lib/difficulty').default;
buildContent: typeof import('./lib/content').buildContent;
mail: typeof import('./lib/mail');
rating: typeof import('./lib/rating').default;
testdataConfig: typeof import('./lib/testdataConfig');
problemSearch: ProblemSearch;
}
export type UIInjectableFields = 'ProblemAdd' | 'Notification' | 'Nav' | 'UserDropdown' | 'DomainManage' | 'ControlPanel';
export interface UI {
template: Record<string, string>,
nodes: Record<UIInjectableFields, any[]>,
getNodes: typeof import('./lib/ui').getNodes,
inject: typeof import('./lib/ui').inject,
}
export interface ModuleInterfaces {
oauth: {
text: string;
icon?: string;
get: (this: Handler) => Promise<void>;
callback: (this: Handler, args: Record<string, any>) => Promise<OAuthUserResponse>;
lockUsername?: boolean;
};
hash: (password: string, salt: string, user: User) => boolean | string | Promise<string>;
problemSearch: ProblemSearch;
}
export interface HydroGlobal {
version: Record<string, string>;
model: Model;
script: Record<string, Script>;
service: HydroService;
lib: Lib;
module: { [K in keyof ModuleInterfaces]: Record<string, ModuleInterfaces[K]> };
ui: UI;
error: typeof import('./error');
@@ -815,5 +638,5 @@ declare global {
var bus: Context; // eslint-disable-line
var app: Context; // eslint-disable-line
var Hydro: HydroGlobal; // eslint-disable-line
var addons: string[]; // eslint-disable-line
var addons: Record<string, string>; // eslint-disable-line
}
-1
View File
@@ -34,4 +34,3 @@ export function validate(input: string) {
}
export default avatar;
global.Hydro.lib.avatar = avatar;
+3 -5
View File
@@ -48,7 +48,7 @@ export function buildContent(source: ProblemSource, type: 'markdown' | 'html' =
...source.description ? [`<h2>${_('Description')}</h2>`, source.description] : [],
...source.input ? [`<h2>${_('Input Format')}</h2>`, source.input] : [],
...source.output ? [`<h2>${_('Output Format')}</h2>`, source.output] : [],
...(source.samples).map((sample, i) => [
...(source.samples || []).map((sample, i) => [
`<pre><code class="language-input${i + 1}">`,
sample[0],
`</code></pre><pre><code class="language-output${i + 1}">`,
@@ -63,18 +63,16 @@ export function buildContent(source: ProblemSource, type: 'markdown' | 'html' =
...source.description ? [`## ${_('Description')}`, '', source.description, ''] : [],
...source.input ? [`## ${_('Input Format')}`, '', source.input, ''] : [],
...source.output ? [`## ${_('Output Format')}`, '', source.output, ''] : [],
...(source.samples).map((sample, i) => [
...(source.samples || []).flatMap((sample, i) => [
`\`\`\`input${i + 1}`,
sample[0],
'```',
`\`\`\`output${i + 1}`,
sample[1],
'```',
].join('\n')),
]),
...source.samplesRaw ? [source.samplesRaw] : [],
...source.hint ? [`## ${_('Hint')}`, '', source.hint, ''] : [],
...source.source ? [`## ${_('Source')}`, '', source.source, ''] : [],
].join('\n');
}
global.Hydro.lib.buildContent = buildContent;
-1
View File
@@ -43,4 +43,3 @@ function difficultyAlgorithm(nSubmit: number, nAccept: number) {
}
export default difficultyAlgorithm;
global.Hydro.lib.difficulty = difficultyAlgorithm;
+9 -27
View File
@@ -12,15 +12,12 @@ declare module '../service/bus' {
'app/i18n/update': (lang: string) => void;
}
}
declare global {
interface String {
translate: (...languages: string[]) => string;
}
}
class I18nService extends Service {
export class I18nService extends Service {
constructor(ctx: Context) {
super(ctx, 'i18n', true);
super(ctx, 'i18n');
this.translate = this.translate.bind(this);
this.load('ja', { __flag: '🇯🇵', __id: 'ja', __langname: '日本語' });
}
load(lang: string, content: Record<string, string>) {
@@ -46,33 +43,18 @@ class I18nService extends Service {
if (languages[0]?.startsWith('en')) {
// For most use cases, source text equals to translated text in English.
// So if it doesn't exist, we should use the original text instead of fallback.
return app.i18n.get(str, languages[0]) || app.i18n.get(str, 'en') || this.toString();
return this.get(str, languages[0]) || this.get(str, 'en') || str.toString();
}
for (const language of languages.filter(Boolean)) {
const curr = app.i18n.get(str, language) || app.i18n.get(str, language.split('_')[0])
|| app.i18n.get(str, language.split('-')[0]);
const curr = this.get(str, language) || this.get(str, language.split('_')[0])
|| this.get(str, language.split('-')[0]);
if (curr) return curr;
}
return this.toString();
return str.toString();
}
}
app.provide('i18n', undefined, true);
app.i18n = new I18nService(app);
String.prototype.translate = function translate(...languages: string[]) {
if (languages[0]?.startsWith('en')) {
// For most use cases, source text equals to translated text in English.
// So if it doesn't exist, we should use the original text instead of fallback.
return app.i18n.get(this, languages[0]) || app.i18n.get(this, 'en') || this.toString();
}
for (const language of languages.filter(Boolean)) {
const curr = app.i18n.get(this, language) || app.i18n.get(this, language.split('_')[0])
|| app.i18n.get(this, language.split('-')[0]);
if (curr) return curr;
}
return this.toString();
};
export const name = 'i18n';
function collect(lang: string) {
const s = translations[lang];
-4
View File
@@ -37,7 +37,3 @@ export async function sendMail(to: string, subject: string, text: string, html:
}
return t;
}
global.Hydro.lib.mail = {
sendMail,
};
-2
View File
@@ -119,5 +119,3 @@ function calculate(users: RatingInputUser[]): RatingOutputUser[] {
}
export default calculate;
global.Hydro.lib.rating = calculate;
+4 -5
View File
@@ -1,7 +1,8 @@
import { load } from 'js-yaml';
import readYamlCases from '@hydrooj/utils/lib/cases';
import type { ProblemConfig, ProblemConfigFile } from '../interface';
import { normalizeSubtasks, parseMemoryMB, parseTimeMS } from '../utils';
import { normalizeSubtasks, ProblemConfigFile } from '@hydrooj/common';
import { readYamlCases } from '@hydrooj/common/cases';
import { parseMemoryMB, parseTimeMS } from '@hydrooj/utils';
import type { ProblemConfig } from '../interface';
export async function parseConfig(config: string | ProblemConfigFile = {}) {
const cfg: ProblemConfigFile = typeof config === 'string'
@@ -36,5 +37,3 @@ export async function parseConfig(config: string | ProblemConfigFile = {}) {
if (cfg.filename && result.type === 'default') result.subType = cfg.filename;
return result;
}
global.Hydro.lib.testdataConfig = { parseConfig };
+2 -10
View File
@@ -42,14 +42,6 @@ export function inject(node: UIInjectableFields, name: string, args: Record<stri
export function getNodes(name: UIInjectableFields) {
return nodes[name];
}
/** @deprecated */
export const Nav = (name, args, prefix, ...permPrivChecker) => {
inject('Nav', name, { ...args, prefix }, ...permPrivChecker);
};
/** @deprecated */
export const ProblemAdd = (name, args, icon = 'add', text = 'Create Problem') => {
inject('ProblemAdd', name, { ...args, icon, text });
};
inject('Nav', 'homepage', { prefix: 'homepage' });
inject('Nav', 'problem_main', { prefix: 'problem' }, PERM.PERM_VIEW_PROBLEM);
@@ -62,7 +54,8 @@ inject('Nav', 'record_main', {
query: (handler) => (handler.user.hasPriv(PRIV.PRIV_USER_PROFILE)
? ({ uidOrName: handler.user._id })
: ({})),
});
}, (handler) => (handler.user.hasPriv(PRIV.PRIV_USER_PROFILE)
? true : handler.user.hasPerm(PERM.PERM_VIEW_RECORD)));
inject('Nav', 'ranking', { prefix: 'ranking' }, PERM.PERM_VIEW_RANKING);
inject('Nav', 'domain_dashboard', { prefix: 'domain' }, PERM.PERM_EDIT_DOMAIN);
inject('Nav', 'manage_dashboard', { prefix: 'manage' }, PRIV.PRIV_EDIT_SYSTEM);
@@ -84,4 +77,3 @@ inject('DomainManage', 'domain_group', { family: 'Access Control', icon: 'user'
global.Hydro.ui.inject = inject;
global.Hydro.ui.nodes = nodes as any;
global.Hydro.ui.getNodes = getNodes;
Object.assign(global.Hydro.ui, { ProblemAdd, Nav });
+7 -5
View File
@@ -1,5 +1,5 @@
import { ZipFS, ZipOpenFS } from '@yarnpkg/libzip';
import AdmZip from 'adm-zip';
import * as Zip from '@zip.js/zip.js';
import _AdmZip from 'adm-zip';
import _ from 'lodash';
import { ObjectId as ObjectID } from 'mongodb';
import Schema from 'schemastery';
@@ -10,13 +10,15 @@ export { WebSocket, WebSocketServer } from '@hydrooj/framework';
export * from './utils';
export * from './interface';
export * from './typeutils';
export * as LibZip from '@yarnpkg/libzip';
export {
Schema, AdmZip, superagent, _, ObjectID, ZipFS, ZipOpenFS,
Schema, superagent, _, ObjectID, Zip,
};
/** @deprecated Use ZipReader/ZipWriter instead */
export const AdmZip = _AdmZip;
export const definePlugin = <T = never>(args: {
using?: keyof Context[];
inject?: keyof Context[] | Record<keyof Context, any>;
apply: (ctx: Context, config: T) => Promise<void> | void;
schema?: Schema<T>;
name?: string;
Config?: Schema<T>;
}) => args;
+90 -81
View File
@@ -1,8 +1,6 @@
/* eslint-disable import/no-dynamic-require */
/* eslint-disable consistent-return */
/* eslint-disable simple-import-sort/imports */
/* eslint-disable no-await-in-loop */
/* eslint-disable no-eval */
import './init';
import './interface';
import path from 'path';
@@ -11,14 +9,17 @@ import child from 'child_process';
import './utils';
import cac from 'cac';
import './ui';
import './lib/i18n';
import { I18nService } from './lib/i18n';
import { Logger } from './logger';
import { Context } from './context';
import {
Context, Service, FiberState, Fiber,
} from './context';
// eslint-disable-next-line import/no-duplicates
import { sleep, unwrapExports } from './utils';
import { PRIV } from './model/builtin';
import { getAddons } from './options';
import Schema from 'schemastery';
const argv = cac().parse();
const logger = new Logger('loader');
@@ -31,10 +32,11 @@ const HYDROPATH = [];
if (process.env.NIX_PROFILES) {
try {
const result = JSON.parse(child.execSync('nix-env -q --json --out-path --meta').toString()) as Record<string, any>;
for (const derivation of Object.values(result)) {
if (derivation.name.startsWith('hydro-plugin-') && derivation.outputs?.out) {
HYDROPATH.push(derivation.outputs.out);
const result = JSON.parse(child.execSync('nix profile list --json').toString()) as any;
for (const [name, derivation] of Object.entries(result.elements) as any) {
if (!derivation.active) continue;
if (name.startsWith('hydro-plugin-') && derivation.storePaths) {
HYDROPATH.push(...derivation.storePaths);
}
}
} catch (e) {
@@ -42,92 +44,98 @@ if (process.env.NIX_PROFILES) {
}
}
export function resolveConfig(plugin: any, config: any) {
if (config === false) return;
if (config === true) config = undefined;
config ??= {};
const schema = plugin['Config'] || plugin['schema'];
if (schema && plugin['schema'] !== false) config = schema(config);
return config;
}
const timeout = Symbol.for('loader.timeout');
const showLoadTime = argv.options.showLoadTime;
export class Loader {
static readonly Record = Symbol.for('loader.record');
public app: Context;
public config: {};
export class Loader extends Service {
public state: Record<string, Fiber> = Object.create(null);
public suspend = false;
public cache: Record<string, string> = Object.create(null);
// public warnings: Record<string, string> = Object.create(null);
unloadPlugin(ctx: Context, key: string) {
const fork = ctx.state[Loader.Record][key];
static inject = ['setting', 'timer', 'i18n', 'logger'];
constructor(ctx: Context) {
super(ctx, 'loader');
ctx.on('app/started', () => {
ctx.interval(async () => {
const pending = Object.entries(this.state).filter((v) => v[1].state === FiberState.PENDING);
if (pending.length) {
logger.warn('Plugins are still pending: %s', pending.map((v) => v[0]).join(', '));
for (const [key, value] of pending) {
logger.warn('Plugin %s is still pending', key);
console.log(value);
}
}
const loading = Object.entries(this.state).filter((v) => v[1].state === FiberState.LOADING);
if (loading.length) {
logger.warn('Plugins are still loading: %s', loading.map((v) => v[0]).join(', '));
for (const [key, value] of loading) {
logger.warn('Plugin %s is still loading', key);
console.log(value);
}
}
const failed = Object.entries(this.state).filter((v) => v[1].state === FiberState.FAILED);
if (failed.length) {
logger.warn('Plugins failed to load: %s', failed.map((v) => v[0]).join(', '));
for (const [key, value] of failed) {
logger.warn('Plugin %s failed to load', key);
console.log(value);
}
}
}, 10000);
});
}
unloadPlugin(key: string) {
const fork = this.state[key];
if (fork) {
fork.dispose();
delete ctx.state[Loader.Record][key];
delete this.state[key];
logger.info('unload plugin %c', key);
}
}
async reloadPlugin(parent: Context, key: string, config: any, asName = '') {
let fork = parent.state[Loader.Record]?.[key];
async resolveConfig(plugin: any, configScope: string) {
const schema = plugin['Config'] || plugin['schema'];
if (!schema) return {};
const schemaRequest = configScope ? Schema.object({
[configScope]: schema,
}) : schema;
await this.ctx.setting._tryMigrateConfig(schemaRequest);
const res = this.ctx.setting.requestConfig(schemaRequest);
return configScope ? res[configScope] : res;
}
async reloadPlugin(key: string, configScope: string) {
const plugin = this.resolvePlugin(key);
if (!plugin) return;
const config = await this.resolveConfig(plugin, configScope);
let fork = this.state[key];
const displayPath = key.includes('node_modules')
? key.split('node_modules').pop()
: path.relative(process.cwd(), key);
logger.info(
`%s plugin %c${configScope ? ' with scope %c' : ''}`,
fork ? 'reload' : 'apply', displayPath, configScope,
);
if (fork) {
logger.info('reload plugin %c', key.split('node_modules').pop());
fork.update(config);
} else {
logger.info('apply plugin %c', key.split('node_modules').pop());
let plugin = await this.resolvePlugin(key);
if (!plugin) return;
resolveConfig(plugin, config);
if (asName) plugin.name = asName;
// fork = parent.plugin(plugin, this.interpolate(config));
if (plugin.apply) {
const original = plugin.apply;
const apply = (...args) => {
const start = Date.now();
const result = Promise.resolve()
.then(() => original(...args))
.catch((e) => logger.error(e));
Promise.race([
result,
new Promise((resolve) => {
setTimeout(() => resolve(timeout), 10000);
}),
]).then((t) => {
if (t === timeout) {
logger.warn('Plugin %s took too long to load', key);
}
});
if (showLoadTime && (typeof showLoadTime !== 'number' || Date.now() - start > showLoadTime)) {
logger.info('Plugin %s loaded in %dms', key, Date.now() - start);
}
return result;
};
plugin = Object.create(plugin);
Object.defineProperty(plugin, 'apply', {
writable: true,
value: apply,
enumerable: true,
});
}
fork = parent.plugin(plugin, config);
fork = this.ctx.plugin(plugin, config);
if (!fork) return;
parent.state[Loader.Record] ||= Object.create(null);
parent.state[Loader.Record][key] = fork;
this.state[key] = fork;
}
return fork;
}
async resolvePlugin(name: string) {
resolvePlugin(name: string) {
try {
this.cache[name] ||= require.resolve(name);
} catch (err) {
try {
this.cache[name] ||= require.resolve(name, { paths: HYDROPATH });
} catch (e) {
logger.error(err.message);
logger.error('Failed to resolve plugin %s', name);
logger.error(err);
return;
}
}
@@ -135,23 +143,24 @@ export class Loader {
}
}
const loader = new Loader();
app.provide('loader');
app.loader = loader;
loader.app = app;
app.state[Loader.Record] = Object.create(null);
app.plugin(I18nService);
app.plugin(Loader);
function preload() {
async function preload() {
global.app = await new Promise((resolve) => {
app.inject(['timer', 'i18n', 'logger', '$api'], (c) => {
resolve(c);
});
});
for (const a of [path.resolve(__dirname, '..'), ...getAddons()]) {
try {
// Is a npm package
const packagejson = require.resolve(`${a}/package.json`);
// eslint-disable-next-line import/no-dynamic-require
const payload = require(packagejson);
const name = payload.name.startsWith('@hydrooj/') ? payload.name.split('@hydrooj/')[1] : payload.name;
global.Hydro.version[name] = payload.version;
const modulePath = path.dirname(packagejson);
global.addons.push(modulePath);
global.addons[name] = modulePath;
} catch (e) {
logger.error(`Addon not found: ${a}`);
logger.error(e);
@@ -161,7 +170,7 @@ function preload() {
}
export async function load() {
preload();
await preload();
Error.stackTraceLimit = 50;
try {
const { simpleGit } = require('simple-git') as typeof import('simple-git');
@@ -200,7 +209,7 @@ export async function load() {
export async function loadCli() {
process.env.HYDRO_CLI = 'true';
preload();
await preload();
await require('./entry/cli').load(app);
setTimeout(() => process.exit(0), 300);
}
+5 -6
View File
@@ -13,12 +13,11 @@ class BlackListModel {
else if (typeof expire === 'number') expireAt = moment().add(expire, 'months').toDate();
else if (expire instanceof Date) expireAt = expire;
else expireAt = new Date(new Date().getTime() + 365 * 24 * 60 * 60 * 1000);
const res = await coll.findOneAndUpdate(
return await coll.findOneAndUpdate(
{ _id: id },
{ $set: { expireAt } },
{ upsert: true, returnDocument: 'after' },
);
return res.value;
}
@ArgMethod
@@ -32,12 +31,12 @@ class BlackListModel {
}
}
export function apply(ctx: Context) {
coll = db.collection('blacklist');
ctx.on('ready', () => db.ensureIndexes(
export async function apply(ctx: Context) {
coll = ctx.db.collection('blacklist');
await ctx.db.ensureIndexes(
coll,
{ key: { expireAt: -1 }, name: 'expire', expireAfterSeconds: 0 },
));
);
}
export default BlackListModel;
global.Hydro.model.blacklist = BlackListModel;
+5 -4
View File
@@ -1,10 +1,10 @@
import {
getScoreColor, STATUS, STATUS_CODES, STATUS_SHORT_TEXTS,
getScoreColor, NORMAL_STATUS, STATUS, STATUS_CODES, STATUS_SHORT_TEXTS,
STATUS_TEXTS, USER_GENDER_FEMALE, USER_GENDER_ICONS, USER_GENDER_MALE,
USER_GENDER_OTHER, USER_GENDER_RANGE, USER_GENDERS,
} from '@hydrooj/utils/lib/status';
} from '@hydrooj/common';
export * from '@hydrooj/utils/lib/status';
export * from '@hydrooj/common/status';
export const PERM = {
PERM_NONE: 0n,
@@ -101,7 +101,7 @@ export const PERM = {
PERM_NEVER: 1n << 60n,
};
export const Permission = (family: string, key: BigInt, desc: string) => ({ family, key, desc });
export const Permission = (family: string, key: bigint, desc: string) => ({ family, key, desc });
export const PERMS = [
Permission('perm_general', PERM.PERM_VIEW, 'View this domain'),
@@ -475,6 +475,7 @@ global.Hydro.model.builtin = {
STATUS_TEXTS,
STATUS_SHORT_TEXTS,
STATUS_CODES,
NORMAL_STATUS,
USER_GENDER_MALE,
USER_GENDER_FEMALE,
USER_GENDER_OTHER,
+64 -44
View File
@@ -1,6 +1,8 @@
import { sumBy } from 'lodash';
import { Filter, ObjectId } from 'mongodb';
import { Counter, formatSeconds, Time } from '@hydrooj/utils/lib/utils';
import {
Counter, formatSeconds, getAlphabeticId, Time,
} from '@hydrooj/utils/lib/utils';
import {
ContestAlreadyAttendedError, ContestNotFoundError,
ContestScoreboardHiddenError, ValidationError,
@@ -9,7 +11,7 @@ import {
BaseUserDict, ContestRule, ContestRules, ProblemDict, RecordDoc,
ScoreboardConfig, ScoreboardNode, ScoreboardRow, SubtaskResult, Tdoc,
} from '../interface';
import * as bus from '../service/bus';
import bus from '../service/bus';
import db from '../service/db';
import type { Handler } from '../service/server';
import { Optional } from '../typeutils';
@@ -91,7 +93,6 @@ const acm = buildContestRule({
submitAfterAccept: false,
showScoreboard: (tdoc, now) => now > tdoc.beginAt,
showSelfRecord: () => true,
// eslint-disable-next-line @typescript-eslint/no-use-before-define
showRecord: (tdoc, now) => now > tdoc.endAt && !isLocked(tdoc),
stat(tdoc, journal: AcmJournal[]) {
const naccept = Counter<number>();
@@ -100,11 +101,11 @@ const acm = buildContestRule({
const detail: Record<number, AcmDetail> = {};
let accept = 0;
let time = 0;
// eslint-disable-next-line @typescript-eslint/no-use-before-define
const lockAt = isLocked(tdoc) ? tdoc.lockAt : null;
for (const j of journal) {
if (!tdoc.pids.includes(j.pid)) continue;
if (!this.submitAfterAccept && display[j.pid]?.status === STATUS.STATUS_ACCEPTED) continue;
if (![STATUS.STATUS_ACCEPTED, STATUS.STATUS_COMPILE_ERROR, STATUS.STATUS_FORMAT_ERROR].includes(j.status)) {
if (![STATUS.STATUS_ACCEPTED, STATUS.STATUS_COMPILE_ERROR, STATUS.STATUS_FORMAT_ERROR, STATUS.STATUS_CANCELED].includes(j.status)) {
naccept[j.pid]++;
}
const real = Math.floor((j.rid.getTimestamp().getTime() - tdoc.beginAt.getTime()) / 1000);
@@ -159,7 +160,7 @@ const acm = buildContestRule({
} else {
columns.push({
type: 'problem',
value: String.fromCharCode(65 + i - 1),
value: getAlphabeticId(i - 1),
raw: pid,
});
}
@@ -223,7 +224,7 @@ const acm = buildContestRule({
return row;
},
async scoreboard(config, _, tdoc, pdict, cursor) {
const rankedTsdocs = await db.ranked(cursor, (a, b) => a.score === b.score && a.time === b.time);
const rankedTsdocs = await db.ranked(cursor, (a, b) => (a.score || 0) === (b.score || 0) && (a.time || 0) === (b.time || 0));
const uids = rankedTsdocs.map(([, tsdoc]) => tsdoc.uid);
const udict = await user.getListForRender(tdoc.domainId, uids, config.showDisplayName ? ['displayName'] : []);
// Find first accept
@@ -280,7 +281,7 @@ const oi = buildContestRule({
const detail = {};
const display = {};
let score = 0;
// eslint-disable-next-line @typescript-eslint/no-use-before-define
const lockAt = isLocked(tdoc) ? tdoc.lockAt : null;
for (const j of journal.filter((i) => tdoc.pids.includes(i.pid))) {
if (lockAt && j.rid.getTimestamp() > lockAt) {
@@ -290,9 +291,8 @@ const oi = buildContestRule({
continue;
}
if (!detail[j.pid] || detail[j.pid].score < j.score || this.submitAfterAccept) {
detail[j.pid] = j;
display[j.pid] ||= {};
display[j.pid] = j;
detail[j.pid] = { ...j };
display[j.pid] = { ...j };
}
}
for (const i in display) {
@@ -326,7 +326,7 @@ const oi = buildContestRule({
} else {
columns.push({
type: 'problem',
value: String.fromCharCode(65 + i - 1),
value: getAlphabeticId(i - 1),
raw: tdoc.pids[i - 1],
});
}
@@ -359,10 +359,10 @@ const oi = buildContestRule({
accepted[s.pid] = true;
}
}
const tsddict = (config.lockAt ? tsdoc.display : tsdoc.detail) || {};
const tsddict = ((config.lockAt && isLocked(tdoc, new Date())) ? tsdoc.display : tsdoc.detail) || {};
for (const pid of tdoc.pids) {
const index = `${tsdoc.uid}/${tdoc.domainId}/${pid}`;
// eslint-disable-next-line @typescript-eslint/no-use-before-define
const node: ScoreboardNode = (!config.isExport && !config.lockAt && isDone(tdoc)
&& meta?.psdict?.[index]?.rid
&& tsddict[pid]?.rid?.toHexString() !== meta?.psdict?.[index]?.rid?.toHexString()
@@ -386,29 +386,29 @@ const oi = buildContestRule({
raw: tsddict[pid]?.rid || null,
score: tsddict[pid]?.score,
};
if (tsddict[pid]?.status === STATUS.STATUS_ACCEPTED && tsddict[pid]?.rid.getTimestamp().getTime() === meta?.first?.[pid]) {
node.style = 'background-color: rgb(217, 240, 199);';
if (tsddict[pid]?.status === STATUS.STATUS_ACCEPTED) {
if (tsddict[pid].rid.getTimestamp().getTime() - (tsdoc.startAt || tdoc.beginAt).getTime() === meta?.first?.[pid]) {
node.style = 'background-color: rgb(217, 240, 199);';
}
}
row.push(node);
}
return row;
},
async scoreboard(config, _, tdoc, pdict, cursor) {
const rankedTsdocs = await db.ranked(cursor, (a, b) => a.score === b.score);
const rankedTsdocs = await db.ranked(cursor, (a, b) => (a.score || 0) === (b.score || 0));
const uids = rankedTsdocs.map(([, tsdoc]) => tsdoc.uid);
const udict = await user.getListForRender(tdoc.domainId, uids, config.showDisplayName ? ['displayName'] : []);
const psdict = {};
const first = {};
await Promise.all(tdoc.pids.map(async (pid) => {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
const [data] = await getMultiStatus(tdoc.domainId, {
docType: document.TYPE_CONTEST,
docId: tdoc.docId,
[`detail.${pid}.status`]: STATUS.STATUS_ACCEPTED,
}).sort({ [`detail.${pid}.rid`]: 1 }).limit(1).toArray();
first[pid] = data ? data.detail[pid].rid.getTimestamp().getTime() : Date.now() / 1000;
}));
// eslint-disable-next-line @typescript-eslint/no-use-before-define
for (const [, tsdoc] of rankedTsdocs) {
for (const [pid, detail] of Object.entries(tsdoc.detail || {})) {
if (detail.status !== STATUS.STATUS_ACCEPTED) continue;
const time = detail.rid.getTimestamp().getTime() - (tsdoc.startAt || tdoc.beginAt).getTime();
if (!first[pid] || first[pid] > time) first[pid] = time;
}
}
if (isDone(tdoc)) {
const psdocs = await Promise.all(
tdoc.pids.map((pid) => problem.getMultiStatus(tdoc.domainId, { docId: pid, uid: { $in: uids } }).toArray()),
@@ -450,7 +450,7 @@ const oi = buildContestRule({
const ioi = buildContestRule({
TEXT: 'IOI',
submitAfterAccept: false,
// eslint-disable-next-line @typescript-eslint/no-use-before-define
showRecord: (tdoc, now) => now > tdoc.endAt && !isLocked(tdoc),
showSelfRecord: () => true,
showScoreboard: (tdoc, now) => now > tdoc.beginAt,
@@ -504,16 +504,35 @@ const strictioi = buildContestRule({
}
}
for (const pid of tdoc.pids) {
row.push({
type: 'record',
value: ((tsddict[pid]?.score || 0) * ((tdoc.score?.[pid] || 100) / 100)).toString() || '',
hover: Object.values(tsddict[pid]?.subtasks || {}).map((i: SubtaskResult) => `${STATUS_SHORT_TEXTS[i.status]} ${i.score}`).join(','),
raw: tsddict[pid]?.rid,
score: tsddict[pid]?.score,
style: tsddict[pid]?.status === STATUS.STATUS_ACCEPTED && tsddict[pid]?.rid.getTimestamp().getTime() === meta?.first?.[pid]
? 'background-color: rgb(217, 240, 199);'
: undefined,
});
const index = `${tsdoc.uid}/${tdoc.domainId}/${pid}`;
const n: ScoreboardNode = (!config.isExport && !config.lockAt && isDone(tdoc)
&& meta?.psdict?.[index]?.rid
&& tsddict[pid]?.rid?.toHexString() !== meta?.psdict?.[index]?.rid?.toHexString()
&& meta?.psdict?.[index]?.rid?.getTimestamp() > tdoc.endAt)
? {
type: 'records',
value: '',
raw: [{
value: ((tsddict[pid]?.score || 0) * ((tdoc.score?.[pid] || 100) / 100)).toString() || '',
raw: tsddict[pid]?.rid || null,
score: tsddict[pid]?.score,
}, {
value: ((meta?.psdict?.[index]?.score || 0) * ((tdoc.score?.[pid] || 100) / 100)).toString() || '',
raw: meta?.psdict?.[index]?.rid ?? null,
score: meta?.psdict?.[index]?.score,
}],
} : {
type: 'record',
value: ((tsddict[pid]?.score || 0) * ((tdoc.score?.[pid] || 100) / 100)).toString() || '',
raw: tsddict[pid]?.rid,
score: tsddict[pid]?.score,
};
n.hover = Object.values(tsddict[pid]?.subtasks || {}).map((i: SubtaskResult) => `${STATUS_SHORT_TEXTS[i.status]} ${i.score}`).join(',');
if (tsddict[pid]?.status === STATUS.STATUS_ACCEPTED
&& tsddict[pid].rid.getTimestamp().getTime() - (tsdoc.startAt || tdoc.beginAt).getTime() === meta?.first?.[pid]) {
n.style = 'background-color: rgb(217, 240, 199);';
}
row.push(n);
}
return row;
},
@@ -586,7 +605,8 @@ const ledo = buildContestRule({
hover: tsddict[pid]?.ntry ? `-${tsddict[pid].ntry} (${Math.round(Math.max(0.7, 0.95 ** tsddict[pid].ntry) * 100)}%)` : '',
raw: tsddict[pid]?.rid,
score: tsddict[pid]?.score,
style: tsddict[pid]?.status === STATUS.STATUS_ACCEPTED && tsddict[pid]?.rid.getTimestamp().getTime() === meta?.first?.[pid]
style: tsddict[pid]?.status === STATUS.STATUS_ACCEPTED
&& tsddict[pid].rid.getTimestamp().getTime() - (tsdoc.startAt || tdoc.beginAt).getTime() === meta?.first?.[pid]
? 'background-color: rgb(217, 240, 199);'
: undefined,
});
@@ -681,7 +701,7 @@ const homework = buildContestRule({
} else {
columns.push({
type: 'problem',
value: String.fromCharCode(65 + i - 1),
value: getAlphabeticId(i - 1),
raw: pid,
});
}
@@ -783,11 +803,11 @@ export async function add(
});
RULES[rule].check(data);
await bus.parallel('contest/before-add', data);
const res = await document.add(domainId, content, owner, document.TYPE_CONTEST, null, null, null, {
...data, title, rule, beginAt, endAt, pids, attend: 0, rated,
const docId = await document.add(domainId, content, owner, document.TYPE_CONTEST, null, null, null, {
assign: [], ...data, title, rule, beginAt, endAt, pids, attend: 0, rated,
});
await bus.parallel('contest/add', data, res);
return res;
await bus.parallel('contest/add', data, docId);
return docId;
}
export async function edit(domainId: string, tid: ObjectId, $set: Partial<Tdoc>) {
+10 -9
View File
@@ -5,7 +5,7 @@ import { DiscussionNodeNotFoundError, DocumentNotFoundError } from '../error';
import {
DiscussionHistoryDoc, DiscussionReplyDoc, DiscussionTailReplyDoc, Document,
} from '../interface';
import * as bus from '../service/bus';
import bus from '../service/bus';
import db from '../service/db';
import { NumberKeys } from '../typeutils';
import { buildProjection } from '../utils';
@@ -37,7 +37,6 @@ export const typeDisplay = {
[document.TYPE_CONTEST]: 'contest',
[document.TYPE_DISCUSSION_NODE]: 'node',
[document.TYPE_TRAINING]: 'training',
[document.TYPE_HOMEWORK]: 'homework',
};
export const coll = db.collection('discussion.history');
@@ -85,9 +84,11 @@ export async function get<T extends Field>(
}
export async function edit(domainId: string, did: ObjectId, $set: Partial<DiscussionDoc>) {
await coll.insertOne({
domainId, docId: did, content: $set.content, uid: $set.editor, ip: $set.ip, time: new Date(),
});
if ($set.content) {
await coll.insertOne({
domainId, docId: did, content: $set.content, uid: $set.editor, ip: $set.ip, time: new Date(),
});
}
return document.set(domainId, document.TYPE_DISCUSSION, did, $set);
}
@@ -275,15 +276,15 @@ export function flushNodes(domainId: string) {
export async function getVnode(domainId: string, type: number, id: string, uid?: number) {
if (type === document.TYPE_PROBLEM) {
const pdoc = await problem.get(domainId, Number.isSafeInteger(+id) ? +id : id, problem.PROJECTION_LIST);
if (!pdoc) throw new DiscussionNodeNotFoundError(id);
if (!pdoc) throw new DiscussionNodeNotFoundError(`problem/${id}`);
return { ...pdoc, type, id: pdoc.docId };
}
if ([document.TYPE_CONTEST, document.TYPE_TRAINING].includes(type as any)) {
const model = type === document.TYPE_TRAINING ? training : contest;
if (!ObjectId.isValid(id)) throw new DiscussionNodeNotFoundError(id);
if (!ObjectId.isValid(id)) throw new DiscussionNodeNotFoundError(`contest/${id}`);
const _id = new ObjectId(id);
const tdoc = await model.get(domainId, _id);
if (!tdoc) throw new DiscussionNodeNotFoundError(id);
if (!tdoc) throw new DiscussionNodeNotFoundError(`contest/${id}`);
if (uid) {
const tsdoc = await model.getStatus(domainId, _id, uid);
tdoc.attend = tsdoc?.attend || tsdoc?.enroll;
@@ -320,7 +321,7 @@ export async function getListVnodes(domainId: string, ddocs: any, getHidden = fa
export function checkVNodeVisibility(type: number, vnode: any, user: User) {
if (type === document.TYPE_PROBLEM) {
if (vnode.hidden && !(user.own(vnode) || user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN))) return false;
if (vnode.hidden && !user.own(vnode) && !user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN)) return false;
}
if ([document.TYPE_CONTEST, document.TYPE_TRAINING].includes(type as any)) {
if (!user.own(vnode) && vnode.assign?.length && !Set.intersection(vnode.assign, user.group).size) return false;
+44 -61
View File
@@ -9,28 +9,26 @@ import {
DiscussionReplyDoc, ProblemDoc, ProblemStatusDoc,
Tdoc, TrainingDoc,
} from '../interface';
import * as bus from '../service/bus';
import bus from '../service/bus';
import db from '../service/db';
import { ArrayKeys, MaybeArray, NumberKeys, Projection } from '../typeutils';
import { buildProjection } from '../utils';
type DocID = ObjectId | string | number;
type NormalArrayKeys<O, P = any> = Exclude<ArrayKeys<O, P>, Symbol>;
type NormalArrayKeys<O, P = any> = Exclude<ArrayKeys<O, P>, symbol>;
export const coll = db.collection('document');
export const collStatus = db.collection('document.status');
export const TYPE_PROBLEM: 10 = 10;
export const TYPE_PROBLEM_SOLUTION: 11 = 11;
export const TYPE_PROBLEM_LIST: 12 = 12;
export const TYPE_DISCUSSION_NODE: 20 = 20;
export const TYPE_DISCUSSION: 21 = 21;
export const TYPE_DISCUSSION_REPLY: 22 = 22;
export const TYPE_CONTEST: 30 = 30;
export const TYPE_CONTEST_CLARIFICATION: 31 = 31;
export const TYPE_TRAINING: 40 = 40;
/** @deprecated use `TYPE_CONTEST` with rule `homework` instead. */
export const TYPE_HOMEWORK: 60 = 60;
export const TYPE_PROBLEM = 10 as const;
export const TYPE_PROBLEM_SOLUTION = 11 as const;
export const TYPE_PROBLEM_LIST = 12 as const;
export const TYPE_DISCUSSION_NODE = 20 as const;
export const TYPE_DISCUSSION = 21 as const;
export const TYPE_DISCUSSION_REPLY = 22 as const;
export const TYPE_CONTEST = 30 as const;
export const TYPE_CONTEST_CLARIFICATION = 31 as const;
export const TYPE_TRAINING = 40 as const;
export interface DocType {
[TYPE_PROBLEM]: ProblemDoc;
@@ -106,15 +104,14 @@ export async function set<K extends keyof DocType>(
): Promise<DocType[K]> {
await bus.parallel('document/set', domainId, docType, docId, $set, $unset);
const update: UpdateFilter<DocType[K]> = {};
if ($set) update.$set = $set;
if ($unset) update.$unset = $unset;
if ($push) update.$push = $push;
const res = await coll.findOneAndUpdate(
if ($set && Object.keys($set).length) update.$set = $set;
if ($unset && Object.keys($unset).length) update.$unset = $unset;
if ($push && Object.keys($push).length) update.$push = $push;
return await coll.findOneAndUpdate(
{ domainId, docType, docId },
update,
{ returnDocument: 'after', upsert: true },
);
return res.value;
}
export function deleteOne<K extends keyof DocType>(
@@ -150,24 +147,22 @@ export async function inc<K extends keyof DocType>(
domainId: string, docType: K, docId: DocType[K]['docId'],
key: NumberKeys<DocType[K]>, value: number,
) {
const res = await coll.findOneAndUpdate(
return await coll.findOneAndUpdate(
{ domainId, docType, docId },
{ $inc: { [key]: value } },
{ returnDocument: 'after' },
);
return res.value;
}
export async function incAndSet<K extends keyof DocType>(
domainId: string, docType: K, docId: DocType[K]['docId'],
key: NumberKeys<DocType[K]>, value: number, args: Partial<DocType[K]>,
) {
const res = await coll.findOneAndUpdate(
return await coll.findOneAndUpdate(
{ domainId, docType, docId },
{ $inc: { [key]: value }, $set: args },
{ returnDocument: 'after' },
);
return res.value;
}
export function count<K extends keyof DocType>(
@@ -204,34 +199,32 @@ export async function push(
{ $push: { [key]: v } },
{ returnDocument: 'after' },
);
return [doc.value, _id];
return [doc, _id];
}
export async function pull<K extends keyof DocType, T extends ArrayKeys<DocType[K]>>(
domainId: string, docType: K, docId: DocType[K]['docId'],
setKey: T, contents: Filter<DocType[K][T][0]>,
): Promise<DocType[K]> {
const res = await coll.findOneAndUpdate(
return await coll.findOneAndUpdate(
{ domainId, docType, docId },
// @ts-ignore
{ $pull: { [setKey]: { $in: contents } } },
{ returnDocument: 'after' },
);
return res.value;
}
export async function deleteSub<T extends keyof DocType, K extends ArrayKeys<DocType[T]>>(
domainId: string, docType: T, docId: DocType[T]['docId'],
key: K, subId: MaybeArray<DocType[T][K][0]['_id']>,
): Promise<DocType[T]> {
subId = (subId instanceof Array) ? subId : [subId];
const res = await coll.findOneAndUpdate(
subId = Array.isArray(subId) ? subId : [subId];
return await coll.findOneAndUpdate(
{ domainId, docType, docId },
// @ts-ignore
{ $pull: { [key]: { _id: { $in: subId } } } },
{ returnDocument: 'after' },
);
return res.value;
}
export async function getSub<T extends keyof DocType, K extends ArrayKeys<DocType[T]>>(
@@ -257,7 +250,7 @@ export async function setSub<T extends keyof DocType, K extends NormalArrayKeys<
): Promise<DocType[T]> {
const $set: Record<string, any> = {};
for (const k in args) $set[`${key}.$.${k}`] = args[k];
const res = await coll.findOneAndUpdate(
return await coll.findOneAndUpdate(
{
domainId,
docType,
@@ -267,20 +260,18 @@ export async function setSub<T extends keyof DocType, K extends NormalArrayKeys<
{ $set },
{ returnDocument: 'after' },
);
return res.value;
}
export async function addToSet<T extends keyof DocType, K extends ArrayKeys<DocType[T], string>>(
domainId: string, docType: T, docId: DocType[T]['docId'],
setKey: K, content: string,
) {
const res = await coll.findOneAndUpdate(
return await coll.findOneAndUpdate(
{ domainId, docType, docId },
// @ts-ignore
{ $addToSet: { [setKey]: content } },
{ returnDocument: 'after' },
);
return res.value;
}
export async function getStatus<K extends keyof DocStatusType>(
@@ -305,7 +296,7 @@ export async function setStatus<K extends keyof DocStatusType>(
domainId: string, docType: K, docId: DocStatusType[K]['docId'], uid: number,
args: UpdateFilter<DocStatusType[K]>['$set'], returnDocument: 'before' | 'after' = 'after',
): Promise<DocStatusType[K]> {
const res = await collStatus.findOneAndUpdate(
return await collStatus.findOneAndUpdate(
{ domainId, docType, docId, uid },
{ $set: args, $inc: { counter: 1 } },
{
@@ -315,7 +306,6 @@ export async function setStatus<K extends keyof DocStatusType>(
...(returnDocument === 'before' ? { readConcern: { level: 'majority' }, readPreference: 'primary' } : {}),
},
);
return res.value;
}
export async function setMultiStatus<K extends keyof DocStatusType>(
@@ -327,18 +317,17 @@ export async function setMultiStatus<K extends keyof DocStatusType>(
);
}
export async function setStatusIfNotCondition<T extends keyof DocStatusType>(
export async function setStatusIfCondition<T extends keyof DocStatusType>(
domainId: string, docType: T, docId: DocStatusType[T]['docId'], uid: number,
filter: Filter<DocStatusType[T]>, args: Partial<DocStatusType[T]> = {},
returnDocument: 'before' | 'after' = 'after',
): Promise<DocStatusType[T]> {
try {
const res = await collStatus.findOneAndUpdate(
{ domainId, docType, docId, uid, $nor: [filter] },
return await collStatus.findOneAndUpdate(
{ domainId, docType, docId, uid, ...filter },
{ $set: args },
{ upsert: true, returnDocument },
);
return res.value;
} catch (e) {
return false;
}
@@ -349,8 +338,8 @@ export async function setIfNotStatus<T extends keyof DocStatusType, K extends ke
key: K, value: DocStatusType[T][K], ifNot: DocStatusType[T][K], args: Partial<DocStatusType[T]>,
returnDocument: 'before' | 'after' = 'after',
): Promise<DocStatusType[T]> {
return await setStatusIfNotCondition(
domainId, docType, docId, uid, { [key]: ifNot } as any,
return await setStatusIfCondition(
domainId, docType, docId, uid, { [key]: { $ne: ifNot } } as any,
{ [key]: value, ...args }, returnDocument,
);
}
@@ -364,24 +353,22 @@ export async function cappedIncStatus<T extends keyof DocStatusType>(
const $not = value > 0 ? { $gte: maxValue } : { $lte: minValue };
const operation = { $inc: { [key]: value } } as UpdateFilter<DocStatusType[T]>;
if (Object.keys(setPayload).length) operation.$set = setPayload;
const res = await collStatus.findOneAndUpdate(
return await collStatus.findOneAndUpdate(
{ domainId, docType, docId, uid, [key]: { $not } },
operation,
{ upsert: true, returnDocument: 'after' },
);
return res.value;
}
export async function incStatus<T extends keyof DocStatusType>(
domainId: string, docType: T, docId: DocStatusType[T]['docId'], uid: number,
key: NumberKeys<DocStatusType[T]>, value: number,
): Promise<DocStatusType[T]> {
const res = await collStatus.findOneAndUpdate(
return await collStatus.findOneAndUpdate(
{ domainId, docType, docId, uid },
{ $inc: { [key]: value } },
{ upsert: true, returnDocument: 'after' },
);
return res.value;
}
export async function revPushStatus<T extends keyof DocStatusType>(
@@ -393,26 +380,23 @@ export async function revPushStatus<T extends keyof DocStatusType>(
{ $set: { [`${key}.$`]: value }, $inc: { rev: 1 } },
{ returnDocument: 'after' },
);
if (!res.value) {
res = await collStatus.findOneAndUpdate(
{ domainId, docType, docId, uid },
// @ts-ignore
{ $push: { [key]: value }, $inc: { rev: 1 } },
{ upsert: true, returnDocument: 'after' },
);
}
return res.value;
res ||= await collStatus.findOneAndUpdate(
{ domainId, docType, docId, uid },
// @ts-ignore
{ $push: { [key]: value }, $inc: { rev: 1 } },
{ upsert: true, returnDocument: 'after' },
);
return res;
}
export async function revInitStatus<T extends keyof DocStatusType>(
domainId: string, docType: T, docId: DocStatusType[T]['docId'], uid: number,
): Promise<DocStatusType[T]> {
const res = await collStatus.findOneAndUpdate(
return await collStatus.findOneAndUpdate(
{ domainId, docType, docId, uid },
{ $inc: { rev: 1 } },
{ upsert: true, returnDocument: 'after' },
);
return res.value;
}
export async function revSetStatus<T extends keyof DocStatusType>(
@@ -421,8 +405,7 @@ export async function revSetStatus<T extends keyof DocStatusType>(
): Promise<any> {
const filter = { domainId, docType, docId, uid, rev };
const update = { $set: args, $inc: { rev: 1 } };
const res = await collStatus.findOneAndUpdate(filter, update, { returnDocument: 'after' });
return res.value;
return await collStatus.findOneAndUpdate(filter, update, { returnDocument: 'after' });
}
export async function apply(ctx: Context) {
@@ -431,6 +414,7 @@ export async function apply(ctx: Context) {
collStatus.deleteMany({ domainId }),
]));
await db.clearIndexes(coll, ['tag', 'hidden']);
const onlyFor = (docType: number) => ({ partialFilterExpression: { docType } });
await db.ensureIndexes(
coll,
{ key: { domainId: 1, docType: 1, docId: 1 }, name: 'basic', unique: true },
@@ -441,8 +425,8 @@ export async function apply(ctx: Context) {
// For problem solution
{ key: { domainId: 1, docType: 1, parentType: 1, parentId: 1, vote: -1, docId: -1 }, name: 'solution', sparse: true },
// For discussion
{ key: { domainId: 1, docType: 1, pin: -1, docId: -1 }, name: 'discussionSort', sparse: true },
{ key: { domainId: 1, docType: 1, parentType: 1, parentId: 1, pin: -1, docId: -1 }, name: 'discussionNodeSort', sparse: true },
{ key: { docType: 1, domainId: 1, hidden: 1, pin: -1, docId: -1 }, name: 'discussionSort', ...onlyFor(TYPE_DISCUSSION) },
{ key: { docType: 1, domainId: 1, hidden: 1, parentType: 1, parentId: 1, pin: -1, docId: -1 }, name: 'discussionNodeSort', sparse: true },
// Hidden doc
{ key: { domainId: 1, docType: 1, hidden: 1, docId: -1 }, name: 'hiddenDoc', sparse: true },
// For contest
@@ -490,7 +474,7 @@ global.Hydro.model.document = {
revSetStatus,
set,
setIfNotStatus,
setStatusIfNotCondition,
setStatusIfCondition,
setStatus,
setMultiStatus,
setSub,
@@ -500,7 +484,6 @@ global.Hydro.model.document = {
TYPE_DISCUSSION,
TYPE_DISCUSSION_NODE,
TYPE_DISCUSSION_REPLY,
TYPE_HOMEWORK,
TYPE_PROBLEM,
TYPE_PROBLEM_LIST,
TYPE_PROBLEM_SOLUTION,
+52 -36
View File
@@ -1,8 +1,9 @@
import { Dictionary, escapeRegExp } from 'lodash';
import { LRUCache } from 'lru-cache';
import { Filter } from 'mongodb';
import { Context } from '../context';
import { DomainDoc } from '../interface';
import * as bus from '../service/bus';
import bus from '../service/bus';
import db from '../service/db';
import { MaybeArray, NumberKeys } from '../typeutils';
import { ArgMethod } from '../utils';
@@ -58,7 +59,7 @@ class DomainModel {
};
await bus.parallel('domain/create', ddoc);
await coll.insertOne(ddoc);
await DomainModel.setUserRole(domainId, owner, 'root');
await DomainModel.setUserRole(domainId, owner, 'root', true);
return domainId;
}
@@ -100,23 +101,23 @@ class DomainModel {
domainId = domainId.toLowerCase();
await bus.parallel('domain/before-update', domainId, $set);
const result = await coll.findOneAndUpdate({ lower: domainId }, { $set }, { returnDocument: 'after' });
if (result.value) {
await bus.parallel('domain/update', domainId, $set, result.value);
if (result) {
await bus.parallel('domain/update', domainId, $set, result);
bus.broadcast('domain/delete-cache', domainId);
}
return result.value;
return result;
}
@ArgMethod
static async inc(domainId: string, field: NumberKeys<DomainDoc>, n: number): Promise<number | null> {
domainId = domainId.toLowerCase();
const res = await coll.findOneAndUpdate(
const value = await coll.findOneAndUpdate(
{ _id: domainId },
{ $inc: { [field]: n } as any },
{ returnDocument: 'after' },
);
bus.broadcast('domain/delete-cache', domainId);
return res.value?.[field];
return value?.[field];
}
@ArgMethod
@@ -127,14 +128,19 @@ class DomainModel {
}
static async countUser(domainId: string, role?: string) {
if (role) return await collUser.countDocuments({ domainId, role });
return await collUser.countDocuments({ domainId });
if (role) return await collUser.countDocuments({ domainId, role, join: true });
return await collUser.countDocuments({ domainId, join: true });
}
@ArgMethod
static async setUserRole(domainId: string, uid: MaybeArray<number>, role: string) {
if (!(uid instanceof Array)) {
const res = await collUser.findOneAndUpdate({ domainId, uid }, { $set: { role } }, { upsert: true, returnDocument: 'after' });
static async setUserRole(domainId: string, uid: MaybeArray<number>, role: string, autojoin = false) {
const update = { $set: { role, ...(autojoin ? { join: true } : {}) } };
if (!(Array.isArray(uid))) {
const res = await collUser.findOneAndUpdate(
{ domainId, uid },
update,
{ upsert: true, returnDocument: 'after', includeResultMetadata: true },
);
const udoc = await UserModel.getById(domainId, uid);
deleteUserCache(udoc);
return res;
@@ -143,7 +149,16 @@ class DomainModel {
.project<{ _id: number, mail: string, uname: string }>({ mail: 1, uname: 1 })
.toArray();
for (const udoc of affected) deleteUserCache(udoc);
return await collUser.updateMany({ domainId, uid: { $in: uid } }, { $set: { role } }, { upsert: true });
return await collUser.updateMany({ domainId, uid: { $in: uid } }, update, { upsert: true });
}
static async setJoin(domainId: string, uid: MaybeArray<number>, join: boolean) {
if (!(Array.isArray(uid))) {
await DomainModel.updateUserInDomain(domainId, uid, { $set: { join } });
return;
}
await collUser.updateMany({ domainId, uid: { $in: uid } }, { $set: { join } });
deleteUserCache(domainId);
}
static async getRoles(domainId: string, count?: boolean): Promise<any[]>;
@@ -165,10 +180,8 @@ class DomainModel {
}
}
if (count) {
await Promise.all(roles.map(async (role) => {
if (['default', 'guest'].includes(role._id)) return role;
await Promise.all(roles.filter((i) => i._id !== 'guest').map(async (role) => {
role.count = await DomainModel.countUser(ddoc._id, role._id);
return role;
}));
}
return roles;
@@ -206,6 +219,7 @@ class DomainModel {
let dudoc = await collUser.findOne({ domainId, uid: udoc._id });
dudoc ||= { domainId, uid: udoc._id };
if (!(udoc.priv & PRIV.PRIV_USER_PROFILE)) dudoc.role = 'guest';
if (!dudoc.join && !(udoc.priv & PRIV.PRIV_VIEW_ALL_DOMAIN)) dudoc.role = 'guest';
if (udoc.priv & PRIV.PRIV_MANAGE_ALL_DOMAIN) dudoc.role = 'root';
dudoc.role ||= 'default';
const ddoc = await DomainModel.get(domainId);
@@ -251,7 +265,7 @@ class DomainModel {
@ArgMethod
static async getDictUserByDomainId(uid: number) {
const dudocs = await collUser.find({ uid }).toArray();
const dudocs = await collUser.find({ uid, join: true }).toArray();
const dudict: Record<string, any> = {};
for (const dudoc of dudocs) dudict[dudoc.domainId] = dudoc;
return dudict;
@@ -284,24 +298,26 @@ class DomainModel {
}
}
bus.on('ready', () => Promise.all([
db.ensureIndexes(
coll,
{ key: { lower: 1 }, name: 'lower', unique: true },
),
db.ensureIndexes(
collUser,
{ key: { domainId: 1, uid: 1 }, name: 'uid', unique: true },
{ key: { domainId: 1, rp: -1, uid: 1 }, name: 'rp', sparse: true },
),
]));
bus.on('domain/delete-cache', async (domainId: string) => {
const ddoc = await DomainModel.get(domainId);
if (!ddoc) return;
for (const host of ddoc.hosts || []) {
cache.delete(`host::${host}`);
}
cache.delete(`id::${domainId}`);
});
export async function apply(ctx: Context) {
ctx.on('domain/delete-cache', async (domainId: string) => {
const ddoc = await DomainModel.get(domainId);
if (!ddoc) return;
for (const host of ddoc.host || []) {
cache.delete(`host::${host}`);
}
cache.delete(`id::${domainId}`);
});
await Promise.all([
db.ensureIndexes(
coll,
{ key: { lower: 1 }, name: 'lower', unique: true },
),
db.ensureIndexes(
collUser,
{ key: { domainId: 1, uid: 1 }, name: 'uid', unique: true },
{ key: { domainId: 1, rp: -1, uid: 1 }, name: 'rp', sparse: true },
),
]);
}
export default DomainModel;
global.Hydro.model.domain = DomainModel;
+12 -15
View File
@@ -1,6 +1,6 @@
import { Filter, ObjectId } from 'mongodb';
import { MessageDoc } from '../interface';
import * as bus from '../service/bus';
import bus from '../service/bus';
import db from '../service/db';
import { ArgMethod } from '../utils';
import { PRIV } from './builtin';
@@ -18,25 +18,23 @@ class MessageModel {
@ArgMethod
static async send(
from: number, to: number,
from: number, to: number | number[],
content: string, flag: number = MessageModel.FLAG_UNREAD,
) {
const _id = new ObjectId();
const mdoc: MessageDoc = {
_id, from, to, content, flag,
};
await MessageModel.coll.insertOne(mdoc);
if (from !== to) bus.broadcast('user/message', to, mdoc);
if (!Array.isArray(to)) to = [to];
const base = { from, content, flag };
if (!to.length) return base;
await MessageModel.coll.insertMany(to.map((t) => ({ ...base, to: t })));
bus.broadcast('user/message', to, base);
if (flag & MessageModel.FLAG_UNREAD) await user.inc(to, 'unreadMsg', 1);
return mdoc;
return base;
}
static async sendInfo(to: number, content: string) {
const _id = new ObjectId();
const mdoc: MessageDoc = {
_id, from: 1, to, content, flag: MessageModel.FLAG_INFO | MessageModel.FLAG_I18N,
from: 1, to, content, flag: MessageModel.FLAG_INFO | MessageModel.FLAG_I18N,
};
bus.broadcast('user/message', to, mdoc);
bus.broadcast('user/message', [to], mdoc);
}
static async get(_id: ObjectId) {
@@ -55,12 +53,11 @@ class MessageModel {
}
static async setFlag(messageId: ObjectId, flag: number) {
const result = await MessageModel.coll.findOneAndUpdate(
return await MessageModel.coll.findOneAndUpdate(
{ _id: messageId },
{ $bit: { flag: { xor: flag } } },
{ returnDocument: 'after' },
);
return result.value || null;
}
static async del(_id: ObjectId) {
@@ -80,7 +77,7 @@ class MessageModel {
const targets = await user.getMulti({ priv: { $bitsAllSet: PRIV.PRIV_VIEW_SYSTEM_NOTIFICATION } })
.project({ _id: 1, viewLang: 1 }).toArray();
return Promise.all(targets.map(({ _id, viewLang }) => {
const msg = message.translate(viewLang || system.get('server.language')).format(...args);
const msg = app.i18n.translate(message, [viewLang || system.get('server.language')]).format(...args);
return MessageModel.send(1, _id, msg, MessageModel.FLAG_RICHTEXT);
}));
}
+79 -12
View File
@@ -1,23 +1,90 @@
import db from '../service/db';
import { Collection } from 'mongodb';
import { Context, Service } from '../context';
import type { Handler } from '../service/server';
const coll = db.collection('oauth');
export interface OauthMap {
platform: string;
/** source openId */
id: string;
/** target uid */
uid: number;
}
class OauthModel {
static async get(_id: string) {
const doc = await coll.findOne({ _id });
declare module '../context' {
interface Context {
oauth: OauthModel;
}
}
export interface OAuthUserResponse {
_id: string;
email: string;
avatar?: string;
bio?: string;
uname?: string[];
viewLang?: string;
set?: Record<string, any>;
setInDomain?: Record<string, any>;
}
export interface OAuthProvider {
text: string;
name: string;
icon?: string;
hidden?: boolean;
get: (this: Handler) => Promise<void>;
callback: (this: Handler, args: Record<string, any>) => Promise<OAuthUserResponse>;
canRegister?: boolean;
lockUsername?: boolean;
}
export default class OauthModel extends Service {
static inject = ['db'];
coll: Collection<OauthMap>;
providers: Record<string, OAuthProvider> = Object.create(null);
constructor(ctx: Context) {
super(ctx, 'oauth');
this.coll = ctx.db.collection('oauth');
}
async [Context.init]() {
await this.ctx.db.ensureIndexes(this.coll,
// { key: { platform: 1, id: 1 }, name: 'platform_id', unique: true },
{ key: { uid: 1, platform: 1 }, name: 'uid_platform' },
);
}
async get(platform: string, id: string) {
const doc = await this.coll.findOne({ platform, id });
if (doc) return doc.uid;
return null;
}
static async set(_id: string, uid: number) {
const res = await coll.findOneAndUpdate(
{ _id },
async set(platform: string, id: string, uid: number) {
const res = await this.coll.findOneAndUpdate(
{ platform, id },
{ $set: { uid } },
{ upsert: true, returnDocument: 'after' },
);
return res.value?.uid;
return res?.uid;
}
async unbind(platform: string, id: string, uid: number) {
await this.coll.deleteOne({ platform, id, uid });
}
async list(uid: number) {
return this.coll.find({ uid }).toArray();
}
async provide(name: string, provider: OAuthProvider) {
if (this.providers[name]) throw new Error(`OAuth provider ${name} already exists`);
this.ctx.effect(() => {
this.providers[name] = provider;
return () => {
delete this.providers[name];
};
});
}
}
export default OauthModel;
global.Hydro.model.oauth = OauthModel;
+1 -1
View File
@@ -13,7 +13,7 @@ export async function inc(op: string, ident: string, periodSecs: number, maxOper
expireAt,
opcount: { $lt: maxOperations },
}, { $inc: { opcount: 1 } }, { upsert: true, returnDocument: 'after' });
return res.value.opcount;
return res.opcount;
} catch (e) {
if (e.message.includes('duplicate')) throw new OpcountExceededError(op, periodSecs, maxOperations);
throw e;
+4 -3
View File
@@ -1,9 +1,9 @@
import { cloneDeep } from 'lodash';
import { ObjectId } from 'mongodb';
import { OplogDoc } from '../interface';
import * as bus from '../service/bus';
import bus from '../service/bus';
import db from '../service/db';
import type { Handler } from '../service/server';
import type { ConnectionHandler, Handler } from '../service/server';
export const coll = db.collection('oplog');
@@ -37,7 +37,7 @@ function safeKeys(data: any) {
return data;
}
export async function log<T extends Handler>(handler: T, type: string, data: any) {
export async function log<T extends Handler | ConnectionHandler>(handler: T, type: string, data: any) {
const args = safeKeys(cloneDeep(handler.args));
await bus.parallel('oplog/log', type, handler, args, data);
const res = await coll.insertOne({
@@ -52,6 +52,7 @@ export async function log<T extends Handler>(handler: T, type: string, data: any
args,
operator: handler.user?._id,
operateIp: handler.request.ip,
json: handler.request.json,
});
return res.insertedId;
}
+70 -37
View File
@@ -2,20 +2,23 @@
import child from 'child_process';
import os from 'os';
import path from 'path';
import AdmZip from 'adm-zip';
import { Readable } from 'stream';
import { Entry, ZipReader } from '@zip.js/zip.js';
import fs from 'fs-extra';
import yaml from 'js-yaml';
import { pick } from 'lodash';
import { keyBy, pick } from 'lodash';
import { Filter, ObjectId } from 'mongodb';
import type { Readable } from 'stream';
import { Logger, size, streamToBuffer } from '@hydrooj/utils/lib/utils';
import { ProblemConfigFile, ProblemType } from '@hydrooj/common';
import {
extractZip, Logger, size, streamToBuffer,
} from '@hydrooj/utils/lib/utils';
import { Context } from '../context';
import { FileUploadError, ProblemNotFoundError, ValidationError } from '../error';
import type {
Document, LocalizedContent, ProblemConfigFile, ProblemDict, ProblemStatusDoc, User,
Document, LocalizedContent, ProblemDict, ProblemStatusDoc, User,
} from '../interface';
import { parseConfig } from '../lib/testdataConfig';
import * as bus from '../service/bus';
import bus from '../service/bus';
import db from '../service/db';
import {
ArrayKeys, MaybeArray, NumberKeys, Projection,
@@ -40,6 +43,7 @@ function sortable(source: string, namespaces: Record<string, string>) {
}
function findOverrideContent(dir: string, base: string) {
if (!fs.existsSync(dir)) return null;
let files = fs.readdirSync(dir);
if (files.includes(`${base}.md`)) return [{ name: 'default', content: fs.readFileSync(path.join(dir, `${base}.md`), 'utf8'), lang: 'en' }];
if (files.includes(`${base}.pdf`)) return [{ name: 'default', content: `@[PDF](file://${base}.pdf)`, lang: 'en' }];
@@ -63,6 +67,7 @@ interface ProblemImportOptions {
override?: boolean;
operator?: number;
delSource?: boolean;
hidden?: boolean;
}
interface ProblemCreateOptions {
@@ -144,6 +149,7 @@ export class ProblemModel {
) {
const c = typeof content === 'string' ? [{ content, lang: 'en', name: 'default' }] : content;
const [doc] = await ProblemModel.getMulti(domainId, {})
.withReadPreference('primary')
.sort({ docId: -1 }).limit(1).project({ docId: 1 })
.toArray();
const result = await ProblemModel.addWithId(
@@ -160,7 +166,14 @@ export class ProblemModel {
) {
const ddoc = await DomainModel.get(domainId);
const args: Partial<ProblemDoc> = {
title, tag, hidden: meta.hidden || false, nSubmit: 0, nAccept: 0, sort: sortable(pid || `P${docId}`, ddoc?.namespaces),
title,
tag,
hidden: meta.hidden || false,
nSubmit: 0,
nAccept: 0,
sort: sortable(pid || `P${docId}`, ddoc?.namespaces),
data: [],
additional_file: [],
};
if (pid) args.pid = pid;
if (meta.difficulty) args.difficulty = meta.difficulty;
@@ -222,19 +235,20 @@ export class ProblemModel {
static async edit(domainId: string, _id: number, $set: Partial<ProblemDoc>): Promise<ProblemDoc> {
const delpid = $set.pid === '';
const ddoc = await DomainModel.get(domainId);
const $unset = delpid ? { pid: '' } : {};
if (delpid) {
delete $set.pid;
$set.sort = sortable(`P${_id}`, ddoc.namespaces);
} else if ($set.pid) {
$set.sort = sortable($set.pid, ddoc.namespaces);
}
await bus.parallel('problem/before-edit', $set);
const result = await document.set(domainId, document.TYPE_PROBLEM, _id, $set, delpid ? { pid: '' } : undefined);
await bus.parallel('problem/before-edit', $set, $unset);
const result = await document.set(domainId, document.TYPE_PROBLEM, _id, $set, $unset);
await bus.emit('problem/edit', result);
return result;
}
static async copy(domainId: string, _id: number, target: string, pid?: string) {
static async copy(domainId: string, _id: number, target: string, pid?: string, hidden?: boolean) {
const original = await ProblemModel.get(domainId, _id);
if (!original) throw new ProblemNotFoundError(domainId, _id);
if (original.reference) throw new ValidationError('reference');
@@ -242,7 +256,7 @@ export class ProblemModel {
if (!pid && original.pid && !await ProblemModel.get(target, original.pid)) pid = original.pid;
return await ProblemModel.add(
target, pid, original.title, original.content,
original.owner, original.tag, { hidden: original.hidden, reference: { domainId, pid: _id } },
original.owner, original.tag, { hidden: hidden || original.hidden, reference: { domainId, pid: _id } },
);
}
@@ -380,7 +394,6 @@ export class ProblemModel {
}
for (const pdoc of pdocs) {
try {
// eslint-disable-next-line no-await-in-loop
pdoc.config = await parseConfig(pdoc.config as string);
} catch (e) {
pdoc.config = `Cannot parse: ${e.message}`;
@@ -391,7 +404,7 @@ export class ProblemModel {
// TODO enhance
if (pdocs.length !== pids.length) {
for (const pid of pids) {
if (!(r[pid] || l[pid])) {
if (!r[pid] && !l[pid]) {
if (doThrow) throw new ProblemNotFoundError(domainId, pid);
if (!indexByDocIdOnly) r[pid] = { ...ProblemModel.default, domainId, pid: pid.toString() };
}
@@ -404,22 +417,17 @@ export class ProblemModel {
const psdocs = await ProblemModel.getMultiStatus(
domainId, { uid, docId: { $in: Array.from(new Set(pids)) } },
).toArray();
const r: Record<string, ProblemStatusDoc> = {};
for (const psdoc of psdocs) {
r[psdoc.docId] = psdoc;
r[`${psdoc.domainId}#${psdoc.docId}`] = psdoc;
}
return r;
return keyBy(psdocs, 'docId');
}
static async updateStatus(
domainId: string, pid: number, uid: number,
rid: ObjectId, status: number, score: number,
) {
const filter: Filter<ProblemStatusDoc> = { rid: { $ne: rid }, status: STATUS.STATUS_ACCEPTED };
const res = await document.setStatusIfNotCondition(
const condition = status === STATUS.STATUS_ACCEPTED ? {} : { status: { $ne: STATUS.STATUS_ACCEPTED } };
const res = await document.setStatusIfCondition(
domainId, document.TYPE_PROBLEM, pid, uid,
filter, { rid, status, score },
condition, { rid, status, score },
);
return !!res;
}
@@ -452,31 +460,34 @@ export class ProblemModel {
const {
preferredPrefix, progress, override = false, operator = 1,
} = options;
let delSource = options.delSource;
let problems: string[];
try {
if (filepath.endsWith('.zip')) {
tmpdir = path.join(os.tmpdir(), 'hydro', `${Math.random()}.import`);
let zip: AdmZip;
const zip = new ZipReader(Readable.toWeb(fs.createReadStream(filepath)));
let entries: Entry[];
try {
zip = new AdmZip(filepath);
entries = await zip.getEntries();
} catch (e) {
throw new ValidationError('zip', null, e.message);
}
await new Promise((resolve, reject) => {
zip.extractAllToAsync(tmpdir, true, (err) => {
if (err) reject(err);
resolve(null);
});
});
delSource = true;
await extractZip(entries, tmpdir);
} else if (fs.statSync(filepath).isDirectory()) {
tmpdir = filepath;
} else {
throw new ValidationError('file', null, 'Invalid file');
}
const files = await fs.readdir(tmpdir, { withFileTypes: true });
problems = files.filter((f) => f.isDirectory()).map((i) => i.name);
if (files.find((f) => f.name === 'problem.yaml')) {
problems = ['.']; // special case for ICPC problem package
} else {
problems = files.filter((f) => f.isDirectory()).map((i) => i.name);
}
} catch (e) {
if (options.delSource) await fs.remove(tmpdir);
if (delSource) await fs.remove(tmpdir);
throw e;
}
for (const i of problems) {
try {
@@ -490,7 +501,7 @@ export class ProblemModel {
let overridePid = null;
const isValidPid = async (id: string) => {
if (!(/^[A-Za-z]+[0-9A-Za-z]*$/.test(id))) return false;
if (!(/^[A-Za-z][0-9A-Za-z]*$/.test(id))) return false;
const doc = await ProblemModel.get(domainId, id);
if (doc) {
if (!override) return false;
@@ -545,6 +556,7 @@ export class ProblemModel {
if ((pdoc as any).limits) {
config.time = (pdoc as any).limits.time_limit;
config.memory = (pdoc as any).limits.memory;
configChanged = true;
}
const docId = overridePid
? (await ProblemModel.edit(domainId, overridePid, {
@@ -554,10 +566,11 @@ export class ProblemModel {
}],
tag,
difficulty: pdoc.difficulty,
...(options.hidden ? { hidden: true } : {}),
})).docId
: await ProblemModel.add(
domainId, pid, title.trim(), overrideContent || pdoc.content?.toString() || 'No content',
operator || pdoc.owner, tag, { hidden: pdoc.hidden, difficulty: pdoc.difficulty },
operator || pdoc.owner, tag, { hidden: options.hidden || pdoc.hidden, difficulty: pdoc.difficulty },
);
// TODO delete unused file when updating pdoc
for (const [f, loc] of await getFiles('testdata', 'attachments', 'generators', 'include')) {
@@ -575,6 +588,22 @@ export class ProblemModel {
: null)?.(domainId, docId, file, path.join(loc, file));
}
}
for (const [f, loc] of await getFiles('output_validators')) {
if (f.isFile()) continue;
const sub = await fs.readdir(loc);
for (const file of sub) {
if (file === 'testlib.h') continue;
await ProblemModel.addTestdata(domainId, docId, file, path.join(loc, file));
if (file === 'checker') {
config.checker_type = 'testlib';
config.checker = file;
} else if (file === 'interactor') {
config.type = ProblemType.Interactive;
config.interactor = file;
}
configChanged = true;
}
}
for (const [f, loc] of await getFiles('additional_file', 'attachments', 'statement', 'problem_statement')) {
if (!f.isFile()) continue;
await ProblemModel.addAdditionalFile(domainId, docId, f.name, loc);
@@ -605,14 +634,18 @@ export class ProblemModel {
(process.env.HYDRO_CLI ? logger.info : progress)?.(`Error importing problem ${i}: ${e.message}`);
}
}
if (options.delSource) await fs.remove(tmpdir);
if (delSource) await fs.remove(tmpdir);
}
static async export(domainId: string) {
static async export(domainId: string, pidFilter = '') {
console.log('Exporting problems...');
const tmpdir = path.join(os.tmpdir(), 'hydro', `${Math.random()}.export`);
await fs.mkdir(tmpdir);
const pdocs = await ProblemModel.getMulti(domainId, {}, ProblemModel.PROJECTION_PUBLIC).toArray();
const pdocs = await ProblemModel.getMulti(
domainId, pidFilter ? { pid: new RegExp(pidFilter) } : {},
ProblemModel.PROJECTION_PUBLIC,
).toArray();
if (process.env.HYDRO_CLI) logger.info(`Exporting ${pdocs.length} problems`);
for (const pdoc of pdocs) {
if (process.env.HYDRO_CLI) logger.info(`Exporting problem ${pdoc.pid || (`P${pdoc.docId}`)} (${pdoc.title})`);
const problemPath = path.join(tmpdir, `${pdoc.docId}`);
+56 -22
View File
@@ -1,26 +1,27 @@
/* eslint-disable object-curly-newline */
import { sum } from 'lodash';
import { pick, sum } from 'lodash';
import moment from 'moment-timezone';
import {
Filter, MatchKeysAndValues,
Filter, FindOptions, MatchKeysAndValues,
ObjectId, OnlyFieldsOfType, PushOperator, UpdateFilter,
} from 'mongodb';
import { ProblemConfigFile } from '@hydrooj/common';
import { Context } from '../context';
import { ProblemNotFoundError } from '../error';
import {
JudgeMeta, ProblemConfigFile, RecordDoc,
} from '../interface';
import { JudgeMeta, RecordDoc } from '../interface';
import db from '../service/db';
import { MaybeArray, NumberKeys } from '../typeutils';
import { ArgMethod, buildProjection, Time } from '../utils';
import { STATUS } from './builtin';
import DomainModel from './domain';
import problem from './problem';
import * as SystemModel from './system';
import task from './task';
export default class RecordModel {
static coll = db.collection('record');
static collStat = db.collection('record.stat');
static collHistory = db.collection('record.history');
static PROJECTION_LIST: (keyof RecordDoc)[] = [
'_id', 'score', 'time', 'memory', 'lang',
'uid', 'pid', 'rejudged', 'progress', 'domainId',
@@ -78,15 +79,23 @@ export default class RecordModel {
};
}
static async judge(domainId: string, rids: MaybeArray<ObjectId>, priority = 0, config: ProblemConfigFile = {}, meta: Partial<JudgeMeta> = {}) {
rids = rids instanceof Array ? rids : [rids];
if (!rids.length) return null;
const rdocs = await RecordModel.getMulti(domainId, { _id: { $in: rids } }).toArray();
static async judge(
domainId: string, rids: MaybeArray<ObjectId> | RecordDoc, priority = 0,
config: ProblemConfigFile = {}, meta: Partial<JudgeMeta> = {},
) {
let rdocs: RecordDoc[];
const _rids = rids instanceof Array ? rids
: (rids instanceof ObjectId) ? [rids]
: [rids._id];
if (!_rids.length) return null;
if (rids instanceof Array || rids instanceof ObjectId || ObjectId.isValid(rids.toString())) {
rdocs = await RecordModel.getMulti(domainId, { _id: { $in: _rids } }, { readPreference: 'primary' }).toArray();
} else rdocs = [rids];
if (!rdocs.length) return null;
let source = `${domainId}/${rdocs[0].pid}`;
let [pdoc] = await Promise.all([
problem.get(domainId, rdocs[0].pid),
task.deleteMany({ rid: { $in: rids } }),
task.deleteMany({ rid: { $in: _rids } }),
]);
if (!pdoc) throw new ProblemNotFoundError(domainId, rdocs[0].pid);
if (pdoc.reference) {
@@ -102,8 +111,8 @@ export default class RecordModel {
if (pdoc.config.type === 'remote_judge' && rdoc.contest?.toHexString() !== '0'.repeat(24)) type = 'remotejudge';
else if (meta?.type === 'generate') type = 'generate';
return ({
...rdoc,
...(pdoc.config as any), // TODO deprecate this
lang: rdoc.lang,
priority,
type,
rid: rdoc._id,
@@ -167,7 +176,7 @@ export default class RecordModel {
bus.broadcast('record/change', data);
if (addTask) {
const priority = await RecordModel.submissionPriority(uid, args.type === 'pretest' ? -20 : (isContest ? 50 : 0));
await RecordModel.judge(domainId, res.insertedId, priority, isContest ? { detail: false } : {}, {
await RecordModel.judge(domainId, data, priority, isContest ? { detail: false } : {}, {
type: args.type,
rejudge: data.rejudged,
});
@@ -175,9 +184,9 @@ export default class RecordModel {
return res.insertedId;
}
static getMulti(domainId: string, query: any) {
static getMulti(domainId: string, query: any, options?: FindOptions) {
if (domainId) query = { domainId, ...query };
return RecordModel.coll.find(query);
return RecordModel.coll.find(query, options);
}
static getMultiStat(domainId: string, query: any, sortBy: any = { _id: -1 }) {
@@ -201,14 +210,13 @@ export default class RecordModel {
return null;
}
if (Object.keys($update).length) {
const res = await RecordModel.coll.findOneAndUpdate(
return await RecordModel.coll.findOneAndUpdate(
{ _id, domainId },
$update,
{ returnDocument: 'after' },
);
return res.value || null;
}
return await RecordModel.get(domainId, _id);
return await RecordModel.coll.findOne({ _id }, { readPreference: 'primary' });
}
static async updateMulti(
@@ -226,6 +234,7 @@ export default class RecordModel {
}
static async reset(domainId: string, rid: MaybeArray<ObjectId>, isRejudge: boolean) {
const rids = Array.isArray(rid) ? rid : [rid];
const upd: any = {
score: 0,
status: STATUS.STATUS_WAITING,
@@ -239,8 +248,21 @@ export default class RecordModel {
judger: null,
};
if (isRejudge) upd.rejudged = true;
await RecordModel.collStat.deleteMany(rid instanceof Array ? { _id: { $in: rid } } : { _id: rid });
await task.deleteMany(rid instanceof Array ? { rid: { $in: rid } } : { rid });
const [rdocs] = await Promise.all([
RecordModel.coll.find({ _id: { $in: rids }, judgeAt: { $exists: true, $ne: null } }).toArray(),
RecordModel.collStat.deleteMany({ _id: { $in: rids } }),
task.deleteMany({ rid: { $in: rids } }),
]);
if (rdocs.length) {
await RecordModel.collHistory.insertMany(rdocs.map((rdoc) => ({
...pick(rdoc, [
'compilerTexts', 'judgeTexts', 'testCases', 'subtasks',
'score', 'time', 'memory', 'status', 'judgeAt', 'judger',
]),
rid: rdoc._id,
_id: new ObjectId(),
})));
}
return RecordModel.update(domainId, rid, upd);
}
@@ -261,7 +283,7 @@ export default class RecordModel {
}
}
export function apply(ctx: Context) {
export async function apply(ctx: Context) {
// Mark problem as deleted
ctx.on('problem/delete', (domainId, docId) => Promise.all([
RecordModel.coll.deleteMany({ domainId, pid: docId }),
@@ -270,6 +292,14 @@ export function apply(ctx: Context) {
ctx.on('domain/delete', (domainId) => RecordModel.coll.deleteMany({ domainId }));
ctx.on('record/judge', async (rdoc, updated) => {
if (rdoc.status === STATUS.STATUS_ACCEPTED && updated) {
if (SystemModel.get('record.statMode') === 'unique') {
await RecordModel.collStat.deleteMany({
_id: { $ne: rdoc._id },
uid: rdoc.uid,
pid: rdoc.pid,
domainId: rdoc.domainId,
});
}
await RecordModel.collStat.updateOne({
_id: rdoc._id,
}, {
@@ -285,7 +315,7 @@ export function apply(ctx: Context) {
}, { upsert: true });
}
});
ctx.on('ready', () => Promise.all([
await Promise.all([
db.ensureIndexes(
RecordModel.coll,
{ key: { domainId: 1, pid: 1 }, name: 'delete' },
@@ -302,6 +332,10 @@ export function apply(ctx: Context) {
{ key: { domainId: 1, pid: 1, uid: 1, memory: 1 }, name: 'memory' },
{ key: { domainId: 1, pid: 1, uid: 1, length: 1 }, name: 'length' },
),
]) as any);
db.ensureIndexes(
RecordModel.collHistory,
{ key: { rid: 1, _id: -1 }, name: 'basic' },
),
]);
}
global.Hydro.model.record = RecordModel;
+7 -7
View File
@@ -4,7 +4,7 @@ import { Context } from '../context';
import { Schedule } from '../interface';
import { Logger } from '../logger';
import db from '../service/db';
import type { WorkerService } from '../service/worker';
import type WorkerService from '../service/worker';
import RecordModel from './record';
const logger = new Logger('model/schedule');
@@ -15,13 +15,13 @@ async function getFirst(query: Filter<Schedule>) {
const q = { ...query };
q.executeAfter ||= { $lt: new Date() };
const res = await coll.findOneAndDelete(q);
if (res.value) {
logger.debug('%o', res.value);
if (res.value.interval) {
const executeAfter = moment(res.value.executeAfter).add(...res.value.interval).toDate();
await coll.insertOne({ ...res.value, executeAfter });
if (res) {
logger.debug('%o', res);
if (res.interval) {
const executeAfter = moment(res.executeAfter).add(...res.interval).toDate();
await coll.insertOne({ ...res, executeAfter });
}
return res.value;
return res;
}
return null;
}
+110 -72
View File
@@ -1,18 +1,21 @@
/* eslint-disable max-len */
/* eslint-disable no-await-in-loop */
import * as cordis from 'cordis';
import fs from 'fs';
import yaml from 'js-yaml';
import { Dictionary } from 'lodash';
import moment from 'moment-timezone';
import { LangConfig, parseLang } from '@hydrooj/utils/lib/lang';
import { retry } from '@hydrooj/utils/lib/utils';
import { Context, Service } from '../context';
import Schema from 'schemastery';
import { LangConfig, parseLang } from '@hydrooj/common';
import { findFileSync, randomstring, retry } from '@hydrooj/utils';
import { Context } from '../context';
import { Setting as _Setting } from '../interface';
import { Logger } from '../logger';
import * as builtin from './builtin';
type SettingDict = Dictionary<_Setting>;
const settingFile = yaml.load(fs.readFileSync(findFileSync('hydrooj/setting.yaml'), 'utf-8')) as any;
const logger = new Logger('model/setting');
const countries = moment.tz.countries();
const tzs = new Set();
@@ -23,10 +26,6 @@ for (const country of countries) {
const timezones = Array.from(tzs).sort().map((tz) => [tz, tz]) as [string, string][];
const langRange: Dictionary<string> = {};
for (const lang in global.Hydro.locales) {
langRange[lang] = global.Hydro.locales[lang].__langname;
}
export const FLAG_HIDDEN = 1;
export const FLAG_DISABLED = 2;
export const FLAG_SECRET = 4;
@@ -43,7 +42,6 @@ export const DOMAIN_USER_SETTINGS_BY_KEY: SettingDict = {};
export const DOMAIN_SETTINGS_BY_KEY: SettingDict = {};
export const SYSTEM_SETTINGS_BY_KEY: SettingDict = {};
// eslint-disable-next-line max-len
export type SettingType = 'text' | 'yaml' | 'number' | 'float' | 'markdown' | 'password' | 'boolean' | 'textarea' | [string, string][] | Record<string, string> | 'json';
export const Setting = (
@@ -69,7 +67,57 @@ export const Setting = (
};
};
export const PreferenceSetting = (...settings: _Setting[]) => {
declare global {
namespace Schemastery {
interface Meta<T> { // eslint-disable-line @typescript-eslint/no-unused-vars
family?: string;
secret?: boolean;
}
}
}
function schemaToSettings(schema: Schema<any>) {
const result: _Setting[] = [];
const processNode = (key: string, s: Schema<number> | Schema<string> | Schema<boolean>, defaultFamily = 'setting_basic') => {
if (s.dict) throw new Error('Dict is not supported here');
let flag = (s.meta?.hidden ? FLAG_HIDDEN : 0)
| (s.meta?.disabled ? FLAG_DISABLED : 0);
const type = s.type === 'number' ? 'number'
: s.type === 'boolean' ? 'boolean'
: s.meta?.role === 'markdown' ? 'markdown'
: s.meta?.role === 'textarea' ? 'textarea' : 'text';
if (s.meta?.role === 'password') flag |= FLAG_SECRET;
const options = {};
for (const item of s.list || []) {
if (item.type !== 'const') throw new Error('List item must be a constant');
options[item.value] = item.meta?.description || item.value;
}
return {
family: s.meta?.family || defaultFamily,
key,
value: s.meta?.default,
name: key,
desc: s.meta?.description,
flag,
subType: '',
type: s.list ? 'select' : type,
range: s.list ? options : null,
} as _Setting;
};
if (!schema.dict) return [];
for (const key in schema.dict) {
const value = schema.dict[key];
if (value.dict) {
for (const subkey in value.dict) {
result.push(processNode(`${key}.${subkey}`, value.dict[subkey], value.meta?.family));
}
} else result.push(processNode(key, value));
}
return result;
}
export const PreferenceSetting = (...settings: _Setting[] | Schema<any>[]) => {
settings = settings.flatMap((s) => (s instanceof Schema ? schemaToSettings(s) : s) as _Setting[]);
for (const setting of settings) {
if (PREFERENCE_SETTINGS.find((s) => s.key === setting.key)) logger.warn(`Duplicate setting key: ${setting.key}`);
PREFERENCE_SETTINGS.push(setting);
@@ -88,7 +136,8 @@ export const PreferenceSetting = (...settings: _Setting[]) => {
}
};
};
export const AccountSetting = (...settings: _Setting[]) => {
export const AccountSetting = (...settings: _Setting[] | Schema<any>[]) => {
settings = settings.flatMap((s) => (s instanceof Schema ? schemaToSettings(s) : s) as _Setting[]);
for (const setting of settings) {
if (ACCOUNT_SETTINGS.find((s) => s.key === setting.key)) logger.warn(`Duplicate setting key: ${setting.key}`);
ACCOUNT_SETTINGS.push(setting);
@@ -107,7 +156,8 @@ export const AccountSetting = (...settings: _Setting[]) => {
}
};
};
export const DomainUserSetting = (...settings: _Setting[]) => {
export const DomainUserSetting = (...settings: _Setting[] | Schema<any>[]) => {
settings = settings.flatMap((s) => (s instanceof Schema ? schemaToSettings(s) : s) as _Setting[]);
for (const setting of settings) {
if (DOMAIN_USER_SETTINGS.find((s) => s.key === setting.key)) logger.warn(`Duplicate setting key: ${setting.key}`);
DOMAIN_USER_SETTINGS.push(setting);
@@ -122,7 +172,8 @@ export const DomainUserSetting = (...settings: _Setting[]) => {
}
};
};
export const DomainSetting = (...settings: _Setting[]) => {
export const DomainSetting = (...settings: _Setting[] | Schema<any>[]) => {
settings = settings.flatMap((s) => (s instanceof Schema ? schemaToSettings(s) : s) as _Setting[]);
for (const setting of settings) {
if (DOMAIN_SETTINGS.find((s) => s.key === setting.key)) logger.warn(`Duplicate setting key: ${setting.key}`);
DOMAIN_SETTINGS.push(setting);
@@ -137,7 +188,8 @@ export const DomainSetting = (...settings: _Setting[]) => {
}
};
};
export const SystemSetting = (...settings: _Setting[]) => {
export const SystemSetting = (...settings: _Setting[] | Schema<any>[]) => {
settings = settings.flatMap((s) => (s instanceof Schema ? schemaToSettings(s) : s) as _Setting[]);
for (const setting of settings) {
if (SYSTEM_SETTINGS.find((s) => s.key === setting.key)) logger.warn(`Duplicate setting key: ${setting.key}`);
SYSTEM_SETTINGS.push(setting);
@@ -221,6 +273,7 @@ DomainUserSetting(
Setting('setting_storage', 'rpdelta', 0, 'number', 'RP.delta', null, FLAG_HIDDEN | FLAG_DISABLED),
Setting('setting_storage', 'rank', 0, 'number', 'Rank', null, FLAG_DISABLED | FLAG_HIDDEN),
Setting('setting_storage', 'level', 0, 'number', 'level', null, FLAG_HIDDEN | FLAG_DISABLED),
Setting('setting_storage', 'join', 0, 'number', 'join', null, FLAG_HIDDEN | FLAG_DISABLED),
);
const ignoreUA = [
@@ -232,30 +285,38 @@ const ignoreUA = [
'YandexBot',
].join('\n');
// This is a showcase of how to use Schema to define settings.
SystemSetting(Schema.object({
smtp: Schema.object({
user: Schema.string().default('').description('SMTP Username'),
pass: Schema.string().default('').description('SMTP Password').role('password'),
host: Schema.string().default('').description('SMTP Server Host'),
port: Schema.number().step(1).min(1).max(65535).default(465).description('SMTP Server Port'),
from: Schema.string().default('').description('Mail From'),
secure: Schema.boolean().default(false).description('SSL'),
verify: Schema.boolean().default(true).description('Verify register email'),
}).extra('family', 'setting_smtp'),
server: Schema.object({
center: Schema.string().default('https://hydro.ac/center').description('Server Center').role('url').hidden(),
name: Schema.string().default('Hydro').description('Server Name'),
url: Schema.string().default('/').description('Server BaseURL'),
upload: Schema.string().default('256m').description('Max upload file size'),
cdn: Schema.string().default('/').description('CDN Prefix'),
ws: Schema.string().default('/').description('WebSocket Prefix'),
host: Schema.string().default('127.0.0.1').description('Listen host'),
port: Schema.number().step(1).min(1).max(65535).default(8888).description('Server Port'),
xff: Schema.string().default('').description('IP Header'),
xhost: Schema.string().default('').description('Hostname Header'),
xproxy: Schema.boolean().default(false).description('Use reverse_proxy'),
cors: Schema.string().default('').description('CORS domains'),
login: Schema.boolean().default(true).description('Allow builtin-login').hidden(),
checkUpdate: Schema.boolean().default(true).description('Daily update check'),
ignoreUA: Schema.string().default(ignoreUA).description('ignoredUA').role('textarea'),
}).extra('family', 'setting_server'),
}));
// We will keep the old settings as-is until new setting ui is ready.
SystemSetting(
Setting('setting_smtp', 'smtp.user', null, 'text', 'smtp.user', 'SMTP Username'),
Setting('setting_smtp', 'smtp.pass', null, 'password', 'smtp.pass', 'SMTP Password', FLAG_SECRET),
Setting('setting_smtp', 'smtp.host', null, 'text', 'smtp.host', 'SMTP Server Host'),
Setting('setting_smtp', 'smtp.port', 465, 'number', 'smtp.port', 'SMTP Server Port'),
Setting('setting_smtp', 'smtp.from', null, 'text', 'smtp.from', 'Mail From'),
Setting('setting_smtp', 'smtp.secure', false, 'boolean', 'smtp.secure', 'SSL'),
Setting('setting_smtp', 'smtp.verify', true, 'boolean', 'smtp.verify', 'Verify register email'),
Setting('setting_server', 'server.center', 'https://hydro.ac/center', 'text', 'server.center', '', FLAG_HIDDEN),
Setting('setting_server', 'server.name', 'Hydro', 'text', 'server.name', 'Server Name'),
Setting('setting_server', 'server.url', '/', 'text', 'server.url', 'Server BaseURL'),
Setting('setting_server', 'server.upload', '256m', 'text', 'server.upload', 'Max upload file size'),
Setting('setting_server', 'server.cdn', '/', 'text', 'server.cdn', 'CDN Prefix'),
Setting('setting_server', 'server.ws', '/', 'text', 'server.ws', 'WebSocket Prefix'),
Setting('setting_server', 'server.host', '127.0.0.1', 'text', 'server.host', 'Listen host'),
Setting('setting_server', 'server.port', 8888, 'number', 'server.port', 'Server Port'),
Setting('setting_server', 'server.xff', null, 'text', 'server.xff', 'IP Header'),
Setting('setting_server', 'server.xhost', null, 'text', 'server.xhost', 'Hostname Header'),
Setting('setting_server', 'server.xproxy', false, 'boolean', 'server.xproxy', 'Use reverse_proxy'),
Setting('setting_server', 'server.cors', '', 'text', 'server.cors', 'CORS domains'),
Setting('setting_server', 'server.language', 'zh_CN', langRange, 'server.language', 'Default display language'),
Setting('setting_server', 'server.login', true, 'boolean', 'server.login', 'Allow builtin-login', FLAG_PRO),
Setting('setting_server', 'server.checkUpdate', true, 'boolean', 'server.checkUpdate', 'Daily update check'),
Setting('setting_server', 'server.ignoreUA', ignoreUA, 'textarea', 'server.ignoreUA', 'ignoredUA'),
ServerLangSettingNode,
Setting('setting_limits', 'limit.by_user', false, 'boolean', 'limit.by_user', 'Use per-user limits instead of per ip limits'),
Setting('setting_limits', 'limit.problem_files_max', 100, 'number', 'limit.problem_files_max', 'Max files per problem'),
@@ -273,6 +334,7 @@ SystemSetting(
Setting('setting_basic', 'discussion.nodes', builtin.DEFAULT_NODES, 'yaml', 'discussion.nodes', 'Discussion Nodes'),
Setting('setting_basic', 'problem.categories', builtin.CATEGORIES, 'yaml', 'problem.categories', 'Problem Categories'),
Setting('setting_basic', 'training.enrolled-users', true, 'boolean', 'training.enrolled-users', 'Show enrolled users for training'),
Setting('setting_basic', 'record.statMode', 'unique', 'text', 'record.statMode', 'Record stat mode'),
Setting('setting_basic', 'pagination.problem', 100, 'number', 'pagination.problem', 'Problems per page'),
Setting('setting_basic', 'pagination.contest', 20, 'number', 'pagination.contest', 'Contests per page'),
Setting('setting_basic', 'pagination.discussion', 50, 'number', 'pagination.discussion', 'Discussions per page'),
@@ -281,56 +343,31 @@ SystemSetting(
Setting('setting_basic', 'pagination.solution', 20, 'number', 'pagination.solution', 'Solutions per page'),
Setting('setting_basic', 'pagination.training', 10, 'number', 'pagination.training', 'Trainings per page'),
Setting('setting_basic', 'pagination.reply', 50, 'number', 'pagination.reply', 'Replies per page'),
Setting('setting_session', 'session.keys', [String.random(32)], 'text', 'session.keys', 'session.keys', FLAG_HIDDEN),
Setting('setting_basic', 'hydrooj.homepage', settingFile.homepage.default, 'yaml', 'hydrooj.homepage', 'Homepage config'),
Setting('setting_basic', 'hydrooj.langs', settingFile.langs.default, 'yaml', 'hydrooj.langs', 'Language config'),
Setting('setting_session', 'session.keys', [randomstring(32)], 'text', 'session.keys', 'session.keys', FLAG_HIDDEN),
Setting('setting_session', 'session.domain', '', 'text', 'session.domain', 'session.domain', FLAG_HIDDEN),
Setting('setting_session', 'session.saved_expire_seconds', 3600 * 24 * 30,
'number', 'session.saved_expire_seconds', 'Saved session expire seconds'),
Setting('setting_session', 'session.unsaved_expire_seconds', 3600 * 3,
'number', 'session.unsaved_expire_seconds', 'Unsaved session expire seconds'),
Setting('setting_storage', 'db.ver', 0, 'number', 'db.ver', 'Database version', FLAG_DISABLED | FLAG_HIDDEN),
Setting('setting_storage', 'installid', String.random(64), 'text', 'installid', 'Installation ID', FLAG_HIDDEN | FLAG_DISABLED),
Setting('setting_storage', 'installid', randomstring(64), 'text', 'installid', 'Installation ID', FLAG_HIDDEN | FLAG_DISABLED),
);
// eslint-disable-next-line import/no-mutable-exports
export const langs: Record<string, LangConfig> = {};
declare module 'cordis' {
interface Context {
setting: SettingService;
}
}
const T = <F extends (...args: any[]) => any>(origFunc: F, disposeFunc?) =>
function method(this: cordis.Service, ...args: Parameters<F>) {
this.ctx.effect(() => {
const res = origFunc(...args);
return () => (disposeFunc ? disposeFunc(res) : res());
});
};
export class SettingService extends Service {
PreferenceSetting = T(PreferenceSetting);
AccountSetting = T(AccountSetting);
DomainSetting = T(DomainSetting);
DomainUserSetting = T(DomainUserSetting);
SystemSetting = T(SystemSetting);
constructor(ctx: Context) {
super(ctx, 'setting', true);
}
get(key: string) {
return (this.ctx ? this.ctx.domain?.config?.[key.replace(/\./g, '$')] : null) ?? global.Hydro.model.system.get(key);
}
}
export const inject = ['db'];
export async function apply(ctx: Context) {
ctx.provide('setting', undefined, true);
ctx.plugin(SettingService);
logger.info('Ensuring settings');
for (const lang in global.Hydro.locales) {
if (!global.Hydro.locales[lang].__interface) continue;
langRange[lang] = global.Hydro.locales[lang].__langname;
}
const system = global.Hydro.model.system;
for (const setting of SYSTEM_SETTINGS) {
if (!setting.value) continue;
const current = await global.Hydro.service.db.collection('system').findOne({ _id: setting.key });
const current = await ctx.db.collection('system').findOne({ _id: setting.key });
if (!current || current.value == null || current.value === '') {
await retry(system.set, setting.key, setting.value);
}
@@ -354,7 +391,8 @@ export async function apply(ctx: Context) {
global.Hydro.model.setting = {
apply,
SettingService,
inject,
Setting,
PreferenceSetting,
AccountSetting,
+4 -2
View File
@@ -1,6 +1,6 @@
import { ObjectId } from 'mongodb';
import { SolutionNotFoundError } from '../error';
import * as bus from '../service/bus';
import bus from '../service/bus';
import * as document from './document';
class SolutionModel {
@@ -70,6 +70,8 @@ class SolutionModel {
}
static async vote(domainId: string, psid: ObjectId, uid: number, value: number) {
const doc = await document.get(domainId, document.TYPE_PROBLEM_SOLUTION, psid);
if (!doc) throw new SolutionNotFoundError(domainId, psid);
const before = await document.setStatus(
domainId, document.TYPE_PROBLEM_SOLUTION, psid, uid,
{ vote: value }, 'before',
@@ -79,7 +81,7 @@ class SolutionModel {
return [
inc
? await document.inc(domainId, document.TYPE_PROBLEM_SOLUTION, psid, 'vote', inc)
: await document.get(domainId, document.TYPE_PROBLEM_SOLUTION, psid),
: doc,
await document.getStatus(domainId, document.TYPE_PROBLEM_SOLUTION, psid, uid),
];
}
+24 -26
View File
@@ -35,7 +35,7 @@ export class StorageModel {
}
static async get(path: string, savePath?: string) {
const { value } = await StorageModel.coll.findOneAndUpdate(
const value = await StorageModel.coll.findOneAndUpdate(
{ path, autoDelete: null },
{ $set: { lastUsage: new Date() } },
{ returnDocument: 'after' },
@@ -101,7 +101,7 @@ export class StorageModel {
}
static async getMeta(path: string) {
const { value } = await StorageModel.coll.findOneAndUpdate(
const value = await StorageModel.coll.findOneAndUpdate(
{ path, autoDelete: null },
{ $set: { lastUsage: new Date() } },
{ returnDocument: 'after' },
@@ -120,7 +120,7 @@ export class StorageModel {
{ path: target, autoDelete: null },
{ $set: { lastUsage: new Date() } },
);
return await storage.signDownloadLink(res.value?.link || res.value?._id || target, filename, noExpire, useAlternativeEndpointFor);
return await storage.signDownloadLink(res?.link || res?._id || target, filename, noExpire, useAlternativeEndpointFor);
}
static async move(src: string, dst: string) {
@@ -128,7 +128,7 @@ export class StorageModel {
{ path: src, autoDelete: null },
{ $set: { path: dst } },
);
return !!res.value;
return !!res;
}
static async exists(path: string) {
@@ -137,11 +137,12 @@ export class StorageModel {
}
static async copy(src: string, dst: string) {
const { value } = await StorageModel.coll.findOneAndUpdate(
const value = await StorageModel.coll.findOneAndUpdate(
{ path: src, autoDelete: null },
{ $set: { lastUsage: new Date() } },
{ returnDocument: 'after' },
);
if (!value) throw new Error(`Original file ${src} not found`);
const meta = {};
await StorageModel.del([dst]);
meta['Content-Type'] = mime(dst);
@@ -150,7 +151,7 @@ export class StorageModel {
// eslint-disable-next-line no-await-in-loop
while (await StorageModel.coll.findOne({ _id })) _id = StorageModel.generateId(extname(dst));
await StorageModel.coll.insertOne({
...value, _id, path: dst, link: value._id, lastModified: new Date(), owner: value.owner || 1,
...value, _id, path: dst, link: value.link || value._id, lastModified: new Date(), owner: value.owner || 1,
});
return _id;
}
@@ -169,15 +170,15 @@ async function cleanFiles() {
}
if (system.get('server.keepFiles')) return;
let res = await StorageModel.coll.findOneAndDelete({ autoDelete: { $lte: new Date() } });
while (res.value) {
while (res) {
// eslint-disable-next-line no-await-in-loop
if (!res.value.link) await storage.del(res.value._id);
if (!res.link) await storage.del(res._id);
// eslint-disable-next-line no-await-in-loop
res = await StorageModel.coll.findOneAndDelete({ autoDelete: { $lte: new Date() } });
}
}
export function apply(ctx: Context) {
export async function apply(ctx: Context) {
ctx.inject(['worker'], (c) => {
c.worker.addHandler('storage.prune', cleanFiles);
});
@@ -189,24 +190,21 @@ export function apply(ctx: Context) {
]);
await StorageModel.del(problemFiles.concat(contestFiles).concat(trainingFiles).map((i) => i.path));
});
if (process.env.NODE_APP_INSTANCE !== '0') return;
ctx.on('ready', async () => {
await db.ensureIndexes(
StorageModel.coll,
{ key: { path: 1 }, name: 'path' },
{ key: { path: 1, autoDelete: 1 }, sparse: true, name: 'autoDelete' },
{ key: { link: 1 }, sparse: true, name: 'link' },
);
if (!await ScheduleModel.count({ type: 'schedule', subType: 'storage.prune' })) {
await ScheduleModel.add({
type: 'schedule',
subType: 'storage.prune',
executeAfter: moment().startOf('hour').toDate(),
interval: [1, 'hour'],
});
}
});
await db.ensureIndexes(
StorageModel.coll,
{ key: { path: 1 }, name: 'path' },
{ key: { path: 1, autoDelete: 1 }, sparse: true, name: 'autoDelete' },
{ key: { link: 1 }, sparse: true, name: 'link' },
);
if (!await ScheduleModel.count({ type: 'schedule', subType: 'storage.prune' })) {
await ScheduleModel.add({
type: 'schedule',
subType: 'storage.prune',
executeAfter: moment().startOf('hour').toDate(),
interval: [1, 'hour'],
});
}
}
global.Hydro.model.storage = StorageModel;
+5 -5
View File
@@ -1,5 +1,5 @@
import { SystemKeys } from '../interface';
import * as bus from '../service/bus';
import bus from '../service/bus';
import db from '../service/db';
import { NumberKeys } from '../typeutils';
import { SYSTEM_SETTINGS } from './setting';
@@ -45,8 +45,8 @@ export async function set(_id: string, value: any, broadcast = true) {
{ $set: { value } },
{ upsert: true, returnDocument: 'after' },
);
cache[_id] = res.value.value;
return res.value.value;
cache[_id] = res.value;
return res.value;
}
export async function inc<K extends NumberKeys<SystemKeys>>(_id: K) {
@@ -55,8 +55,8 @@ export async function inc<K extends NumberKeys<SystemKeys>>(_id: K) {
{ $inc: { value: 1 } as any },
{ upsert: true, returnDocument: 'after' },
);
cache[_id] = res.value.value;
return res.value.value;
cache[_id] = res.value;
return res.value;
}
export async function runConfig() {
+29 -16
View File
@@ -6,7 +6,7 @@ import { sleep } from '@hydrooj/utils/lib/utils';
import { Context } from '../context';
import { EventDoc, Task } from '../interface';
import { Logger } from '../logger';
import * as bus from '../service/bus';
import bus from '../service/bus';
import db from '../service/db';
const logger = new Logger('model/task');
@@ -16,13 +16,20 @@ const argv = cac().parse();
async function getFirst(query: Filter<Task>) {
if (process.env.CI) return null;
const q = { ...query };
const res = await coll.findOneAndDelete(q, { sort: { priority: -1 } });
if (res.value) {
logger.debug('%o', res.value);
return res.value;
try {
const q = { ...query };
const res = await coll.findOneAndDelete(q, { sort: { priority: -1 } });
if (res) {
logger.debug('%o', res);
return res;
}
return null;
} catch (e) {
// Usually caused by the database being down
// Should recover once it is back
logger.error(e);
return null;
}
return null;
}
export class Consumer {
@@ -137,12 +144,13 @@ const id = process.env.exec_mode === 'cluster_mode' ? hostname() : nanoid();
export async function apply(ctx: Context) {
ctx.on('domain/delete', (domainId) => coll.deleteMany({ domainId }));
ctx.on('bus/broadcast', (event, payload) => {
ctx.on('bus/broadcast', (event, payload, trace) => {
collEvent.insertOne({
ack: [id],
event,
payload: BSON.EJSON.stringify(payload),
expire: new Date(Date.now() + 10000),
trace,
});
});
@@ -161,16 +169,21 @@ export async function apply(ctx: Context) {
stream.on('error', async () => {
// The $changeStream stage is only supported on replica sets
logger.info('No replica set found.');
// eslint-disable-next-line no-constant-condition
while (true) {
let res;
try {
// eslint-disable-next-line no-await-in-loop
res = await collEvent.findOneAndUpdate(
{ expire: { $gt: new Date() }, ack: { $nin: [id] } },
{ $push: { ack: id } },
);
} catch (e) {
logger.error(e);
continue;
}
if (argv.options.showEvent) logger.info('Event: %o', res);
// eslint-disable-next-line no-await-in-loop
const res = await collEvent.findOneAndUpdate(
{ expire: { $gt: new Date() }, ack: { $nin: [id] } },
{ $push: { ack: id } },
);
if (argv.options.showEvent) logger.info('Event: %o', res.value);
// eslint-disable-next-line no-await-in-loop
await (res.value ? handleEvent(res.value) : sleep(500));
await (res ? handleEvent(res) : sleep(500));
}
});
await db.ensureIndexes(collEvent, { name: 'expire', key: { expire: 1 }, expireAfterSeconds: 0 });
+11 -9
View File
@@ -1,8 +1,8 @@
import { Filter } from 'mongodb';
import { Context } from '../context';
import { TokenDoc } from '../interface';
import * as bus from '../service/bus';
import db from '../service/db';
import { ArgMethod } from '../utils';
import { ArgMethod, randomstring } from '../utils';
class TokenModel {
static coll = db.collection('token');
@@ -26,7 +26,7 @@ class TokenModel {
};
static async add(
tokenType: number, expireSeconds: number, data: any, id = String.random(32),
tokenType: number, expireSeconds: number, data: any, id = randomstring(32),
): Promise<[string, TokenDoc]> {
const now = new Date();
const payload = {
@@ -67,7 +67,7 @@ class TokenModel {
},
{ returnDocument: 'after' },
);
return res.value;
return res;
}
@ArgMethod
@@ -107,10 +107,12 @@ class TokenModel {
}
}
bus.on('ready', () => db.ensureIndexes(
TokenModel.coll,
{ key: { uid: 1, tokenType: 1, updateAt: -1 }, name: 'basic', sparse: true },
{ key: { expireAt: -1 }, name: 'expire', expireAfterSeconds: 0 },
));
export async function apply(ctx: Context) {
await ctx.db.ensureIndexes(
TokenModel.coll,
{ key: { uid: 1, tokenType: 1, updateAt: -1 }, name: 'basic', sparse: true },
{ key: { expireAt: -1 }, name: 'expire', expireAfterSeconds: 0 },
);
}
export default TokenModel;
global.Hydro.model.token = TokenModel;
+52 -45
View File
@@ -9,10 +9,10 @@ import {
} from '../interface';
import avatar from '../lib/avatar';
import pwhash from '../lib/hash.hydro';
import * as bus from '../service/bus';
import bus from '../service/bus';
import db from '../service/db';
import { Value } from '../typeutils';
import { ArgMethod, buildProjection } from '../utils';
import { ArgMethod, buildProjection, randomstring } from '../utils';
import { PERM, PRIV } from './builtin';
import domain from './domain';
import * as setting from './setting';
@@ -127,18 +127,12 @@ export class User {
: doc.owner === this._id || (doc.maintainer || []).includes(this._id);
}
hasPerm(...perm: bigint[]) {
for (const i in perm) {
if ((this.perm & this.scope & perm[i]) === perm[i]) return true;
}
return false;
hasPerm(...perms: bigint[]) {
return perms.some((perm) => (this.perm & this.scope & perm) === perm);
}
hasPriv(...priv: number[]) {
for (const i in priv) {
if ((this.priv & priv[i]) === priv[i]) return true;
}
return false;
hasPriv(...privs: number[]) {
return privs.some((priv) => (this.priv & priv) === priv);
}
async checkPassword(password: string) {
@@ -165,7 +159,7 @@ export class User {
return user;
}
serialize(_, h) {
serialize(h) {
if (!this._isPrivate) {
const fields = ['_id', 'uname', 'mail', 'perm', 'role', 'priv', 'regat', 'loginat', 'tfa', 'authn'];
if (h?.user?.hasPerm(PERM.PERM_VIEW_DISPLAYNAME)) fields.push('displayName');
@@ -195,6 +189,7 @@ async function initAndCache(udoc: Udoc, dudoc, scope: bigint = PERM.PERM_ALL) {
class UserModel {
static coll = coll;
static collGroup = collGroup;
static User = User;
static cache = cache;
static defaultUser: Udoc = {
@@ -215,6 +210,9 @@ class UserModel {
loginip: '127.0.0.1',
};
static _handleMailLower = handleMailLower;
static _deleteUserCache = deleteUserCache;
@ArgMethod
static async getById(domainId: string, _id: number, scope: bigint | string = PERM.PERM_ALL): Promise<User> {
if (cache.has(`id/${_id}/${domainId}`)) return cache.get(`id/${_id}/${domainId}`) || null;
@@ -271,7 +269,7 @@ class UserModel {
deleteUserCache(udoc);
}
const res = await coll.findOneAndUpdate({ _id: uid }, op, { returnDocument: 'after' });
deleteUserCache(res.value);
deleteUserCache(res);
return res;
}
@@ -287,24 +285,25 @@ class UserModel {
@ArgMethod
static async setPassword(uid: number, password: string): Promise<Udoc> {
const salt = String.random();
const salt = randomstring();
const res = await coll.findOneAndUpdate(
{ _id: uid },
{ $set: { salt, hash: await pwhash(password, salt), hashType: 'hydro' } },
{ returnDocument: 'after' },
);
deleteUserCache(res.value);
return res.value;
deleteUserCache(res);
return res;
}
@ArgMethod
static async inc(_id: number, field: string, n: number = 1) {
if (_id < -999) return null;
const udoc = await coll.findOne({ _id });
if (!udoc) throw new UserNotFoundError(_id);
await coll.updateOne({ _id }, { $inc: { [field]: n } });
deleteUserCache(udoc);
return udoc;
static async inc(_id: number | number[], field: string, n: number = 1) {
const ids = (Array.isArray(_id) ? _id : [_id]).filter((i) => i >= -999);
if (!ids.length) return null;
const udocs = await coll.find({ _id: { $in: ids } }).toArray();
if (udocs.length !== ids.length) throw new UserNotFoundError(_id);
await coll.updateMany({ _id: { $in: ids } }, { $inc: { [field]: n } });
for (const udoc of udocs) deleteUserCache(udoc);
return udocs;
}
@ArgMethod
@@ -318,8 +317,8 @@ class UserModel {
uid = Math.max((udoc?._id || 0) + 1, 2);
autoAlloc = true;
}
const salt = String.random();
while (true) { // eslint-disable-line no-constant-condition
const salt = randomstring();
while (true) {
try {
// eslint-disable-next-line no-await-in-loop
await coll.insertOne({
@@ -339,6 +338,12 @@ class UserModel {
priv,
avatar: `gravatar:${mail}`,
});
// eslint-disable-next-line no-await-in-loop
await domain.collUser.updateOne(
{ uid, domainId: 'system' },
{ $set: { join: true } },
{ upsert: true },
);
return uid;
} catch (e) {
if (e?.code === 11000) {
@@ -424,8 +429,8 @@ class UserModel {
{ $set: { priv } },
{ returnDocument: 'after' },
);
deleteUserCache(res.value);
return res.value;
deleteUserCache(res);
return res;
}
@ArgMethod
@@ -471,22 +476,24 @@ class UserModel {
}
}
bus.on('ready', () => Promise.all([
db.ensureIndexes(
coll,
{ key: { unameLower: 1 }, name: 'uname', unique: true },
{ key: { mailLower: 1 }, name: 'mail', unique: true },
),
db.ensureIndexes(
collV,
{ key: { unameLower: 1 }, name: 'uname', unique: true },
{ key: { mailLower: 1 }, name: 'mail', unique: true },
),
db.ensureIndexes(
collGroup,
{ key: { domainId: 1, name: 1 }, name: 'name', unique: true },
{ key: { domainId: 1, uids: 1 }, name: 'uid' },
),
]));
export async function apply() {
await Promise.all([
db.ensureIndexes(
coll,
{ key: { unameLower: 1 }, name: 'uname', unique: true },
{ key: { mailLower: 1 }, name: 'mail', unique: true },
),
db.ensureIndexes(
collV,
{ key: { unameLower: 1 }, name: 'uname', unique: true },
{ key: { mailLower: 1 }, name: 'mail', unique: true },
),
db.ensureIndexes(
collGroup,
{ key: { domainId: 1, name: 1 }, name: 'name', unique: true },
{ key: { domainId: 1, uids: 1 }, name: 'uid' },
),
]);
}
export default UserModel;
global.Hydro.model.user = UserModel;
+10 -6
View File
@@ -2,9 +2,11 @@ import pwsh from './lib/hash.hydro';
import db from './service/db';
export { nanoid } from 'nanoid';
export { default as moment, isMoment } from 'moment-timezone';
export {
WebService, Router, HandlerCommon, httpServer,
Query, Mutation, Subscription, Apis, APIS,
} from '@hydrooj/framework';
export * from './pipelineUtils';
@@ -33,23 +35,25 @@ export { default as DomainModel } from './model/domain';
export { default as StorageModel } from './model/storage';
export { default as TaskModel } from './model/task';
export * from './model/builtin';
/** @deprecated */
export * as JudgeHandler from './handler/judge';
export { registerResolver, registerValue, registerUnion } from './service/api';
export { postJudge } from './handler/judge';
export { postJudge, JudgeResultCallbackContext } from './handler/judge';
export { Collections } from './service/db';
export { Handler, ConnectionHandler, requireSudo } from './service/server';
export { Service, Context } from './context';
export { buildContent } from './lib/content';
export { default as mime } from './lib/mime';
export { default as difficultyAlgorithm } from './lib/difficulty';
export { default as rating } from './lib/rating';
export { default as avatar } from './lib/avatar';
/** @deprecated use Handler.paginate instead */
export const paginate = db.paginate.bind(db);
/** @deprecated use db.ranked instead */
export const rank = db.ranked.bind(db);
export { parseConfig as testdataConfig } from './lib/testdataConfig';
export { sendMail } from './lib/mail';
export { UiContextBase } from './service/layers/base';
export * from '@hydrooj/framework/decorators';
export * from '@hydrooj/framework/validator';
export * as StorageService from './service/storage';
export { EventMap } from './service/bus';
export { db, pwsh };
// to load services into to context
export { } from './handler/contest';
+53
View File
@@ -0,0 +1,53 @@
/* eslint-disable no-await-in-loop */
import { resolve } from 'path';
import {
existsSync, mkdir, readdir, rename, rmdir,
} from 'fs-extra';
import Schema from 'schemastery';
import { Context } from '../context';
export const apply = (ctx: Context) => ctx.addScript(
'fixStorage', 'Rename all files to lowercase',
Schema.object({}),
async () => {
const config = await ctx.get('storage').status();
console.log(config);
if (config.type !== 'Local') throw new Error('Only local storage is supported');
const dir = (config as any).dir;
if (!dir) throw new Error('Storage dir not found');
const files = await readdir(dir);
for (const file of files) {
let inner = await readdir(resolve(dir, file));
if (file.toLowerCase() !== file && !existsSync(resolve(dir, file.toLowerCase()))) {
await mkdir(resolve(dir, file.toLowerCase()));
console.log('+: ', resolve(dir, file.toLowerCase()));
}
for (const f of inner) {
if (resolve(dir, file, f) === resolve(dir, file.toLowerCase(), f.toLowerCase())) continue;
await rename(resolve(dir, file, f), resolve(dir, file.toLowerCase(), f.toLowerCase()));
console.log('R: ', resolve(dir, file, f), '->', resolve(dir, file.toLowerCase(), f.toLowerCase()));
}
inner = await readdir(resolve(dir, file));
if (!inner.length) {
await rmdir(resolve(dir, file));
console.log('-: ', resolve(dir, file));
}
}
const coll = ctx.get('db').collection('storage');
while (true) {
let hasItem = false;
for await (const t of coll.find({ $or: [{ _id: /[A-Z]/ }, { link: /[A-Z]/ }] })) {
hasItem = true;
await coll.insertOne({
...t,
_id: t._id.toLowerCase(),
...(t.link ? { link: t.link.toLowerCase() } : {}),
});
await coll.deleteOne({ _id: t._id });
console.log('Q: ', t._id, '->', t._id.toLowerCase());
}
if (!hasItem) break;
}
return true;
},
);
@@ -1,4 +1,3 @@
/* eslint-disable no-await-in-loop */
import Schema from 'schemastery';
import { STATUS } from '../model/builtin';
import * as document from '../model/document';
@@ -1,4 +1,3 @@
/* eslint-disable no-await-in-loop */
import Schema from 'schemastery';
import { STATUS } from '../model/builtin';
import storage from '../model/storage';
-221
View File
@@ -1,221 +0,0 @@
import { makeExecutableSchema } from '@graphql-tools/schema';
import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils';
import { defaultFieldResolver, graphql, GraphQLSchema } from 'graphql';
import { resolvers, typeDefs } from 'graphql-scalars';
import { debounce } from 'lodash';
import { Context, Service } from '../context';
import { PERM, PRIV } from '../model/builtin';
import { Handler } from './server';
const types: Record<string, Record<string, string>> = {};
const unions: Record<string, string> = {};
const descriptions: Record<string, Record<string, string>> = {};
const handlers: Record<string, Record<string, any>> = {
Query: {},
};
let root: Record<string, any> = {};
interface ApiContext extends ApiHandler {
[key: string]: any;
}
declare module '../context' {
interface Context {
api: ApiService;
}
}
function setDescription(desc: string) {
if (desc.includes('\n')) return ['"""', desc, '"""'].join('\n');
return JSON.stringify(desc);
}
let schema: GraphQLSchema;
let schemaStr = '';
root = handlers.Query;
const applyAuthDirective = (s) => mapSchema(s, {
// eslint-disable-next-line consistent-return
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const authDirective = getDirective(s, fieldConfig, 'auth')?.[0];
if (authDirective) {
const { resolve = defaultFieldResolver } = fieldConfig;
return {
...fieldConfig,
async resolve(source, args, context, info) {
if (authDirective.perm) {
const perm = PERM[authDirective.perm];
if (!context.user.hasPerm(perm)) throw new Error(`Permission denied: ${authDirective.perm}`);
}
if (authDirective.priv) {
const priv = PRIV[authDirective.priv];
if (!context.user.hasPriv(priv)) throw new Error(`Permission denied: ${authDirective.priv}`);
}
return await resolve(source, args, context, info);
},
};
}
const ifDirective = getDirective(s, fieldConfig, 'if')?.[0];
if (ifDirective) {
const { resolve = defaultFieldResolver } = fieldConfig;
return {
...fieldConfig,
async resolve(source, args, context, info) {
if (ifDirective.perm && !context.user.hasPerm(PERM[ifDirective.perm])) return null;
if (ifDirective.priv && !context.user.hasPriv(PRIV[ifDirective.priv])) return null;
return await resolve(source, args, context, info);
},
};
}
},
});
class ApiHandler extends Handler {
category = '#api';
// FIXME: adding PERM_VIEW check will break omnibar search
query(q: string, variables: any) {
return graphql({
schema,
source: q,
rootValue: root,
contextValue: this,
variableValues: variables,
});
}
async get() {
const q = decodeURIComponent(this.request.querystring);
if (q === 'schema') {
this.response.type = 'application/json';
this.response.body = { schema: schemaStr };
} else if (q) {
this.response.type = 'application/json';
this.response.body = await this.query(q, {});
} else this.response.template = 'api.html';
}
async post() {
this.response.type = 'application/json';
this.response.body = await this.query(this.args.query, this.args.variables);
}
}
/** @deprecated use ctx.api.value() instead */
export function registerValue(...args: any[]) {
// @ts-ignore
return app.api.value(...args);
}
/** @deprecated use ctx.api.resolver() instead */
export function registerResolver(...args: any[]) {
// @ts-ignore
return app.api.resolver(...args);
}
/** @deprecated use ctx.api.union() instead */
export function registerUnion(...args: any[]) {
// @ts-ignore
return app.api.resolver(...args);
}
// TODO support dispose
class ApiService extends Service {
constructor(ctx: Context) {
super(ctx, 'api', true);
this.rebuild = debounce(this.rebuild.bind(this), 500, { trailing: true });
ctx.on('ready', this.rebuild);
}
private rebuild() {
try {
const defs = [
...Object.keys(unions).map((i) => `union ${i} = ${unions[i]}`),
'directive @auth(perm: String, priv: String) on FIELD_DEFINITION',
'directive @if(perm: String, priv: String) on FIELD_DEFINITION',
...typeDefs,
...Object.keys(types).map((key) => {
let def = '';
if (descriptions[key]?._description) def += `${setDescription(descriptions[key]._description)}\n`;
def += `type ${key} {\n`;
for (const k in types[key]) {
if (descriptions[key]?.[k]) def += ` ${setDescription(descriptions[key][k])}\n`;
def += ` ${k}: ${types[key][k]}\n`;
}
def += '}\n';
return def;
}),
];
schema = makeExecutableSchema({
typeDefs: defs,
resolvers,
});
schema = applyAuthDirective(schema);
schemaStr = defs.join('\n');
} catch (e) {
console.error(e);
}
this.ctx.emit('api/update');
}
value(typeName: string, key: string, value: string, description?: string): void;
value(typeName: string, vals: [string, string, string?][]): void;
value(typeName: string, arg1: [string, string, string?][] | string, value?: string, description?: string) {
if (typeof arg1 === 'string') arg1 = [[arg1, value!, description]];
for (const [k, v, d] of arg1) {
if (!types[typeName]) types[typeName] = { [k]: v };
else types[typeName][k] = v;
if (d) {
if (!descriptions[typeName]) descriptions[typeName] = { [k]: d };
else descriptions[typeName][k] = d;
}
}
this.rebuild();
}
resolver(
typeName: string, key: string, value: string,
func: (args: any, ctx: ApiContext, info: any) => any,
description?: string,
) {
this.value(typeName, key, value, description);
const wrappedFunc = async (arg, ctx, info) => {
const res = await func(arg, ctx, info);
if (typeof res !== 'object' || res === null) return res;
let node = value.includes('!') ? value.split('!')[0] : value;
const isArray = node.includes('[');
node = node.replace('[', '').replace(']', '');
if (handlers[node]) {
if (!isArray) Object.assign(res, handlers[node]);
else if (res instanceof Array) for (const i of res) { Object.assign(i, handlers[node]); }
}
ctx.parent = res;
return res;
};
handlers[typeName] ||= {};
handlers[typeName][key.split('(')[0].trim()] = wrappedFunc;
this.rebuild();
}
union(typeName: string, ...unionTypes: string[]) {
unions[typeName] = unionTypes.join(' | ');
this.rebuild();
}
query(query: string, args: Record<string, any>) {
return graphql({
schema,
source: query,
rootValue: root,
contextValue: this,
variableValues: args,
});
}
}
export const sideEffect = true;
export const using = ['server'];
export function apply(ctx: Context) {
ctx.plugin(ApiService);
ctx.Route('api', '/api', ApiHandler);
}
+13 -31
View File
@@ -1,12 +1,9 @@
/* eslint-disable no-await-in-loop */
import {
BSON, Db, Filter, ObjectId, OnlyFieldsOfType,
} from 'mongodb';
import type { Handler, ServerEvents } from '@hydrooj/framework';
import type { ConnectionHandler, Handler } from '@hydrooj/framework';
import pm2 from '@hydrooj/utils/lib/locate-pm2';
import { Context } from '../context';
import type { ProblemSolutionHandler } from '../handler/problem';
import type { UserRegisterHandler } from '../handler/user';
import type {
BaseUserDict, ContestBalloonDoc, DiscussionDoc, DomainDoc, FileInfo,
MessageDoc, ProblemDict, ProblemDoc, RecordDoc,
@@ -16,14 +13,8 @@ import type { DocType } from '../model/document';
export type Disposable = () => void;
export type VoidReturn = Promise<any> | any;
type HookType = 'before-prepare' | 'before' | 'before-operation' | 'after' | 'finish';
type MapHandlerEvents<N extends string, H extends Handler> = Record<`handler/${HookType}/${N}`, (thisArg: H) => VoidReturn>;
type KnownHandlerEvents =
MapHandlerEvents<'UserRegister', UserRegisterHandler>
& MapHandlerEvents<'ProblemSolution', ProblemSolutionHandler>;
/* eslint-disable @typescript-eslint/naming-convention */
export interface EventMap extends KnownHandlerEvents {
export interface EventMap {
'app/listen': () => void
'app/started': () => void
'app/ready': () => VoidReturn
@@ -31,6 +22,12 @@ export interface EventMap extends KnownHandlerEvents {
'app/before-reload': (entries: Set<string>) => VoidReturn
'app/reload': (entries: Set<string>) => VoidReturn
'subscription/init': (h: ConnectionHandler<Context>, privileged: boolean) => VoidReturn
'subscription/subscribe': (channel: string, user: User, metadata: Record<string, string>) => VoidReturn
'subscription/enable': (
channel: string, h: ConnectionHandler<Context>, privileged: boolean, onDispose: (disposable: () => void) => void
) => VoidReturn
'app/watch/change': (path: string) => VoidReturn
'app/watch/unlink': (path: string) => VoidReturn
@@ -38,14 +35,14 @@ export interface EventMap extends KnownHandlerEvents {
'database/config': () => VoidReturn
'system/setting': (args: Record<string, any>) => VoidReturn
'bus/broadcast': (event: keyof EventMap | keyof ServerEvents, payload: any) => VoidReturn
'bus/broadcast': (event: keyof EventMap, payload: any, trace?: string) => VoidReturn
'monitor/update': (type: 'server' | 'judge', $set: any) => VoidReturn
'monitor/collect': (info: any) => VoidReturn
'api/update': () => void;
'task/daily': () => void;
'task/daily/finish': (pref: Record<string, number>) => void;
'user/message': (uid: number, mdoc: MessageDoc) => void
'user/message': (uid: number[], mdoc: Omit<MessageDoc, 'to'>) => void
'user/get': (udoc: User) => void
'user/delcache': (content: string | true) => void
@@ -71,7 +68,7 @@ export interface EventMap extends KnownHandlerEvents {
'problem/before-add': (domainId: string, content: ProblemDoc['content'], owner: number, docId: number, doc: Partial<ProblemDoc>) => VoidReturn
'problem/add': (doc: Partial<ProblemDoc>, docId: number) => VoidReturn
'problem/before-edit': (doc: Partial<ProblemDoc>) => VoidReturn
'problem/before-edit': (doc: Partial<ProblemDoc>, $unset: OnlyFieldsOfType<ProblemDoc, any, true | '' | 1>) => VoidReturn
'problem/edit': (doc: ProblemDoc) => VoidReturn
'problem/before-del': (domainId: string, docId: number) => VoidReturn
'problem/del': (domainId: string, docId: number) => VoidReturn
@@ -93,18 +90,13 @@ export interface EventMap extends KnownHandlerEvents {
'contest/balloon': (domainId: string, tid: ObjectId, bdoc: ContestBalloonDoc) => VoidReturn
'contest/del': (domainId: string, tid: ObjectId) => VoidReturn
'oplog/log': (type: string, handler: Handler, args: any, data: any) => VoidReturn;
'oplog/log': (type: string, handler: Handler<Context> | ConnectionHandler<Context>, args: any, data: any) => VoidReturn;
'training/list': (query: Filter<TrainingDoc>, handler: any) => VoidReturn
'training/get': (tdoc: TrainingDoc, handler: any) => VoidReturn
'record/change': (rdoc: RecordDoc, $set?: any, $push?: any, body?: any) => void
'record/judge': (rdoc: RecordDoc, updated: boolean, pdoc?: ProblemDoc) => VoidReturn
}
/* eslint-enable @typescript-eslint/naming-convention */
declare module 'cordis' {
interface Events extends EventMap { }
'record/judge': (rdoc: RecordDoc, updated: boolean, pdoc?: ProblemDoc, updater?: any) => VoidReturn
}
export function apply(ctx: Context) {
@@ -127,15 +119,5 @@ export function apply(ctx: Context) {
}
export default app;
export const on = (a, b, c?) => app.on(a, b, c);
export const off = (a, b) => app.off(a, b);
export const once = (a, b, c?) => app.once(a, b, c);
export const parallel = app.parallel.bind(app);
export const emit = app.parallel.bind(app);
export const bail = app.bail.bind(app);
// For backward compatibility
export const serial: any = app.parallel.bind(app);
export const broadcast = app.broadcast.bind(app);
global.Hydro.service.bus = app as any;
global.bus = app;
+47 -53
View File
@@ -1,36 +1,70 @@
// TODO check more
import os from 'os';
import { Dictionary } from 'lodash';
import { DisposableList } from 'cordis';
import { randomstring } from '@hydrooj/utils';
import { Context, Service } from '../context';
import * as system from '../model/system';
import db from './db';
import storage from './storage';
type CheckItem = (context: any, log: Function, warn: Function, error: Function) => Promise<void>;
type ReportFunction = (message: string, ...args: any[]) => Promise<void>;
type CheckItem = (context: any, log: ReportFunction, warn: ReportFunction, error: ReportFunction) => Promise<void>;
export class CheckService extends Service {
checkers: Dictionary<CheckItem> = {};
export default class CheckService extends Service {
checkers = new DisposableList<CheckItem>();
c = {};
addChecker(type: string, checkFunc: CheckItem) {
this.ctx.effect(() => {
this.checkers[`check${type}`] = checkFunc;
return () => {
delete this.checkers[`check${type}`];
};
constructor(ctx: Context) {
super(ctx, 'check');
this.addChecker('Db', async (c, log, warn, error) => {
try {
const coll = db.collection('check');
const d = await coll.findOne({ _id: 'check' });
if (!d) await coll.insertOne({ _id: 'check', value: 'check' });
await coll.createIndex('check');
await coll.deleteOne({ _id: 'check' });
await coll.drop();
} catch (e) {
error(`Mongo Error: Database read-write failed.\n${e.message}`);
}
});
this.addChecker('Storage', async (c, log, warn, error) => {
const status = await storage.status();
if (!status.status) error(`Connect to ${status.type} Storage ${status.bucket} Error: ${status.error}`);
});
this.addChecker('System', async (c, log, warn) => {
const platform = os.platform();
if (platform !== 'linux') {
warn(`Hydro is running on ${platform}, suggest to use linux4.4+.`);
}
});
this.addChecker('Mail', async (c, log, warn) => {
const from = system.get('smtp.from');
if (!from) warn('SMTP account is not provided, email verification disabled.');
});
this.addChecker('Setting', async (c, log, warn) => {
const url = system.get('server.url');
if (url === '/') warn("server.url isn't set.");
const header = system.get('server.xff');
if (header && !c.request.ip) warn('IP header seems incorrect.\nCheck dashboard>settings>server.');
});
}
addChecker(type: string, checkFunc: CheckItem) {
this.checkers.push(checkFunc);
}
async run(ctx, log, warn, error, cb: (id: string) => void) {
const id = String.random(6);
const id = randomstring(6);
cb(id);
for (const name in this.checkers) {
for (const check of this.checkers) {
if (this.c[id]) {
delete this.c[id];
return;
}
// eslint-disable-next-line no-await-in-loop
await this.checkers[name](ctx, log, warn, error);
await check(ctx, log, warn, error);
}
}
@@ -38,43 +72,3 @@ export class CheckService extends Service {
this.c[id] = true;
}
}
export const check = new CheckService(app, 'check', true);
check.addChecker('Db', async (ctx, log, warn, error) => {
try {
const coll = db.collection('check');
const d = await coll.findOne({ _id: 'check' });
if (!d) await coll.insertOne({ _id: 'check', value: 'check' });
await coll.createIndex('check');
await coll.deleteOne({ _id: 'check' });
await coll.drop();
} catch (e) {
error(`Mongo Error: Database read-write failed.\n${e.message}`);
}
});
check.addChecker('Storage', async (ctx, log, warn, error) => {
const status = await storage.status();
if (!status.status) error(`Connect to ${status.type} Storage ${status.bucket} Error: ${status.error}`);
});
check.addChecker('System', async (ctx, log, warn) => {
const platform = os.platform();
if (platform !== 'linux') {
warn(`Hydro is running on ${platform}, suggest to use linux4.4+.`);
}
});
check.addChecker('Mail', async (ctx, log, warn) => {
const from = system.get('smtp.from');
if (!from) warn('SMTP account is not provided, email verification disabled.');
});
check.addChecker('Setting', async (ctx, log, warn) => {
const url = system.get('server.url');
if (url === '/') warn("server.url isn't set.");
const header = system.get('server.xff');
if (header && !ctx.request.ip) warn('IP header seems incorrect.\nCheck dashboard>settings>server.');
});
export async function apply(ctx: Context) {
ctx.provide('check', CheckService, true);
ctx.check = check;
}
+39 -31
View File
@@ -4,10 +4,11 @@ import {
} from 'mongodb';
import mongoUri from 'mongodb-uri';
import { Time } from '@hydrooj/utils';
import { Context, Service } from '../context';
import { ValidationError } from '../error';
import { Logger } from '../logger';
import { load } from '../options';
import * as bus from './bus';
import bus from './bus';
const logger = new Logger('mongo');
export interface Collections { }
@@ -25,12 +26,28 @@ interface MongoConfig {
collectionMap?: Record<string, string>,
}
class MongoService {
declare module '../context' {
interface Context {
db: MongoService;
}
}
export class MongoService extends Service {
public client: MongoClient;
public db: Db;
private opts: MongoConfig;
static buildUrl(opts: MongoConfig) {
constructor(ctx: Context, private config: MongoConfig = {}) {
super(ctx, 'db');
}
static async getUrl() {
if (process.env.CI) {
const { MongoMemoryServer } = require('mongodb-memory-server');
const mongod = await MongoMemoryServer.create();
return mongod.getUri();
}
const opts = load();
if (!opts) return null;
let mongourl = `${opts.protocol || 'mongodb'}://`;
if (opts.username) mongourl += `${opts.username}:${encodeURIComponent(opts.password)}@`;
mongourl += `${opts.host}:${opts.port}/${opts.name}`;
@@ -38,32 +55,28 @@ class MongoService {
return mongourl;
}
async start() {
const opts = load() || {};
let mongourl = MongoService.buildUrl(opts);
async *[Context.init]() {
const mongourl = await MongoService.getUrl();
const url = mongoUri.parse(mongourl);
if (process.env.CI) {
const { MongoMemoryServer } = require('mongodb-memory-server');
const mongod = await MongoMemoryServer.create();
mongourl = mongod.getUri();
}
this.opts = opts;
this.client = await MongoClient.connect(mongourl);
yield () => this.client.close();
this.db = this.client.db(url.database || 'hydro');
await bus.parallel('database/connect', this.db);
setInterval(() => this.fixExpireAfter(), Time.hour);
yield this.ctx.interval(() => this.fixExpireAfter(), Time.hour);
}
public collection<K extends keyof Collections>(c: K) {
let coll = this.opts.prefix ? `${this.opts.prefix}.${c}` : c;
if (this.opts.collectionMap?.[coll]) coll = this.opts.collectionMap[coll];
let coll = this.config.prefix ? `${this.config.prefix}.${c}` : c;
if (this.config.collectionMap?.[coll]) coll = this.config.collectionMap[coll];
return this.db.collection<Collections[K]>(coll);
}
public async fixExpireAfter() {
// Sometimes mongo's expireAfterSeconds is not working in non-replica set mode;
const collections = await this.db.listCollections().toArray();
const ignore = ['system.profile', 'system.users', 'system.version', 'system.views'];
for (const c of collections) {
if (ignore.includes(c.name)) continue;
const coll = this.db.collection(c.name);
const indexes = await coll.listIndexes().toArray();
for (const i of indexes) {
@@ -133,12 +146,8 @@ class MongoService {
cursor: FindCursor<T>, page: number, pageSize: number,
): Promise<[docs: T[], numPages: number, count: number]> {
if (page <= 0) throw new ValidationError('page');
let filter = {};
for (const key of Object.getOwnPropertySymbols(cursor)) {
if (key.toString() !== 'Symbol(filter)') continue;
filter = cursor[key];
break;
}
// this is for mongodb driver v6
const filter = (cursor as any).cursorFilter;
const coll = this.db.collection(cursor.namespace.collection as any);
const [count, pageDocs] = await Promise.all([
Object.keys(filter).length ? coll.count(filter) : coll.countDocuments(filter),
@@ -166,14 +175,13 @@ class MongoService {
}
return results;
}
public async apply(ctx) {
await this.start();
ctx.on('dispose', () => this.client.close());
}
}
const service = new MongoService();
global.Hydro.service.db = service;
export default service;
export const collection = service.collection.bind(service);
/** @deprecated use ctx.db instead */
const deprecatedDb = new Proxy({} as MongoService, {
get(target, prop) {
return app.get('db')?.[prop];
},
});
export default deprecatedDb;
@@ -2,13 +2,54 @@
/* eslint-disable consistent-return */
/* eslint-disable import/no-dynamic-require */
/* eslint-disable @typescript-eslint/no-shadow */
import { relative, resolve, sep } from 'path';
import { FSWatcher, watch } from 'chokidar';
import { debounce } from 'lodash';
import { Context, MainScope, Service } from '../context';
import { readFileSync } from 'fs';
import { relative } from 'path';
import { codeFrameColumns } from '@babel/code-frame';
import { BuildFailure } from 'esbuild';
import { Context, Plugin, Service } from '../context';
import { Logger } from '../logger';
import { unwrapExports } from '../utils';
declare module '../context' {
interface Events {
'hmr/reload': (reloads: Map<Plugin, { filename: string, runtime: Plugin.Runtime<Context> }>) => void;
}
interface Context {
hmr: HMR;
}
}
function isBuildFailure(e: any): e is BuildFailure {
return Array.isArray(e?.errors) && e.errors.every((error: any) => error.text);
}
export function handleError(ctx: Context, e: any) {
if (!isBuildFailure(e)) {
ctx.logger.warn(e);
return;
}
for (const error of e.errors) {
if (!error.location) {
ctx.logger.warn(error.text);
continue;
}
try {
const { file, line, column } = error.location;
const source = readFileSync(file, 'utf8');
const formatted = codeFrameColumns(source, {
start: { line, column },
}, {
highlightCode: true,
message: error.text,
});
ctx.logger.warn(`File: ${file}:${line}:${column}\n${formatted}`);
} catch (e) {
ctx.logger.warn(e);
}
}
}
function loadDependencies(filename: string, ignored: Set<string>) {
const dependencies = new Set<string>();
function traverse({ filename, children }: NodeJS.Module) {
@@ -20,66 +61,30 @@ function loadDependencies(filename: string, ignored: Set<string>) {
return dependencies;
}
const logger = new Logger('watch');
const logger = new Logger('hmr');
function coerce(val: any) {
// resolve error when stack is undefined, e.g. axios error with status code 401
const { message, stack } = val instanceof Error && val.stack ? val : new Error(val as any);
const lines = stack.split('\n');
const index = lines.findIndex((line) => line.endsWith(message));
return lines.slice(index).join('\n');
}
export default class Watcher extends Service {
private root: string;
private watcher: FSWatcher;
export default class HMR extends Service {
private root = process.cwd();
private externals: Set<string>;
private accepted: Set<string>;
private declined: Set<string>;
private stashed = new Set<string>();
constructor(public ctx: Context) {
super(ctx, 'watcher', true);
constructor(public ctx: Context, config: { watch: boolean }) {
super(ctx, 'hmr');
this.externals = new Set(Object.keys(require.cache));
}
start() {
this.root = resolve(process.cwd());
const roots = [this.root];
if (process.env.WATCH_ROOT) roots.push(process.env.WATCH_ROOT);
this.watcher = watch(roots, {
...this.config,
ignored: (file) => [
'node_modules', '.git', 'logs', '.cache', '.yarn', 'tsconfig.tsbuildinfo',
].some((rule) => file.startsWith(`${rule}/`) || file.endsWith(`/${rule}`) || file.includes(`/${rule}/`)),
});
logger.info(`Start watching changes in ${this.root}`);
// files independent from any plugins will trigger a full reload
const triggerLocalReload = debounce(() => this.triggerLocalReload(), 1000);
this.watcher.on('change', (path) => {
if (path.includes(`${sep}.`)) return;
logger.debug('change detected:', relative(this.root, path));
this.ctx.emit('app/watch/change', path);
const debouncedReload = ctx.debounce(() => this.triggerLocalReload(), 1000);
this.ctx.on('app/watch/change', (path) => {
if (this.externals.has(path)) {
logger.warn('Require full reload');
} else if (require.cache[path]) {
this.stashed.add(path);
this.ctx.emit('app/before-reload', this.stashed);
triggerLocalReload();
debouncedReload();
this.ctx.emit('app/reload', this.stashed);
}
});
this.watcher.on('unlink', (path) => {
logger.debug('change detected:', `-${relative(this.root, path)}`);
this.ctx.emit('app/watch/unlink', path);
});
}
stop() {
return this.watcher.close();
if (config.watch) ctx.plugin(require('./watch'));
}
private analyzeChanges() {
@@ -102,7 +107,7 @@ export default class Watcher extends Service {
let hasUpdate = false;
while (index < pending.length) {
const filename = pending[index];
const { children } = require.cache[filename];
const children = require.cache[filename]?.children || [];
let isDeclined = true;
let isAccepted = false;
for (const { filename } of children) {
@@ -139,33 +144,33 @@ export default class Watcher extends Service {
}
}
private triggerLocalReload() {
const start = Date.now();
private async triggerLocalReload() {
this.analyzeChanges();
/** plugins pending classification */
const pending = new Map<string, MainScope>();
const pending = new Map<string, Plugin>();
/** plugins that should be reloaded */
const reloads = new Map<MainScope, string>();
const reloads = new Map<Plugin, { filename: string, runtime: Plugin.Runtime<Context> }>();
// we assume that plugin entry files are "atomic"
// that is, reloading them will not cause any other reloads
for (const filename in require.cache) {
const module = require.cache[filename];
const plugin = unwrapExports(module.exports);
if (!(typeof plugin === 'object' && plugin && 'apply' in plugin)) continue;
if (!plugin) continue;
const runtime = this.ctx.registry.get(plugin);
if (!runtime || this.declined.has(filename)) continue;
pending.set(filename, runtime);
if (!(plugin && 'sideEffect' in plugin && plugin['sideEffect'])) this.declined.add(filename);
pending.set(filename, plugin);
if (!plugin?.sideEffect) this.declined.add(filename);
}
for (const [filename, runtime] of pending) {
for (const [filename, plugin] of pending) {
// check if it is a dependent of the changed file
this.declined.delete(filename);
const dependencies = [...loadDependencies(filename, this.declined)];
if (!runtime.plugin['sideEffect']) this.declined.add(filename);
const runtime = this.ctx.registry.get(plugin);
if (!plugin['sideEffect']) this.declined.add(filename);
// we only detect reloads at plugin level
// a plugin will be reloaded if any of its dependencies are accepted
@@ -173,25 +178,13 @@ export default class Watcher extends Service {
for (const dep of dependencies) this.accepted.add(dep);
// prepare for reload
let isMarked = false;
const visited = new Set<MainScope>();
const queued = [runtime];
while (queued.length) {
const runtime = queued.shift();
if (visited.has(runtime)) continue;
visited.add(runtime);
if (reloads.has(runtime)) {
isMarked = true;
break;
}
for (const state of runtime.children) {
queued.push(state.runtime);
}
}
if (!isMarked) reloads.set(runtime, filename);
reloads.set(plugin, {
filename,
runtime,
});
}
const backup: Record<string, NodeJS.Module> = {};
const backup: Record<string, NodeJS.Module> = Object.create(null);
for (const filename of this.accepted) {
backup[filename] = require.cache[filename];
delete require.cache[filename];
@@ -204,54 +197,64 @@ export default class Watcher extends Service {
}
logger.debug('Will reload the following file(s): %o', this.accepted.keys());
const attempts = {};
const attempts: Record<string, any> = {};
try {
for (const [, filename] of reloads) {
for (const [, { filename }] of reloads) {
attempts[filename] = unwrapExports(require(filename));
}
} catch (err) {
logger.warn(err);
} catch (e) {
handleError(this.ctx, e);
return rollback();
}
const reload = async (plugin: any, runtime?: Plugin.Runtime) => {
if (!runtime) return;
for (const oldFiber of runtime.fibers) {
// eslint-disable-next-line no-await-in-loop
await oldFiber.parent.plugin(plugin, oldFiber.config);
}
};
try {
for (const [runtime, filename] of reloads) {
for (const [plugin, { filename, runtime }] of reloads) {
const path = relative(this.root, filename);
const states = runtime.children.slice();
try {
this.ctx.registry.delete(runtime.plugin);
this.ctx.registry.delete(plugin);
} catch (err) {
logger.warn(`failed to dispose plugin at %c\n${coerce(err)}`, path);
logger.warn('failed to dispose plugin at %c', path);
logger.warn(err);
}
try {
const plugin = attempts[filename];
for (const state of states) {
state.parent.plugin(plugin, state.config);
}
// eslint-disable-next-line no-await-in-loop
await reload(attempts[filename], runtime);
logger.info('reload plugin at %c', path);
} catch (err) {
logger.warn(`failed to reload plugin at %c\n${coerce(err)}`, path);
logger.warn('failed to reload plugin at %c', path);
logger.warn(err);
throw err;
}
}
} catch {
// rollback require.cache and plugin states
rollback();
for (const [runtime, filename] of reloads) {
for (const [plugin, { filename, runtime }] of reloads) {
try {
this.ctx.registry.delete(attempts[filename]);
runtime.parent.plugin(runtime.plugin, runtime.config);
// eslint-disable-next-line no-await-in-loop
await reload(plugin, runtime);
} catch (err) {
logger.warn(err);
}
}
logger.warn('Rolling back changes');
return;
}
// emit reload event on success
this.ctx.emit('hmr/reload', reloads);
// reset stashed files
this.stashed = new Set();
logger.success('Reload done in %d ms', Date.now() - start);
}
}
+22 -5
View File
@@ -1,9 +1,12 @@
/* eslint-disable no-await-in-loop */
import cac from 'cac';
import { Logger } from '@hydrooj/utils';
import { Context, Service } from '../context';
import * as system from '../model/system';
export type MigrationScript = null | (() => Promise<boolean | void>);
const argv = cac().parse();
export type MigrationScript = null | ((ctx: Context) => Promise<boolean | void>);
const logger = new Logger('migration');
declare module '../context' {
@@ -17,7 +20,7 @@ export default class MigrationService extends Service {
private called = false;
constructor(ctx: Context) {
super(ctx, 'migration', true);
super(ctx, 'migration');
}
async registerChannel(name: string, s: MigrationScript[]) {
@@ -39,10 +42,22 @@ export default class MigrationService extends Service {
const isFresh = !dbVer;
const expected = scripts.length;
if (isFresh) {
await scripts[0]?.();
await scripts[0]?.(this.ctx);
await system.set(name, expected);
continue;
}
let upgraded = false;
if (dbVer > expected) {
logger.warn('Current database version for [%s] is %d, expected %d.', channel, dbVer, expected);
logger.warn('You are likely trying to apply a downgrade.');
logger.warn('This version of Hydro is not compatible with newer data version.');
if (!argv.options.ignoreVersion) {
logger.warn('To prevent data corruption, the startup has been aborted.');
logger.warn('If you want to continue, use --ignore-version on startup.');
logger.warn('Do it at your own risk.');
await new Promise(() => { });
}
}
while (dbVer < expected) {
const func = scripts[dbVer];
if (typeof func !== 'function') {
@@ -53,16 +68,18 @@ export default class MigrationService extends Service {
if ('dontWait' in func) {
logger.info('[Background Task]');
// For those scripts we don't really care if they fail
func().then(() => {
func(this.ctx).then(() => {
logger.info('Background Task Completed [%s]: from %d to %d', channel, func.dontWait, expected);
}).catch(logger.error);
} else {
const result = await func();
const result = await func(this.ctx);
if (!result) break;
}
dbVer++;
await system.set(name, dbVer);
upgraded = true;
}
if (upgraded) logger.success('Database upgraded [%s]: current version %d', channel, dbVer);
}
}
}
+11 -14
View File
@@ -4,7 +4,7 @@ import type { StatusUpdate } from '@hydrooj/utils/lib/sysinfo';
import * as sysinfo from '@hydrooj/utils/lib/sysinfo';
import { Context } from '../context';
import { Logger } from '../logger';
import * as bus from './bus';
import bus from './bus';
import db from './db';
const coll = db.collection('status');
@@ -39,7 +39,7 @@ export async function feedback(): Promise<[string, StatusUpdate]> {
problemCount,
discussionCount,
recordCount,
addons: global.addons,
addons: Object.values(global.addons),
memory: inf.memory,
osinfo: inf.osinfo,
cpu: inf.cpu,
@@ -98,17 +98,14 @@ export async function updateJudge(args) {
);
}
export function apply(ctx: Context) {
export async function apply(ctx: Context) {
if (process.env.NODE_APP_INSTANCE !== '0') return;
ctx.on('app/started', async () => {
sysinfo.get().then((info) => {
coll.updateOne(
{ mid: info.mid, type: 'server' },
{ $set: { ...info, updateAt: new Date(), type: 'server' } },
{ upsert: true },
);
feedback();
setInterval(update, 1800 * 1000);
});
});
const info = await sysinfo.get();
coll.updateOne(
{ mid: info.mid, type: 'server' },
{ $set: { ...info, updateAt: new Date(), type: 'server' } },
{ upsert: true },
);
feedback();
return ctx.interval(update, 1800 * 1000); // eslint-disable-line
}
+85 -102
View File
@@ -1,11 +1,12 @@
import fs from 'fs';
import { resolve } from 'path';
import cac from 'cac';
import proxy from 'koa-proxies';
import fs from 'fs-extra';
import cache from 'koa-static-cache';
import { type FindCursor, ObjectId } from 'mongodb';
import {
ConnectionHandler as ConnectionHandlerOriginal, Handler as HandlerOriginal, HydroError, NotFoundError, UserFacingError,
applyApiHandler, ConnectionHandler as ConnectionHandlerOriginal,
Handler as HandlerOriginal, HydroError, NotFoundError, UserFacingError,
WebService,
} from '@hydrooj/framework';
import { errorMessage, Time } from '@hydrooj/utils';
import { Context } from '../context';
@@ -16,7 +17,6 @@ import { PERM, PRIV } from '../model/builtin';
import * as opcount from '../model/opcount';
import * as OplogModel from '../model/oplog';
import * as system from '../model/system';
import { builtinConfig } from '../settings';
import db from './db';
import baseLayer from './layers/base';
import domainLayer from './layers/domain';
@@ -28,7 +28,7 @@ const ignoredLimit = `,${argv.options.ignoredLimit},`;
const logger = new Logger('server');
declare module '@hydrooj/framework' {
export interface HandlerCommon {
export interface HandlerCommon<C> { // eslint-disable-line @typescript-eslint/no-unused-vars
domain: DomainDoc;
paginate<T>(cursor: FindCursor<T>, page: number, key: string): Promise<[docs: T[], numPages: number, count: number]>;
@@ -41,6 +41,7 @@ declare module '@hydrooj/framework' {
export * from '@hydrooj/framework/decorators';
export * from '@hydrooj/framework/validator';
export { Query, Mutation } from '@hydrooj/framework/api';
/*
* For security concern, some API requires sudo privilege to access.
@@ -71,69 +72,15 @@ export function requireSudo(target: any, funcName: string, obj: any) {
return obj;
}
export interface Handler {
export class Handler extends HandlerOriginal<Context> {
domain: DomainDoc;
ctx: Context;
}
export class Handler extends HandlerOriginal {
constructor(_, ctx: Context) {
super(_, ctx);
this.ctx = ctx.extend({ domain: this.domain });
this.renderHTML = ((orig) => function (name: string, args: Record<string, any>) {
const s = name.split('.');
let templateName = `${s[0]}.${args.domainId}.${s[1]}`;
if (!global.Hydro.ui.template[templateName]) templateName = name;
return orig(templateName, args);
})(this.renderHTML).bind(this);
}
async onerror(error: HydroError) {
error.msg ||= () => error.message;
if (error instanceof UserFacingError && !process.env.DEV) error.stack = '';
if (!(error instanceof NotFoundError) && !('nolog' in error)) {
// eslint-disable-next-line max-len
logger.error(`User: ${this.user._id}(${this.user.uname}) ${this.request.method}: /d/${this.domain._id}${this.request.path}`, error.msg(), error.params);
if (error.stack) logger.error(error.stack);
}
if (this.user?._id === 0 && (error instanceof PermissionError || error instanceof PrivilegeError)) {
this.response.redirect = this.url('user_login', {
query: {
redirect: (this.context.originalPath || this.request.path) + this.context.search,
},
});
} else {
this.response.status = error instanceof UserFacingError ? error.code : 500;
this.response.template = error instanceof UserFacingError ? 'error.html' : 'bsod.html';
this.response.body = {
UserFacingError,
error: { message: error.msg(), params: error.params, stack: errorMessage(error.stack || '') },
};
}
}
}
export interface ConnectionHandler {
export class ConnectionHandler extends ConnectionHandlerOriginal<Context> {
domain: DomainDoc;
ctx: Context;
}
export class ConnectionHandler extends ConnectionHandlerOriginal {
constructor(_, ctx: Context) {
super(_, ctx);
this.ctx = ctx.extend({ domain: this.domain });
}
onerror(err: HydroError) {
if (!(err instanceof NotFoundError)
&& !((err instanceof PrivilegeError || err instanceof PermissionError) && this.user?._id === 0)) {
logger.error(`Path:${this.request.path}, User:${this.user?._id}(${this.user?.uname})`);
logger.error(err);
}
super.onerror(err);
}
}
export async function apply(ctx: Context) {
ctx.plugin(require('@hydrooj/framework'), {
ctx.plugin(WebService, {
keys: system.get('session.keys'),
proxy: !!system.get('server.xproxy') || !!system.get('server.xff'),
cors: system.get('server.cors') || '',
@@ -144,27 +91,8 @@ export async function apply(ctx: Context) {
xhost: system.get('server.xhost'),
});
if (process.env.HYDRO_CLI) return;
ctx.inject(['server'], ({ server, on }) => {
let endpoint = builtinConfig.file.endPoint;
if (builtinConfig.file.type === 's3' && !builtinConfig.file.pathStyle) {
try {
const parsed = new URL(builtinConfig.file.endPoint);
parsed.hostname = `${builtinConfig.file.bucket}.${parsed.hostname}`;
endpoint = parsed.toString();
} catch (e) {
logger.warn('Failed to parse file endpoint');
}
}
const proxyMiddleware = proxy('/fs', {
target: endpoint,
changeOrigin: true,
rewrite: (p) => p.replace('/fs', ''),
});
server.addCaptureRoute('/fs/', async (c, next) => {
if (c.request.search.toLowerCase().includes('x-amz-credential')) return await proxyMiddleware(c, next);
c.request.path = c.path = c.path.split('/fs')[1];
return await next();
});
await ctx.inject(['server', 'oauth', 'setting'], (childContext) => {
const { server, on, oauth } = childContext;
server.addHandlerLayer('init', async (c, next) => {
const init = Date.now();
try {
@@ -180,7 +108,10 @@ export async function apply(ctx: Context) {
}
});
for (const addon of [...global.addons].reverse()) {
applyApiHandler(childContext, 'api', '/api/:op');
server.setDefaultContext(childContext);
for (const addon of [...Object.values(global.addons)].reverse()) {
const dir = resolve(addon, 'public');
if (!fs.existsSync(dir)) continue;
server.addServerLayer(`${addon}_public`, cache(dir, {
@@ -193,6 +124,8 @@ export async function apply(ctx: Context) {
server.addLayer('base', baseLayer);
server.addLayer('user', userLayer);
const cachedTranslate = ctx.i18n.translate;
server.handlerMixin({
url(name: string, ...kwargsList: Record<string, any>[]) {
if (name === '#') return '#';
@@ -220,7 +153,7 @@ export async function apply(ctx: Context) {
? (!host.includes(this.request.host))
: this.request.host !== host)
)) withDomainId ||= domainId;
res = ctx.server.router.url.call(ctx.server.router, name, args, { query }).toString();
res = server.router.url.call(server.router, name, args, { query }).toString();
if (anchor) res = `${res}#${anchor}`;
if (withDomainId) res = `/d/${withDomainId}${res}`;
} catch (e) {
@@ -233,10 +166,10 @@ export async function apply(ctx: Context) {
translate(str: string) {
if (!str) return '';
const lang = this.user?.viewLang || this.session?.viewLang;
const res = lang
? str.toString().translate(lang, ...this.context.acceptsLanguages())
: str.toString().translate(...this.context.acceptsLanguages(), system.get('server.language'));
return res;
const langs = lang
? [lang, ...this.context.acceptsLanguages()]
: [...this.context.acceptsLanguages(), system.get('server.language')];
return cachedTranslate(str.toString(), langs);
},
paginate<T>(cursor: FindCursor<T>, page: number, key: string | number) {
return db.paginate(cursor, page, typeof key === 'number' ? key : (this.ctx.setting.get(`pagination.${key}`) || 20));
@@ -266,33 +199,83 @@ export async function apply(ctx: Context) {
await opcount.inc(op, id, periodSecs, maxOperations);
},
renderTitle(str: string) {
const name = this.ctx.setting.get('server.name');
const name = this.ctx.get('setting')?.get('server.name') || system.get('server.name');
if (this.UiContext.extraTitleContent) return `${this.UiContext.extraTitleContent} - ${this.translate(str)} - ${name}`;
return `${this.translate(str)} - ${name}`;
},
});
server.httpHandlerMixin({
async onerror(error: HydroError) {
error.msg ||= () => error.message;
if (error instanceof UserFacingError && !process.env.DEV) error.stack = '';
if (!(error instanceof NotFoundError) && !('nolog' in error)) {
// eslint-disable-next-line max-len
logger.error(`User: ${this.user._id}(${this.user.uname}) ${this.request.method}: /d/${this.domain._id}${this.request.path}`, error.msg(), error.params);
if (error.stack) logger.error(error.stack);
}
if (this.user?._id === 0 && (error instanceof PermissionError || error instanceof PrivilegeError)) {
this.response.redirect = this.url('user_login', {
query: {
redirect: (this.context.originalPath || this.request.path) + this.context.search,
},
});
} else if (!this.user._dudoc.join && error instanceof PermissionError) {
this.response.redirect = this.url('domain_join', {
domainId: 'system',
query: {
redirect: (this.context.originalPath || this.request.path) + this.context.search,
target: this.domain._id,
},
});
} else {
this.response.status = error instanceof UserFacingError ? error.code : 500;
this.response.template = error instanceof UserFacingError ? 'error.html' : 'bsod.html';
this.response.body = {
UserFacingError,
error: { message: error.msg(), params: error.params, stack: errorMessage(error.stack || '') },
};
}
},
});
server.wsHandlerMixin({
async onerror(err: HydroError) {
if (![NotFoundError, PrivilegeError, PermissionError].some((i) => err instanceof i) || this.user?._id !== 0) {
logger.error(`Path:${this.request.path}, User:${this.user?._id}(${this.user?.uname})`);
logger.error(err);
}
if (err instanceof UserFacingError) err.stack = this.request.path;
this.send({
error: {
name: err.name,
params: err.params || [],
},
});
this.close(4000, err.toString());
},
});
on('handler/create', async (h) => {
h.user = h.context.HydroContext.user as any;
h.domain = h.context.HydroContext.domain as any;
h.translate = h.translate.bind(h);
h.url = h.url.bind(h);
h.ctx = h.ctx.extend({
domain: h.domain,
});
h.ctx = h.ctx.extend({ domain: h.domain });
});
on('handler/create/http', async (h) => {
if (!argv.options.benchmark && !h.notUsage) await h.limitRate('global', 5, 100);
h.loginMethods = Object.keys(global.Hydro.module.oauth)
.map((key) => ({
id: key,
icon: global.Hydro.module.oauth[key].icon,
text: global.Hydro.module.oauth[key].text,
h.loginMethods = Object.entries(oauth.providers)
.filter(([_, v]) => !v.hidden)
.map(([k, v]) => ({
id: k,
icon: v.icon,
text: v.text,
name: v.name,
}));
if (!h.noCheckPermView && !h.user.hasPriv(PRIV.PRIV_VIEW_ALL_DOMAIN)) h.checkPerm(PERM.PERM_VIEW);
if ((!('noCheckPermView' in h) || !h.noCheckPermView) && !h.user.hasPriv(PRIV.PRIV_VIEW_ALL_DOMAIN)) h.checkPerm(PERM.PERM_VIEW);
if (h.context.pendingError) throw h.context.pendingError;
});
on('app/listen', () => {
server.listen();
on('handler/create/ws', async (h) => {
if (h.context.pendingError) throw h.context.pendingError;
});
});
}
+113 -16
View File
@@ -12,9 +12,12 @@ import {
copyFile, createReadStream, createWriteStream, ensureDir,
existsSync, remove, stat, writeFile,
} from 'fs-extra';
import proxy from 'koa-proxies';
import { lookup } from 'mime-types';
import { nanoid } from 'nanoid';
import Schema from 'schemastery';
import { Context } from '../context';
import { Logger } from '../logger';
import { builtinConfig } from '../settings';
import { MaybeArray } from '../typeutils';
import { md5, streamToBuffer } from '../utils';
@@ -57,6 +60,43 @@ const convertPath = (p: string) => {
return p;
};
const defaultPath = process.env.CI ? '/tmp/file'
: process.env.DEFAULT_STORE_PATH || '/data/file/hydro';
const FileSetting = Schema.intersect([
Schema.object({
type: Schema.union([
Schema.const('file').i18n({ en: 'Local Directory', zh: '本地目录' }),
Schema.const('s3').description('S3'),
] as const).i18n({ en: 'Storage Provider Type', zh: '存储提供商类型' }),
endPointForUser: Schema.string().default('/fs/'),
endPointForJudge: Schema.string().default('/fs/'),
}).i18n({ en: 'File Storage Setting', zh: '文件存储设置' }),
Schema.union([
Schema.object({
type: Schema.const('file').required(),
path: Schema.string().default(defaultPath).i18n({ en: 'Storage path', zh: '存储路径' }),
secret: Schema.string().default(nanoid()).i18n({ en: 'Download file sign secret', zh: '下载文件签名密钥' }),
}),
Schema.object({
type: Schema.const('s3').required(),
endPoint: Schema.string(),
accessKey: Schema.string(),
secretKey: Schema.string().role('secret'),
bucket: Schema.string().default('hydro'),
region: Schema.string().default('us-east-1'),
pathStyle: Schema.boolean().default(true),
}),
] as const),
] as const).default({
type: 'file',
path: defaultPath,
endPointForUser: '/fs/',
endPointForJudge: '/fs/',
secret: nanoid(),
});
export const Config = FileSetting;
class RemoteStorageService {
public client: S3Client;
public error = '';
@@ -67,9 +107,12 @@ class RemoteStorageService {
judge: null,
};
constructor(private config: ReturnType<typeof FileSetting>) {
}
async start() {
try {
logger.info('Starting storage service with endpoint:', builtinConfig.file.endPoint);
logger.info('Starting storage service with endpoint:', this.config.endPoint);
const {
endPoint,
accessKey,
@@ -79,7 +122,7 @@ class RemoteStorageService {
pathStyle,
endPointForUser,
endPointForJudge,
} = builtinConfig.file;
} = this.config;
this.bucket = bucket;
const base = {
region,
@@ -209,7 +252,8 @@ class RemoteStorageService {
Key: target,
ResponseContentDisposition: filename ? `attachment; filename="${encodeRFC5987ValueChars(filename)}"` : '',
}), {
expiresIn: noExpire ? 24 * 60 * 60 * 7 : 10 * 60,
// aliyun s3 will reject download if expires >= 7 days
expiresIn: noExpire ? 24 * 60 * 60 * 7 - 1 : 30 * 60,
});
// using something like /fs/
if (useAlternativeEndpointFor && this.replaceWithAlternativeUrlFor[useAlternativeEndpointFor]) {
@@ -218,6 +262,10 @@ class RemoteStorageService {
return url;
}
async isLinkValid(_: string) {
return false;
}
async signUpload(target: string, size: number) {
const client = this.alternatives.user || this.client;
const { url, fields } = await createPresignedPost(client, {
@@ -260,13 +308,16 @@ class LocalStorageService {
opts: null;
private replaceWithAlternativeUrlFor: Record<'user' | 'judge', (originalUrl: string) => string>;
constructor(private config: ReturnType<typeof FileSetting>) {
}
async start() {
logger.debug('Loading local storage service with path:', builtinConfig.file.path);
await ensureDir(builtinConfig.file.path);
this.dir = builtinConfig.file.path;
logger.debug('Loading local storage service with path:', this.config.path);
await ensureDir(this.config.path);
this.dir = this.config.path;
this.replaceWithAlternativeUrlFor = {
user: parseAlternativeEndpointUrl(builtinConfig.file.endPointForUser),
judge: parseAlternativeEndpointUrl(builtinConfig.file.endPointForJudge),
user: parseAlternativeEndpointUrl(this.config.endPointForUser),
judge: parseAlternativeEndpointUrl(this.config.endPointForJudge),
};
}
@@ -274,7 +325,7 @@ class LocalStorageService {
target = resolve(this.dir, convertPath(target));
await ensureDir(dirname(target));
if (typeof file === 'string') await copyFile(file, target);
else if (file instanceof Buffer) await writeFile(target, file);
else if (Buffer.isBuffer(file)) await writeFile(target, file);
else await writeFile(target, await streamToBuffer(file));
}
@@ -313,11 +364,19 @@ class LocalStorageService {
if (filename) url.searchParams.set('filename', filename);
const expire = (Date.now() + (noExpire ? 7 * 24 * 3600 : 600) * 1000).toString();
url.searchParams.set('expire', expire);
url.searchParams.set('secret', md5(`${target}/${expire}/${builtinConfig.file.secret}`));
url.searchParams.set('secret', md5(`${target}/${expire}/${this.config.secret}`));
if (useAlternativeEndpointFor) return this.replaceWithAlternativeUrlFor[useAlternativeEndpointFor](url.toString());
return `/${url.toString().split('localhost/')[1]}`;
}
async isLinkValid(link: string) {
const parts = link.split('/');
const secret = parts.pop();
parts.push(this.config.secret);
const expected = md5(parts.join('/'));
return expected === secret;
}
async signUpload() {
throw new Error('Not implemented');
}
@@ -328,20 +387,58 @@ class LocalStorageService {
status: !this.error,
error: this.error,
bucket: 'Hydro',
dir: this.config.path,
};
}
}
let service; // eslint-disable-line import/no-mutable-exports
let service;
export async function loadStorageService() {
service = builtinConfig.file.type === 's3' ? new RemoteStorageService() : new LocalStorageService();
global.Hydro.service.storage = service;
export async function apply(ctx: Context, config: ReturnType<typeof FileSetting>) {
if (config.type === 's3') {
service = new RemoteStorageService(config);
} else {
service = new LocalStorageService(config);
}
await service.start();
await ctx.inject(['server'], ({ server }) => {
let endpoint = config.endPoint;
if (config.type === 's3' && !config.pathStyle) {
try {
const parsed = new URL(config.endPoint);
parsed.hostname = `${config.bucket}.${parsed.hostname}`;
endpoint = parsed.toString();
} catch (e) {
logger.warn('Failed to parse file endpoint');
}
}
const proxyMiddleware = proxy('/fs', {
target: endpoint,
changeOrigin: true,
rewrite: (p) => p.replace('/fs', ''),
});
server.addCaptureRoute('/fs/', async (c, next) => {
if (c.request.search.toLowerCase().includes('x-amz-credential')) {
c.nolog = true;
return await proxyMiddleware(c, next);
}
c.request.path = c.path = c.path.split('/fs')[1];
return await next();
});
});
ctx.provide('storage', service);
}
export default new Proxy({}, {
declare module '../context' {
interface Context {
storage: RemoteStorageService | LocalStorageService;
}
}
/** @deprecated use ctx.storage instead */
const serviceProxy = new Proxy({}, {
get(self, key) {
return service[key];
},
}) as RemoteStorageService | LocalStorageService;
export default serviceProxy;
+31
View File
@@ -0,0 +1,31 @@
import { relative, resolve, sep } from 'path';
import { watch } from 'chokidar';
import { Context } from '../context';
import { Logger } from '../logger';
const logger = new Logger('watcher');
export const apply = (ctx: Context) => {
const root = resolve(process.cwd());
const roots = [root];
if (process.env.WATCH_ROOT) roots.push(process.env.WATCH_ROOT);
const watcher = watch(roots, {
ignored: (file) => [
'node_modules', '.git', 'logs', '.cache', '.yarn', 'tsconfig.tsbuildinfo',
].some((rule) => file.startsWith(`${rule}/`) || file.endsWith(`/${rule}`) || file.includes(`/${rule}/`)),
});
logger.info(`Start watching changes in ${root}`);
// files independent from any plugins will trigger a full reload
watcher.on('change', (path) => {
if (path.includes(`${sep}.`)) return;
logger.debug('change detected:', relative(root, path));
ctx.emit('app/watch/change', path);
});
watcher.on('unlink', (path) => {
logger.debug('change detected:', `-${relative(root, path)}`);
ctx.emit('app/watch/unlink', path);
});
return () => {
watcher.close();
};
};
+19 -18
View File
@@ -1,3 +1,4 @@
import cac from 'cac';
import moment from 'moment-timezone';
import { sleep } from '@hydrooj/utils';
import { Context, Service } from '../context';
@@ -9,7 +10,9 @@ declare module '../context' {
}
}
export class WorkerService extends Service {
const argv = cac().parse();
export default class WorkerService extends Service {
private handlers: Record<string, Function> = {};
consuming = true;
running = null;
@@ -17,8 +20,16 @@ export class WorkerService extends Service {
coll = db.collection('schedule');
constructor(ctx: Context) {
super(ctx, 'worker', true);
super(ctx, 'worker');
}
*[Service.init]() {
if (argv.options.disableWorker) return;
this.consume();
yield () => {
this.consuming = false;
return this.promise;
};
}
async getFirst() {
@@ -29,13 +40,13 @@ export class WorkerService extends Service {
subType: { $in: Object.keys(this.handlers) },
};
const res = await this.coll.findOneAndDelete(q);
if (res.value) {
this.ctx.logger.debug('%o', res.value);
if (res.value.interval) {
const executeAfter = moment(res.value.executeAfter).add(...res.value.interval).toDate();
await this.coll.insertOne({ ...res.value, executeAfter });
if (res) {
this.ctx.logger.debug('%o', res);
if (res.interval) {
const executeAfter = moment(res.executeAfter).add(...res.interval).toDate();
await this.coll.insertOne({ ...res, executeAfter });
}
return res.value;
return res;
}
return null;
}
@@ -79,14 +90,4 @@ export class WorkerService extends Service {
};
});
}
public async stop() {
this.consuming = false;
await this.promise;
}
}
export async function apply(ctx: Context) {
ctx.provide('worker', undefined, true);
ctx.worker = new WorkerService(ctx);
}
+182 -99
View File
@@ -1,110 +1,193 @@
import yaml from 'js-yaml';
import { nanoid } from 'nanoid';
import Schema from 'schemastery';
import { Context, Service } from './context';
import { Logger } from './logger';
import { Flatten } from './typeutils';
import {
AccountSetting, DomainSetting, DomainUserSetting, PreferenceSetting, SystemSetting,
} from './model/setting';
const defaultPath = process.env.CI ? '/tmp/file'
: process.env.DEFAULT_STORE_PATH || '/data/file/hydro';
const FileSetting = Schema.intersect([
Schema.object({
type: Schema.union([
Schema.const('file').description('local file provider'),
Schema.const('s3').description('s3 provider'),
] as const).description('provider type'),
endPointForUser: Schema.string().default('/fs/'),
endPointForJudge: Schema.string().default('/fs/'),
}).description('setting_file'),
Schema.union([
Schema.object({
type: Schema.const('file').required(),
path: Schema.string().default(defaultPath).description('Storage path'),
secret: Schema.string().description('Download file sign secret').default(nanoid()),
}),
Schema.object({
type: Schema.const('s3').required(),
endPoint: Schema.string(),
accessKey: Schema.string().description('access key'),
secretKey: Schema.string().description('secret key').role('secret'),
bucket: Schema.string().default('hydro'),
region: Schema.string().default('us-east-1'),
pathStyle: Schema.boolean().default(true),
}),
] as const),
] as const).default({
type: 'file',
path: defaultPath,
endPointForUser: '/fs/',
endPointForJudge: '/fs/',
secret: nanoid(),
});
const builtinSettings = Schema.object({
file: FileSetting,
});
export const SystemSettings: Schema[] = [builtinSettings];
export let configSource = ''; // eslint-disable-line import/no-mutable-exports
export let systemConfig: any = {}; // eslint-disable-line import/no-mutable-exports
const logger = new Logger('settings');
const update = [];
export async function loadConfig() {
const config = await global.Hydro.service.db.collection('system').findOne({ _id: 'config' });
try {
configSource = config?.value || '{}';
systemConfig = yaml.load(configSource);
logger.info('Successfully loaded config');
for (const u of update) u();
} catch (e) {
logger.error('Failed to load config', e.message);
declare module 'cordis' {
interface Context {
setting: SettingService;
}
}
export async function saveConfig(config: any) {
Schema.intersect(SystemSettings)(config);
const value = yaml.dump(config);
await global.Hydro.service.db.collection('system').updateOne({ _id: 'config' }, { $set: { value } }, { upsert: true });
loadConfig();
}
export async function setConfig(key: string, value: any) {
const path = key.split('.');
if (path.filter((i) => ['__proto__', 'prototype'].includes(i)).length) throw new Error('Invalid key');
const t = path.pop();
let cursor = systemConfig;
for (const p of path) {
cursor[p] ||= {};
cursor = cursor[p];
}
cursor[t] = value;
await saveConfig(systemConfig);
}
export function requestConfig<T, S>(s: Schema<T, S>): {
config: ReturnType<Schema<T, S>>,
setConfig: (key: keyof Flatten<ReturnType<Schema<T, S>>> & string, value: any) => Promise<void>,
} {
SystemSettings.push(s);
let curValue = s(systemConfig);
update.push(() => {
try {
curValue = s(systemConfig);
} catch (e) {
logger.warn('Cannot read config: ', e.message);
curValue = null;
}
});
return {
config: new Proxy(curValue as any, {
get(self, key: string) {
return curValue?.[key];
},
set(self) {
throw new Error(`Not allowed to set setting ${self.p.join('.')}`);
},
}),
setConfig,
const T = <F extends (...args: any[]) => any>(origFunc: F, disposeFunc?) =>
function method(this: Service, ...args: Parameters<F>) {
this.ctx.effect(() => {
const res = origFunc(...args);
return () => (disposeFunc ? disposeFunc(res) : res());
});
};
}
const builtin = requestConfig(builtinSettings);
export const builtinConfig = builtin.config;
export const setBuiltinConfig = builtin.setConfig;
export class SettingService extends Service {
static inject = ['db'];
static name = 'setting';
static blacklist = ['__proto__', 'prototype', 'constructor'];
settings: Schema[] = [];
private systemConfig: any = {};
configSource: string = '';
private applied: any = {};
private initialValues = {};
private _lastMigrate = Promise.resolve();
PreferenceSetting = T(PreferenceSetting);
AccountSetting = T(AccountSetting);
DomainSetting = T(DomainSetting);
DomainUserSetting = T(DomainUserSetting);
SystemSetting = T(SystemSetting);
constructor(ctx: Context) {
super(ctx, 'setting');
}
async [Context.init]() {
const payload = await this.ctx.db.collection('system').find({}).toArray();
this.initialValues = Object.fromEntries(payload.map((v) => [v._id, v.value]));
return await this.loadConfig();
}
_applySchema() {
this.applied = this.settings.length ? Schema.intersect(this.settings)(this.systemConfig) : this.systemConfig;
}
async loadConfig() {
const config = await this.ctx.db.collection('system').findOne({ _id: 'config' }, {
readPreference: 'primary', readConcern: 'majority',
});
try {
this.configSource = config?.value || '{}';
this.systemConfig = yaml.load(this.configSource);
this._applySchema();
this.ctx.emit('system/setting', { config: this.configSource });
logger.info('Successfully loaded config');
} catch (e) {
logger.error('Failed to load config', e.message);
}
}
applyDelta(source: any, key: string, value: any) {
const path = key.split('.');
if (path.some((i) => SettingService.blacklist.includes(i))) return false;
const t = path.pop();
const root = JSON.parse(JSON.stringify(source));
let cursor = root;
for (const p of path) {
cursor[p] ??= {};
cursor = cursor[p];
}
cursor[t] = value;
return root;
}
isPatchValid(key: string, value: any) {
const root = this.applyDelta(this.systemConfig, key, value);
try {
Schema.intersect(this.settings)(root);
} catch (e) {
return false;
}
return true;
}
async saveConfig(config: any) {
Schema.intersect(this.settings)(config);
const value = yaml.dump(config);
await this.ctx.db.collection('system').updateOne({ _id: 'config' }, { $set: { value } }, { upsert: true });
await this.loadConfig();
}
async _actualMigrate(schema: Schema<any>) {
const processNode = async (path: string[], node: Schema<any, any>) => {
for (const item of node.list || []) await processNode(path, item); // eslint-disable-line no-await-in-loop
for (const key in node.dict || {}) await processNode(path.concat(key), node.dict[key]); // eslint-disable-line no-await-in-loop
if (['string', 'number', 'boolean'].includes(node.type)) {
const value = this.initialValues[path.join('.')];
const migrated = this.initialValues[`${path.join('.')}__migrated`];
if (migrated || !Object.hasOwn(this.initialValues, path.join('.'))) return;
let parsed;
try {
if (node.type === 'string') parsed = value;
if (node.type === 'number') parsed = +value;
if (node.type === 'boolean') parsed = !!value && !['off', '0', 'false'].includes(value);
} catch (e) { }
if (parsed === undefined) return;
await this.ctx.db.collection('system').updateOne({ _id: `${path.join('.')}__migrated` }, { $set: { value: true } }, { upsert: true });
this.ctx.logger.info('Migrating %s: %o', path.join('.'), parsed);
await this.saveConfig(this.applyDelta(this.systemConfig, path.join('.'), parsed));
}
};
await processNode([], schema);
}
async _tryMigrateConfig(schema: Schema<any>) {
this._lastMigrate = this._lastMigrate.then(() => this._actualMigrate(schema));
await this._lastMigrate;
}
_get(key: string) {
const parts = key.split('.');
if (parts.some((p) => SettingService.blacklist.includes(p.toString()))) throw new Error('Invalid path');
let currentValue = this.applied;
for (const p of parts) {
if (!currentValue) return undefined;
currentValue = currentValue[p];
}
return currentValue;
}
get(key: string) {
return (this.ctx ? this.ctx.domain?.config?.[key.replace(/\./g, '$')] : null)
?? (this._get(key) || global.Hydro?.model?.system?.get?.(key));
}
async setConfig(key: string, value: any) {
const newConfig = this.applyDelta(this.systemConfig, key, value);
await this.saveConfig(newConfig);
}
requestConfig<T, S>(s: Schema<T, S>, dynamic = true): S {
if (dynamic) {
this.ctx.effect(() => {
this.settings.push(s);
this._applySchema();
return () => {
this.settings = this.settings.filter((v) => v !== s);
this._applySchema();
};
});
}
let curValue = s(this.systemConfig);
if (!dynamic) return curValue;
this.ctx.on('system/setting', () => {
try {
curValue = s(this.systemConfig);
} catch (e) {
logger.warn('Cannot read config: ', e.message);
curValue = null;
}
});
const that = this;
const getAccess = (path: (string | symbol)[]) => {
if (path.some((p) => SettingService.blacklist.includes(p.toString()))) throw new Error('Invalid path');
let currentValue = curValue;
for (const p of path) {
currentValue = currentValue[p];
}
if (typeof currentValue !== 'object' || !currentValue) return currentValue;
return new Proxy(currentValue, {
get(self, key: string) {
return getAccess(path.concat(key));
},
set(self, p: string | symbol, newValue: any) {
that.setConfig(path.concat(p).join(','), newValue);
return true;
},
});
};
return getAccess([]);
}
}
+2 -2
View File
@@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/no-shadow */
import { Logger } from './logger';
import * as bus from './service/bus';
import bus from './service/bus';
let terminating = false;
async function terminate() {
@@ -12,6 +11,7 @@ async function terminate() {
}, 1000);
try {
await bus.parallel('app/exit');
await app.fiber.dispose();
} catch (e) {
hasError = true;
}
+59 -144
View File
@@ -3,10 +3,8 @@
/* eslint-disable no-await-in-loop */
/* eslint-disable @typescript-eslint/naming-convention */
import yaml from 'js-yaml';
import { pick } from 'lodash';
import moment from 'moment-timezone';
import { ObjectId } from 'mongodb';
import { sleep } from '@hydrooj/utils';
import { randomstring, sleep } from '@hydrooj/utils';
import { buildContent } from './lib/content';
import { Logger } from './logger';
import { PERM, PRIV, STATUS } from './model/builtin';
@@ -28,23 +26,24 @@ import {
} from './pipelineUtils';
import db from './service/db';
import { MigrationScript } from './service/migration';
import { setBuiltinConfig } from './settings';
import welcome from './welcome';
const logger = new Logger('upgrade');
const unsupportedUpgrade = async function _26_27() {
throw new Error('This upgrade was no longer supported in hydrooj@4. \
Please use hydrooj@3 to perform these upgrades before upgrading to v4');
throw new Error('This upgrade was no longer supported in hydrooj@5. \
Please use hydrooj@4 to perform these upgrades before upgrading to v5');
};
const onPrimary = { readPreference: 'primary' } as const;
export const coreScripts: MigrationScript[] = [
// Mark as used
async function init() {
if (!await user.getById('system', 0)) {
await user.create('Guest@hydro.local', 'Guest', String.random(32), 0, '127.0.0.1', PRIV.PRIV_REGISTER_USER);
await user.create('Guest@hydro.local', 'Guest', randomstring(32), 0, '127.0.0.1', PRIV.PRIV_REGISTER_USER);
}
if (!await user.getById('system', 1)) {
await user.create('Hydro@hydro.local', 'Hydro', String.random(32), 1, '127.0.0.1', PRIV.PRIV_USER_PROFILE);
await user.create('Hydro@hydro.local', 'Hydro', randomstring(32), 1, '127.0.0.1', PRIV.PRIV_USER_PROFILE);
}
const ddoc = await domain.get('system');
if (!ddoc) await domain.add('system', 1, 'Hydro', 'Welcome to Hydro!');
@@ -52,141 +51,7 @@ export const coreScripts: MigrationScript[] = [
return true;
},
// Init
...new Array(28).fill(unsupportedUpgrade),
async function _29_30() {
return await iterateAllDomain((ddoc) => RecordModel.coll.updateMany({ domainId: ddoc._id }, { $set: { pdomain: ddoc._id } }));
},
// Add send_message priv to user
async function _30_31() {
return await iterateAllUser((udoc) => user.setPriv(udoc._id, udoc.priv | PRIV.PRIV_SEND_MESSAGE));
},
null,
// Write builtin users to database
async function _32_33() {
if (!await user.getById('system', 0)) {
await user.create('Guest@hydro.local', 'Guest', String.random(32), 0, '127.0.0.1', PRIV.PRIV_REGISTER_USER);
}
if (!await user.getById('system', 1)) {
await user.create('Hydro@hydro.local', 'Hydro', String.random(32), 1, '127.0.0.1', PRIV.PRIV_USER_PROFILE);
}
return true;
},
async function _33_34() {
return await iterateAllProblem(['content'], async (pdoc) => {
if (typeof pdoc.content !== 'string') return;
await problem.edit(pdoc.domainId, pdoc.docId, { content: pdoc.content.replace(/%file%:\/\//g, 'file://') });
});
},
async function _34_35() {
return await iterateAllDomain((ddoc) => domain.edit(ddoc._id, { lower: ddoc._id.toLowerCase() }));
},
async function _35_36() {
await RecordModel.coll.updateMany({}, { $unset: { effective: '' } });
return true;
},
async function _36_37() {
for await (const doc of document.collStatus.find()) {
await document.collStatus.deleteMany({
...pick(doc, ['docId', 'domainId', 'uid', 'docType']),
_id: { $gt: doc._id },
});
}
return true;
},
async function _37_38() {
return await iterateAllProblem(['docId', 'domainId', 'config'], async (pdoc) => {
logger.info('%s/%s', pdoc.domainId, pdoc.docId);
if (typeof pdoc.config !== 'string') return;
if (!pdoc.config.includes('type: subjective')) return;
await problem.addTestdata(
pdoc.domainId, pdoc.docId, 'config.yaml',
Buffer.from(pdoc.config.replace('type: subjective', 'type: objective')),
);
});
},
async function _38_39() {
return await iterateAllDomain(async (ddoc) => {
ddoc.roles.root = '-1';
await domain.setRoles(ddoc._id, ddoc.roles);
});
},
async function _39_40() {
return await iterateAllDomain(async ({ _id }) => {
const ddocs = await discussion.getMulti(_id, { parentType: document.TYPE_PROBLEM }).toArray();
for (const ddoc of ddocs) {
const pdoc = await problem.get(_id, ddoc.parentId as any);
await document.set(_id, document.TYPE_DISCUSSION, ddoc.docId, { parentId: pdoc.docId });
}
});
},
async function _40_41() {
// Ignore drop index failure
await db.collection('storage').dropIndex('path_1').catch(() => { });
await db.collection('storage').updateMany(
{ autoDelete: { $gte: moment().add(5, 'days').toDate() } },
{ $unset: { autoDelete: '' } },
);
return true;
},
async function _41_42() {
return await iterateAllDomain(async (ddoc) => {
const cursor = discussion.getMulti(ddoc._id, { parentType: document.TYPE_CONTEST });
while (await cursor.hasNext()) {
const doc = await cursor.next();
const tdoc = await document.coll.findOne({ docType: doc.parentType, docId: doc.parentId });
if (!tdoc) await discussion.del(ddoc._id, doc.docId);
}
});
},
null,
async function _43_44() {
const processer = (i) => {
i.status = i.accept ? STATUS.STATUS_ACCEPTED : STATUS.STATUS_WRONG_ANSWER;
return i;
};
return await iterateAllDomain(async (ddoc) => {
const tdocs = await contest.getMulti(ddoc._id, { rule: { $ne: 'acm' } }).toArray();
for (const tdoc of tdocs) {
const tsdocs = await contest.getMultiStatus(ddoc._id, { docId: tdoc.docId }).toArray();
for (const tsdoc of tsdocs) {
const $set: any = {};
if (tsdoc.journal?.length) $set.journal = tsdoc.journal.map(processer);
if (tsdoc.detail?.length) $set.detail = tsdoc.detail.map(processer);
if (Object.keys($set).length) {
await contest.setStatus(ddoc._id, tdoc.docId, tsdoc.uid, $set);
}
}
}
});
},
async function _44_45() {
return await iterateAllUser((udoc) => user.setById(udoc._id, { ip: [udoc.regip] }, { regip: '' }));
},
async function _45_46() {
return await iterateAllDomain(async (ddoc) => {
const ddocs = await discussion.getMulti(ddoc._id, {
docType: document.TYPE_DISCUSSION,
parentType: { $in: [document.TYPE_CONTEST, 60, document.TYPE_TRAINING] },
parentId: { $type: 'string' },
}).toArray();
for (const doc of ddocs) {
if (ObjectId.isValid(doc.parentId)) {
await document.set(ddoc._id, document.TYPE_DISCUSSION, doc.docId, { parentId: new ObjectId(doc.parentId) });
}
}
});
},
null,
async function _47_48() {
await document.coll.updateMany({ docType: document.TYPE_HOMEWORK }, { $set: { docType: document.TYPE_CONTEST } });
await document.collStatus.updateMany({ docType: document.TYPE_HOMEWORK }, { $set: { docType: document.TYPE_CONTEST } });
await RecordModel.coll.deleteMany({ 'contest.tid': { $ne: null }, hidden: true });
await RecordModel.coll.updateMany({}, { $unset: { hidden: '' } });
await RecordModel.coll.updateMany({ 'contest.tid': { $exists: true } }, { $rename: { 'contest.tid': 'contest1' } });
await RecordModel.coll.updateMany({ contest1: { $exists: true } }, { $rename: { contest1: 'contest' } });
await RecordModel.coll.updateMany({ contest: null }, { $unset: { contest: '' } });
return true;
},
...new Array(47).fill(unsupportedUpgrade), // oxlint-disable-line no-new-array
async function _48_49() {
await RecordModel.coll.updateMany({ input: { $exists: true } }, { $set: { contest: new ObjectId('000000000000000000000000') } });
return true;
@@ -423,7 +288,7 @@ export const coreScripts: MigrationScript[] = [
'file.pathStyle', 'file.endPointForUser', 'file.endPointForJudge',
] as any[]) as any;
if ((endPoint && accessKey) || process.env.MINIO_ACCESS_KEY) {
await setBuiltinConfig('file', {
await app.get('setting').setConfig('file', {
type: 's3',
endPoint: process.env.MINIO_ACCESS_KEY ? 'http://127.0.0.1:9000/' : endPoint,
accessKey: process.env.MINIO_ACCESS_KEY || accessKey,
@@ -683,6 +548,9 @@ export const coreScripts: MigrationScript[] = [
}
}
await iterateAllProblem(['domainId', 'docId', 'tag'], async (pdoc) => {
if (pdoc.tag && ['string', 'number'].includes(typeof pdoc.tag)) {
return { tag: [pdoc.tag.toString().trim()].filter((i) => i) };
}
if (pdoc.tag?.some((i) => typeof i !== 'string')) {
return { tag: pdoc.tag.filter((i) => i).map((i) => i.toString()) };
}
@@ -702,4 +570,51 @@ export const coreScripts: MigrationScript[] = [
} catch { }
});
},
// Start Hydro v5
async function _91_92() {
await domain.collUser.updateMany({}, { $set: { join: true } });
await domain.coll.updateMany(
{ $or: [{ _join: { $exists: false } }, { _join: { $eq: null } }, { '_join.method': domain.JOIN_METHOD_NONE }] },
{ $set: { _join: { method: domain.JOIN_METHOD_ALL, role: 'default', expire: null }, _migratedJoin: true } },
);
const domainUser = await domain.collUser.find({ domainId: 'system', join: true }, onPrimary)
.project({ uid: 1 }).toArray();
const otherUsers = await user.coll.find({ _id: { $gt: 1, $nin: domainUser.map((u) => u.uid) } }, onPrimary)
.project({ _id: 1 }).toArray();
await domain.collUser.updateMany(
{ uid: { $in: otherUsers.map((u) => u._id) }, domainId: 'system' },
{ $set: { join: true } },
{ upsert: true },
);
return true;
},
async function _92_93(ctx) {
await ctx.inject(['oauth'], async (c) => {
await iterateAllUser(async (udoc) => {
if (udoc.mailLower.endsWith('.local')) return;
await c.oauth.set('mail', udoc.mailLower, udoc._id);
});
const old = await c.oauth.coll.find({ _id: { $type: 'string' } }).toArray();
for (const r of old) {
if ((r._id as any).endsWith('@github.local')) await c.oauth.set('github', (r._id as any).split('@')[0], r.uid);
}
});
return true;
},
async function _93_94(ctx) {
await ctx.inject(['oauth'], async (c) => {
await c.oauth.coll.deleteMany({ _id: { $type: 'string' } });
const docs = await c.oauth.coll.find({ id: { $type: 'number' as any } }).toArray();
const op = c.oauth.coll.initializeUnorderedBulkOp();
for (const doc of docs) {
op.find({ _id: doc._id }).updateOne({ $set: { id: doc.id.toString() } });
}
if (op.length) await op.execute();
});
return true;
},
async function _94_95() {
await discussion.coll.deleteMany({ content: { $not: { $type: 'string' } } });
return true;
},
];
+9 -3
View File
@@ -1,3 +1,5 @@
import { isNullable } from 'cosmokit';
export function buildProjection<T extends string | number = string>(fields: readonly T[]): Record<T, true> {
const o: Record<T, true> = Object.create(null);
for (const k of fields) o[k] = true;
@@ -17,9 +19,13 @@ export function ArgMethod(target: any, funcName: string, obj: any) {
}
export function unwrapExports(module: any) {
return (!module || typeof module !== 'object') ? module
: 'apply' in module && typeof module.apply === 'function' ? module
: 'default' in module ? module.default : module;
if (isNullable(module)) return module;
if (typeof module !== 'object') return module;
if ('apply' in module && typeof module.apply === 'function') return module;
// https://github.com/evanw/esbuild/issues/2623
// https://esbuild.github.io/content-types/#default-interop
if (!module.__esModule) return module;
return module.default ?? module;
}
export * from '@hydrooj/utils/lib/utils';