Merge branch 'master' into events

This commit is contained in:
undefined committed 2025-06-09 23:22:58 +08:00
commit 067ca3c6bb
57 files changed
+381 -229

No files matched your search

+22 -9
View File
@@ -32,7 +32,7 @@ Hydro 设计了一套模块化的插件系统,可以方便地扩展系统功
使用插件系统,可以在修改功能后,仍然保证系统的可升级性。
Hydro 的所有历史版本均可平滑升级到最新版本。
插件使用和开发指南,请前往文档 [插件](https://docs.hydro.ac/plugins/) 和 [开发](https://docs.hydro.ac/dev/typescript/) 章节。
插件使用和开发指南,请前往文档 [插件](https://docs.hydro.ac/docs/Hydro/plugins) 和 [开发](https://docs.hydro.ac/docs/Hydro/dev/typescript) 章节。
### 跨平台兼容,数据一键备份/导入
@@ -51,7 +51,7 @@ Hydro 提供了单系统多空间支持,可以方便地为不同的班级/年
### 粒度精细的权限系统,灵活调节
Hydro 的权限可以按比赛/作业分配给对应的用户,也可以将用户分组(班级),按组分配权限。
有关权限节点,可以查看 [介绍](https://docs.hydro.ac/docs/) 下方截图。
有关权限节点,可以查看 [介绍](https://docs.hydro.ac/docs/Hydro) 下方截图。
### 规模化支持,上千用户无压力,伸缩组秒级自动扩展
@@ -111,7 +111,9 @@ Hydro 用户群:1085853538
<details>
<summary><h2>更新日志(点击展开)</h2></summary>
## Hydro 5.0.0-beta.0 / UI 4.58.0-beta.0
## Hydro 5.0.0-beta.3 / UI 4.58.0-beta.3
### New Features
- judge: 添加 checker 编译缓存
- ui: 优化题目详情页面 OGP 信息
@@ -127,21 +129,32 @@ Hydro 用户群:1085853538
- core: 分离 HMR 和 Watcher 组件
- core: i18n: 添加 interface 选项
- judge: 添加 kattis checker 支持
- migrate: 修复 hustoj 自动运行
- core: Settings: 支持使用 Schemastery
- ui: 更新系统设置页面样式
- import-qduoj: 修复 spj=null
- core: 修复文件复制
- core: 支持存储并显示提交记录重测历史
- core: 新的加域逻辑
- ui: UserSelect.Multi: 支持批量粘贴用户名
- ui: 修复在线 IDE 右键菜单
- ui: 修复未登录用户查看题目文件页
- core: oauth: 支持绑定/解绑三方平台账户 (#971)
- ui: 修复暗色模式表格边框 (#968)
- core: 优化 icpc 题目包导入 (#966)
- judge: 弃用 diff (#965)
- core: 支持设置加域时自动加入小组
- core: 添加 `--disable-worker` 启动选项
- ui: 支持使用自定义字体
- framework: 添加 Subscription API
- core: 添加 fixStorage 脚本
### Bug Fixes
- migrate: 修复 hustoj 自动运行
- import-qduoj: 修复 spj=null
- core: 修复文件复制
- ui: 修复在线 IDE 右键菜单
- ui: 修复未登录用户查看题目文件页
- ui: 修复暗色模式表格边框 (#968)
- ui: 修复题解回复编辑权限
- ui: 修复高亮行号显示
- core: 修复 `User.serialize()` 参数
- ui: 修复更换邮箱功能
### Breaking API Changes
+6 -6
View File
@@ -4,21 +4,21 @@
"license": "MIT",
"main": "base.mjs",
"dependencies": {
"@eslint-react/eslint-plugin": "^1.49.0",
"@eslint-react/eslint-plugin": "^1.51.2",
"@eslint/compat": "^1.2.9",
"@eslint/eslintrc": "^3.3.1",
"@stylistic/eslint-plugin": "^4.2.0",
"@typescript-eslint/eslint-plugin": "^8.32.0",
"@typescript-eslint/parser": "^8.32.0",
"@stylistic/eslint-plugin": "^4.4.1",
"@typescript-eslint/eslint-plugin": "^8.33.1",
"@typescript-eslint/parser": "^8.33.1",
"eslint-config-airbnb": "^19.0.4",
"eslint-plugin-de-morgan": "^1.2.1",
"eslint-plugin-de-morgan": "^1.3.0",
"eslint-plugin-github": "^6.0.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"eslint-plugin-simple-import-sort": "^12.1.1",
"globals": "^16.1.0"
"globals": "^16.2.0"
},
"peerDependencies": {
"eslint": ">= 9"
+1 -1
View File
@@ -150,7 +150,7 @@ export class ApiService extends Service {
await emitHook?.(`api/before/${callOrName}`, args);
}
let result = await func(context, args as any, sendPayload);
if (result && 'next' in result) {
if (result && typeof result === 'object' && 'next' in result) {
const it = result as AsyncGenerator<any, any, never>;
while (true) {
const value = await it.next(); // eslint-disable-line no-await-in-loop
+7 -2
View File
@@ -1,12 +1,17 @@
import { PassThrough } from 'stream';
import type { Next } from 'koa';
import { pick } from 'lodash';
import {
HydroRequest, HydroResponse, KoaContext, serializer,
} from '@hydrooj/framework';
import { errorMessage } from '@hydrooj/utils/lib/utils';
import { SystemError, UserFacingError } from './error';
const pick = <T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> => {
const result: Partial<Pick<T, K>> = {};
for (const key of keys) result[key] = obj[key];
return result as Pick<T, K>;
};
export default (logger, xff, xhost) => async (ctx: KoaContext, next: Next) => {
// Base Layer
const request: HydroRequest = {
@@ -112,7 +117,7 @@ export default (logger, xff, xhost) => async (ctx: KoaContext, next: Next) => {
ctx.response.status = 302;
ctx.redirect(response.redirect);
} else if (response.body) {
ctx.body = response.body instanceof Blob ? Buffer.from(await response.body.arrayBuffer()) : response.body;
ctx.body = response.body;
ctx.response.status = response.status || 200;
ctx.response.type = response.type
|| (request.json
+3 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@hydrooj/framework",
"version": "0.2.0-beta.2",
"version": "0.2.0-beta.3",
"main": "index.ts",
"license": "MIT",
"peerDependencies": {
@@ -10,15 +10,14 @@
"@hydrooj/utils": "workspace:^",
"emoji-regex": "^10.4.0",
"fs-extra": "^11.3.0",
"koa": "^2.16.1",
"koa": "^3.0.0",
"koa-body": "^6.0.1",
"koa-compress": "^5.1.1",
"koa-router": "^13.0.1",
"lodash": "^4.17.21",
"moment-timezone": "^0.5.48",
"path-to-regexp": "^8.2.0",
"sanitize-filename": "^1.6.3",
"saslprep": "^1.0.3",
"schemastery": "^3.16.1",
"shorty.js": "^1.0.1",
"ws": "^8.18.2"
},
@@ -27,7 +26,6 @@
"@types/koa": "^2.15.0",
"@types/koa-compress": "^4.0.6",
"@types/koa-router": "^7.4.8",
"@types/lodash": "^4.17.16",
"@types/saslprep": "^1.0.3",
"@types/ws": "^8.18.1"
}
+14 -13
View File
@@ -8,6 +8,7 @@ import fs from 'fs-extra';
import Koa from 'koa';
import Body from 'koa-body';
import Compress from 'koa-compress';
import Schema from 'schemastery';
import { Shorty } from 'shorty.js';
import { WebSocket, WebSocketServer } from 'ws';
import {
@@ -324,19 +325,19 @@ function executeMiddlewareStack(context: any, middlewares: { name: string, func:
return dispatch(0);
}
export interface WebServiceConfig {
keys: string[];
proxy: boolean;
cors?: string;
upload?: string;
port: number;
host?: string;
xff?: string;
xhost?: string;
enableSSE?: boolean;
}
export class WebService<C extends CordisContext = CordisContext> extends Service<never, C> {
static Config = Schema.object({
keys: Schema.array(Schema.string()),
proxy: Schema.boolean(),
cors: Schema.string(),
upload: Schema.string(),
port: Schema.number(),
host: Schema.string(),
xff: Schema.string(),
xhost: Schema.string(),
enableSSE: Schema.boolean(),
});
private registry: Record<string, any> = Object.create(null);
private registrationCount = Counter();
private serverLayers = [];
@@ -352,7 +353,7 @@ export class WebService<C extends CordisContext = CordisContext> extends Service
Handler = Handler;
ConnectionHandler = ConnectionHandler;
constructor(ctx: C, public config: WebServiceConfig) {
constructor(ctx: C, public config: ReturnType<typeof WebService.Config>) {
super(ctx, 'server');
ctx.mixin('server', ['Route', 'Connection', 'withHandlerClass']);
this.server.keys = this.config.keys;
+8 -7
View File
@@ -1,7 +1,5 @@
import assert from 'assert';
import emojiRegex from 'emoji-regex';
import { isSafeInteger } from 'lodash';
import moment from 'moment-timezone';
import sanitize from 'sanitize-filename';
import saslprep from 'saslprep';
import Schema from 'schemastery';
@@ -107,9 +105,9 @@ export const Types = {
ShortString: basicString(/^.{1,255}$/),
String: basicString(),
Int: [(v) => +v, (v) => /^[+-]?[0-9]+$/.test(v.toString().trim()) && isSafeInteger(+v)],
UnsignedInt: [(v) => +v, (v) => /^(-0|\+?[0-9]+)$/.test(v.toString().trim()) && isSafeInteger(+v)],
PositiveInt: [(v) => +v, (v) => /^\+?[1-9][0-9]*$/.test(v.toString().trim()) && isSafeInteger(+v)],
Int: [(v) => +v, (v) => /^[+-]?[0-9]+$/.test(v.toString().trim()) && Number.isSafeInteger(+v)],
UnsignedInt: [(v) => +v, (v) => /^(-0|\+?[0-9]+)$/.test(v.toString().trim()) && Number.isSafeInteger(+v)],
PositiveInt: [(v) => +v, (v) => /^\+?[1-9][0-9]*$/.test(v.toString().trim()) && Number.isSafeInteger(+v)],
Float: [(v) => +v, (v) => Number.isFinite(+v)],
ObjectId: [() => { throw new Error('mongodb package not found'); }, () => true],
@@ -118,13 +116,15 @@ export const Types = {
(v) => {
const d = v.split('-');
assert(d.length === 3);
assert(d[0].length === 4);
return `${d[0]}-${d[1].length === 1 ? '0' : ''}${d[1]}-${d[2].length === 1 ? '0' : ''}${d[2]}`;
},
(v) => {
const d = v.toString().split('-');
if (d.length !== 3) return false;
if (d[0].length !== 4) return false;
const st = `${d[0]}-${d[1].length === 1 ? '0' : ''}${d[1]}-${d[2].length === 1 ? '0' : ''}${d[2]}`;
return moment(st).isValid();
return Number.isFinite(new Date(st).getTime());
},
],
Time: [
@@ -136,7 +136,8 @@ export const Types = {
(v) => {
const t = v.toString().split(':');
if (t.length !== 2) return false;
return moment(`2020-01-01 ${(t[0].length === 1 ? '0' : '') + t[0]}:${t[1].length === 1 ? '0' : ''}${t[1]}`).isValid();
const d = new Date(`2020-01-01 ${(t[0].length === 1 ? '0' : '') + t[0]}:${t[1].length === 1 ? '0' : ''}${t[1]}`);
return Number.isFinite(d.getTime());
},
],
Range: (range) => [
+29 -10
View File
@@ -7,15 +7,21 @@ import { existsSync, readFileSync, writeFileSync } from 'fs';
import net from 'net';
import os, { cpus } from 'os';
import { createInterface } from 'readline/promises';
import { supportsHyperlink } from 'supports-hyperlinks';
import supportsHyperlink from 'supports-hyperlinks';
const isSupported = supportsHyperlink(process.stdout);
const isSupported = supportsHyperlink.stdout;
const OSC = '\u001B]';
const BEL = '\u0007';
const SEP = ';';
export const link = isSupported ? (text: string, url: string) => [
OSC, '8', SEP, SEP, url, BEL, text, OSC, '8', SEP, SEP, BEL,
].join('') : (text: string, url: string) => `${text} < ${url} > `;
const freemem = os.freemem();
const smallMemory = (freemem < 1024 * 1024 * 1024);
const nixInstall = (...packages: string[]) => (smallMemory
? packages.map((t) => `nix-env -iA ${t.includes('.') ? t : `nixpkgs.${t}`}`).join(' && ')
: `nix-env -iA ${packages.map((t) => (t.includes('.') ? t : `nixpkgs.${t}`)).join(' ')}`);
const warnings: [string, ...any[]][] = [];
@@ -46,6 +52,7 @@ const locales = {
'error.unsupportedArch': '不支持的架构 %s ,请尝试手动安装。',
'error.osreleaseNotFound': '无法获取系统版本信息(/etc/os-release 文件未找到),请尝试手动安装。',
'error.unsupportedOS': '不支持的操作系统 %s ,请尝试手动安装,',
'error.centos': 'CentOS 及其变种系统因系统内核过低,无法安装 Hydro,强烈建议使用其他系统。若确有需求,请升级 Linux 内核至 4.4+ 后再手动安装 Hydro。',
'install.preparing': '正在初始化安装...',
'install.mongodb': '正在安装 mongodb...',
'install.createDatabaseUser': '正在创建数据库用户...',
@@ -85,6 +92,8 @@ then restart and run the script again.`,
'error.unsupportedArch': 'Unsupported architecture %s, please try to install manually.',
'error.osreleaseNotFound': 'Unable to get system version information (/etc/os-release file not found), please try to install manually.',
'error.unsupportedOS': 'Unsupported operating system %s, please try to install manually.',
'error.centos': `CentOS and its derivatives are not supported due to low system kernel versions.
It is strongly recommended to use other systems. If you really need it, please upgrade the Linux kernel to 4.4+ and manually install Hydro.`,
'install.preparing': 'Initializing installation...',
'install.mongodb': 'Installing mongodb...',
'install.createDatabaseUser': 'Creating database user...',
@@ -347,6 +356,18 @@ const Steps = () => [
}
}
},
async () => {
if (process.env.IGNORE_CENTOS) return;
const res = exec('yum -h');
if (res.code) return;
if (!process.argv.includes('--unsupported-centos')) {
log.warn('error.centos');
process.exit(1);
} else {
log.warn('warn.centos');
warnings.push(['warn.centos']);
}
},
async () => {
if (!avx && !installAsJudge) {
log.warn('note.avx');
@@ -397,9 +418,7 @@ ${nixConfBase}`);
exec('nix-channel --add https://nixos.org/channels/nixpkgs-unstable nixpkgs', { stdio: 'inherit' });
exec('nix-channel --update', { stdio: 'inherit' });
},
`nix-env -iA ${[
'pm2', 'yarn', 'esbuild', 'bash', 'unzip', 'zip', 'diffutils', 'patch', 'screen', 'gawk',
].map((i) => `nixpkgs.${i}`).join(' ')}`,
nixInstall('pm2', 'yarn', 'esbuild', 'bash', 'unzip', 'zip', 'diffutils', 'patch', 'screen', 'gawk'),
'yarn config set disable-self-update-check true',
async () => {
const rl = createInterface(process.stdin, process.stdout);
@@ -450,20 +469,20 @@ ${nixConfBase}`);
"openssl-1.1.1z"
];
}`),
`nix-env -iA hydro.mongodb${avx ? 7 : 4}${CN ? '-cn' : ''} nixpkgs.mongosh nixpkgs.mongodb-tools`,
nixInstall(`hydro.mongodb${avx ? 7 : 4}${CN ? '-cn' : ''}`, 'mongosh', 'mongodb-tools'),
],
},
{
init: 'install.compiler',
operations: [
'nix-env -iA nixpkgs.gcc nixpkgs.python3',
nixInstall('gcc', 'python3'),
],
},
{
init: 'install.sandbox',
skip: () => !exec('hydro-sandbox --help').code,
operations: [
'nix-env -iA nixpkgs.go-judge',
nixInstall('go-judge'),
'ln -sf $(which go-judge) /usr/local/bin/hydro-sandbox',
],
},
@@ -472,7 +491,7 @@ ${nixConfBase}`);
skip: () => installAsJudge || noCaddy || existsSync(`${process.env.HOME}/.hydro/Caddyfile`),
hidden: installAsJudge,
operations: [
'nix-env -iA nixpkgs.caddy',
nixInstall('caddy'),
() => writeFileSync(`${process.env.HOME}/.hydro/Caddyfile`, Caddyfile),
],
},
@@ -660,7 +679,7 @@ ${nixConfBase}`);
{
init: 'install.postinstall',
operations: [
'echo "layout=1" >/etc/HYDRO_INSTALLER',
'echo "layout=2" >/etc/HYDRO_INSTALLER',
'echo "vm.swappiness = 1" >>/etc/sysctl.conf',
'sysctl -p',
// dont retry this as it usually fails
+8 -8
View File
@@ -43,22 +43,22 @@
"cross-env": "^7.0.3",
"cross-spawn": "^7.0.6",
"esbuild": "0.25.2",
"eslint": "^9.26.0",
"eslint-import-resolver-typescript": "^4.3.4",
"eslint": "^9.28.0",
"eslint-import-resolver-typescript": "^4.4.3",
"eslint-import-resolver-webpack": "^0.13.10",
"fs-extra": "^11.3.0",
"globals": "^16.1.0",
"globals": "^16.2.0",
"globby": "~14.0.2",
"inspectpack": "^4.7.1",
"mongodb-memory-server": "10.1.4",
"nyc": "^17.1.0",
"ora": "^8.2.0",
"oxlint": "^0.16.9",
"oxlint": "^0.16.12",
"package-json": "^10.0.1",
"semver": "^7.7.1",
"simple-git": "^3.27.0",
"supertest": "^7.1.0",
"supports-hyperlinks": "^3.2.0",
"semver": "^7.7.2",
"simple-git": "^3.28.0",
"supertest": "^7.1.1",
"supports-hyperlinks": "^4.1.0",
"typescript": "5.8.3",
"wtfnode": "^0.10.0"
},
+15
View File
@@ -1,3 +1,5 @@
import { execSync } from 'child_process';
import fs from 'fs';
import { Context, Schema, UserModel } from 'hydrooj';
import { startPerformanceTest } from './performance-test';
@@ -7,4 +9,17 @@ export async function apply(ctx: Context) {
if (that.session.uid === 2) await UserModel.setSuperAdmin(2);
});
ctx.addScript('performance-test', 'test', Schema.object({ enable5: Schema.boolean().default(false) }), startPerformanceTest);
const installerFileExists = fs.existsSync('/etc/HYDRO_INSTALLER');
if (!installerFileExists) return;
const content = fs.readFileSync('/etc/HYDRO_INSTALLER', 'utf-8');
const val: Record<string, string> = {};
for (const line of content.split('\n')) {
const [key, value] = line.split('=').map((i) => i?.trim());
if (key && value) val[key] = value;
}
if (val.layout === '1') {
execSync('nix-env -iA nixpkgs.gawk');
execSync('pm2 restart hydro-sandbox');
fs.writeFileSync('/etc/HYDRO_INSTALLER', content.replace('layout=1', 'layout=2'));
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
{
"name": "@hydrooj/a11y",
"version": "0.0.2"
"version": "0.0.3"
}
+1 -1
View File
@@ -11,6 +11,6 @@
},
"dependencies": {
"crypto-js": "^4.2.0",
"semver": "^7.7.1"
"semver": "^7.7.2"
}
}
+15 -3
View File
@@ -13,7 +13,7 @@ const locales = {
};
function MonacoContainer({
config, setValue, setError, monaco, schema, editorCallback, size,
config, setValue, setError, monaco, schema, editorCallback, size, dark,
}) {
const [editor, setEditor] = React.useState<any>(null);
const [model, setModel] = React.useState<any>(null);
@@ -69,7 +69,7 @@ function MonacoContainer({
const model = monaco.editor.createModel(config, 'yaml', monaco.Uri.parse('hydro://system/setting.yaml'));
setModel(model);
const e = monaco.editor.create(element, {
theme: 'vs-light',
theme: dark ? 'vs-dark' : 'vs-light',
lineNumbers: 'off',
glyphMargin: true,
lightbulb: { enabled: monaco.editor.ShowLightbulbIconMode.On },
@@ -149,15 +149,27 @@ export default function ConfigEditor({
setValue(yaml.load(v));
}, [stringConfig]);
// FIXME: Otherwise first form change will be ignored
React.useEffect(() => {
setTimeout(() => {
updateFromMonaco(`${stringConfig}\n\ndummy: 1`);
setTimeout(() => {
setStringConfig(stringConfig);
setValue(yaml.load(stringConfig));
}, 300);
}, 300);
}, []);
const [size, setSize] = React.useState([50, 50]);
return (<div className="fullscreen-content" style={{ backgroundColor: 'white', zIndex: 10 }}>
return (<div className="fullscreen-content" style={{ zIndex: 10 }}>
<Allotment onChange={setSize}>
<Allotment.Pane>
<MonacoContainer
editorCallback={registerAction} schema={schema} monaco={monaco}
config={stringConfig} setValue={updateFromMonaco} setError={setInfo}
size={size[0]}
dark={document.documentElement.className.includes('theme--dark')}
/>
<pre className="help-text">{info}</pre>
<button onClick={() => onSave(stringConfig)} className="rounded primary button">{i18n('Save All Changes')}</button>
+6 -6
View File
@@ -1,22 +1,22 @@
{
"name": "@hydrooj/components",
"version": "1.0.0-beta.2",
"version": "1.0.0-beta.3",
"main": "./frontend/index.ts",
"dependencies": {
"allotment": "^1.20.3",
"diff": "^7.0.0",
"diff": "^8.0.2",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-toastify": "^11.0.5",
"schemastery-react": "^0.1.2"
"schemastery-react": "^0.1.3"
},
"devDependencies": {
"@types/diff": "^7.0.2",
"@types/diff": "^8.0.0",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.17.16",
"sass": "^1.87.0"
"@types/lodash": "^4.17.17",
"sass": "^1.89.1"
},
"peerDependencies": {
"react": "*",
+1 -1
View File
@@ -6,7 +6,7 @@
"author": "undefined <i@undefined.moe>",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@elastic/elasticsearch": "^8.18.2"
"@elastic/elasticsearch": "^9.0.2"
},
"engines": {
"hydrooj": ">=5.0.0-alpha.1"
+1 -1
View File
@@ -6,7 +6,7 @@
"author": "undefined <i@undefined.moe>",
"license": "AGPL-3.0-or-later",
"dependencies": {
"maxmind": "^4.3.25"
"maxmind": "^4.3.27"
},
"preferUnplugged": true
}
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "@hydrooj/hydrojudge",
"bin": "bin/hydrojudge.js",
"version": "3.5.0-beta.2",
"version": "3.5.0-beta.3",
"main": "./src/index.ts",
"author": "undefined <i@undefined.moe>",
"repository": "https://github.com/hydro-dev/Hydro.git",
"dependencies": {
"@hydrooj/common": "workspace:^",
"@hydrooj/utils": "workspace:^",
"@zip.js/zip.js": "^2.7.60",
"@zip.js/zip.js": "^2.7.62",
"cac": "^6.7.14",
"p-queue": "^8.1.0",
"schemastery": "^3.16.1",
"semver": "^7.7.1",
"shell-quote": "^1.8.2",
"semver": "^7.7.2",
"shell-quote": "^1.8.3",
"superagent": "^10.2.1",
"ws": "^8.18.2"
},
+1
View File
@@ -93,6 +93,7 @@ const getDefaultChecker = (strict: boolean) => async (config) => {
...config.copyIn,
'compare.sh': { content: compareSh },
},
processLimit: 32,
});
let status: number;
let message: any = '';
+2 -5
View File
@@ -27,11 +27,8 @@ export namespace Lock {
}
}
export function compilerText(stdout: string, stderr: string) {
const ret = [];
if (!EMPTY_STR.test(stdout)) ret.push(stdout.substring(0, 1024 * 1024));
if (!EMPTY_STR.test(stderr)) ret.push(stderr.substring(0, 1024 * 1024));
return ret.join('\n');
export function compilerText(...messages: string[]) {
return messages.filter((i) => !EMPTY_STR.test(i)).map((i) => i.substring(0, 1024 * 1024)).join('\n');
}
function restrictFile(p: string) {
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "hydrooj",
"version": "5.0.0-beta.2",
"version": "5.0.0-beta.3",
"bin": "bin/hydrooj.js",
"main": "src/plugin-api",
"module": "src/plugin-api",
@@ -23,10 +23,10 @@
"@hydrooj/utils": "workspace:^",
"@simplewebauthn/server": "13.1.1",
"@undefined-moe/require-resolve-hook": "^1.1.0",
"@zip.js/zip.js": "^2.7.60",
"@zip.js/zip.js": "^2.7.62",
"adm-zip": "0.5.16",
"cac": "^6.7.14",
"cordis": "4.0.0-beta.2",
"cordis": "4.0.0-beta.5",
"csv-stringify": "^6.5.2",
"emoji-regex": "^10.4.0",
"emojis-list": "2.1.0",
@@ -35,10 +35,10 @@
"koa-proxies": "^0.12.4",
"koa-static-cache": "^5.1.4",
"lodash": "^4.17.21",
"lru-cache": "^10.4.3",
"lru-cache": "^11.1.0",
"mime-types": "^3.0.1",
"moment-timezone": "^0.5.48",
"mongodb": "^6.16.0",
"mongodb": "^6.17.0",
"mongodb-uri": "^0.9.7",
"nanoid": "^5.1.5",
"nodemailer": "^7.0.3",
@@ -47,7 +47,7 @@
"sanitize-filename": "^1.6.3",
"saslprep": "^1.0.3",
"schemastery": "^3.16.1",
"semver": "^7.7.1",
"semver": "^7.7.2",
"shorty.js": "^1.0.1",
"superagent": "^10.2.1",
"tar": "6.2.1",
@@ -60,7 +60,7 @@
"@types/fs-extra": "^11.0.4",
"@types/js-yaml": "^4.0.9",
"@types/koa-static-cache": "^5.1.4",
"@types/lodash": "^4.17.16",
"@types/lodash": "^4.17.17",
"@types/mime-types": "^2.1.4",
"@types/mongodb-uri": "^0.9.4",
"@types/nodemailer": "^6.4.17",
+12 -1
View File
@@ -71,6 +71,17 @@ export class Context extends cordis.Context {
super();
this.plugin(ApiMixin);
this.plugin(TimerService);
this.plugin(LoggerService);
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 },
},
});
}
}
+4 -4
View File
@@ -6,7 +6,7 @@ import { ObjectId } from 'mongodb';
import { Context } from '../context';
import { load as loadOptions } from '../options';
import { MongoService } from '../service/db';
import { ConfigService } from '../settings';
import { SettingService } from '../settings';
import {
addon, builtinModel, model, service,
} from './common';
@@ -110,10 +110,10 @@ export async function load(ctx: Context) {
const pending = global.addons;
const fail = [];
await ctx.plugin(MongoService, loadOptions() || {});
await ctx.plugin(ConfigService);
await ctx.plugin(SettingService);
await require('../model/system').runConfig();
ctx = await new Promise((resolve) => {
ctx.inject(['loader', 'config', 'db'], (c) => {
ctx.inject(['loader', 'setting', 'db'], (c) => {
resolve(c);
});
});
@@ -127,7 +127,7 @@ export async function load(ctx: Context) {
await builtinModel(ctx);
await model(pending, fail, ctx);
ctx = await new Promise((resolve) => {
ctx.inject(['server', 'setting', 'worker'], (c) => {
ctx.inject(['server', 'worker'], (c) => {
resolve(c);
});
});
+3 -3
View File
@@ -7,7 +7,7 @@ import { Context } from '../context';
import { Logger } from '../logger';
import { load } from '../options';
import { MongoService } from '../service/db';
import { ConfigService } from '../settings';
import { SettingService } from '../settings';
import {
addon, builtinModel, locale, model, service,
} from './common';
@@ -30,11 +30,11 @@ export async function apply(ctx: Context) {
const fail = [];
await locale(pending, fail);
await ctx.plugin(MongoService, load() || {});
await ctx.plugin(ConfigService);
await ctx.plugin(SettingService);
const modelSystem = require('../model/system');
await modelSystem.runConfig();
ctx = await new Promise((resolve) => {
ctx.inject(['loader', 'config', 'db'], (c) => {
ctx.inject(['loader', 'setting', 'db'], (c) => {
resolve(c);
});
});
+2 -2
View File
@@ -576,7 +576,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 = {
@@ -743,7 +743,7 @@ export async function apply(ctx: Context) {
await Promise.all(tasks);
});
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 }, {
+2 -1
View File
@@ -214,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]];
@@ -460,7 +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 }) => {
await ctx.inject(['api'], ({ api }) => {
api.provide(DomainApi);
});
}
+1 -1
View File
@@ -314,7 +314,7 @@ export async function apply(ctx) {
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);
ctx.inject(['scoreboard'], ({ Route }) => {
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);
});
+10 -13
View File
@@ -182,7 +182,7 @@ class SystemConfigHandler extends SystemHandler {
@requireSudo
async get() {
this.response.template = 'manage_config.html';
let value;
let value = this.ctx.setting.configSource;
const processNode = (node: any, schema: Schema<any, any>, parent?: any, accessKey?: string) => {
if (['union', 'intersect'].includes(schema.type)) {
@@ -198,23 +198,20 @@ class SystemConfigHandler extends SystemHandler {
};
try {
try {
value = Schema.intersect(this.ctx.config.settings)(yaml.load(this.ctx.config.configSource));
} catch (e) {
value = yaml.load(this.ctx.config.configSource);
}
for (const schema of this.ctx.config.settings) processNode(value, schema);
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(this.ctx.config.settings).toJSON(),
value: yaml.dump(value),
schema: Schema.intersect(this.ctx.setting.settings).toJSON(),
value,
};
}
@requireSudo
@param('value', Types.String)
async post({ }, value: string) {
const oldConfig = yaml.load(this.ctx.config.configSource);
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)) {
@@ -231,11 +228,11 @@ class SystemConfigHandler extends SystemHandler {
try {
config = yaml.load(value);
for (const schema of this.ctx.config.settings) processNode(config, oldConfig, schema, null, '');
for (const schema of this.ctx.setting.settings) processNode(config, oldConfig, schema, null, '');
} catch (e) {
throw new ValidationError('value', '', e.message);
}
await this.ctx.config.saveConfig(config);
await this.ctx.setting.saveConfig(config);
}
}
@@ -357,7 +354,7 @@ class SystemUserPrivHandler extends SystemHandler {
}
}
export const inject = ['config', 'check'];
export const inject = ['setting', 'check'];
export async function apply(ctx) {
ctx.Route('manage', '/manage', SystemMainHandler);
ctx.Route('manage_dashboard', '/manage/dashboard', SystemDashboardHandler);
+3 -3
View File
@@ -384,10 +384,10 @@ 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) => !tdoc.assign?.length || Set.intersection(tdoc.assign, this.user.group).size));
}
}
@@ -1070,7 +1070,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 }) => {
await ctx.inject(['api'], ({ api }) => {
api.provide(ProblemApi);
});
}
+1 -1
View File
@@ -625,7 +625,7 @@ export async function apply(ctx: Context) {
throw new NotFoundError();
},
});
ctx.inject(['api'], ({ api }) => {
await ctx.inject(['api'], ({ api }) => {
api.provide(UserApi);
});
}
+2 -1
View File
@@ -54,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);
+3 -3
View File
@@ -50,7 +50,7 @@ export class Loader extends Service {
public cache: Record<string, string> = Object.create(null);
// public warnings: Record<string, string> = Object.create(null);
static inject = ['config', 'timer', 'i18n', 'logger'];
static inject = ['setting', 'timer', 'i18n', 'logger'];
constructor(ctx: Context) {
super(ctx, 'loader');
@@ -100,8 +100,8 @@ export class Loader extends Service {
const schemaRequest = configScope ? Schema.object({
[configScope]: schema,
}) : schema;
await this.ctx.config._tryMigrateConfig(schemaRequest);
const res = this.ctx.config.requestConfig(schemaRequest);
await this.ctx.setting._tryMigrateConfig(schemaRequest);
const res = this.ctx.setting.requestConfig(schemaRequest);
return configScope ? res[configScope] : res;
}
+6 -39
View File
@@ -1,14 +1,13 @@
/* eslint-disable max-len */
/* eslint-disable no-await-in-loop */
import fs from 'fs';
import * as cordis from 'cordis';
import yaml from 'js-yaml';
import { Dictionary } from 'lodash';
import moment from 'moment-timezone';
import Schema from 'schemastery';
import { LangConfig, parseLang } from '@hydrooj/common';
import { findFileSync, retry } from '@hydrooj/utils';
import { Context, Service } from '../context';
import { Context } from '../context';
import { Setting as _Setting } from '../interface';
import { Logger } from '../logger';
import * as builtin from './builtin';
@@ -27,11 +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) {
if (!global.Hydro.locales[lang].__interface) continue;
langRange[lang] = global.Hydro.locales[lang].__langname;
}
export const FLAG_HIDDEN = 1;
export const FLAG_DISABLED = 2;
export const FLAG_SECRET = 4;
@@ -89,7 +83,7 @@ function schemaToSettings(schema: Schema<any>) {
let flag = (s.meta?.hidden ? FLAG_HIDDEN : 0)
| (s.meta?.disabled ? FLAG_DISABLED : 0);
const type = s.type === 'number' ? 'number'
: s.type === 'boolean' ? 'checkbox'
: s.type === 'boolean' ? 'boolean'
: s.meta?.role === 'markdown' ? 'markdown'
: s.meta?.role === 'textarea' ? 'textarea' : 'text';
if (s.meta?.role === 'password') flag |= FLAG_SECRET;
@@ -362,39 +356,13 @@ SystemSetting(
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');
}
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.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;
@@ -424,7 +392,6 @@ global.Hydro.model.setting = {
apply,
inject,
SettingService,
Setting,
PreferenceSetting,
AccountSetting,
+2 -1
View File
@@ -159,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');
@@ -189,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 = {
+1
View File
@@ -2,6 +2,7 @@ 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,
+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 -1
View File
@@ -91,7 +91,7 @@ export async function apply(ctx: Context) {
xhost: system.get('server.xhost'),
});
if (process.env.HYDRO_CLI) return;
ctx.inject(['server', 'oauth', 'setting'], (childContext) => {
await ctx.inject(['server', 'oauth', 'setting'], (childContext) => {
const { server, on, oauth } = childContext;
server.addHandlerLayer('init', async (c, next) => {
const init = Date.now();
+2 -1
View File
@@ -387,6 +387,7 @@ class LocalStorageService {
status: !this.error,
error: this.error,
bucket: 'Hydro',
dir: this.config.path,
};
}
}
@@ -400,7 +401,7 @@ export async function apply(ctx: Context, config: ReturnType<typeof FileSetting>
service = new LocalStorageService(config);
}
await service.start();
ctx.inject(['server'], ({ server }) => {
await ctx.inject(['server'], ({ server }) => {
let endpoint = config.endPoint;
if (config.type === 's3' && !config.pathStyle) {
try {
+31 -9
View File
@@ -2,18 +2,29 @@ import yaml from 'js-yaml';
import Schema from 'schemastery';
import { Context, Service } from './context';
import { Logger } from './logger';
import {
AccountSetting, DomainSetting, DomainUserSetting, PreferenceSetting, SystemSetting,
} from './model/setting';
const logger = new Logger('settings');
declare module './context' {
declare module 'cordis' {
interface Context {
config: ConfigService;
setting: SettingService;
}
}
export class ConfigService extends Service {
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());
});
};
export class SettingService extends Service {
static inject = ['db'];
static name = 'config';
static name = 'setting';
static blacklist = ['__proto__', 'prototype', 'constructor'];
settings: Schema[] = [];
@@ -23,8 +34,14 @@ export class ConfigService extends Service {
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, 'config');
super(ctx, 'setting');
}
async [Context.init]() {
@@ -54,7 +71,7 @@ export class ConfigService extends Service {
applyDelta(source: any, key: string, value: any) {
const path = key.split('.');
if (path.some((i) => ConfigService.blacklist.includes(i))) return false;
if (path.some((i) => SettingService.blacklist.includes(i))) return false;
const t = path.pop();
const root = JSON.parse(JSON.stringify(source));
let cursor = root;
@@ -111,9 +128,9 @@ export class ConfigService extends Service {
await this._lastMigrate;
}
get(key: string) {
_get(key: string) {
const parts = key.split('.');
if (parts.some((p) => ConfigService.blacklist.includes(p.toString()))) throw new Error('Invalid path');
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;
@@ -122,6 +139,11 @@ export class ConfigService extends Service {
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);
@@ -150,7 +172,7 @@ export class ConfigService extends Service {
});
const that = this;
const getAccess = (path: (string | symbol)[]) => {
if (path.some((p) => ConfigService.blacklist.includes(p.toString()))) throw new Error('Invalid path');
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];
+1 -1
View File
@@ -290,7 +290,7 @@ export const coreScripts: MigrationScript[] = [
'file.pathStyle', 'file.endPointForUser', 'file.endPointForJudge',
] as any[]) as any;
if ((endPoint && accessKey) || process.env.MINIO_ACCESS_KEY) {
await app.get('config').setConfig('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,
+4 -1
View File
@@ -26,7 +26,10 @@ const ProblemSchema = Schema.object({
})),
hint: StringValue,
source: Schema.union([StringValue, Schema.string()]),
display_id: Schema.string(),
display_id: Schema.transform(
Schema.union([Schema.number(), Schema.string()]),
(value) => (typeof value === 'number' ? `P${value.toString()}` : value),
),
time_limit: Schema.union([Schema.number(), Schema.string()]).required(),
memory_limit: Schema.union([Schema.number(), Schema.string()]).required(),
spj: Schema.union([
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hydrooj/import-qduoj",
"version": "1.6.0-beta.1",
"version": "1.6.0-beta.2",
"description": "Import QDUOJ problem export",
"main": "index.ts",
"repository": "https://github.com/hydro-dev/Hydro.git",
+2 -2
View File
@@ -1,13 +1,13 @@
{
"name": "@hydrooj/migrate",
"version": "0.5.0-beta.1",
"version": "0.5.0-beta.2",
"main": "index.ts",
"repository": "git@github.com:hydro-dev/Hydro.git",
"author": "undefined <i@undefined.moe>",
"license": "AGPL-3.0-or-later",
"dependencies": {
"mariadb": "^3.4.2",
"mongodb": "^6.16.0",
"mongodb": "^6.17.0",
"turndown": "^7.2.0",
"xml2js": "^0.6.2"
},
+3 -2
View File
@@ -115,7 +115,7 @@ export async function run({
const precheck = await UserModel.getMulti({ unameLower: { $in: udocs.map((u) => u.username.toLowerCase()) } }).toArray();
if (precheck.length) throw new Error(`Conflict username: ${precheck.map((u) => u.unameLower).join(', ')}`);
for (const udoc of udocs) {
if (randomMail === 'always') delete udoc.email;
if (randomMail === 'always' || !udoc.email.includes('@')) delete udoc.email;
let current = await UserModel.getByEmail(domainId, udoc.email || `${udoc.username}@syzoj.local`);
current ||= await UserModel.getByUname(domainId, udoc.username);
if (current && randomMail === 'needed') {
@@ -147,6 +147,7 @@ export async function run({
displayName: udoc.nickname || '',
nSubmit: udoc.submit_num,
nAccept: udoc.ac_num,
join: true,
});
}
}
@@ -359,7 +360,7 @@ export async function run({
}
}
}
if (rdoc.type) {
if (rdoc.type && rdoc.type_info && tidMap[rdoc.type_info]) {
data.contest = new ObjectId(tidMap[rdoc.type_info]);
await ContestModel.attend(domainId, data.contest, uidMap[rdoc.user_id]).catch(noop);
}
+1 -1
View File
@@ -118,7 +118,7 @@ export function apply(ctx: Context) {
let oid = 1;
for (const i of organizations) orgId[i] = `org-${oid++}`;
const duration = moment(tdoc.endAt).diff(tdoc.beginAt, 'seconds');
const lockDuration = tdoc.lockAt ? moment(tdoc.lockAt).diff(tdoc.beginAt, 'seconds') : null;
const lockDuration = tdoc.lockAt ? moment(tdoc.endAt).diff(tdoc.lockAt, 'seconds') : null;
const eventfeed: Record<string, any>[] = [
getFeed('contest', {
id: tdoc._id.toHexString(),
+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "@hydrooj/onsite-toolkit",
"version": "0.1.0-beta.1",
"version": "0.1.0-beta.2",
"dependencies": {
"@react-spring/web": "^9.7.5",
"@react-spring/web": "^10.0.1",
"react-use": "^17.6.0"
}
}
@@ -7,10 +7,10 @@ const highlighterPage = new AutoloadPage('highlighterPage', () => {
function runHighlight($container) {
$container.find('pre code').get().forEach((code) => {
const language = ($(code).attr('class') || '').trim();
if (language.includes('line-numbers')) $(code).parent().addClass('line-numbers');
const m = language.match(/language-([a-z0-9]+)(\|[\d,-]+)/);
if (m?.[2]) {
$(code).parent().attr('data-line', m[2].substring(1));
if (language.includes('line-numbers')) $(code).parent().addClass('line-numbers');
$(code).attr('class', `language-${m[1]}`);
}
});
@@ -64,10 +64,7 @@ let scratchpad: ScratchpadService;
export default function ScratchpadContainer() {
const store = useStore();
if (!scratchpad) {
scratchpad = new ScratchpadService(store);
ctx.provide('scratchpad', scratchpad);
}
scratchpad ||= new ScratchpadService(store);
const [, updateState] = React.useState<any>();
const forceUpdate = React.useCallback(() => updateState({}), []);
React.useEffect(() => {
+1 -1
View File
@@ -76,7 +76,7 @@ class MarkdownHandler extends Handler {
class SystemConfigSchemaHandler extends Handler {
async get() {
const schema = convert(Schema.intersect(this.ctx.config.settings) as any, true);
const schema = convert(Schema.intersect(this.ctx.setting.settings) as any, true);
this.response.body = schema;
}
}
+1
View File
@@ -4,6 +4,7 @@ __langname: 简体中文
'{0} comments': '{0} 条评论'
'{0} discussions': '{0} 条讨论'
'{0} is a moderator of this domain.': '{}是这个域的管理者之一。'
'{0} is a superuser.': '{0}是超级管理员。'
'{0} limit exceeded (limit: {2} operations in {1} seconds).': '{0} 超过频率限制。限制:{1} 秒内最多 {2} 次操作。'
'{0} problems': '{0} 道题'
'{0} sections': '{0} 小节'
+22 -23
View File
@@ -1,6 +1,6 @@
{
"name": "@hydrooj/ui-default",
"version": "4.58.0-beta.2",
"version": "4.58.0-beta.3",
"author": "undefined <i@undefined.moe>",
"license": "AGPL-3.0",
"main": "index.ts",
@@ -12,17 +12,17 @@
},
"devDependencies": {
"@blueprintjs/core": "^5.19.0",
"@fontsource/dm-mono": "^5.2.5",
"@fontsource/dm-mono": "^5.2.6",
"@fontsource/fira-code": "^5.2.6",
"@fontsource/inconsolata": "^5.2.5",
"@fontsource/jetbrains-mono": "^5.2.5",
"@fontsource/pt-mono": "^5.2.5",
"@fontsource/roboto-mono": "^5.2.5",
"@fontsource/source-code-pro": "^5.2.5",
"@fontsource/ubuntu-mono": "^5.2.5",
"@fontsource/inconsolata": "^5.2.6",
"@fontsource/jetbrains-mono": "^5.2.6",
"@fontsource/pt-mono": "^5.2.6",
"@fontsource/roboto-mono": "^5.2.6",
"@fontsource/source-code-pro": "^5.2.6",
"@fontsource/ubuntu-mono": "^5.2.6",
"@hydrooj/utils": "workspace:^",
"@sentry/browser": "^9.17.0",
"@sentry/webpack-plugin": "^3.4.0",
"@sentry/browser": "^9.27.0",
"@sentry/webpack-plugin": "^3.5.0",
"@simplewebauthn/browser": "13.1.0",
"@svgr/webpack": "^8.1.0",
"@types/html-to-text": "^9.0.4",
@@ -34,8 +34,8 @@
"@types/nunjucks": "^3.2.6",
"@types/pickadate": "^3.5.35",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.1.3",
"@types/react-dom": "^19.1.3",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@types/redux-logger": "^3.0.13",
"@types/uuid": "^10.0.0",
"@types/webpack-env": "^1.18.8",
@@ -49,12 +49,12 @@
"clean-webpack-plugin": "^4.0.0",
"clipboard": "^2.0.11",
"copy-webpack-plugin": "^13.0.0",
"cordis": "4.0.0-beta.2",
"core-js": "^3.42.0",
"core-js-compat": "^3.42.0",
"cordis": "4.0.0-beta.5",
"core-js": "^3.43.0",
"core-js-compat": "^3.43.0",
"css-loader": "^7.1.2",
"diff": "^7.0.0",
"diff-dom": "^5.1.4",
"diff": "^8.0.2",
"diff-dom": "^5.2.0",
"echarts": "^5.6.0",
"emojis-keywords": "2.0.0",
"emojis-list": "2.1.0",
@@ -66,7 +66,7 @@
"jquery.easing": "^1.4.1",
"jquery.transit": "^0.9.12",
"matchmedia-polyfill": "^0.3.2",
"md-editor-rt": "^5.5.1",
"md-editor-rt": "^5.6.1",
"mini-css-extract-plugin": "^2.9.2",
"moment": "^2.30.1",
"monaco-editor": "0.52.2",
@@ -98,10 +98,9 @@
"redux-promise-middleware": "^6.2.0",
"redux-thunk": "^3.1.0",
"rupture": "^0.7.1",
"sass": "^1.87.0",
"sass": "^1.89.1",
"sass-loader": "^16.0.5",
"schemastery": "^3.16.1",
"schemastery-react": "^0.1.2",
"shorty.js": "^1.0.1",
"slideout": "^1.0.1",
"sticky-kit": "^1.1.3",
@@ -115,15 +114,15 @@
"timeago.js": "^4.0.2",
"ts-loader": "^9.5.2",
"web-streams-polyfill": "^4.1.0",
"webpack": "^5.99.8",
"webpack": "^5.99.9",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-dev-server": "^5.2.1",
"webpack-dev-server": "^5.2.2",
"webpack-manifest-plugin": "^5.0.1",
"webpackbar": "^7.0.0"
},
"dependencies": {
"@hydrooj/common": "workspace:^",
"ansi_up": "^6.0.5",
"ansi_up": "^6.0.6",
"cssfilter": "^0.0.11",
"esbuild": "0.25.2",
"fs-extra": "^11.3.0",
@@ -7,6 +7,10 @@ import { NamedPage } from 'vj/misc/Page';
import { i18n, request } from 'vj/utils';
const page = new NamedPage('manage_config', async () => {
if (document.documentElement.className.includes('theme--dark')) {
document.documentElement.className += ' dark';
}
const [{ ConfigEditor, ComponentsProvider }, { load }] = await Promise.all([
import('@hydrooj/components'),
import('vj/components/monaco/loader'),
@@ -35,6 +39,7 @@ const page = new NamedPage('manage_config', async () => {
onSave={(value) => {
request.post('', { value }).then(() => {
Notification.success(i18n('Changes saved successfully'));
window.location.reload();
}).catch((e) => {
Notification.error(i18n('Failed to save changes:'), e.message);
});
+1
View File
@@ -95,6 +95,7 @@ function shouldCachePath(path: string) {
if (!path.split('?')[0].split('/').pop()) return false;
if (!path.split('?')[0].split('/').pop().includes('.')) return false;
if (process.env.NODE_ENV !== 'production' && (path.includes('.hot-update.') || path.includes('?version='))) return false;
if (path.includes('?v=')) return false;
return true;
}
function shouldCache(request: Request) {
@@ -142,12 +142,12 @@
{% if handler.user.hasPerm(reply_post_perm) %}
<a href="javascript:;" data-tooltip="{{ _('Reply') }}" data-op="reply" data-type="reply"><span class="icon icon-reply"></span></a>
{% endif %}
{% if (handler.user.hasPerm(reply_edit_self_perm) and handler.user.own(doc)) or handler.user.hasPerm(reply_edit_perm) %}
{% if (handler.user.hasPerm(reply_edit_self_perm) and handler.user.own(rdoc)) or handler.user.hasPerm(reply_edit_perm) %}
<a href="javascript:;" data-tooltip="{{ _('Edit') }}" data-op="edit" data-type="reply"
data-form="{{ {operation: reply_edit_op, psid: doc._id, psrid: rdoc._id}|json }}"
><span class="icon icon-edit"></span></a>
{% endif %}
{% if (handler.user.hasPerm(reply_delete_self_perm) and handler.user.own(doc)) or handler.user.hasPerm(reply_delete_perm) %}
{% if (handler.user.hasPerm(reply_delete_self_perm) and handler.user.own(rdoc)) or handler.user.hasPerm(reply_delete_perm) %}
<a href="javascript:;" data-tooltip="{{ _('Delete') }}" data-op="delete" data-type="reply"
data-form="{{ {operation: reply_delete_op, psid: doc._id, psrid: rdoc._id}|json }}"
><span class="icon icon-delete"></span></a>
@@ -25,7 +25,7 @@
<li class="footer__category__item"><a href="https://github.com/hydro-dev/Hydro" target="_blank">{{ _('Open Source') }}</a></li>
</ul></div>
</div>
<div class="medium-3 large-2 columns footer__category expandable end">
<div class="medium-3 large-2 columns footer__category expandable">
<h1>
{{ _('Support') }}
<span class="expand-icon">
@@ -34,7 +34,21 @@
</h1>
<div class="footer__category__expander"><ul class="footer__category__list">
<li class="footer__category__item"><a href="{{ url('wiki_help') }}">{{ _('Help') }}</a></li>
<li class="footer__category__item"><a href="{{ url('wiki_about', anchor='contact') }}">{{ _('QQ Group') }}</a></li>
<li class="footer__category__item"><a href="{{ url('wiki_about', anchor='contact') }}">{{ _('Contact Us') }}</a></li>
</ul></div>
</div>
<div class="medium-3 large-2 columns footer__category expandable end">
<h1>
{{ _('About') }}
<span class="expand-icon">
<span class="icon icon-expand_more"></span>
</span>
</h1>
<div class="footer__category__expander"><ul class="footer__category__list">
<li class="footer__category__item"><a href="{{ url('wiki_about') }}">{{ _('About') }}</a></li>
<li class="footer__category__item"><a href="{{ url('wiki_about', anchor='privacy') }}">{{ _('Privacy') }}</a></li>
<li class="footer__category__item"><a href="{{ url('wiki_about', anchor='tos') }}">{{ _('Terms of Service') }}</a></li>
<li class="footer__category__item"><a href="{{ url('wiki_about', anchor='contact') }}">{{ _('Copyright Complaint') }}</a></li>
</ul></div>
</div>
</div>
@@ -42,11 +56,13 @@
<div class="footer__extra-link clearfix">
<div class="footer__extra-left">
<ol class="clearfix">
{% if not show_topics|default(true) %}
<li class="footer__extra-link-item"><a href="{{ url('wiki_about') }}">{{ _('About') }}</a></li>
<li class="footer__extra-link-item"><a href="{{ url('wiki_about', anchor='contact') }}">{{ _('Contact Us') }}</a></li>
<li class="footer__extra-link-item"><a href="{{ url('wiki_about', anchor='privacy') }}">{{ _('Privacy') }}</a></li>
<li class="footer__extra-link-item"><a href="{{ url('wiki_about', anchor='tos') }}">{{ _('Terms of Service') }}</a></li>
<li class="footer__extra-link-item"><a href="{{ url('wiki_about', anchor='contact') }}">{{ _('Copyright Complaint') }}</a></li>
{% endif %}
<li class="footer__extra-link-item nojs--hide" data-dropdown-target="#menu-footer-lang">
<span><span class="icon icon-global"></span> Language <span class="icon icon-expand_less"></span></span>
<ol class="dropdown-target menu" id="menu-footer-lang">
+4 -4
View File
@@ -8,14 +8,14 @@
"preview": "vite preview"
},
"dependencies": {
"@vitejs/plugin-react": "^4.4.1",
"@vitejs/plugin-react": "^4.5.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^19.1.3",
"@types/react-dom": "^19.1.3",
"@vitejs/plugin-react-swc": "^3.9.0",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react-swc": "^3.10.1",
"vite": "^6.3.5"
}
}
+14 -4
View File
@@ -5,8 +5,7 @@ import { Duplex, PassThrough, Writable } from 'stream';
import { inspect } from 'util';
import type { Entry, ZipReader } from '@zip.js/zip.js';
import fs from 'fs-extra';
import moment, { isMoment, Moment } from 'moment-timezone';
import { ObjectId } from 'mongodb';
import type { Moment } from 'moment';
import { Exporter, Factory, Logger as Reggol } from 'reggol';
import type * as superagent from 'superagent';
export * as yaml from 'js-yaml';
@@ -25,8 +24,8 @@ factory.addExporter(new Exporter.Console({
margin: 1,
},
timestamp: Date.now(),
levels: { default: process.env.DEV ? 3 : 2 },
}));
factory.exporters.get(1).levels = { default: process.env.DEV ? 3 : 2 };
function createLogger(name: string) {
return factory.createLogger(name);
@@ -34,7 +33,6 @@ function createLogger(name: string) {
export type Logger = Reggol & { new(name: string): Reggol & Logger };
export const Logger = createLogger as any as Logger;
export { moment };
const encrypt = (algorithm, content) => crypto.createHash(algorithm).update(content).digest('hex');
export const sha1 = (content: string) => encrypt('sha1', content);
@@ -146,6 +144,18 @@ export namespace Time {
}
export function getObjectID(timestamp: string | Date | Moment, allZero = true) {
let isMoment: (x: any) => x is Moment;
let ObjectId: typeof import('bson').ObjectId; // eslint-disable-line
try {
({ ObjectId } = require('bson'));
} catch (e) {
throw new Error('No bson module found');
}
try {
({ isMoment } = require('moment'));
} catch (e) {
throw new Error('No moment module found');
}
let _timestamp: number;
if (typeof timestamp === 'string') _timestamp = new Date(timestamp).getTime();
else if (isMoment(timestamp)) _timestamp = timestamp.toDate().getTime();
+6 -4
View File
@@ -1,22 +1,24 @@
{
"name": "@hydrooj/utils",
"version": "1.4.34-beta.2",
"version": "1.4.34-beta.3",
"description": "hydrooj utils",
"main": "lib/utils.ts",
"repository": "https://github.com/hydro-dev/Hydro.git",
"author": "undefined <i@undefined.moe>",
"license": "AGPL-3.0-or-later",
"preferUnplugged": true,
"optionalPeerDependencies": {
"bson": "^6.17.0",
"moment-timezone": "^0.5.48"
},
"dependencies": {
"@hydrooj/register": "workspace:^",
"cac": "^6.7.14",
"fs-extra": "^11.3.0",
"js-yaml": "^4.1.0",
"moment-timezone": "^0.5.48",
"mongodb": "^6.16.0",
"reggol": "^2.1.0",
"search-query-parser": "^1.6.0",
"systeminformation": "^5.25.11"
"systeminformation": "^5.27.1"
},
"devDependencies": {
"@types/fs-extra": "^11.0.4"