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

+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'));
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
{
"name": "@hydrooj/a11y",
"version": "0.0.2"
"version": "0.0.4",
"repository": "https://github.com/hydro-dev/Hydro"
}
+9 -9
View File
@@ -1,10 +1,10 @@
import {
_, Context, DiscussionNotFoundError, DocumentModel, Filter,
Handler, NumberKeys, ObjectId, OplogModel, paginate,
Handler, NumberKeys, ObjectId, OplogModel,
param, PRIV, Types, UserModel,
} from 'hydrooj';
export const TYPE_BLOG: 70 = 70;
export const TYPE_BLOG = 70 as const;
export interface BlogDoc {
docType: 70;
docId: ObjectId;
@@ -116,7 +116,7 @@ class BlogUserHandler extends BlogHandler {
@param('uid', Types.Int)
@param('page', Types.PositiveInt, true)
async get(domainId: string, uid: number, page = 1) {
const [ddocs, dpcount] = await paginate(
const [ddocs, dpcount] = await this.ctx.db.paginate(
BlogModel.getMulti({ owner: uid }),
page,
10,
@@ -134,7 +134,7 @@ class BlogUserHandler extends BlogHandler {
class BlogDetailHandler extends BlogHandler {
@param('did', Types.ObjectId)
async get(domainId: string, did: ObjectId) {
async get({ domainId }, did: ObjectId) {
const dsdoc = this.user.hasPriv(PRIV.PRIV_USER_PROFILE)
? await BlogModel.getStatus(did, this.user._id)
: null;
@@ -156,13 +156,13 @@ class BlogDetailHandler extends BlogHandler {
}
@param('did', Types.ObjectId)
async postStar(domainId: string, did: ObjectId) {
async postStar({ }, did: ObjectId) {
await BlogModel.setStar(did, this.user._id, true);
this.back({ star: true });
}
@param('did', Types.ObjectId)
async postUnstar(domainId: string, did: ObjectId) {
async postUnstar({ }, did: ObjectId) {
await BlogModel.setStar(did, this.user._id, false);
this.back({ star: false });
}
@@ -176,7 +176,7 @@ class BlogEditHandler extends BlogHandler {
@param('title', Types.Title)
@param('content', Types.Content)
async postCreate(domainId: string, title: string, content: string) {
async postCreate({ }, title: string, content: string) {
await this.limitRate('add_blog', 3600, 60);
const did = await BlogModel.add(this.user._id, title, content, this.request.ip);
this.response.body = { did };
@@ -186,7 +186,7 @@ class BlogEditHandler extends BlogHandler {
@param('did', Types.ObjectId)
@param('title', Types.Title)
@param('content', Types.Content)
async postUpdate(domainId: string, did: ObjectId, title: string, content: string) {
async postUpdate({ }, did: ObjectId, title: string, content: string) {
if (!this.user.own(this.ddoc!)) this.checkPriv(PRIV.PRIV_EDIT_SYSTEM);
await Promise.all([
BlogModel.edit(did, title, content),
@@ -197,7 +197,7 @@ class BlogEditHandler extends BlogHandler {
}
@param('did', Types.ObjectId)
async postDelete(domainId: string, did: ObjectId) {
async postDelete({ }, did: ObjectId) {
if (!this.user.own(this.ddoc!)) this.checkPriv(PRIV.PRIV_EDIT_SYSTEM);
await Promise.all([
BlogModel.del(did),
+2 -1
View File
@@ -1,5 +1,6 @@
{
"name": "@hydrooj/blog",
"version": "0.0.4",
"version": "0.1.0-beta.2",
"repository": "https://github.com/hydro-dev/Hydro",
"license": "AGPL-3.0-or-later"
}
+1 -1
View File
@@ -11,6 +11,6 @@
},
"dependencies": {
"crypto-js": "^4.2.0",
"semver": "^7.6.3"
"semver": "^7.7.2"
}
}
@@ -1,7 +1,8 @@
import { findFileSync, parseMemoryMB, parseTimeMS } from './utils';
import { findFileSync, parseMemoryMB, parseTimeMS } from '@hydrooj/utils';
import { CompilableSource, ProblemConfigFile } from './types';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export default async function readYamlCases(cfg: Record<string, any> = {}, checkFile = (s: string, errMsg: string) => s) {
export async function readYamlCases(cfg: ProblemConfigFile = {}, checkFile = (s: CompilableSource, errMsg: string) => s) {
const config: any = {
checker_type: cfg.checker_type || 'default',
judge_extra_files: [],
@@ -13,12 +14,14 @@ export default async function readYamlCases(cfg: Record<string, any> = {}, check
}
} else {
if (cfg.checker) {
if (!cfg.checker.includes('.')) {
config.checker = findFileSync(`@hydrooj/hydrojudge/vendor/testlib/checkers/${cfg.checker}.cpp`, false);
const name = typeof cfg.checker === 'string' ? cfg.checker : cfg.checker.file;
if (!name.includes('.')) {
config.checker = findFileSync(`@hydrooj/hydrojudge/vendor/testlib/checkers/${name}.cpp`, false);
}
config.checker ||= checkFile(cfg.checker, 'Cannot find checker {0}.');
}
if (cfg.interactor) config.interactor = checkFile(cfg.interactor, 'Cannot find interactor {0}.');
if (cfg.manager) config.manager = checkFile(cfg.manager, 'Cannot find Manager {0}.');
if (cfg.validator) config.validator = checkFile(cfg.validator, 'Cannot find validator {0}.');
for (const n of ['judge', 'user']) {
const conf = cfg[`${n}_extra_files`];
@@ -38,22 +41,3 @@ export default async function readYamlCases(cfg: Record<string, any> = {}, check
if (cfg.memory) config.memory = parseMemoryMB(cfg.memory);
return Object.assign(cfg, config);
}
export function convertIniConfig(ini: string) {
const f = ini.split('\n');
const count = parseInt(f[0], 10);
const res = { subtasks: [] };
for (let i = 1; i <= count; i++) {
if (!f[i]?.trim()) throw new Error('Testdata count incorrect.');
const [input, output, time, score, memory] = f[i].split('|');
const cur = {
cases: [{ input, output }],
score: parseInt(score, 10),
time: `${time}s`,
memory: '256m',
};
if (!Number.isNaN(parseInt(memory, 10))) cur.memory = `${Math.floor(parseInt(memory, 10) / 1024)}m`;
res.subtasks.push(cur);
}
return res;
}
+4
View File
@@ -0,0 +1,4 @@
export * from './lang';
export * from './status';
export * from './types';
export * from './subtask';
@@ -17,8 +17,7 @@ export interface LangConfig {
hidden: boolean;
isBinary?: boolean;
analysis?: string;
/** @deprecated */
remote?: string;
remote: boolean;
validAs?: Record<string, string>;
/** @deprecated */
pretest?: string | false;
@@ -51,6 +50,7 @@ export function parseLang(config: string): Record<string, LangConfig> {
entry.code_file ||= `foo.${key}`;
entry.execute ||= '/w/foo';
entry.key = key;
entry.remote = !!entry.remote;
entry.hidden ||= false;
entry.disabled ||= false;
entry.isBinary ||= false;
+10
View File
@@ -0,0 +1,10 @@
{
"name": "@hydrooj/common",
"main": "./index.ts",
"version": "0.0.3",
"repository": "https://github.com/hydro-dev/Hydro",
"sideEffects": false,
"dependencies": {
"@hydrooj/utils": "workspace:^"
}
}
@@ -79,6 +79,16 @@ export const STATUS_CODES: Record<STATUS, string> = {
[STATUS.STATUS_HACK_UNSUCCESSFUL]: 'fail',
};
export const NORMAL_STATUS: STATUS[] = [
STATUS.STATUS_ACCEPTED,
STATUS.STATUS_WRONG_ANSWER,
STATUS.STATUS_TIME_LIMIT_EXCEEDED,
STATUS.STATUS_MEMORY_LIMIT_EXCEEDED,
STATUS.STATUS_OUTPUT_LIMIT_EXCEEDED,
STATUS.STATUS_RUNTIME_ERROR,
STATUS.STATUS_COMPILE_ERROR,
];
export function getScoreColor(score: number | string): string {
if (score === null || score === undefined || !Number.isFinite(+score)) return '#000000';
return [
+176
View File
@@ -0,0 +1,176 @@
import { parseMemoryMB, parseTimeMS } from '@hydrooj/utils/lib/common';
export function convertIniConfig(ini: string) {
const f = ini.split('\n');
const count = parseInt(f[0], 10);
const res = { subtasks: [] };
for (let i = 1; i <= count; i++) {
if (!f[i]?.trim()) throw new Error('Testdata count incorrect.');
const [input, output, time, score, memory] = f[i].split('|');
const cur = {
cases: [{ input, output }],
score: parseInt(score, 10),
time: `${time}s`,
memory: '256m',
};
if (!Number.isNaN(parseInt(memory, 10))) cur.memory = `${Math.floor(parseInt(memory, 10) / 1024)}m`;
res.subtasks.push(cur);
}
return res;
}
interface MatchRule {
regex: RegExp;
output: (a: RegExpExecArray) => string[];
id: (a: RegExpExecArray) => number;
subtask: (a: RegExpExecArray) => number;
preferredScorerType: (a: RegExpExecArray) => 'min' | 'max' | 'sum';
}
const SubtaskMatcher: MatchRule[] = [
{
regex: /^(([A-Za-z0-9._-]*?)(?:(\d*)[-_])?(\d+))\.(in|IN|txt|TXT|in\.txt|IN\.TXT)$/,
output: (a) => ['out', 'ans']
.flatMap((i) => [i, i.toUpperCase(), `${i}.txt`, `${i.toUpperCase()}.TXT`])
.flatMap((i) => [`${a[1]}.${i}`, `${a[1]}.${i}`.replace(/input/g, 'output').replace(/INPUT/g, 'OUTPUT')])
.concat(a[1].includes('input') ? `${a[1]}.txt`.replace(/input/g, 'output') : null),
id: (a) => +a[4],
subtask: (a) => +(a[3] || 1),
preferredScorerType: (a) => (a[3] ? 'min' : 'sum'),
},
{
regex: /^([^\d]*)\.(in|IN)(\d+)$/,
output: (a) => [
`${a[1]}.${a[2] === 'in' ? 'ou' : 'OU'}${a[3]}`,
`${a[1]}.${a[2] === 'in' ? 'out' : 'OUT'}${a[3]}`,
].flatMap((i) => [i, i.replace(/input/g, 'output').replace(/INPUT/g, 'OUTPUT')]),
id: (a) => +a[2],
subtask: () => 1,
preferredScorerType: () => 'sum',
},
{
regex: /^([^\d]*)([0-9]+)([-_])([0-9]+)\.(in|IN)$/,
output: (a) => ['out', 'ans', 'OUT', 'ANS'].flatMap((i) => `${a[1]}${a[2]}${a[3]}${a[4]}.${i}`),
id: (a) => +a[4],
subtask: (a) => +a[2],
preferredScorerType: () => 'min',
},
{
regex: /^(([0-9]+)[-_](?:.*))\.(in|IN)$/,
output: (a) => ['out', 'ans', 'OUT', 'ANS'].flatMap((i) => `${a[1]}.${i}`),
id: (a) => +a[2],
subtask: () => 1,
preferredScorerType: () => 'sum',
},
];
function* getScore(totalScore: number, count: number) {
const base = Math.floor(totalScore / count);
const extra = count - (totalScore % count);
for (let i = 0; i < count; i++) {
if (i >= extra) yield base + 1;
else yield base;
}
}
interface ParsedCase {
id?: number;
time?: number | string;
memory?: number | string;
score?: number;
input?: string;
output?: string;
}
interface ParsedSubtask {
cases: ParsedCase[];
type: 'min' | 'max' | 'sum';
time?: number | string;
memory?: number | string;
score?: number;
id?: number;
if?: number[];
}
export function readSubtasksFromFiles(files: string[], config) {
const subtask: Record<number, ParsedSubtask> = {};
for (const s of config.subtasks || []) if (s.id) subtask[s.id] = s;
for (const file of files) {
let match = false;
for (const rule of SubtaskMatcher) {
const data = rule.regex.exec(file);
if (!data) continue;
const sid = rule.subtask(data);
const c = { input: file, output: '', id: rule.id(data) };
const type = rule.preferredScorerType(data);
const outputs = (config.noOutputFile ? ['/dev/null'] : rule.output(data)).filter((i) => i);
for (const output of outputs) {
if (output === file) continue;
if (output === '/dev/null' || files.includes(output)) {
match = true;
c.output = output;
if (!subtask[sid]) {
subtask[sid] = {
time: config.time,
memory: config.memory,
type,
cases: [c],
id: sid,
};
} else if (!subtask[sid].cases) subtask[sid].cases = [c];
else subtask[sid].cases.push(c);
break;
}
}
if (match) break;
}
}
return Object.values(subtask);
}
export interface NormalizedCase extends Required<ParsedCase> {
time: number;
memory: number;
}
export interface NormalizedSubtask extends Required<ParsedSubtask> {
cases: NormalizedCase[];
time: number;
memory: number;
}
export function normalizeSubtasks(
subtasks: ParsedSubtask[], checkFile: (name: string, errMsg: string) => string,
time: number | string = '1000ms', memory: number | string = '256m', ignoreParseError = false,
timeRate = 1, memoryRate = 1,
): NormalizedSubtask[] {
subtasks.sort((a, b) => (a.id - b.id));
const subtaskScore = getScore(
Math.max(100 - Math.sum(subtasks.map((i) => i.score || 0)), 0),
subtasks.filter((i) => !i.score).length,
);
return subtasks.map((s, id) => {
s.cases.sort((a, b) => (a.id - b.id));
const score = s.score || subtaskScore.next().value as number;
const caseScore = getScore(
Math.max(score - Math.sum(s.cases.map((i) => i.score || 0)), 0),
s.cases.filter((i) => !i.score).length,
);
return {
id: id + 1,
type: 'min',
if: [],
...s,
score,
time: parseTimeMS(s.time || time, !ignoreParseError) * timeRate,
memory: parseMemoryMB(s.memory || memory, !ignoreParseError) * memoryRate,
cases: s.cases.map((c, index) => ({
id: index + 1,
...c,
score: c.score || (s.type === 'sum' ? caseScore.next().value as number : score),
time: parseTimeMS(c.time || s.time || time, !ignoreParseError) * timeRate,
memory: parseMemoryMB(c.memory || s.memory || memory, !ignoreParseError) * memoryRate,
input: c.input ? checkFile(c.input, 'Cannot find input file {0}.') : '/dev/null',
output: c.output ? checkFile(c.output, 'Cannot find output file {0}.') : '/dev/null',
})) as NormalizedCase[],
};
});
}
@@ -1,6 +1,6 @@
import { expect } from 'chai';
import { describe, it } from 'node:test';
import { readSubtasksFromFiles } from '../lib/common';
import { readSubtasksFromFiles } from '../subtask';
interface Info {
subtask: number;
+171
View File
@@ -0,0 +1,171 @@
export type CompilableSource = string | {
file: string;
lang: string;
};
export enum ProblemType {
Default = 'default',
SubmitAnswer = 'submit_answer',
Interactive = 'interactive',
Communication = 'communication',
Objective = 'objective',
Remote = 'remote_judge',
}
export interface TestCaseConfig {
input: string;
output: string;
time?: string;
memory?: string;
score?: number;
}
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 type DetailType = 'full' | 'case' | 'none';
export interface ProblemConfigFile {
type?: ProblemType;
subType?: string;
target?: string;
score?: number;
time?: string;
memory?: string;
filename?: string;
checker_type?: string;
num_processes?: number;
user_extra_files?: string[];
judge_extra_files?: string[];
detail?: DetailType | boolean;
answers?: Record<string, [string | string[], number]>;
redirect?: string;
cases?: TestCaseConfig[];
subtasks?: SubtaskConfig[];
langs?: string[];
checker?: CompilableSource;
interactor?: CompilableSource;
manager?: CompilableSource;
validator?: CompilableSource;
time_limit_rate?: Record<string, number>;
memory_limit_rate?: Record<string, number>;
}
export interface FileInfo {
/** storage path */
_id: string,
/** filename */
name: string,
/** file size (in bytes) */
size: number,
etag: string,
lastModified: Date,
}
export interface JudgeMeta {
problemOwner: number;
hackRejudge?: string;
rejudge?: boolean | 'controlled';
// FIXME stricter types
type?: string;
}
export interface RecordJudgeInfo {
score: number;
memory: number;
time: number;
judgeTexts: (string | JudgeMessage)[];
compilerTexts: string[];
testCases: Required<TestCase>[];
/** judge uid */
judger: number;
judgeAt: Date;
status: number;
subtasks?: Record<number, SubtaskResult>;
}
export interface RecordPayload extends RecordJudgeInfo {
domainId: string;
pid: number;
uid: number;
lang: string;
code: string;
rejudged: boolean;
source?: string;
progress?: number;
/** pretest */
input?: string;
/** hack target rid */
hackTarget?: string;
/** 0 if pretest&script */
contest?: string;
files?: Record<string, string>
}
export interface JudgeRequest extends Omit<RecordPayload, 'testCases'> {
priority: number;
type: 'judge' | 'generate';
rid: string;
config: ProblemConfigFile;
meta: JudgeMeta;
data: FileInfo[];
source: string;
trusted: boolean;
}
export interface TestCase {
id?: number;
subtaskId?: number;
score?: number;
time: number;
memory: number;
status: number;
message: string;
}
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: string;
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>;
}
@@ -0,0 +1,187 @@
import 'schemastery-react/lib/schemastery-react.css';
import { Allotment } from 'allotment';
import { diffLines } from 'diff';
import yaml from 'js-yaml';
import React from 'react';
import { createSchemasteryReact } from 'schemastery-react';
import { ComponentsContext } from './provider';
const locales = {
zh: 'zh-CN',
en: 'en-US',
};
function MonacoContainer({
config, setValue, setError, monaco, schema, editorCallback, size, dark,
}) {
const [editor, setEditor] = React.useState<any>(null);
const [model, setModel] = React.useState<any>(null);
const [initialized, setInitialized] = React.useState(false);
const { codeFontFamily } = React.useContext(ComponentsContext);
React.useEffect(() => {
if (!monaco) return;
const interval = setInterval(() => {
monaco.editor.remeasureFonts();
}, 1000);
return () => clearInterval(interval); // eslint-disable-line consistent-return
}, [monaco]);
React.useEffect(() => {
const listener = () => {
if (!editor) return;
editor.layout();
};
window.addEventListener('resize', listener);
return () => window.removeEventListener('resize', listener);
}, [editor]);
React.useEffect(() => {
if (!editor) return;
editor.layout();
}, [size]);
React.useEffect(() => {
if (!editor || !model) return;
const decoration = editor.createDecorationsCollection();
const updateDecoration = () => {
const found = model.findMatches("'[hidden]'", true, false, true, '', true).map((i) => i.range);
decoration.set(found.map((range) => ({ range, options: { inlineClassName: 'decoration-hide' } })));
};
updateDecoration();
const disposable = editor.onDidChangeModelContent(() => {
const val = editor.getValue({ lineEnding: '\n', preserveBOM: false });
updateDecoration();
try {
const loaded = yaml.load(val);
schema(loaded);
setValue(val);
setError('');
} catch (err) {
setError(err.message);
}
});
return () => disposable.dispose(); // eslint-disable-line
}, [editor, model, setValue, setError, schema]);
const initializeEditor = React.useCallback((element) => {
if (!element || initialized) return;
setInitialized(true);
// eslint-disable-next-line
const model = monaco.editor.createModel(config, 'yaml', monaco.Uri.parse('hydro://system/setting.yaml'));
setModel(model);
const e = monaco.editor.create(element, {
theme: dark ? 'vs-dark' : 'vs-light',
lineNumbers: 'off',
glyphMargin: true,
lightbulb: { enabled: monaco.editor.ShowLightbulbIconMode.On },
model,
minimap: { enabled: false },
hideCursorInOverviewRuler: true,
overviewRulerLanes: 0,
overviewRulerBorder: false,
fontFamily: codeFontFamily,
fontLigatures: '',
unicodeHighlight: {
ambiguousCharacters: true,
},
});
editorCallback(e, model, element);
setEditor(e);
}, [config, monaco, editorCallback, codeFontFamily, initialized]);
React.useEffect(() => {
if (!editor) return;
const current = editor.getValue({ lineEnding: '\n', preserveBOM: false });
const diff = diffLines(current, config);
const ops: { range: any; text: string }[] = [];
let cursor = 1;
for (const line of diff) {
if (line.added) {
let range = model.getFullModelRange();
range = range.setStartPosition(cursor, 0);
range = range.setEndPosition(cursor, 0);
ops.push({ range, text: line.value });
} else if (line.removed) {
let range = model.getFullModelRange();
range = range.setStartPosition(cursor, 0);
cursor += line.count;
range = range.setEndPosition(cursor, 0);
ops.push({ range, text: '' });
} else cursor += line.count;
}
model.pushEditOperations([], ops, undefined);
}, [editor, config]);
return (
<div ref={initializeEditor} style={{
width: '100%', height: '500px',
}} />
);
}
export default function ConfigEditor({
schema, config, monaco, Markdown, onSave, registerAction, sidebar, dynamic,
}) {
const getValue = () => {
try {
return yaml.load(config);
} catch (e) {
return {};
}
};
const [value, setValue] = React.useState(getValue);
const [info, setInfo] = React.useState('');
const [stringConfig, setStringConfig] = React.useState(config);
const initial = React.useMemo(getValue, []);
const { i18n } = React.useContext(ComponentsContext);
const Form = React.useMemo(() => createSchemasteryReact({
locale: locales[i18n('__id')] || 'en-US',
Markdown,
}), []);
const updateFromForm = React.useCallback((v) => {
const newDump = yaml.dump(v);
if (newDump === stringConfig) return;
setStringConfig(newDump);
setValue(v);
}, [stringConfig]);
const updateFromMonaco = React.useCallback((v) => {
if (v === stringConfig) return;
setStringConfig(v);
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={{ 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>
</Allotment.Pane>
<Allotment.Pane>
<div style={{
overflowY: 'scroll', top: 0, bottom: 0, left: 0, right: 0, position: 'absolute', marginLeft: 10,
}}>
<Form schema={schema} initial={initial} value={value} onChange={updateFromForm} dynamic={dynamic} />
</div>
</Allotment.Pane>
{sidebar && <Allotment.Pane maxSize={220}>{sidebar}</Allotment.Pane>}
</Allotment>
</div>);
}
+14
View File
@@ -0,0 +1,14 @@
import { omit } from 'lodash';
import React from 'react';
export default function DomComponent(props: React.HTMLAttributes<HTMLDivElement> & { childDom: HTMLElement }) {
const [dom, setDom] = React.useState<HTMLElement | null>(null);
React.useEffect(() => {
if (!dom) return;
dom.appendChild(props.childDom);
return () => { // eslint-disable-line consistent-return
dom.removeChild(props.childDom);
};
}, [dom]);
return <div {...omit(props, 'childDom')} ref={setDom}></div>;
}
+20
View File
@@ -0,0 +1,20 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
export default function IconComponent(props: { name: string, className?: string } & React.HTMLAttributes<HTMLSpanElement>) {
const {
name,
className,
...rest
} = props;
const cn = classNames(className, `icon icon-${name}`);
return (
<span {...rest} className={cn} />
);
}
IconComponent.propTypes = {
name: PropTypes.string.isRequired,
className: PropTypes.string,
};
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
export function Row({ children }: { children: React.ReactNode }) {
return <div className="row">
{children}
</div>;
}
export function Column({ children, md }: { children: React.ReactNode, md?: number }) {
return <div className={`columns medium-${md || 12}`}>
{children}
</div>;
}
@@ -0,0 +1,26 @@
import { toast } from 'react-toastify';
export default class Notification {
type: string;
action: any;
$dom: JQuery<HTMLElement>;
$n: JQuery<HTMLElement>;
duration: number;
autoHideTimer?: NodeJS.Timeout;
static async success(message: string, duration?: number) {
return toast.success(message, { autoClose: duration });
}
static async info(message: string, duration?: number) {
return toast.info(message, { autoClose: duration });
}
static async warn(message: string, duration?: number) {
return toast.warning(message, { autoClose: duration });
}
static async error(message: string, duration?: number) {
return toast.error(message, { autoClose: duration });
}
}
@@ -1,14 +1,13 @@
/* eslint-disable jsx-a11y/role-supports-aria-props */
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import './autocomplete.scss';
import { debounce, uniqueId } from 'lodash';
import PropTypes from 'prop-types';
import React, {
forwardRef, useEffect,
useImperativeHandle, useRef, useState,
} from 'react';
import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import Icon from 'vj/components/react/IconComponent';
import Icon from '../Icon';
export interface AutoCompleteProps<Item> {
width?: string;
@@ -39,17 +38,13 @@ export interface AutoCompleteProps<Item> {
export interface AutoCompleteHandle<Item> {
getSelectedItems: () => Item[];
getSelectedItemKeys: () => string[];
getSelectedItemsAsString: () => string;
setSelectedItems: (items: Item[]) => void;
getQuery: () => string;
setQuery: (query: string) => void;
setSelectedKeys: (keys: string[]) => void;
triggerQuery: () => any;
closeList: () => void;
/** returns comma seperated value text */
getValue: () => string;
/** return value detail */
getValueArray: () => Item[];
clear: () => void;
focus: () => void;
}
@@ -84,7 +79,7 @@ function DraggableSelection({
const AutoComplete = forwardRef(function Impl<T>(props: AutoCompleteProps<T>, ref: React.Ref<AutoCompleteHandle<T>>) {
const {
multi = false, width = '100%', height = 'auto',
freeSolo = false, allowEmptyQuery = false, listStyle = {},
freeSolo = false, allowEmptyQuery = false, listStyle = {}, // eslint-disable-line @eslint-react/no-unstable-default-props
disabled = false, disabledHint = '', draggable = multi,
} = props;
const queryItems = props.queryItems ?? (() => []);
@@ -95,15 +90,14 @@ const AutoComplete = forwardRef(function Impl<T>(props: AutoCompleteProps<T>, re
const freeSoloConverter = freeSolo ? props.freeSoloConverter ?? ((i) => i) : ((i) => i);
const [focused, setFocused] = useState(false); // is focused
const [selected, setSelected] = useState([]); // selected items
const [selectedKeys, setSelectedKeys] = useState(props.selectedKeys || []); // keys of selected items
const [itemList, setItemList] = useState([]); // items list
const [currentItem, setCurrentItem] = useState(null); // index of current item (in item list)
const [rerender, setRerender] = useState(false);
const [draggableId] = useState(uniqueId());
const [draggableId] = useState(() => uniqueId());
const inputRef = useRef<HTMLInputElement>();
const listRef = useRef<HTMLUListElement>();
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
let [queryCache, valueCache] = [useRef({}).current, useRef({}).current];
if (props.cacheKey) {
@@ -153,24 +147,17 @@ const AutoComplete = forwardRef(function Impl<T>(props: AutoCompleteProps<T>, re
if (multi) {
const idx = selectedKeys.indexOf(key);
if (idx !== -1) {
setSelected((s) => {
const newSelected = [...s];
newSelected.splice(idx, 1);
return newSelected;
});
setSelectedKeys((s) => {
const newSelectedKeys = [...s];
newSelectedKeys.splice(idx, 1);
return newSelectedKeys;
});
} else {
setSelected((s) => [...s, item]);
setSelectedKeys((s) => [...s, key]);
}
if (!preserve) inputRef.current.value = '';
inputRef.current.focus();
} else {
setSelected([item]);
setSelectedKeys([key]);
inputRef.current.value = key;
}
@@ -202,7 +189,6 @@ const AutoComplete = forwardRef(function Impl<T>(props: AutoCompleteProps<T>, re
if (key === 'Backspace') {
if (target.value.length) return;
if (selectedKeys.length) {
setSelected((s) => s.slice(0, -1));
setSelectedKeys((s) => s.slice(0, -1));
}
return;
@@ -230,28 +216,24 @@ const AutoComplete = forwardRef(function Impl<T>(props: AutoCompleteProps<T>, re
};
useImperativeHandle(ref, () => ({
getSelectedItems: () => selected,
getSelectedItems: () => selectedKeys.map((key) => valueCache[key]),
getSelectedItemKeys: () => [...selectedKeys, inputRef.current?.value].filter((v) => v?.trim().length),
getSelectedItemsAsString: () => [...selectedKeys, inputRef.current?.value].filter((v) => v?.trim().length).join(','),
setSelectedItems: (items) => {
setSelected(items);
setSelectedKeys(items.map((i) => itemKey(i)));
if (!multi && inputRef.current) inputRef.current.value = items.map((i) => itemKey(i)).join(',');
},
setSelectedKeys,
getQuery: () => inputRef.current?.value,
setQuery: (query) => {
if (inputRef.current) inputRef.current.value = query;
},
setSelectedKeys: (keys) => setSelectedKeys(keys),
triggerQuery: () => queryList(inputRef.current?.value),
closeList: () => {
setItemList([]);
setCurrentItem(null);
},
getValue: () => (multi ? selectedKeys.join(',') : (inputRef.current.value ?? '')),
getValueArray: () => (multi ? selected : [inputRef.current?.value].filter((i) => !!i)),
clear: () => {
setSelected([]);
setSelectedKeys([]);
if (inputRef.current) inputRef.current.value = '';
},
@@ -259,7 +241,7 @@ const AutoComplete = forwardRef(function Impl<T>(props: AutoCompleteProps<T>, re
setFocused(true);
inputRef.current?.focus();
},
}), [selected, selectedKeys, inputRef, multi]);
}), [selectedKeys, inputRef, multi]);
const move = (dragId: string, hoverId: string) => {
if (dragId === hoverId || !draggable) return;
@@ -308,10 +290,9 @@ const AutoComplete = forwardRef(function Impl<T>(props: AutoCompleteProps<T>, re
e.preventDefault();
const ids = text.replace(/,/g, ',').split(',').filter((v) => v?.trim().length && !selectedKeys.includes(v));
if (!ids.length) return;
const fetched = await props.fetchItems(ids.filter((v) => !valueCache[v]));
const fetched = await props.fetchItems(ids);
for (const item of fetched) valueCache[itemKey(item)] = item;
setSelected([...selected, ...ids.map((id) => valueCache[id])]);
setSelectedKeys([...selectedKeys, ...ids.map((id) => itemKey(valueCache[id]))]);
setSelectedKeys([...selectedKeys, ...fetched.map((val) => itemKey(val))]);
}}
placeholder={props.placeholder}
onBlur={() => setFocused(false)}
@@ -329,58 +310,26 @@ const AutoComplete = forwardRef(function Impl<T>(props: AutoCompleteProps<T>, re
)}
{focused && itemList.length > 0 && (
<ul ref={listRef} className="autocomplete-list" style={listStyle} onMouseDown={(e) => e.preventDefault()}>
{itemList.map((item, idx) => (
<li
{itemList.map((item, idx) => {
const inner = renderItem(item);
if (!inner) return null;
return <li
key={itemKey(item)}
onClick={() => toggleItem(item)}
onMouseMove={() => setCurrentItem(idx)}
aria-selected={selectedKeys.includes(itemKey(item))}
data-selected={selectedKeys.includes(itemKey(item))}
data-focus={idx === currentItem}
>
<div>{renderItem(item)}</div>
<div>{inner}</div>
{selectedKeys.includes(itemKey(item)) && <Icon name="check" />}
</li>
))}
</li>;
})}
</ul>
)}
</div>
);
}) as (<T>(props: AutoCompleteProps<T> & { ref: React.Ref<AutoCompleteHandle<T>> }) => React.ReactElement) & React.FC;
AutoComplete.propTypes = {
width: PropTypes.string,
height: PropTypes.string,
disabled: PropTypes.bool,
disabledHint: PropTypes.string,
listStyle: PropTypes.object,
queryItems: PropTypes.func.isRequired,
itemKey: PropTypes.func,
renderItem: PropTypes.func,
itemText: PropTypes.func,
onChange: PropTypes.func.isRequired,
multi: PropTypes.bool,
selectedKeys: PropTypes.arrayOf(PropTypes.string),
allowEmptyQuery: PropTypes.bool,
freeSolo: PropTypes.bool,
freeSoloConverter: PropTypes.func,
placeholder: PropTypes.string,
};
AutoComplete.defaultProps = {
width: '100%',
height: 'auto',
disabled: false,
disabledHint: '',
listStyle: {},
renderItem: (item) => item,
itemText: (item) => item.toString(),
multi: false,
selectedKeys: [],
allowEmptyQuery: false,
freeSolo: false,
freeSoloConverter: (input) => input,
};
AutoComplete.displayName = 'AutoComplete';
export default AutoComplete;
@@ -1,3 +1,4 @@
import { defaults } from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import AutoComplete from './AutoComplete';
@@ -7,18 +8,27 @@ type DefaultProps = {
name: string;
} & string;
const CustomSelectAutoComplete = React.forwardRef<any, any>((props, ref) => (
<AutoComplete<DefaultProps>
const CustomSelectAutoComplete = React.forwardRef<any, any>((props, ref) => {
const def = defaults(props, {
width: '100%',
height: 'auto',
listStyle: {},
multi: false,
selectedKeys: [],
freeSolo: false,
freeSoloConverter: (input) => input,
});
return <AutoComplete<DefaultProps>
ref={ref as any}
fetchItems={(keys) => props.data.filter((i) => (i._id ? keys.includes(i._id) : keys.includes(i)))}
queryItems={(query) => props.data.filter((i) => (i.name || i).toString().toLowerCase().includes(query.toLowerCase()))}
fetchItems={(keys) => def.data.filter((i) => (i._id ? keys.includes(i._id) : keys.includes(i)))}
queryItems={(query) => def.data.filter((i) => (i.name || i).toString().toLowerCase().includes(query.toLowerCase()))}
itemText={(item) => `${item.name || item}`}
itemKey={(item) => `${item._id?.toString() || item.name || item}`}
renderItem={(item) => `${item.name || item}`}
allowEmptyQuery
{...props}
/>
));
{...def}
/>;
});
CustomSelectAutoComplete.propTypes = {
width: PropTypes.string,
@@ -35,14 +45,4 @@ CustomSelectAutoComplete.propTypes = {
CustomSelectAutoComplete.displayName = 'CustomSelectAutoComplete';
CustomSelectAutoComplete.defaultProps = {
width: '100%',
height: 'auto',
listStyle: {},
multi: false,
selectedKeys: [],
freeSolo: false,
freeSoloConverter: (input) => input,
};
export default CustomSelectAutoComplete;
@@ -0,0 +1,129 @@
@use '../common' as *;
.autocomplete-dummy {
display: none !important;
}
.autocomplete-wrapper {
border: 1px solid #d9d9d9;
padding: 1px;
display: flex;
flex-wrap: wrap;
overflow-y: auto;
background-color: $input-background-color;
outline: $input-outline;
transition: outline-color .2s, border-color .2s;
transition-timing-function: ease-out-cubic;
&:focus,
&.focused {
border-color: $input-focus-border-color;
outline: $input-focus-outline;
outline-offset: 0;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
// Overwrite, for IE compatibility
&:read-only {
outline: 0;
}
&:disabled {
opacity: 0.5;
}
&:hover {
border-color: #40a9ff;
}
input {
background-color: $input-background-color;
color: rgba(0, 0, 0, .85);
height: 30px;
box-sizing: border-box;
padding: 4px 6px;
min-width: 30px;
flex-grow: 1;
border: 0;
margin: 0;
outline: 0;
}
}
.autocomplete-tag {
display: flex;
align-items: center;
height: 24px;
margin: 2px;
line-height: 22px;
background-color: #fafafa;
border: 1px solid #e8e8e8;
border-radius: 2px;
box-sizing: content-box;
padding: 0 4px 0 10px;
outline: 0;
overflow: hidden;
&:focus {
border-color: #40a9ff;
background-color: #e6f7ff;
}
&>div {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&>span.bp6-icon>svg {
font-size: 12px;
cursor: pointer;
padding: 4px;
}
}
.autocomplete-list {
min-width: 300px;
margin: 2px 0 0;
padding: 0;
position: absolute;
list-style: none;
background-color: #fff;
overflow: auto;
max-height: 500px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 999;
li {
padding: 5px 12px;
display: flex;
&>div {
flex-grow: 1;
}
&>span.bp6-icon>svg {
color: transparent;
}
&[aria-selected='true'] {
background-color: #fafafa;
font-weight: 600;
&>span.bp6-icon>svg {
color: #1890ff;
}
}
&[data-focus='true'] {
background-color: #e6f7ff;
cursor: pointer;
&>span.bp6-icon>svg {
color: currentColor;
}
}
}
}
+202
View File
@@ -0,0 +1,202 @@
@use "sass:color";
@use "sass:list";
@use "sass:math";
@function toRem($value) {
$remValue: calc($value / 16px) * 1rem;
@return $remValue;
}
@function rem($values) {
$remValues: ();
@each $value in $values {
$remValues: list.append($remValues, toRem($value));
}
@return $remValues;
}
@function lighten($color, $amount) {
@return color.adjust($color, $lightness: $amount);
}
// layout
$grid-padding: 15px; // half gutter
// fonts
$primary-font-family: var(--font-family);
$header-font-family: var(--font-family);
$code-font-family: var(--code-font-family);
// primary palette
$default-color: #ddd;
$primary-color: #5f9fd6;
$secondary-color: #ed5f82;
$highlight-color: #df6589;
$code-color: #c92a2a;
$text-1-color: #555;
$text-2-color: lighten($text-1-color, 15%);
$text-3-color: lighten($text-1-color, 30%);
$header-1-color: #3d3d3d;
$header-2-color: lighten($header-1-color, 20%);
$blockquote-color: #999;
$page-bg-color: #edf0f2;
$content-bg-color: #fff;
$border-1-color: $default-color;
$border-2-color: lighten($default-color, 20%);
$immersive-primary-color: #ffef87;
$immersive-text-color: #ffffff;
$immersive-header-color: #ffffff;
$success-color: #25ad40;
$fail-color: #fb5555;
$progress-color: #f39800;
$invalid-color: #9fa0a0;
// sizes
$font-size: 16px; // main content
$font-size-secondary: 14px; // secondary content
$font-size-small: 13px; // additional informations, usually gray colored
$font-size-icon: 16px;
$font-size-title: 20px;
// components
$section-gap-v: 15px;
$section-gap-h: $grid-padding;
$section-list-v: 25px;
$section-margin: 30px;
$section-shadow: rem(0 6px 22px) rgba(#AFC2C9, 0.5);
$section-bg-color: $content-bg-color;
$section-title-color: $header-2-color;
$section-toolbtn-color: #AAA;
$section-toolbtn-color-hover: #444;
$autocomplete-empty-row-color: #AAA;
$userselect-uid-color: #BBB;
$editor-border-color: $border-1-color;
$datepicker-shadow: rem(0 2px 7px) rgba(#000, 0.3);
$datapicker-list-item-bg-color-hover: #F0F0F0;
$dialog-layer-bg-color: rgba(#F0F0F0, 0.6);
$dialog-bg-color: $content-bg-color;
$dialog-border-color: $border-1-color;
$dialog-shadow: rem(0 2px 10px) rgba(#000, 0.2);
$dialog-title-color: $header-2-color;
$comment-placeholder-color: #AAA;
$comment-op-color: lighten($text-1-color, 30%);
$comment-op-link-color: lighten($text-1-color, 50%);
$menu-item-bg-color-hover: #F4F4F4;
$menu-drop-shadow: rem(0 2px 7px) rgba(#000, 0.3);
$menu-drop-bg-color: $content-bg-color;
$menu-drop-triangle-size: 4px;
$menu-drop-triangle-shadow: rem(-2px -2px 2px) rgba(#000, 0.15);
$nav-item-height: 45px;
$nav-item-hover-color: $primary-color;
$nav-item-active-color: $secondary-color;
$nav-item-active-immersive-color: #9AE3F3;
$nav-item-round-height: 32px;
$nav-item-round-color: $primary-color;
$nav-item-round-border: 2px;
// $nav-logo-height: 32px;
// $nav-logo-height-mobile: 23px;
$header-layer-height: 100px;
$header-bg-width: 1920px;
$header-bg-height: 320px;
$header-bg-height-mini: $header-bg-height - $header-layer-height;
//$header-logo-width = 280px
//$header-logo-height = 150px
//$header-logo-width-real = 280px
//$header-logo-height-real = 150px
$header-logo-width: 252px;
$header-logo-height: 150px;
$header-logo-width-real: 381px;
$header-logo-height-real: 201px;
$header-logo-domain-lh: 60px;
$header-logo-system-lh: 30px;
$footer-bg-color: $content-bg-color;
$input-focus-color: #C1E0FF;
$form-control-height: 35px;
$compact-control-height: 28px;
$input-margin: 0 0 1rem;
$input-multi-line-margin: 0 0.3rem 0.15rem 0;
$input-image-radio-margin: 0 0.3rem 0.3rem 0;
$input-background-color: $content-bg-color;
$input-border: 1px solid $border-1-color;
$input-outline: 2px solid transparent;
$input-focus-border-color: $primary-color;
$input-focus-outline: 2px solid $input-focus-color;
$label-color: lighten($text-1-color, 10%);
$help-text-color: lighten($text-1-color, 30%);
$table-border-color: $border-2-color;
$table-alternate-bg-color: #F4F4F4;
$table-header-bg-color: rgba(#FFF, 0.95);
$table-header-shadow: rem(0 3px 2px) rgba(#000, 0.03);
$table-header-shadow-floating: rem(0 2px 5px) rgba(#000, 0.2);
$table-padding-h: 10px;
$table-lh: 26px;
$toolbar-bg: #f2f6f7;
$toolbar-fore: #8395a1;
$toolbar-fore-hover: #5a6b75;
$toolbar-bg-hover: #e3e7e8;
$toolbar-fore-selected: #FFF;
$toolbar-bg-selected: $secondary-color;
$toolbar-disabled-opacity: 0.3;
$supplementary-color: lighten($text-1-color, 50%);
$supplementary-border-color: lighten($supplementary-color, 62%);
$supplementary-link-color: lighten($text-1-color, 40%);
$pager-border-color: $table-border-color;
$nprogress-color: $primary-color;
$syntax-hl-bg-color: #F8F8F8;
// // Notification
// $notification-color = {
// 'success': #9BDB7B,
// 'info': #78D6F4,
// 'warn': #FCD279,
// 'error': #FD848D,
// }
// $notification-icon = {
// 'success': $icon-check--circle,
// 'info': $icon-info--circle,
// 'warn': $icon-info--circle,
// 'error': $icon-close--circle,
// }
@mixin form-styles() {
appearance: none;
display: block;
width: 100%;
font-size: rem($font-size-secondary);
margin: $input-margin;
height: rem($form-control-height);
line-height: 1.2;
padding: rem(5px);
border: $input-border;
&.inline {
display: inline-block;
width: auto;
}
}
.fullscreen-content {
position: absolute;
left: 0;
top: $nav-item-height;
width: 100%;
bottom: 0;
}
+6
View File
@@ -0,0 +1,6 @@
export { default as ComponentsProvider } from './provider';
export { default as ConfigEditor } from './ConfigEditor';
export { default as Icon } from './Icon';
export { default as Notification } from './Notification';
export { default as AutoComplete, type AutoCompleteHandle, type AutoCompleteProps } from './autocomplete/AutoComplete';
export { default as CustomSelectAutoComplete } from './autocomplete/CustomSelectAutoComplete';
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
import { Slide, ToastContainer } from 'react-toastify';
interface C {
i18n: (key: string, ...args: any[]) => string;
theme: 'light' | 'dark';
codeFontFamily: string;
}
// eslint-disable-next-line
export const ComponentsContext = React.createContext<C>({
i18n: (key) => key,
theme: 'light',
codeFontFamily: 'monospace',
});
export default function ComponentsProvider(props: { children: React.ReactNode } & C) {
return <ComponentsContext.Provider value={props}>
<ToastContainer
position="bottom-left"
autoClose={5000}
hideProgressBar
newestOnTop={false}
closeOnClick={false}
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="colored"
transition={Slide}
/>
{props.children}
</ComponentsContext.Provider>;
}
+21
View File
@@ -0,0 +1,21 @@
declare module '*.svg?react' {
import { ComponentType } from 'react';
const content: ComponentType;
export default content;
}
declare module '*.vue' {
const content: any;
export default content;
}
declare module '*.css' {
const content: string;
export default content;
}
declare module '*.styl' {
const content: string;
export default content;
}
declare module '*.scss' {
const content: string;
export default content;
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@hydrooj/components",
"version": "1.0.0-beta.4",
"main": "./frontend/index.ts",
"repository": "https://github.com/hydro-dev/Hydro",
"sideEffects": [
"*.scss",
"*.css"
],
"dependencies": {
"allotment": "^1.20.4",
"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.4"
},
"devDependencies": {
"@types/diff": "^8.0.0",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.17.19",
"sass": "^1.89.2"
},
"peerDependencies": {
"react": "*",
"react-dom": "*",
"schemastery": "*"
}
}
+100 -87
View File
@@ -1,11 +1,9 @@
import { Client } from '@elastic/elasticsearch';
import {
_, Context, iterateAllProblem, iterateAllProblemInDomain,
ProblemDoc, ProblemModel, Schema, SystemModel,
ProblemDoc, ProblemModel, ProblemSearch, Schema, Service, SystemModel,
} from 'hydrooj';
const client = new Client({ node: SystemModel.get('elastic-search.url') || 'http://127.0.0.1:9200' });
const indexOmit = ['_id', 'docType', 'data', 'additional_file', 'config', 'stats', 'assign'];
const processDocument = (doc: Partial<ProblemDoc>) => {
doc.content &&= doc.content.replace(/[[\]【】()()]/g, ' ');
@@ -19,94 +17,109 @@ const processDocument = (doc: Partial<ProblemDoc>) => {
return _.omit(doc, indexOmit);
};
global.Hydro.lib.problemSearch = async (domainId, q, opts) => {
const allowedSize = SystemModel.get('elasic-search.indexSize') || 10000;
const size = opts?.limit || SystemModel.get('pagination.problem');
const from = Math.min(allowedSize - size, opts?.skip || 0);
const res = await client.search({
index: 'problem',
size,
from,
query: {
simple_query_string: {
query: q.replace(/([a-z0-9]{2,})/gi, (i) => `${i}~5`),
fields: ['tag^5', 'pid^4', 'title^3', 'content'],
},
},
post_filter: {
bool: {
minimum_should_match: 1,
should: [{ match: { domainId } }],
},
},
export default class ElasticSearchService extends Service {
static Config = Schema.object({
url: Schema.string().role('url').default('http://127.0.0.1:9200'),
indexSize: Schema.number().role('indexSize').default(10000).min(0).step(1),
});
let hits = res.hits.hits.map((i) => i._id);
if (!opts.skip) {
let pdoc = await ProblemModel.get(domainId, +q || q, ProblemModel.PROJECTION_LIST);
if (pdoc) {
hits = hits.filter((i) => i !== `${pdoc.domainId}/${pdoc.docId}`);
hits.unshift(`${pdoc.domainId}/${pdoc.docId}`);
} else if (/^P\d+$/.test(q)) {
pdoc = await ProblemModel.get(domainId, +q.substring(1), ProblemModel.PROJECTION_LIST);
if (pdoc) hits.unshift(`${pdoc.domainId}/${pdoc.docId}`);
client: Client;
constructor(ctx: Context, private config: ReturnType<typeof ElasticSearchService.Config>) {
super(ctx, 'elastic-search');
this.client = new Client({ node: config.url });
}
async problemSearch(domainId: string, q: string, opts: Parameters<ProblemSearch>[2] = {}): ReturnType<ProblemSearch> {
const size = opts?.limit || SystemModel.get('pagination.problem');
const from = Math.min(this.config.indexSize - size, opts?.skip || 0);
const res = await this.client.search({
index: 'problem',
size,
from,
query: {
simple_query_string: {
query: q.replace(/([a-z0-9]{2,})/gi, (i) => `${i}~5`),
fields: ['tag^5', 'pid^4', 'title^3', 'content'],
},
},
post_filter: {
bool: {
minimum_should_match: 1,
should: [{ match: { domainId } }],
},
},
});
let hits = res.hits.hits.map((i) => i._id);
if (!opts.skip) {
let pdoc = await ProblemModel.get(domainId, +q || q, ProblemModel.PROJECTION_LIST);
if (pdoc) {
hits = hits.filter((i) => i !== `${pdoc.domainId}/${pdoc.docId}`);
hits.unshift(`${pdoc.domainId}/${pdoc.docId}`);
} else if (/^P\d+$/.test(q)) {
pdoc = await ProblemModel.get(domainId, +q.substring(1), ProblemModel.PROJECTION_LIST);
if (pdoc) hits.unshift(`${pdoc.domainId}/${pdoc.docId}`);
}
}
return {
countRelation: typeof res.hits.total === 'number' ? 'eq' : res.hits.total.relation,
total: typeof res.hits.total === 'number' ? res.hits.total : res.hits.total.value,
hits: Array.from(new Set(hits)),
};
}
return {
countRelation: typeof res.hits.total === 'number' ? 'eq' : res.hits.total.relation,
total: typeof res.hits.total === 'number' ? res.hits.total : res.hits.total.value,
hits: Array.from(new Set(hits)),
};
};
async function run({ domainId }, report) {
try {
if (domainId) await client.deleteByQuery({ index: 'problem', query: { match: { domainId } } });
else await client.deleteByQuery({ index: 'problem', query: { match_all: {} } });
} catch (e) {
if (!e.message.includes('index_not_found_exception')) throw e;
async run({ domainId }, report) {
try {
if (domainId) await this.client.deleteByQuery({ index: 'problem', query: { match: { domainId } } });
else await this.client.deleteByQuery({ index: 'problem', query: { match_all: {} } });
} catch (e) {
if (!e.message.includes('index_not_found_exception')) throw e;
}
let i = 0;
const cb = async (pdoc) => {
i++;
if (!(i % 1000)) report({ message: `${i} problems indexed` });
await this.client.index({
index: 'problem',
id: `${pdoc.domainId}/${pdoc.docId}`,
document: processDocument(pdoc),
});
};
if (domainId) await iterateAllProblemInDomain(domainId, ProblemModel.PROJECTION_PUBLIC, cb);
else await iterateAllProblem(ProblemModel.PROJECTION_PUBLIC, cb);
await this.client.indices.refresh({ index: 'problem' });
return true;
}
let i = 0;
const cb = async (pdoc) => {
i++;
if (!(i % 1000)) report({ message: `${i} problems indexed` });
await client.index({
index: 'problem',
id: `${pdoc.domainId}/${pdoc.docId}`,
document: processDocument(pdoc),
async [Context.init]() {
await this.client.nodes.stats();
this.ctx.on('problem/add', async (doc, docId) => {
await this.client.index({
index: 'problem',
id: `${doc.domainId}/${docId}`,
document: processDocument(doc),
});
});
};
if (domainId) await iterateAllProblemInDomain(domainId, ProblemModel.PROJECTION_PUBLIC, cb);
else await iterateAllProblem(ProblemModel.PROJECTION_PUBLIC, cb);
await client.indices.refresh({ index: 'problem' });
return true;
this.ctx.on('problem/edit', async (pdoc) => {
await this.client.index({
index: 'problem',
id: `${pdoc.domainId}/${pdoc.docId}`,
document: processDocument(pdoc),
});
});
this.ctx.on('problem/del', async (domainId, docId) => {
await this.client.delete({
index: 'problem',
id: `${domainId}/${docId}`,
});
});
this.ctx.addScript(
'ensureElasticSearch', 'Elastic problem search re-index',
Schema.object({
domainId: Schema.string(),
}),
this.run.bind(this),
);
this.ctx.provideModule('problemSearch', 'elastic', this.problemSearch.bind(this));
}
}
export const apply = (ctx: Context) => {
ctx.on('problem/add', async (doc, docId) => {
await client.index({
index: 'problem',
id: `${doc.domainId}/${docId}`,
document: processDocument(doc),
});
});
ctx.on('problem/edit', async (pdoc) => {
await client.index({
index: 'problem',
id: `${pdoc.domainId}/${pdoc.docId}`,
document: processDocument(pdoc),
});
});
ctx.on('problem/del', async (domainId, docId) => {
await client.delete({
index: 'problem',
id: `${domainId}/${docId}`,
});
});
ctx.addScript(
'ensureElasticSearch', 'Elastic problem search re-index',
Schema.object({
domainId: Schema.string(),
}),
run,
);
};
+5 -2
View File
@@ -1,11 +1,14 @@
{
"name": "@hydrooj/elastic-search",
"version": "1.1.8",
"version": "1.2.0-beta.2",
"main": "index.ts",
"repository": "https://github.com/hydro-dev/Hydro",
"author": "undefined <i@undefined.moe>",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@elastic/elasticsearch": "^8.15.1"
"@elastic/elasticsearch": "^9.0.2"
},
"engines": {
"hydrooj": ">=5.0.0-alpha.1"
}
}
-4
View File
@@ -1,4 +0,0 @@
url:
value: http://127.0.0.1:9200
indexSize:
value: 10000
+17 -17
View File
@@ -1,9 +1,10 @@
/* eslint-disable no-await-in-loop */
import { Readable } from 'stream';
import decodeHTML from 'decode-html';
import xml2js from 'xml2js';
import {
_, AdmZip, BadRequestError, buildContent, Context, FileTooLargeError, fs, Handler,
PERM, ProblemConfigFile, ProblemModel, ProblemType, SettingModel, SolutionModel, SystemModel, ValidationError, yaml,
_, BadRequestError, buildContent, Context, FileTooLargeError, fs, Handler, PERM, ProblemConfigFile,
ProblemModel, ProblemType, randomstring, Schema, SolutionModel, SystemModel, ValidationError, yaml, Zip,
} from 'hydrooj';
const knownRemoteMapping = {
@@ -64,7 +65,7 @@ class FpsProblemImportHandler extends Handler {
}
if (p.img?.length) {
for (const img of p.img) {
const filename = String.random(8) + img.src[0].split('/').pop().split('.').pop();
const filename = randomstring(8) + img.src[0].split('/').pop().split('.').pop();
tasks.push(ProblemModel.addAdditionalFile(domainId, pid, filename, Buffer.from(img.base64[0], 'base64')));
content = content.replace(img.src[0], `file://${filename}`);
}
@@ -82,28 +83,28 @@ class FpsProblemImportHandler extends Handler {
}
async post({ domainId }) {
if (!this.request.files.file) throw new ValidationError('file');
const file = this.request.files.file;
if (!file) throw new ValidationError('file');
const tasks = [];
try {
const file = await fs.stat(this.request.files.file.filepath);
if (file.size > SystemModel.get('import-fps.limit')) throw new FileTooLargeError();
const content = fs.readFileSync(this.request.files.file.filepath, 'utf-8');
const content = await fs.readFile(file.filepath, 'utf-8');
const result = await xml2js.parseStringPromise(content);
tasks.push(result);
} catch (e) {
if (e instanceof FileTooLargeError) throw e;
console.log(e);
let zip: AdmZip;
const zip = new Zip.ZipReader(Readable.toWeb(fs.createReadStream(file.filepath)));
let entries: Zip.Entry[];
try {
zip = new AdmZip(this.request.files.file.filepath);
entries = await zip.getEntries();
} catch (err) {
throw new ValidationError('zip', null, err.message);
}
for (const entry of zip.getEntries()) {
for (const entry of entries) {
try {
const buf = entry.getData();
if (buf.byteLength > SystemModel.get('import-fps.limit')) throw new FileTooLargeError();
const content = buf.toString();
if (entry.uncompressedSize > SystemModel.get('import-fps.limit')) throw new FileTooLargeError();
const content = entry.getData(new Zip.TextWriter());
const result = await xml2js.parseStringPromise(content);
tasks.push(result);
} catch { }
@@ -115,6 +116,10 @@ class FpsProblemImportHandler extends Handler {
}
}
export const Config = Schema.object({
limit: Schema.number().role('limit').default(64 * 1024 * 1024).min(1).description('Maximum file size for FPS problemset import'),
}).description('FPS Importer');
export async function apply(ctx: Context) {
ctx.Route('problem_import_fps', '/problem/import/fps', FpsProblemImportHandler, PERM.PERM_CREATE_PROBLEM);
ctx.injectUI('ProblemAdd', 'problem_import_fps', { icon: 'copy', text: 'From FPS File' });
@@ -133,9 +138,4 @@ Importing a large problem set on a machine with insufficient memory may cause a
'problem.import.fps.hint3': 'If you really need it, this limit can be changed in the system settings. \
We strongly recommend that you use the zip format to store or exchange problemsets.',
});
ctx.inject(['setting'], (c) => {
c.setting.SystemSetting(
SettingModel.Setting('setting_limits', 'import-fps.limit', 64 * 1024 * 1024, 'text', 'Maximum file size for FPS problemset import'),
);
});
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hydrooj/fps-importer",
"version": "1.5.13",
"version": "1.6.0-beta.2",
"description": "Import FPS problems",
"main": "index.ts",
"repository": "https://github.com/hydro-dev/Hydro.git",
+7 -6
View File
@@ -1,6 +1,7 @@
import fs from 'fs';
import { Reader } from 'maxmind';
import { Context, findFileSync, Service } from 'hydrooj';
import {
Context, findFileSync, fs, Service,
} from 'hydrooj';
const buffer = fs.readFileSync(findFileSync('@hydrooj/geoip/GeoLite2-City.mmdb'));
const reader = new Reader(buffer);
@@ -15,13 +16,13 @@ export interface Result {
export default class GeoIPService extends Service {
constructor(ctx: Context) {
super(ctx, 'geoip', true);
super(ctx, 'geoip');
}
provider = '<a href="http://www.maxmind.com" target="_blank">MaxMind</a>';
lookup(ip: string, locale: string): Result {
const res: any = reader.get(ip);
if (!res) return { display: 'Unknown address'.translate(locale) };
if (!res) return { display: this.ctx.i18n.translate('Unknown address', [locale]) };
const ret: Result = { display: '' };
if (res.location) ret.location = res.location;
if (res.continent) ret.continent = res.continent.names[locale] || res.continent.names.en;
@@ -35,6 +36,6 @@ export default class GeoIPService extends Service {
}
}
export async function apply(ctx: Context) {
ctx.set('geoip', new GeoIPService(ctx));
export function apply(ctx: Context) {
ctx.plugin(GeoIPService);
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@hydrooj/geoip",
"version": "1.3.3",
"version": "1.4.0-beta.1",
"main": "index.ts",
"repository": "git@github.com:hydro-dev/Hydro.git",
"author": "undefined <i@undefined.moe>",
"license": "AGPL-3.0-or-later",
"dependencies": {
"maxmind": "^4.3.22"
"maxmind": "^4.3.27"
},
"preferUnplugged": true
}
+7 -1
View File
@@ -11,6 +11,7 @@ java:
execute: /usr/bin/java -Dfile.encoding="UTF-8" -cp Main.jar Main
time_limit_rate: 2
compile_time_limit: 30000
compile_memory_limit: 1024
py3:
compile: /usr/bin/python3 -c "import py_compile; py_compile.compile('/w/foo.py', '/w/foo', doraise=True)"
code_file: foo.py
@@ -19,14 +20,19 @@ php:
execute: /usr/bin/php foo.php
rs:
compile: /usr/bin/rustc -O -o /w/foo /w/foo.rs
compile_time_limit: 30000
hs:
compile: /usr/bin/ghc -O -outputdir /tmp -o /w/foo /w/foo.hs
compile_time_limit: 30000
js:
execute: /usr/bin/jsc foo.js
go:
compile: /usr/bin/go build -o /w/foo /w/foo.go
compile_time_limit: 30000
rb:
execute: /usr/bin/ruby foo.rb
compile_time_limit: 30000
cs:
compile: /usr/bin/mcs csc -optimize+ -out:/w/foo.exe /w/foo.cs
execute: /usr/bin/mono foo.exe
execute: /usr/bin/mono foo.exe
compile_time_limit: 30000
+10 -10
View File
@@ -1,26 +1,26 @@
{
"name": "@hydrooj/hydrojudge",
"bin": "bin/hydrojudge.js",
"version": "3.3.19",
"version": "3.5.0-beta.4",
"main": "./src/index.ts",
"author": "undefined <i@undefined.moe>",
"repository": "https://github.com/hydro-dev/Hydro.git",
"dependencies": {
"@hydrooj/common": "workspace:^",
"@hydrooj/utils": "workspace:^",
"adm-zip": "0.5.14",
"@zip.js/zip.js": "^2.7.62",
"cac": "^6.7.14",
"p-queue": "^8.0.1",
"schemastery": "^3.14.6",
"semver": "^7.6.3",
"shell-quote": "^1.8.1",
"superagent": "^10.1.1",
"ws": "^8.18.0"
"p-queue": "^8.1.0",
"schemastery": "^3.16.1",
"semver": "^7.7.2",
"shell-quote": "^1.8.3",
"superagent": "^10.2.1",
"ws": "^8.18.3"
},
"preferUnplugged": true,
"license": "AGPL-3.0-or-later",
"devDependencies": {
"@types/adm-zip": "^0.4.34",
"@types/shell-quote": "^1.7.5",
"@types/ws": "^8.5.12"
"@types/ws": "^8.18.1"
}
}
-87
View File
@@ -1,87 +0,0 @@
sandbox_host:
type: text
name: sandbox_host
desc: Sandbox Host
default: http://localhost:5050
tmp_dir:
type: text
name: tmp_dir
desc: Temp directory
default: $TEMP/hydro/judge
stdio_size:
type: text
name: stdio_size
desc: Stdout/Stderr size
default: 32m
processLimit:
type: number
name: processLimit
desc: Process Limit
default: 32
testcases_max:
type: number
name: testcases_max
desc: Max testcases per problem
default: 100
total_time_limit:
type: number
name: total_time_limit
desc: Total time limit per problem
default: 60
memoryMax:
type: text
name: memoryMax
default: 512m
cache_dir:
type: text
name: cache_dir
desc: Testdata cache directory
default: $HOME/.cache/hydro/judge
parallelism:
type: number
name: parallelism
desc: Runner parallelism
default: 2
singleTaskParallelism:
type: number
name: singleTaskParallelism
default: 2
rate:
type: float
name: rate
desc: (?)
default: 1
rerun:
type: number
name: rerun
desc: Re-Run testcase if time-limit-exceeded (max per submission)
default: 0
disable:
type: boolean
name: disable
desc: Disable builtin judge
default: false
strict_memory:
type: boolean
name: strict_memory
desc: Strict Memory Mode
default: false
env:
type: textarea
name: env
desc: Sandbox Env
default: |
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOME=/w
# modify to your python version installed
PYTHONPATH=/lib/python3.10/site-packages
secret:
type: text
name: secret
desc: Judge Token Secret
default: Hydro-Judge-Secret
detail:
type: boolean
name: detail
desc: Show diff detail
default: true
+30 -1
View File
@@ -18,7 +18,7 @@ export = async function main() {
fs.emptyDirSync(CACHE_DIR);
logger.info('Cleaned cache.');
} else if (argv.args[1] === 'prune') {
const duration = +argv.options.duration || 30;
const duration = +argv.options.duration || +argv.options.days || 30;
const now = Date.now();
const hosts = await fs.readdir(CACHE_DIR, { withFileTypes: true });
let cnt = 0;
@@ -59,5 +59,34 @@ export = async function main() {
for (const [domain, s] of top10) {
logger.info(' %s: %s', domain, size(s));
}
} else if (argv.args[1] === 'clearCompileCache') {
const hosts = await fs.readdir(CACHE_DIR, { withFileTypes: true });
let cnt = 0;
for (const host of hosts) {
const hostdir = path.resolve(CACHE_DIR, host.name);
if (!host.isDirectory()) continue;
const domains = await fs.readdir(hostdir, { withFileTypes: true });
for (const domain of domains) {
if (!domain.isDirectory()) continue;
const domaindir = path.resolve(hostdir, domain.name);
const problems = await fs.readdir(domaindir, { withFileTypes: true });
for (const problem of problems) {
const problemdir = path.resolve(domaindir, problem.name);
if (!fs.statSync(problemdir).isDirectory()) continue;
const etags = path.resolve(problemdir, 'etags');
if (fs.existsSync(etags)) {
const etag = JSON.parse(await fs.readFile(etags, 'utf-8'));
if (etag['*cache']) {
const removed = etag['*cache'];
delete etag['*cache'];
await fs.writeFile(etags, JSON.stringify(etag));
logger.info('Removed bianry %s %o', problemdir, removed);
cnt++;
}
}
}
}
}
logger.info('Done! %d items deleted.', cnt);
}
};
+12 -6
View File
@@ -1,11 +1,11 @@
/* eslint-disable no-await-in-loop */
import path from 'path';
import {
changeErrorType, fs, normalizeSubtasks, readSubtasksFromFiles, yaml,
convertIniConfig, LangConfig, normalizeSubtasks, ProblemConfigFile, readSubtasksFromFiles,
} from '@hydrooj/common';
import { readYamlCases } from '@hydrooj/common/cases';
import {
changeErrorType, fs, yaml,
} from '@hydrooj/utils';
import readYamlCases, { convertIniConfig } from '@hydrooj/utils/lib/cases';
import { LangConfig } from '@hydrooj/utils/lib/lang';
import { ProblemConfigFile } from 'hydrooj';
import { getConfig } from './config';
import { FormatError, SystemError } from './error';
import { NextFunction, ParsedConfig } from './interface';
@@ -15,11 +15,14 @@ function isValidConfig(config) {
if (config.type !== 'objective' && config.count > (getConfig('testcases_max') || 100)) {
throw new FormatError('Too many testcases. Cancelled.');
}
const time = Math.sum(...config.subtasks.flatMap((subtask) => subtask.cases.map((c) => c.time)));
const time = (config.num_processes || 1) * Math.sum(...config.subtasks.flatMap((subtask) => subtask.cases.map((c) => c.time)));
if (time > (getConfig('total_time_limit') || 60) * 1000) {
throw new FormatError('Total time limit longer than {0}s. Cancelled.', [+getConfig('total_time_limit') || 60]);
}
const memMax = Math.max(...config.subtasks.flatMap((subtask) => subtask.cases.map((c) => c.memory)));
if (config.type === 'communication' && (config.num_processes || 2) > getConfig('processLimit')) {
throw new FormatError('Number of processes larger than processLimit');
}
if (memMax > parseMemoryMB(getConfig('memoryMax'))) throw new FormatError('Memory limit larger than memory_max');
if (!['default', 'strict'].includes(config.checker_type || 'default') && !config.checker) {
throw new FormatError('You did not specify a checker.');
@@ -84,6 +87,9 @@ export default async function readCases(folder: string, cfg: ProblemConfigFile =
throw new SystemError('Cannot parse testdata.', [e.message, ...(e.params || [])]);
}
}
if (result.detail === true) result.detail = 'full';
else if (result.detail === false) result.detail = 'case';
else result.detail ||= 'full';
result.subtasks = normalizeSubtasks(result.subtasks || [], checkFile, config.time, config.memory, false, timeRate, memoryRate);
if (result.key && args.key !== result.key) throw new FormatError('Incorrect secret key');
if (!result.key && !args.trusted) isValidConfig(result);
+121 -58
View File
@@ -1,5 +1,4 @@
/* eslint-disable no-template-curly-in-string */
import { STATUS } from '@hydrooj/utils/lib/status';
import { DetailType, STATUS } from '@hydrooj/common';
import { FormatError, SystemError } from './error';
import { CopyInFile, runQueued } from './sandbox';
import { parse } from './testlib';
@@ -10,9 +9,10 @@ export interface CheckConfig {
output: CopyInFile;
user_stdout: CopyInFile;
user_stderr: CopyInFile;
code: CopyInFile;
copyIn: Record<string, CopyInFile>;
score: number;
detail: boolean;
detail: DetailType;
env?: Record<string, string>;
}
@@ -23,18 +23,22 @@ type Checker = (config: CheckConfig) => Promise<{
}>;
function parseDiffMsg(msg: string) {
msg = msg.trim();
try {
// Note: we only handle first diff
const desc = msg.split('\n')[0];
if (desc.includes('d')) return 'User output longer than standard answer.';
if (desc.includes('a')) return 'Standard answer longer than user output.';
const pt = msg.split('---');
// Get the first different line
const u = pt[0].split('\n')[1];
const t = pt[1].split('\n')[1];
if (!msg) return '';
if (!msg.startsWith('L=')) throw new Error();
const [meta, u, t] = msg.split('\n');
const lline = meta.split('L=')[1];
if (lline?.trim() === 'EOF') {
const next = u.split('EOF on ')[1].split(' ')[0];
if (next === 'usrout.processed') return 'Standard answer longer than user output.';
if (next === 'answer.processed') return 'User output longer than standard answer.';
return `Unable to parse: ${u}`;
}
const lineNum = +lline;
// Split by token
const usr = u.substring(2).trim().split(' ');
const std = t.substring(2).trim().split(' ');
const usr = u.trim().split(' ');
const std = t.trim().split(' ');
if (std.every((x) => !Number.isNaN(+x))) {
// Number mode, report length not match
if (usr.length > std.length) return 'User output longer than standard answer.';
@@ -44,7 +48,7 @@ function parseDiffMsg(msg: string) {
if (usr[i] === std[i]) continue;
const usrString = usr[i].length > 20 ? `${usr[i].substring(0, 16)}...` : usr[i];
const stdString = std[i].length > 20 ? `${std[i].substring(0, 16)}...` : std[i];
return { message: 'Read {0}, expect {1}.', params: [usrString, stdString] };
return { message: 'On line {0}: Read {1}, expect {2}.', params: [lineNum, usrString, stdString] };
}
throw new Error();
} catch (e) {
@@ -52,44 +56,65 @@ function parseDiffMsg(msg: string) {
}
}
const checkers: Record<string, Checker> = new Proxy({
async default(config) {
const { stdout } = await runQueued('/usr/bin/diff -BZ usrout answer', {
copyIn: {
usrout: config.user_stdout,
answer: config.output,
...config.copyIn,
},
});
let status: number;
let message: any = '';
if (stdout) {
status = STATUS.STATUS_WRONG_ANSWER;
if (config.detail) message = parseDiffMsg(stdout);
} else status = STATUS.STATUS_ACCEPTED;
if (message.length > 1024000) message = '';
return {
score: status === STATUS.STATUS_ACCEPTED ? config.score : 0,
status,
message,
};
},
const compareSh = `#!/bin/bash
set -e
if [ "$1" = "BZ" ]; then
cat usrout | awk '{sub(/[ \\t\\r]+$/, ""); print $0;}' | awk '/^$/{n=n RS}; /./{printf "%s",n; n=""; print}' >usrout.processed
cat answer | awk '{sub(/[ \\t\\r]+$/, ""); print $0;}' | awk '/^$/{n=n RS}; /./{printf "%s",n; n=""; print}' >answer.processed
else
cat usrout | awk '{sub(/[\\r]+$/, ""); print $0;}' >usrout.processed
cat answer | awk '{sub(/[\\r]+$/, ""); print $0;}' >answer.processed
fi
usrsize=$(wc -c < usrout.processed)
stdsize=$(wc -c < answer.processed)
if [ "$usrsize" -gt "$stdsize" ]; then
echo " (EOF)${' '.repeat(64)}" >>answer.processed
elif [ "$usrsize" -lt "$stdsize" ]; then
echo " (EOF)${' '.repeat(64)}" >>usrout.processed
fi
result=$(cmp usrout.processed answer.processed || true)
linenum=$(echo "$result" | awk '{print $NF}')
if [ -n "$linenum" ]; then
echo "L=$linenum"
awk "NR==$linenum" usrout.processed
awk "NR==$linenum" answer.processed
elif [ -n "$result" ]; then
# cmp: EOF on [filename] which is empty
echo "L=EOF"
echo "$result"
fi
`;
async strict(config) {
const { stdout } = await runQueued('/usr/bin/diff usrout answer', {
copyIn: {
usrout: config.user_stdout,
answer: config.output,
...config.copyIn,
},
});
const status = stdout ? STATUS.STATUS_WRONG_ANSWER : STATUS.STATUS_ACCEPTED;
return {
score: status === STATUS.STATUS_ACCEPTED ? config.score : 0,
status,
message: '',
};
},
const getDefaultChecker = (strict: boolean) => async (config: CheckConfig) => {
const { code, stdout } = await runQueued(`/bin/bash compare.sh${strict ? '' : ' BZ'}`, {
copyIn: {
usrout: config.user_stdout,
answer: config.output,
...config.copyIn,
'compare.sh': { content: compareSh },
},
processLimit: 32,
});
let status: number;
let message: any = '';
if (code) {
status = STATUS.STATUS_SYSTEM_ERROR;
message = `Checker returned with status ${code}`;
} else if (stdout) {
status = STATUS.STATUS_WRONG_ANSWER;
if (config.detail === 'full') message = parseDiffMsg(stdout);
} else status = STATUS.STATUS_ACCEPTED;
if (message.length > 1024000) message = '';
return {
score: status === STATUS.STATUS_ACCEPTED ? config.score : 0,
status,
message,
};
};
const checkers: Record<string, Checker> = new Proxy({
default: getDefaultChecker(false),
strict: getDefaultChecker(true),
/*
* argv[1]:输入
@@ -110,7 +135,7 @@ const checkers: Record<string, Checker> = new Proxy({
return {
status,
score: status === STATUS.STATUS_ACCEPTED ? config.score : 0,
message: stdout,
message: config.detail === 'full' ? stdout : '',
};
},
@@ -143,7 +168,7 @@ const checkers: Record<string, Checker> = new Proxy({
const score = Math.floor(+files.score) || 0;
return {
score,
message: files.message,
message: config.detail === 'full' ? files.message : '',
status: score === config.score
? STATUS.STATUS_ACCEPTED
: STATUS.STATUS_WRONG_ANSWER,
@@ -168,7 +193,7 @@ const checkers: Record<string, Checker> = new Proxy({
return {
status: st,
score: (status === STATUS.STATUS_ACCEPTED) ? config.score : 0,
message: stdout,
message: config.detail === 'full' ? stdout : '',
};
},
@@ -176,7 +201,7 @@ const checkers: Record<string, Checker> = new Proxy({
* input:输入
* user_out:选手输出
* answer:标准输出
* code:选手代码 (not impl)
* code:选手代码
* stdout:输出最终得分
* stderr:输出错误报告
*/
@@ -187,14 +212,14 @@ const checkers: Record<string, Checker> = new Proxy({
input: config.input,
user_out: config.user_stdout,
answer: config.output,
code: { content: '' },
code: config.code,
...config.copyIn,
},
});
if (status !== STATUS.STATUS_ACCEPTED) throw new SystemError('Checker returned {0}.', [status]);
const score = +stdout;
status = score === 100 ? STATUS.STATUS_ACCEPTED : STATUS.STATUS_WRONG_ANSWER;
return { status, score: Math.floor((score * config.score) / 100), message: stderr };
return { status, score: Math.floor((score * config.score) / 100), message: config.detail === 'full' ? stderr : '' };
},
async testlib(config) {
@@ -203,6 +228,7 @@ const checkers: Record<string, Checker> = new Proxy({
in: config.input,
user_out: config.user_stdout,
answer: config.output,
user_code: config.code,
...config.copyIn,
},
env: config.env,
@@ -226,7 +252,44 @@ const checkers: Record<string, Checker> = new Proxy({
message: `Checker exited with code ${code}`,
};
}
return parse(stderr, config.score);
return parse(stderr, config.score, config.detail);
},
// https://www.kattis.com/problem-package-format/spec/2023-07-draft.html#output-validator
async kattis(config) {
const { files, code } = await runQueued(`${config.execute} input answer_file feedback_dir`, {
copyIn: {
input: config.input,
answer_file: config.output,
'feedback_dir/placeholder': { content: '' },
...config.copyIn,
},
stdin: config.user_stdout,
copyOut: [
'feedback_dir/score.txt?',
'feedback_dir/judgemessage.txt?',
'feedback_dir/teammessage.txt?',
'feedback_dir/judgeerror.txt?',
],
});
const status = code === 42
? STATUS.STATUS_ACCEPTED
: code === 43
? STATUS.STATUS_WRONG_ANSWER
: STATUS.STATUS_SYSTEM_ERROR;
const score = status === STATUS.STATUS_ACCEPTED
? config.score
: +files['feedback_dir/score.txt'] || 0;
const message = status === STATUS.STATUS_SYSTEM_ERROR
? files['feedback_dir/judgeerror.txt'] || `Checker exited with code ${code}`
: config.detail === 'full'
? files['feedback_dir/teammessage.txt'] || files['feedback_dir/judgemessage.txt'] || ''
: '';
return { status, score, message };
},
}, {
get(self, key) {
+13 -32
View File
@@ -1,10 +1,8 @@
import { LangConfig } from '@hydrooj/utils/lib/lang';
import { STATUS } from '@hydrooj/utils/lib/status';
import { findFileSync } from '@hydrooj/utils/lib/utils';
import { CompileError, FormatError } from './error';
import { LangConfig, STATUS } from '@hydrooj/common';
import { CompileError } from './error';
import { Execute } from './interface';
import {
CopyIn, CopyInFile, del, runQueued,
CopyIn, CopyInFile, runQueued,
} from './sandbox';
import { compilerText } from './utils';
@@ -13,18 +11,20 @@ export default async function compile(
): Promise<Execute> {
const target = lang.target || 'foo';
const execute = copyIn['execute.sh'] ? '/bin/bash execute.sh' : lang.execute;
if (lang.compile) {
const command = copyIn['compile.sh'] ? '/bin/bash compile.sh' : lang.compile;
if (command) {
const {
status, stdout, stderr, fileIds,
status, stdout, stderr, fileIds, [Symbol.asyncDispose]: cleanup,
} = await runQueued(
copyIn['compile.sh'] ? '/bin/bash compile.sh' : lang.compile,
command,
{
copyIn: { ...copyIn, [lang.code_file]: code },
copyOutCached: [target],
env: { HYDRO_LANG: lang.key },
time: lang.compile_time_limit || 10000,
memory: lang.compile_memory_limit || 256 * 1024 * 1024,
memory: lang.compile_memory_limit || 512,
},
`compile[${lang.key}]`,
3,
);
// TODO: distinguish user program and checker
@@ -36,34 +36,15 @@ export default async function compile(
return {
execute,
copyIn: { ...copyIn, [target]: { fileId: fileIds[target] } },
clean: () => del(fileIds[target]),
clean: async () => await cleanup(),
[Symbol.asyncDispose]: async () => await cleanup(),
_cacheable: target,
};
}
return {
execute,
copyIn: { ...copyIn, [lang.code_file]: code },
clean: () => Promise.resolve(null),
[Symbol.asyncDispose]: () => Promise.resolve(null),
};
}
const testlibFile = {
src: findFileSync('@hydrooj/hydrojudge/vendor/testlib/testlib.h'),
};
export async function compileLocalFile(
src: string, type: 'checker' | 'validator' | 'interactor' | 'generator' | 'std',
getLang, copyIn: CopyIn, withTestlib = true, next?: any,
) {
const s = src.replace('@', '.').split('.');
let lang;
let langId = s.pop();
while (s.length) {
lang = getLang(langId, false);
if (lang) break;
langId = `${s.pop()}.${langId}`;
}
if (!lang) throw new FormatError(`Unknown ${type} language.`);
if (withTestlib) copyIn = { ...copyIn, 'testlib.h': testlibFile };
// TODO cache compiled binary
return await compile(lang, { src }, copyIn, next);
}
+37 -18
View File
@@ -2,37 +2,54 @@ import os from 'os';
import path from 'path';
import cac from 'cac';
import Schema from 'schemastery';
import { fs, yaml } from '@hydrooj/utils';
import { fs, randomstring, yaml } from '@hydrooj/utils';
const argv = cac().parse();
const JudgeSettings = Schema.object({
cache_dir: Schema.string().default(path.resolve(os.homedir(), '.cache', 'hydro', 'judge')),
const defaultEnv = `\
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOME=/w
# modify to your python version installed
PYTHONPATH=/lib/python3.12/site-packages
`;
export const JudgeSettings = Schema.object({
cache_dir: Schema.string().default(path.resolve(os.homedir(), '.cache', 'hydro', 'judge')).description('Testdata cache directory'),
tmp_dir: Schema.string().default(path.resolve(os.tmpdir(), 'hydro', 'judge')),
stdio_size: Schema.string().pattern(/^\d+[kmg]b?$/g).default('32m'),
memoryMax: Schema.string().pattern(/^\d+[kmg]b?$/g).default('512m'),
strict_memory: Schema.boolean().default(false),
sandbox_host: Schema.string().role('link').default('http://localhost:5050'),
testcases_max: Schema.number().default(100),
total_time_limit: Schema.number().default(60),
processLimit: Schema.number().default(32),
parallelism: Schema.number().default(2),
strict_memory: Schema.boolean().default(false).description('Use address space memory limit'),
sandbox_host: Schema.string().role('url').default('http://localhost:5050'),
testcases_max: Schema.number().default(100).min(1).step(1),
total_time_limit: Schema.number().default(60).min(1),
processLimit: Schema.number().default(32).min(1).step(1),
parallelism: Schema.number().default(2).min(1).step(1),
concurrency: Schema.number(),
singleTaskParallelism: Schema.number().default(2),
rerun: Schema.number().description('Re-Run testcase if time-limit-exceeded (max per submission)').default(0),
singleTaskParallelism: Schema.number().default(2).min(1).step(1),
rerun: Schema.number().description('Re-Run testcase if time-limit-exceeded (max per submission)').default(0).min(0).step(1),
rate: Schema.number().default(1),
env: Schema.string().default('PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\nHOME=/w'),
env: Schema.string().default(defaultEnv),
host: Schema.any(),
secret: Schema.string().description('Judge Token Secret').default(String.random(32)),
secret: Schema.string().description('Judge Token Secret').default(randomstring(32)),
disable: Schema.boolean().description('Disable builtin judge').default(false),
detail: Schema.boolean().description('Show diff detail').default(true),
detail: Schema.union([
Schema.const('full'),
Schema.const('case'),
Schema.const('none'),
Schema.transform(Schema.union([
Schema.boolean().deprecated(),
Schema.const('full'),
Schema.const('none'),
Schema.const('case'),
]), (v) => (typeof v === 'boolean' ? (v ? 'full' : 'case') : v)),
]).description('Show diff detail').default('full'),
performance: Schema.boolean().description('Performance mode').default(false),
});
const oldPath = path.resolve(os.homedir(), '.config', 'hydro', 'judge.yaml');
const newPath = path.resolve(os.homedir(), '.hydro', 'judge.yaml');
const config = global.Hydro
let config = global.Hydro
? JudgeSettings({})
: (() => {
const base: any = {};
@@ -54,6 +71,8 @@ const config = global.Hydro
return JudgeSettings(cfg);
})();
export const getConfig: <K extends keyof typeof config>(key: K) => typeof config[K] = global.Hydro
? (key) => global.Hydro.model.system.get(`hydrojudge.${key}`) ?? config[key]
: (key) => config[key];
export function overrideConfig(update: ReturnType<typeof JudgeSettings>) {
config = JudgeSettings(update);
}
export const getConfig: <K extends keyof typeof config>(key: K) => typeof config[K] = (key) => config[key];
-2
View File
@@ -1,5 +1,3 @@
/* eslint-disable import/no-duplicates */
/* eslint-disable no-constant-condition */
/* eslint-disable no-await-in-loop */
/* ..
.' @`._
+1 -1
View File
@@ -1,4 +1,4 @@
import { STATUS_TEXTS } from '@hydrooj/utils/lib/status';
import { STATUS_TEXTS } from '@hydrooj/common';
interface CompileErrorInfo {
stdout?: string,
+6 -4
View File
@@ -1,10 +1,10 @@
import Queue from 'p-queue';
import { STATUS } from '@hydrooj/utils/lib/status';
import type { JudgeResultBody } from 'hydrooj';
import {
JudgeResultBody, NormalizedCase, NormalizedSubtask, STATUS,
} from '@hydrooj/common';
import { getConfig } from './config';
import { FormatError } from './error';
import { Context, ContextSubTask } from './judge/interface';
import { NormalizedCase, NormalizedSubtask } from './utils';
interface Task {
compile: () => Promise<void>;
@@ -53,7 +53,9 @@ function judgeSubtask(subtask: NormalizedSubtask, sid: string, judgeCase: Task['
ctx.total_time += res.time;
ctx.total_memory = Math.max(ctx.total_memory, res.memory);
}
ctx.next({ ...res ? { case: res } : {}, addProgress: 100 / ctx.config.count });
if (ctx.config.detail !== 'none') {
ctx.next({ ...res ? { case: res } : {}, addProgress: 100 / ctx.config.count });
}
}));
}
try {
+53 -66
View File
@@ -4,42 +4,39 @@ import path from 'path';
import { fs } from '@hydrooj/utils';
import * as sysinfo from '@hydrooj/utils/lib/sysinfo';
import {
db, JudgeHandler, JudgeResultBody, ObjectId, RecordModel,
SettingModel, StorageModel, SystemModel, TaskModel,
Context as HydroContext, db, JudgeHandler, JudgeResultCallbackContext,
ObjectId, RecordModel, SettingModel, StorageModel, SystemModel, TaskModel,
} from 'hydrooj';
import { langs } from 'hydrooj/src/model/setting';
import { compilerVersions } from '../compiler';
import { getConfig } from '../config';
import { FormatError, SystemError } from '../error';
import { SystemError } from '../error';
import { compilerVersions, stackSize } from '../info';
import { Session } from '../interface';
import { Context } from '../judge/interface';
import logger from '../log';
import { versionCheck } from '../sandbox';
import { JudgeTask } from '../task';
const session = {
const session: Session = {
config: { detail: SystemModel.get('hydrojudge.detail') },
async fetchFile(name: string) {
name = name.split('#')[0];
const target = path.join(getConfig('tmp_dir'), name.replace(/\//g, '_'));
await StorageModel.get(`submission/${name}`, target);
return target;
async fetchFile(namespace, files) {
if (namespace === null) {
const name = Object.keys(files)[0].split('#')[0];
const target = path.join(getConfig('tmp_dir'), name.replace(/\//g, '_'));
await StorageModel.get(`submission/${name}`, target);
return target as any;
}
for (const key in files) {
const target = files[key];
await StorageModel.get(`problem/${namespace}/testdata/${key}`, target);
}
return null;
},
getNext(t: Context) {
t._callbackAwait ||= Promise.resolve();
return (data: Partial<JudgeResultBody>) => {
logger.debug('Next: %o', data);
data.rid = new ObjectId(t.rid);
if (data.case) data.case.message ||= '';
t._callbackAwait = t._callbackAwait.then(() => JudgeHandler.next({ ...data, domainId: t.request.domainId }));
};
},
getEnd(t: Context) {
t._callbackAwait ||= Promise.resolve();
return (data: Partial<JudgeResultBody>) => {
data.key = 'end';
data.rid = new ObjectId(t.rid);
logger.info('End: status=%d score=%d time=%dms memory=%dkb', data.status, data.score, data.time, data.memory);
t._callbackAwait = t._callbackAwait.then(() => JudgeHandler.end({ ...data, domainId: t.request.domainId }));
getReporter(t: Context) {
const reporter = new JudgeResultCallbackContext(app, t.request);
return {
next: (a) => reporter.next(a),
end: (a) => reporter.end(a),
};
},
getLang(lang: string, doThrow = true) {
@@ -51,38 +48,13 @@ const session = {
async postFile(target: string, filename: string, filepath: string) {
return await JudgeHandler.processJudgeFileCallback(new ObjectId(target), filename, filepath);
},
async cacheOpen(source: string, files: any[]) {
const filePath = path.join(getConfig('cache_dir'), source);
await fs.ensureDir(filePath);
if (!files?.length) throw new FormatError('Problem data not found.');
let etags: Record<string, string> = {};
try {
etags = JSON.parse(await fs.readFile(path.join(filePath, 'etags'), 'utf-8'));
} catch (e) { /* ignore */ }
const version = {};
const filenames = new Set<string>();
for (const file of files) {
filenames.add(file.name);
version[file.name] = file.etag + file.lastModified;
if (etags[file.name] !== file.etag + file.lastModified) {
await StorageModel.get(`problem/${source}/testdata/${file.name}`, path.join(filePath, file.name));
}
}
for (const name in etags) {
if (!filenames.has(name) && fs.existsSync(path.join(filePath, name))) await fs.remove(path.join(filePath, name));
}
await Promise.all([
fs.writeFile(path.join(filePath, 'etags'), JSON.stringify(version)),
fs.writeFile(path.join(filePath, 'lastUsage'), Date.now().toString()),
]);
return filePath;
},
};
export async function postInit(ctx) {
if (SystemModel.get('hydrojudge.disable')) return;
export async function apply(ctx: HydroContext) {
ctx.inject(['check'], (c) => {
c.check.addChecker('Judge', (_ctx, log, warn, error) => versionCheck(warn, error));
c.check.addChecker('Judge', async (_ctx, log, warn, error) => {
await versionCheck(warn, error);
});
});
await fs.ensureDir(getConfig('tmp_dir'));
const info = await sysinfo.get();
@@ -94,21 +66,36 @@ export async function postInit(ctx) {
}
await (new JudgeTask(session, JSON.parse(JSON.stringify(Object.assign(rdoc, t))))).handle().catch(logger.error);
};
const parallelism = Math.max(getConfig('parallelism'), 2);
const taskConsumer = TaskModel.consume({ type: 'judge' }, handle, true, parallelism);
function collectCompilerInfo() {
const parallelism = getConfig('parallelism');
async function collectInfo() {
const coll = db.collection('status');
compilerVersions(langs).then((compilers) => coll.updateOne(
const compilers = await compilerVersions(langs);
await coll.updateOne(
{ mid: info.mid, type: 'server' },
{ $set: { compilers } },
{ upsert: true },
)).catch((e) => logger.error(e));
);
const size = await stackSize();
await coll.updateOne(
{ mid: info.mid, type: 'server' },
{ $set: { stackSize: size } },
{ upsert: true },
);
}
ctx.on('system/setting', () => {
taskConsumer.setConcurrency(Math.max(getConfig('parallelism'), 2));
collectCompilerInfo();
ctx.effect(() => {
const taskConsumer = TaskModel.consume({ type: 'judge' }, handle, true, parallelism);
const dispose = ctx.on('system/setting', () => {
taskConsumer.setConcurrency(getConfig('parallelism'));
collectInfo();
});
return () => {
taskConsumer.destroy();
dispose();
};
});
collectCompilerInfo();
TaskModel.consume({ type: 'judge', priority: { $gt: -50 } }, handle);
TaskModel.consume({ type: 'generate' }, handle);
ctx.effect(() => {
const generateConsumer = TaskModel.consume({ type: 'generate' }, handle);
return () => generateConsumer.destroy();
});
collectInfo();
}
+67 -85
View File
@@ -1,19 +1,17 @@
/* eslint-disable no-await-in-loop */
import path from 'path';
import PQueue from 'p-queue';
import superagent from 'superagent';
import WebSocket from 'ws';
import type { LangConfig } from '@hydrooj/common';
import { fs, pipeRequest } from '@hydrooj/utils';
import { LangConfig } from '@hydrooj/utils/lib/lang';
import * as sysinfo from '@hydrooj/utils/lib/sysinfo';
import type { JudgeResultBody } from 'hydrooj';
import { compilerVersions } from '../compiler';
import { getConfig } from '../config';
import { FormatError, SystemError } from '../error';
import { compilerVersions, stackSize as getStackSize } from '../info';
import { Session } from '../interface';
import log from '../log';
import { JudgeTask } from '../task';
import { Lock } from '../utils';
function removeNixPath(text: string) {
return text.replace(/\/nix\/store\/[a-z0-9]{32}-/g, '/nix/');
@@ -51,76 +49,47 @@ export default class Hydro implements Session {
setInterval(() => { this.get(''); }, 30000000); // Cookie refresh only
}
async cacheOpen(source: string, files: any[], next?) {
await Lock.acquire(`${this.config.host}/${source}`);
try {
return await this._cacheOpen(source, files, next);
} catch (e) {
log.warn('CacheOpen Fail: %s %o %o', source, files, e);
throw e;
} finally {
Lock.release(`${this.config.host}/${source}`);
async fetchFile<T extends string | null>(namespace: T, files: Record<string, string>): Promise<T extends null ? string : null> {
if (!namespace) { // record-related resource (code)
const name = Object.keys(files)[0].split('#')[0];
const res = await this.post('judge/files', { id: name });
const target = Object.values(files)[0] || path.join(getConfig('tmp_dir'), Math.random().toString(36).substring(2));
await pipeRequest(this.get(res.body.url), fs.createWriteStream(target), 60000, name);
return target as any;
}
}
async _cacheOpen(source: string, files: any[], next?) {
const [domainId, pid] = source.split('/');
const filePath = path.join(getConfig('cache_dir'), this.config.host, source);
await fs.ensureDir(filePath);
if (!files?.length) throw new FormatError('Problem data not found.');
let etags: Record<string, string> = {};
try {
etags = JSON.parse(await fs.readFile(path.join(filePath, 'etags'), 'utf-8'));
} catch (e) { /* ignore */ }
const version = {};
const filenames = [];
const allFiles = new Set<string>();
for (const file of files) {
allFiles.add(file.name);
version[file.name] = file.etag + file.lastModified;
if (etags[file.name] !== file.etag + file.lastModified) filenames.push(file.name);
}
for (const name in etags) {
if (!allFiles.has(name) && fs.existsSync(path.join(filePath, name))) await fs.remove(path.join(filePath, name));
}
if (filenames.length) {
log.info(`Getting problem data: ${this.config.host}/${source}`);
next?.({ message: 'Syncing testdata, please wait...' });
await this.ensureLogin();
const res = await this.post(`/d/${domainId}/judge/files`, {
pid: +pid,
files: filenames,
const [domainId, pid] = namespace.split('/');
await this.ensureLogin();
const res = await this.post(`/d/${domainId}/judge/files`, {
pid: +pid,
files: Object.keys(files),
});
if (!res.body.links) throw new FormatError('problem not exist');
const queue = new PQueue({ concurrency: 10 });
for (const name in res.body.links) {
queue.add(async () => {
if (name.includes('/')) await fs.ensureDir(path.dirname(files[name]));
const w = fs.createWriteStream(files[name]);
await pipeRequest(this.get(res.body.links[name]), w, 60000, name);
});
if (!res.body.links) throw new FormatError('problem not exist');
const tasks = [];
const queue = new PQueue({ concurrency: 10 });
for (const name in res.body.links) {
tasks.push(queue.add(async () => {
if (name.includes('/')) await fs.ensureDir(path.join(filePath, name.split('/')[0]));
const w = fs.createWriteStream(path.join(filePath, name));
await pipeRequest(this.get(res.body.links[name]), w, 60000, name);
}));
}
await Promise.all(tasks);
await fs.writeFile(path.join(filePath, 'etags'), JSON.stringify(version));
}
await fs.writeFile(path.join(filePath, 'lastUsage'), new Date().getTime().toString());
return filePath;
await queue.onIdle();
return null;
}
async fetchFile(name: string) {
name = name.split('#')[0];
const res = await this.post('judge/files', { id: name });
const target = path.join(getConfig('tmp_dir'), name.replace(/\//g, '_'));
await pipeRequest(this.get(res.body.url), fs.createWriteStream(target), 60000, name);
return target;
}
async postFile(target: string, filename: string, file: string) {
await this.post('judge/upload')
.field('rid', target)
.field('name', filename)
.attach('file', fs.createReadStream(file));
async postFile(target: string, filename: string, file: string, retry = 3) {
try {
await this.post('judge/upload')
.field('rid', target)
.field('name', filename)
.attach('file', await fs.readFile(file));
} catch (e) {
if (!retry) {
log.error('PostFile Fail: %s %s %o', target, filename, e);
throw e;
}
await new Promise((resolve) => { setTimeout(resolve, 1000); });
await this.postFile(target, filename, file, retry - 1);
}
}
getLang(name: string, doThrow = true) {
@@ -137,8 +106,8 @@ export default class Hydro implements Session {
this.ws.send(JSON.stringify({ ...data, rid, key }));
}
getNext(t: JudgeTask) {
return (data: Partial<JudgeResultBody>) => {
getReporter(t: JudgeTask) {
const next = (data: Partial<JudgeResultBody>) => {
log.debug('Next: %o', data);
const performanceMode = getConfig('performance') || t.meta.rejudge || t.meta.hackRejudge;
if (performanceMode && data.case && !data.compilerText && !data.message) {
@@ -150,14 +119,12 @@ export default class Hydro implements Session {
this.send(t.request.rid, 'next', data);
}
};
}
getEnd(t: JudgeTask) {
return (data: Partial<JudgeResultBody>) => {
const end = (data: Partial<JudgeResultBody>) => {
log.info('End: %o', data);
if (t.callbackCache) data.cases = t.callbackCache;
this.send(t.request.rid, 'end', data);
};
return { next, end };
}
async consume(queue: PQueue) {
@@ -175,7 +142,8 @@ export default class Hydro implements Session {
? JSON.stringify({ key: 'config', ...config })
: '{"key":"ping"}';
let compilers = {};
let compilerVersionCallback = () => { };
let sendStatus = () => { };
let stackSize = 0;
this.ws.on('message', (data) => {
if (data.toString() === 'ping') {
this.ws.send('pong');
@@ -184,9 +152,13 @@ export default class Hydro implements Session {
const request = JSON.parse(data.toString());
if (request.language) {
this.language = request.language;
compilerVersions(this.language).then((res) => {
compilers = res;
compilerVersionCallback();
Promise.allSettled([
compilerVersions(this.language),
getStackSize(),
]).then(([compiler, stack]) => {
compilers = compiler.status === 'fulfilled' ? compiler.value : {};
stackSize = stack.status === 'fulfilled' ? stack.value : 0;
sendStatus();
});
}
if (request.task) queue.add(() => new JudgeTask(this, request.task).handle().catch((e) => log.error(e)));
@@ -205,14 +177,24 @@ export default class Hydro implements Session {
this.ws.send('{"key":"start"}');
if (!this.config.noStatus) {
const info = await sysinfo.get();
this.ws.send(JSON.stringify({ key: 'status', info: { ...info } }));
compilerVersionCallback = () => {
this.ws.send(JSON.stringify({ key: 'status', info: { ...info, compilers } }));
};
setInterval(async () => {
this.ws.send(JSON.stringify({ key: 'status', info: { ...info, stackSize } }));
sendStatus = () => this.ws.send(JSON.stringify({ key: 'status', info: { ...info, compilers, stackSize } }));
const interval = setInterval(async () => {
const [mid, inf] = await sysinfo.update();
this.ws.send(JSON.stringify({ key: 'status', info: { mid, ...inf, compilers } }));
this.ws.send(JSON.stringify({
key: 'status',
info: {
mid, ...inf, compilers, stackSize,
},
}));
}, 1200000);
let stopped = false;
const stop = () => {
if (!stopped) clearInterval(interval);
stopped = true;
};
this.ws.on('close', stop);
this.ws.on('error', stop);
}
resolve(null);
});
+21 -31
View File
@@ -1,13 +1,15 @@
/* eslint-disable no-await-in-loop */
import { createReadStream } from 'fs';
import path from 'path';
import AdmZip from 'adm-zip';
import { Readable } from 'stream';
import { ZipReader } from '@zip.js/zip.js';
import PQueue from 'p-queue';
import superagent from 'superagent';
import WebSocket from 'ws';
import { fs, noop } from '@hydrooj/utils';
import { parseLang } from '@hydrooj/utils/lib/lang';
import { STATUS } from '@hydrooj/utils/lib/status';
import type { JudgeResultBody } from 'hydrooj';
import { JudgeResultBody, parseLang, STATUS } from '@hydrooj/common';
import {
extractZip, findFileSync, fs, noop,
} from '@hydrooj/utils';
import { getConfig } from '../config';
import { FormatError, SystemError } from '../error';
import { Session } from '../interface';
@@ -19,14 +21,14 @@ function removeNixPath(text: string) {
return text.replace(/\/nix\/store\/[a-z0-9]{32}-/g, '/nix/');
}
const langs = parseLang(fs.readFileSync(path.resolve(__dirname, '../../langs.yaml'), 'utf-8'));
const langs = parseLang(fs.readFileSync(process.env.VJ4_LANGS || findFileSync('@hydrooj/hydrojudge/langs.yaml'), 'utf-8'));
export default class VJ4 implements Session {
config: any;
progress = 0;
ws: WebSocket;
async fetchFile(): Promise<string> {
async fetchFile(): Promise<any> {
throw new Error('not implemented');
}
@@ -81,18 +83,16 @@ export default class VJ4 implements Session {
this.ws.send(JSON.stringify({ ...data, tag, key }));
}
getNext(t: JudgeTask) {
return (data: Partial<JudgeResultBody>) => {
getReporter(t: JudgeTask) {
const next = (data: Partial<JudgeResultBody>) => {
log.debug('Next: %o', data);
this.send((t.request as any).tag, 'next', data);
};
}
getEnd(t: JudgeTask) {
return (data: Partial<JudgeResultBody>) => {
const end = (data: Partial<JudgeResultBody>) => {
log.info('End: %o', data);
this.send((t.request as any).tag, 'end', data);
};
return { next, end };
}
get(url: string) {
@@ -155,16 +155,11 @@ export default class VJ4 implements Session {
fs.createWriteStream(tmpFilePath),
);
fs.ensureDirSync(path.dirname(savePath));
const zip = new AdmZip(tmpFilePath);
const entries = zip.getEntries();
const zip = new ZipReader(Readable.toWeb(createReadStream(tmpFilePath)));
const entries = await zip.getEntries();
if (entries.length > 512) throw new FormatError('Too many files');
if (Math.sum(entries.map((i) => i.header.size)) > 256 * 1024 * 1024) throw new FormatError('File too large');
await new Promise((resolve, reject) => {
zip.extractAllToAsync(savePath, true, (e) => {
if (e) reject(e);
else resolve(null);
});
});
if (Math.sum(entries.map((i) => i.uncompressedSize)) > 256 * 1024 * 1024) throw new FormatError('File too large');
await extractZip(entries, savePath, { strip: true });
await fs.unlink(tmpFilePath);
await this.processData(savePath).catch(noop);
} catch (e) {
@@ -181,15 +176,10 @@ export default class VJ4 implements Session {
const w = fs.createWriteStream(tmpFilePath);
await pipeRequest(this.get(`records/${rid}/data`), w);
fs.ensureDirSync(path.dirname(savePath));
const zip = new AdmZip(tmpFilePath);
const entries = zip.getEntries();
const zip = new ZipReader(Readable.toWeb(createReadStream(tmpFilePath)));
const entries = await zip.getEntries();
if (entries.length > 512) throw new FormatError('Too many files');
await new Promise((resolve, reject) => {
zip.extractAllToAsync(savePath, true, (e) => {
if (e) reject(e);
else resolve(null);
});
});
await extractZip(entries, savePath, { strip: true });
await fs.unlink(tmpFilePath);
await this.processData(savePath);
return savePath;
@@ -251,7 +241,7 @@ export default class VJ4 implements Session {
}
}
async processData(folder: string) { // eslint-disable-line class-methods-use-this
async processData(folder: string) {
let files = await fs.readdir(folder);
if (files.length <= 2) {
if (files.length === 2) files.splice(files.indexOf('version'), 1);
+6 -15
View File
@@ -1,20 +1,11 @@
import { Context } from 'hydrooj';
import { JudgeSettings, overrideConfig } from './config';
declare module 'hydrooj' {
interface SystemKeys {
'hydrojudge.cache_dir': string;
'hydrojudge.tmp_dir': string;
'hydrojudge.sandbox_host': string;
'hydrojudge.memoryMax': string;
'hydrojudge.testcases_max': number;
'hydrojudge.total_time_limit': number;
'hydrojudge.parallelism': number;
'hydrojudge.disable': boolean;
'hydrojudge.detail': boolean;
}
}
export const Config = JudgeSettings;
export function apply(ctx: Context) {
export function apply(ctx: Context, config: ReturnType<typeof Config>) {
overrideConfig(config);
if (process.env.NODE_APP_INSTANCE !== '0') return;
ctx.once('app/started', () => require('./hosts/builtin').postInit(ctx));
// eslint-disable-next-line consistent-return
if (!config.disable) return require('./hosts/builtin').apply(ctx);
}
@@ -1,4 +1,4 @@
import { LangConfig } from '@hydrooj/utils/lib/lang';
import { LangConfig } from '@hydrooj/common';
import { runQueued } from './sandbox';
export async function compilerVersions(langs: Record<string, LangConfig>) {
@@ -10,9 +10,19 @@ export async function compilerVersions(langs: Record<string, LangConfig>) {
const res = await runQueued(version, {
copyIn: {},
time: 10000,
memory: 256 * 1024 * 1024,
}, 5);
memory: 256,
}, `compilerVersions<${lang}>`, 5);
result[lang] = `${res.stdout}\n${res.stderr}`.trim();
}
return result;
}
export async function stackSize() {
const res = await runQueued('/bin/bash -c "ulimit -s"', {
copyIn: {},
time: 10000,
memory: 256,
}, 'stackSize', 5);
if (res.stderr) return -1;
return +res.stdout;
}
+12 -15
View File
@@ -1,7 +1,6 @@
import type { LangConfig } from '@hydrooj/utils/lib/lang';
import { NormalizedSubtask } from '@hydrooj/utils/lib/utils';
import type { JudgeRequest as OrigJudgeRequest, ObjectId } from 'hydrooj';
import { JudgeResultBody, ProblemConfigFile } from 'hydrooj';
import {
DetailType, JudgeResultBody, type LangConfig, NormalizedSubtask, ProblemConfigFile,
} from '@hydrooj/common';
import { CopyInFile } from './sandbox';
import type { JudgeTask } from './task';
@@ -9,28 +8,26 @@ export interface Execute {
execute: string;
clean: () => Promise<any>;
copyIn: Record<string, CopyInFile>;
[Symbol.asyncDispose]: () => Promise<any>;
_cacheable?: string;
}
export type NextFunction = (body: Partial<JudgeResultBody>) => Promise<void> | void;
export interface ParsedConfig extends Omit<ProblemConfigFile, 'time' | 'memory' | 'subtasks'> {
export interface ParsedConfig extends Omit<ProblemConfigFile, 'time' | 'memory' | 'subtasks' | 'detail'> {
count: number;
time: number;
memory: number;
subtasks: NormalizedSubtask[];
detail: DetailType;
}
// replace ObjectId to string
export type JudgeRequest = {
[K in keyof OrigJudgeRequest]: OrigJudgeRequest[K] extends ObjectId ? string : OrigJudgeRequest[K];
};
export { JudgeRequest } from '@hydrooj/common';
export interface Session {
getLang: (name: string) => LangConfig;
getNext: (task: JudgeTask) => NextFunction;
getEnd: (task: JudgeTask) => NextFunction;
cacheOpen: (source: string, files: any[], next?: NextFunction) => Promise<string>;
fetchFile: (target: string) => Promise<string>;
getLang: (name: string, doThrow?: boolean) => LangConfig;
getReporter: (task: JudgeTask) => { next: NextFunction, end: NextFunction };
fetchFile: <T extends null | string>(namespace: T, files: Record<string, string>) => Promise<T extends null ? string : null>;
postFile: (target: string, filename: string, file: string) => Promise<void>;
config: { detail: boolean, host?: string, trusted?: boolean };
config: { detail: DetailType, host?: string, trusted?: boolean };
}
@@ -0,0 +1,89 @@
import { NormalizedCase, STATUS } from '@hydrooj/common';
import { runFlow } from '../flow';
import { Parameter, runPiped } from '../sandbox';
import signals from '../signals';
import { Context, ContextSubTask } from './interface';
function judgeCase(c: NormalizedCase) {
return async (ctx: Context, ctxSubtask: ContextSubTask) => {
const { address_space_limit, process_limit } = ctx.session.getLang(ctx.lang);
let managerArgs = '';
const execute: Parameter[] = [{
execute: ctx.executeManager.execute,
stdin: c.input ? { src: c.input } : { content: '' },
copyIn: ctx.executeManager.copyIn,
time: c.time * 2,
memory: c.memory * 2,
env: { ...ctx.env, HYDRO_TESTCASE: c.id.toString() },
}];
const pipeMapping = [];
for (let i = 0; i < ctx.config.num_processes; i++) {
managerArgs += ` /proc/self/fd/${i * 2 + 3} /proc/self/fd/${i * 2 + 4}`;
execute.push({
execute: `${ctx.executeUser.execute} ${i}`,
copyIn: ctx.executeUser.copyIn,
time: c.time,
memory: c.memory,
addressSpaceLimit: address_space_limit,
processLimit: process_limit,
});
pipeMapping.push({
name: `sol2mgr[${i}]`,
in: { index: i + 1, fd: 1 },
out: { index: 0, fd: i * 2 + 3 },
});
pipeMapping.push({
name: `mgr2sol[${i}]`,
in: { index: 0, fd: i * 2 + 4 },
out: { index: i + 1, fd: 0 },
});
}
execute[0].execute += managerArgs;
const res = await runPiped(execute, pipeMapping);
const resManager = res[0];
let time = 0;
let memory = 0;
let score = 0;
let status = STATUS.STATUS_ACCEPTED;
let message: any;
for (let i = 0; i < ctx.config.num_processes; i++) {
const result = res[i + 1];
time += result.time;
memory = Math.max(memory, result.memory);
if (result.time > c.time) status = STATUS.STATUS_TIME_LIMIT_EXCEEDED;
else if (result.memory > c.memory * 1024) status = STATUS.STATUS_MEMORY_LIMIT_EXCEEDED;
else if ((result.code && result.code !== 13 /* Broken Pipe */) || (result.code === 13 && !resManager.code)) {
status = STATUS.STATUS_RUNTIME_ERROR;
if (ctx.config.detail === 'full') {
if (result.code < 32 && result.signalled) message = signals[result.code];
else message = { message: 'Your program returned {0}.', params: [result.code] };
}
}
}
if (status === STATUS.STATUS_ACCEPTED) {
score = Math.floor(c.score * (+resManager.stdout || 0));
status = score === c.score ? STATUS.STATUS_ACCEPTED : STATUS.STATUS_WRONG_ANSWER;
message = resManager.stderr;
if (resManager.code) message += ` (Manager exited with code ${resManager.code})`;
}
return {
id: c.id,
subtaskId: ctxSubtask.subtask.id,
status,
score,
time,
memory,
message,
};
};
}
export const judge = async (ctx: Context) => await runFlow(ctx, {
compile: async () => {
[ctx.executeUser, ctx.executeManager] = await Promise.all([
ctx.compile(ctx.lang, ctx.code),
ctx.compileLocalFile('manager', ctx.config.manager),
]);
},
judgeCase,
});
+7 -8
View File
@@ -1,15 +1,14 @@
import { STATUS } from '@hydrooj/utils/lib/status';
import { NormalizedCase, STATUS } from '@hydrooj/common';
import checkers from '../checkers';
import { runFlow } from '../flow';
import { del, runQueued } from '../sandbox';
import { runQueued } from '../sandbox';
import signals from '../signals';
import { NormalizedCase } from '../utils';
import { Context, ContextSubTask } from './interface';
function judgeCase(c: NormalizedCase) {
return async (ctx: Context, ctxSubtask: ContextSubTask, runner?: Function) => {
const { address_space_limit, process_limit } = ctx.session.getLang(ctx.lang);
const res = await runQueued(
await using res = await runQueued(
ctx.execute.execute,
{
stdin: { src: c.input },
@@ -21,6 +20,7 @@ function judgeCase(c: NormalizedCase) {
addressSpaceLimit: address_space_limit,
processLimit: process_limit,
},
`judgeCase[${c.id}]<${ctx.rid}>`,
);
const {
code, signalled, time, memory, fileIds,
@@ -28,7 +28,6 @@ function judgeCase(c: NormalizedCase) {
let { status } = res;
let message: any = '';
let score = 0;
const detail = ctx.config.detail ?? true;
if (status === STATUS.STATUS_ACCEPTED) {
if (time > c.time) {
status = STATUS.STATUS_TIME_LIMIT_EXCEEDED;
@@ -38,12 +37,13 @@ function judgeCase(c: NormalizedCase) {
({ status, score, message } = await checkers[ctx.config.checker_type]({
execute: ctx.checker.execute,
copyIn: ctx.checker.copyIn || {},
code: ctx.code,
input: { src: c.input },
output: { src: c.output },
user_stdout: fileIds.stdout ? { fileId: fileIds.stdout } : { content: '' },
user_stderr: fileIds.stderr ? { fileId: fileIds.stderr } : { content: '' },
score: c.score,
detail,
detail: ctx.config.detail,
env: {
...ctx.env,
HYDRO_TESTCASE: c.id.toString(),
@@ -52,11 +52,10 @@ function judgeCase(c: NormalizedCase) {
},
}));
}
} else if (status === STATUS.STATUS_RUNTIME_ERROR && code && detail) {
} else if (status === STATUS.STATUS_RUNTIME_ERROR && code && ctx.config.detail === 'full') {
if (code < 32 && signalled) message = signals[code];
else message = { message: 'Your program returned {0}.', params: [code] };
}
await Promise.allSettled(Object.values(res.fileIds).map((id) => del(id)));
if (runner && ctx.rerun && c.time <= 5000 && status === STATUS.STATUS_TIME_LIMIT_EXCEEDED) {
ctx.rerun--;
return await runner(ctx, ctxSubtask);
+7 -7
View File
@@ -2,7 +2,7 @@
import { tmpdir } from 'os';
import path from 'path';
import fs from 'fs-extra';
import { STATUS } from '@hydrooj/utils/lib/status';
import { STATUS } from '@hydrooj/common';
import { SystemError } from '../error';
import { CopyInFile, runQueued } from '../sandbox';
import client from '../sandbox/client';
@@ -27,7 +27,7 @@ export const judge = async (ctx: JudgeTask) => {
let totalStatus = 0;
async function runGenerator(i: number) {
const res = await runQueued(
await using res = await runQueued(
`${executeGenerator.execute} ${i}`,
{
stdin: { content: ctx.input || '' },
@@ -37,12 +37,12 @@ export const judge = async (ctx: JudgeTask) => {
time: parseTimeMS('2s'),
memory: parseMemoryMB('256m'),
},
`generate[${i}]<${ctx.rid}>`,
1,
);
const tmp = path.join(tmpdir(), `${ctx.request.rid}.${i}.in`);
ctx.clean.push(() => {
ctx.pushClean(() => {
if (fs.existsSync(tmp)) fs.removeSync(tmp);
return res.fileIds['stdout'] ? client.deleteFile(res.fileIds['stdout']) : Promise.resolve();
});
const {
code, signalled, time, memory, fileIds, stderr,
@@ -79,7 +79,7 @@ export const judge = async (ctx: JudgeTask) => {
return status === STATUS.STATUS_ACCEPTED ? tmp : null;
}
async function runStd(i: number, stdin: CopyInFile) {
const res = await runQueued(
await using res = await runQueued(
`${executeStd.execute} ${i}`,
{
stdin,
@@ -89,12 +89,12 @@ export const judge = async (ctx: JudgeTask) => {
time: parseTimeMS('2s'),
memory: parseMemoryMB('256m'),
},
`generate.std[${i}]<${ctx.rid}>`,
1,
);
const tmp = path.join(tmpdir(), `${ctx.request.rid}.${i}.out`);
ctx.clean.push(() => {
ctx.pushClean(() => {
if (fs.existsSync(tmp)) fs.removeSync(tmp);
return res.fileIds['stdout'] ? client.deleteFile(res.fileIds['stdout']) : Promise.resolve();
});
const {
code, signalled, time, memory, fileIds, stderr,
+14 -11
View File
@@ -1,10 +1,8 @@
/* eslint-disable no-sequences */
import { fs } from '@hydrooj/utils';
import { STATUS } from '@hydrooj/utils/lib/status';
import { STATUS } from '@hydrooj/common';
import { fs, parseMemoryMB, parseTimeMS } from '@hydrooj/utils';
import checkers from '../checkers';
import { del, runQueued } from '../sandbox';
import { runQueued } from '../sandbox';
import signals from '../signals';
import { parseMemoryMB, parseTimeMS } from '../utils';
import { Context } from './interface';
export async function judge(ctx: Context) {
@@ -13,9 +11,12 @@ export async function judge(ctx: Context) {
ctx.compile(ctx.lang, ctx.code),
ctx.compileLocalFile('checker', ctx.config.checker, ctx.config.checker_type),
ctx.compileLocalFile('validator', ctx.config.validator),
ctx.session.fetchFile(ctx.files.hack),
(async () => {
const f = await ctx.session.fetchFile(null, { [ctx.files.hack]: '' });
ctx.pushClean(() => fs.unlink(f));
return f;
})(),
]);
ctx.clean.push(() => fs.unlink(input));
ctx.next({ status: STATUS.STATUS_JUDGING, progress: 0 });
const validateResult = await runQueued(
validator.execute,
@@ -25,23 +26,26 @@ export async function judge(ctx: Context) {
time: parseTimeMS(ctx.config.time || '1s'),
memory: parseMemoryMB(ctx.config.memory || '256m'),
},
`hack.validator[${ctx.rid}]`,
);
if (validateResult.status !== STATUS.STATUS_ACCEPTED) {
const message = `${validateResult.stdout || ''}\n${validateResult.stderr || ''}`.trim();
return ctx.end({ status: STATUS.STATUS_FORMAT_ERROR, message });
}
const { address_space_limit, process_limit } = ctx.session.getLang(ctx.lang);
const res = await runQueued(
await using res = await runQueued(
execute.execute,
{
stdin: { src: input },
copyIn: execute.copyIn,
time: parseTimeMS(ctx.config.time || '1s'),
memory: parseMemoryMB(ctx.config.memory || '256m'),
filename: ctx.config.filename,
cacheStdoutAndStderr: true,
addressSpaceLimit: address_space_limit,
processLimit: process_limit,
},
`hack[${ctx.rid}]`,
);
const {
code, signalled, time, memory,
@@ -61,8 +65,9 @@ export async function judge(ctx: Context) {
output: { content: '' },
user_stdout: res.fileIds.stdout ? { fileId: res.fileIds.stdout } : { content: '' },
user_stderr: { fileId: res.fileIds.stderr },
code: ctx.code,
score: 100,
detail: ctx.config.detail ?? true,
detail: ctx.config.detail ?? 'full',
env: { ...ctx.env, HYDRO_TESTCASE: '0' },
}));
}
@@ -70,8 +75,6 @@ export async function judge(ctx: Context) {
if (code < 32 && signalled) message = signals[code];
else message = { message: 'Your program returned {0}.', params: [code] };
}
await Promise.allSettled(Object.values(res.fileIds).map((id) => del(id)));
if (message) ctx.next({ message });
return ctx.end({
+2 -1
View File
@@ -1,3 +1,4 @@
import * as communication from './communication';
import * as def from './default';
import * as generate from './generate';
import * as hack from './hack';
@@ -8,5 +9,5 @@ import * as run from './run';
import * as submit_answer from './submit_answer';
export = {
default: def, generate, interactive, run, submit_answer, objective, hack,
default: def, generate, interactive, communication, run, submit_answer, objective, hack,
} as Record<string, { judge(ctx: Context): Promise<void> }>;
+8 -7
View File
@@ -1,9 +1,8 @@
import { STATUS } from '@hydrooj/utils/lib/status';
import { NormalizedCase, STATUS } from '@hydrooj/common';
import { runFlow } from '../flow';
import { runPiped } from '../sandbox';
import signals from '../signals';
import { parse } from '../testlib';
import { NormalizedCase } from '../utils';
import { Context, ContextSubTask } from './interface';
function judgeCase(c: NormalizedCase) {
@@ -11,7 +10,7 @@ function judgeCase(c: NormalizedCase) {
const { address_space_limit, process_limit } = ctx.session.getLang(ctx.lang);
const [{
code, signalled, time, memory,
}, resInteractor] = await runPiped(
}, resInteractor] = await runPiped([
{
execute: ctx.executeUser.execute,
copyIn: ctx.executeUser.copyIn,
@@ -32,22 +31,24 @@ function judgeCase(c: NormalizedCase) {
copyOut: ['/w/tout?'],
env: { ...ctx.env, HYDRO_TESTCASE: c.id.toString() },
},
);
], [
{ in: { index: 0, fd: 1 }, out: { index: 1, fd: 0 }, name: 'userToInteractor' },
{ in: { index: 1, fd: 1 }, out: { index: 0, fd: 0 }, name: 'interactorToUser' },
]);
// TODO handle tout (maybe pass to checker?)
let status: number;
let score = 0;
let message: any = '';
const detail = ctx.config.detail ?? true;
if (time > c.time) {
status = STATUS.STATUS_TIME_LIMIT_EXCEEDED;
} else if (memory > c.memory * 1024) {
status = STATUS.STATUS_MEMORY_LIMIT_EXCEEDED;
} else if (detail && ((code && code !== 13/* Broken Pipe */) || (code === 13 && !resInteractor.code))) {
} else if (ctx.config.detail === 'full' && ((code && code !== 13/* Broken Pipe */) || (code === 13 && !resInteractor.code))) {
status = STATUS.STATUS_RUNTIME_ERROR;
if (code < 32 && signalled) message = signals[code];
else message = { message: 'Your program returned {0}.', params: [code] };
} else {
const result = parse(resInteractor.stderr, c.score);
const result = parse(resInteractor.stderr, c.score, ctx.config.detail);
status = result.status;
score = result.score;
message = result.message;
+2 -3
View File
@@ -1,5 +1,5 @@
import PQueue from 'p-queue';
import type { NormalizedSubtask } from '@hydrooj/utils/lib/common';
import { NormalizedSubtask } from '@hydrooj/common';
import { Execute } from '../interface';
import { JudgeTask } from '../task';
@@ -20,9 +20,8 @@ export interface RuntimeContext {
execute?: Execute;
checker?: Execute;
executeInteractor?: Execute;
executeManager?: Execute;
executeUser?: Execute;
_callbackAwait?: Promise<any>;
}
export interface ContextSubTask {
+1 -1
View File
@@ -1,6 +1,6 @@
import assert from 'assert';
import { STATUS } from '@hydrooj/common';
import { fs, yaml } from '@hydrooj/utils';
import { STATUS } from '@hydrooj/utils/lib/status';
import { FormatError } from '../error';
import { Context } from './interface';
+5 -4
View File
@@ -1,8 +1,8 @@
import { STATUS } from '@hydrooj/utils/lib/status';
import { STATUS } from '@hydrooj/common';
import { parseMemoryMB, parseTimeMS } from '@hydrooj/utils';
import { runQueued } from '../sandbox';
import signals from '../signals';
import { JudgeTask } from '../task';
import { parseMemoryMB, parseTimeMS } from '../utils';
export const judge = async (ctx: JudgeTask) => {
ctx.stat.judge = new Date();
@@ -17,11 +17,12 @@ export const judge = async (ctx: JudgeTask) => {
copyIn: execute.copyIn,
// Allow 2x limits for better debugging
time: parseTimeMS(ctx.config.time || '1s') * 2,
memory: parseMemoryMB(ctx.config.memory || '128m'),
memory: parseMemoryMB(ctx.config.memory || '256m'),
filename: ctx.config.filename,
addressSpaceLimit: address_space_limit,
processLimit: process_limit,
},
`judge[${ctx.rid}]`,
1,
);
const {
@@ -31,7 +32,7 @@ export const judge = async (ctx: JudgeTask) => {
const message: string[] = [];
if (time > parseTimeMS(ctx.config.time || '1s')) {
status = STATUS.STATUS_TIME_LIMIT_EXCEEDED;
} else if (memory > parseMemoryMB(ctx.config.memory || '128m') * 1024) {
} else if (memory > parseMemoryMB(ctx.config.memory || '256m') * 1024) {
status = STATUS.STATUS_MEMORY_LIMIT_EXCEEDED;
} else if (code) {
status = STATUS.STATUS_RUNTIME_ERROR;
+13 -12
View File
@@ -1,9 +1,8 @@
import { NormalizedCase, STATUS } from '@hydrooj/common';
import { fs } from '@hydrooj/utils';
import { STATUS } from '@hydrooj/utils/lib/status';
import checkers from '../checkers';
import { runFlow } from '../flow';
import { del, runQueued } from '../sandbox';
import { NormalizedCase } from '../utils';
import { runQueued } from '../sandbox';
import { Context } from './interface';
function judgeCase(c: NormalizedCase) {
@@ -16,28 +15,30 @@ function judgeCase(c: NormalizedCase) {
let status = STATUS.STATUS_ACCEPTED;
let message: any = '';
let score = 0;
const fileIds = [];
await using cleanup = {
clean: async () => { },
async [Symbol.asyncDispose]() { await this.clean(); },
};
if (ctx.config.subType === 'multi') {
const res = await runQueued(
'/usr/bin/unzip foo.zip',
`/usr/bin/unzip -p foo.zip ${name}`,
{
stdin: null,
copyIn: { 'foo.zip': ctx.code },
copyOutCached: [`${name}?`],
time: 1000,
memory: 128,
cacheStdoutAndStderr: true,
},
);
if (res.status === STATUS.STATUS_RUNTIME_ERROR && res.code) {
cleanup.clean = async () => await res[Symbol.asyncDispose]();
if (res.status === STATUS.STATUS_RUNTIME_ERROR && res.code && res.code !== 11) {
message = { message: 'Unzip failed.' };
status = STATUS.STATUS_WRONG_ANSWER;
} else if (!res.fileIds[name]) {
} else if (res.status === STATUS.STATUS_RUNTIME_ERROR && res.code && res.code === 11) {
message = { message: 'File not found.' };
status = STATUS.STATUS_WRONG_ANSWER;
}
file = { fileId: res.fileIds[name] };
fileIds.push(...Object.values(res.fileIds));
file = { fileId: res.fileIds['stdout'] };
}
if (status === STATUS.STATUS_ACCEPTED) {
({ status, score, message } = await checkers[ctx.config.checker_type]({
@@ -47,12 +48,12 @@ function judgeCase(c: NormalizedCase) {
output: { src: c.output },
user_stdout: file,
user_stderr: { content: '' },
code: ctx.code,
score: c.score,
detail: ctx.config.detail ?? true,
detail: ctx.config.detail,
env: { ...ctx.env, HYDRO_TESTCASE: c.id.toString() },
}));
}
await Promise.allSettled(fileIds.map(del));
return {
id: c.id,
status,
+57 -57
View File
@@ -2,14 +2,14 @@ import cac from 'cac';
import PQueue from 'p-queue';
import { gte } from 'semver';
import { ParseEntry } from 'shell-quote';
import { STATUS } from '@hydrooj/utils/lib/status';
import { STATUS } from '@hydrooj/common';
import * as sysinfo from '@hydrooj/utils/lib/sysinfo';
import { getConfig } from './config';
import { FormatError, SystemError } from './error';
import { Logger } from './log';
import client from './sandbox/client';
import {
Cmd, CopyIn, CopyInFile, SandboxResult, SandboxStatus,
Cmd, CopyIn, CopyInFile, PipeMap, SandboxResult, SandboxStatus,
} from './sandbox/interface';
import { cmd, parseMemoryMB } from './utils';
@@ -29,10 +29,12 @@ const statusMap: Map<SandboxStatus, number> = new Map([
[SandboxStatus.Signalled, STATUS.STATUS_RUNTIME_ERROR],
]);
interface Parameter {
export interface Parameter {
/** in ms */
time?: number;
stdin?: CopyInFile;
execute?: string;
/** in MB */
memory?: number;
processLimit?: number;
addressSpaceLimit?: boolean;
@@ -83,7 +85,7 @@ function proc(params: Parameter): Cmd {
copyOutCached.push('stdout', 'stderr');
if (params.filename) copyOutCached.push(`${params.filename}.out?`);
} else if (params.filename) copyOut.push(`${params.filename}.out${supportOptional ? '?' : ''}`);
const copyIn = { ...(params.copyIn || {}) };
const copyIn = { ...params.copyIn };
const stdin = params.stdin || { content: '' };
if (params.filename) copyIn[`${params.filename}.in`] = stdin;
const time = params.time || 16000;
@@ -114,7 +116,7 @@ function proc(params: Parameter): Cmd {
};
}
async function adaptResult(result: SandboxResult, params: Parameter): Promise<SandboxAdaptedResult> {
function adaptResult(result: SandboxResult, params: Parameter): SandboxAdaptedResult {
const rate = getConfig('rate') as number;
// FIXME: Signalled?
const ret: SandboxAdaptedResult = {
@@ -145,102 +147,100 @@ async function adaptResult(result: SandboxResult, params: Parameter): Promise<Sa
return ret;
}
export async function runPiped(execute0: Parameter, execute1: Parameter): Promise<[SandboxAdaptedResult, SandboxAdaptedResult]> {
export async function runPiped(
execute: Parameter[], pipeMapping: Pick<PipeMap, 'in' | 'out' | 'name'>[], params: Parameter = {}, trace: string = '',
): Promise<SandboxAdaptedResult[]> {
let res: SandboxResult[];
const size = parseMemoryMB(getConfig('stdio_size'));
try {
if (!supportOptional) {
const { copyOutOptional } = await client.version();
supportOptional = copyOutOptional;
if (!copyOutOptional) logger.warn('Sandbox version tooooooo low! Please upgrade to at least 1.2.0');
}
const body = {
cmd: [
proc(execute0),
proc(execute1),
],
pipeMapping: [{
in: { index: 0, fd: 1 },
out: { index: 1, fd: 0 },
cmd: execute.map((exe) => proc({ ...exe, ...params })),
pipeMapping: pipeMapping.map((pipe) => ({
proxy: true,
name: 'stdout',
max: 1024 * 1024 * size,
}, {
in: { index: 1, fd: 1 },
out: { index: 0, fd: 0 },
proxy: true,
name: 'stdout',
max: 1024 * 1024 * size,
}],
...pipe,
})),
};
body.cmd[0].files[0] = null;
body.cmd[0].files[1] = null;
body.cmd[1].files[0] = null;
body.cmd[1].files[1] = null;
for (let i = 0; i < body.cmd.length; i++) {
if (pipeMapping.find((pipe) => pipe.out.index === i && pipe.out.fd === 0)) body.cmd[i].files[0] = null;
if (pipeMapping.find((pipe) => pipe.in.index === i && pipe.in.fd === 1)) body.cmd[i].files[1] = null;
}
const id = callId++;
if (argv.options.showSandbox) logger.debug('%d %s', id, JSON.stringify(body));
res = await client.run(body);
res = await client.run(body, trace);
if (argv.options.showSandbox) logger.debug('%d %s', id, JSON.stringify(res));
} catch (e) {
if (e instanceof FormatError || e instanceof SystemError) throw e;
console.error(e);
throw new SystemError('Sandbox Error', [e]);
}
return await Promise.all(res.map((r) => adaptResult(r, {}))) as [SandboxAdaptedResult, SandboxAdaptedResult];
return res.map((r) => adaptResult(r, params)) as SandboxAdaptedResult[];
}
export async function del(fileId: string) {
await client.deleteFile(fileId);
}
export async function get(fileId: string) {
return await client.getFile(fileId);
}
export async function run(execute: string, params?: Parameter): Promise<SandboxAdaptedResult> {
let result: SandboxResult;
try {
if (!supportOptional) {
const res = await client.version();
supportOptional = res.copyOutOptional;
if (!supportOptional) logger.warn('Sandbox version tooooooo low! Please upgrade to at least 1.2.0');
}
const body = { cmd: [proc({ execute, ...params })] };
const id = callId++;
if (argv.options.showSandbox) logger.debug('%d %s', id, JSON.stringify(body));
const res = await client.run(body);
if (argv.options.showSandbox) logger.debug('%d %s', id, JSON.stringify(res));
[result] = res;
} catch (e) {
if (e instanceof FormatError || e instanceof SystemError) throw e;
console.error(e);
// FIXME request body larger than maxBodyLength limit
throw new SystemError('Sandbox Error', e.message);
}
return await adaptResult(result, params);
export async function get(fileId: string, dest?: string) {
return await client.getFile(fileId, dest);
}
const queue = new PQueue({ concurrency: getConfig('concurrency') || getConfig('parallelism') });
export function runQueued(execute: string, params?: Parameter, priority = 0) {
return queue.add(() => run(execute, params), { priority }) as Promise<SandboxAdaptedResult>;
export function runQueued(
execute: Parameter[], pipeMapping: Pick<PipeMap, 'in' | 'out' | 'name'>[],
params: Parameter, trace?: string, priority?: number,
): Promise<SandboxAdaptedResult[] & AsyncDisposable>;
export function runQueued(execute: string, params: Parameter, trace?: string, priority?: number
): Promise<SandboxAdaptedResult & AsyncDisposable>;
export function runQueued(
arg0: string | Parameter[], arg1: Pick<PipeMap, 'in' | 'out' | 'name'>[] | Parameter,
arg2?: string | Parameter, arg3?: string | number, arg4?: number,
) {
const single = !Array.isArray(arg0);
const [execute, pipeMapping, params, trace, priority] = single
? [[{ execute: arg0 }], [], arg1 || {}, arg2 || '', arg3 || 0] as any
: [arg0, arg1, arg2 || {}, arg3 || '', arg4 || 0];
return queue.add(async () => {
const res = await runPiped(execute, pipeMapping, params, trace);
const ret = single ? res[0] : res;
(ret as any)[Symbol.asyncDispose] = () => Promise.allSettled(res.flatMap((t) => Object.values(t.fileIds || {}).map(del)));
return ret;
}, { priority });
}
export async function versionCheck(reportWarn: (str: string) => void, reportError = reportWarn) {
let sandboxVersion: string;
let sandboxCgroup: number;
let sandboxCgroupControllers: string[] | null;
try {
const version = await client.version();
sandboxVersion = version.buildVersion.split('v')[1];
const config = await client.config();
sandboxCgroup = config.runnerConfig?.cgroupType || 0;
sandboxCgroupControllers = config.runnerConfig?.cgroupControllers || null;
} catch (e) {
if (e?.syscall === 'connect') reportError('Connecting to sandbox failed, please check sandbox_host config and if your sandbox is running.');
if (e?.code === 'ECONNREFUSED') reportError('Failed to connect to sandbox, please check sandbox_host config and if your sandbox is running.');
else reportError('Your sandbox version is tooooooo low! Please upgrade!');
return false;
}
const { osinfo } = await sysinfo.get();
if (sandboxCgroup === 2) {
const kernelVersion = osinfo.kernel.split('-')[0];
if (!(gte(kernelVersion, '5.19.0') && gte(sandboxVersion, '1.6.10'))) {
const kernelVersion = osinfo.kernel.match(/^\d+\.\d+\.\d+/)[0];
if (!gte(kernelVersion, '5.19.0') || !gte(sandboxVersion, '1.6.10')) {
reportWarn('You are using cgroup v2 without kernel 5.19+. This could result in inaccurate memory usage measurements.');
}
}
if (sandboxCgroupControllers) {
if (!sandboxCgroupControllers.includes('memory') && gte(sandboxVersion, '1.8.6')) {
reportWarn('The memory cgroup controller is not enabled. This could result in inaccurate memory usage measurements.');
}
}
return true;
}
+27 -15
View File
@@ -3,12 +3,14 @@ import fs from 'fs';
import superagent from 'superagent';
import WebSocket from 'ws';
import { pipeRequest } from '@hydrooj/utils';
import { version } from '../../package.json';
import { getConfig } from '../config';
import {
Input, Output, Resize, SandboxRequest, SandboxResult, SandboxVersion,
} from './interface';
let url;
let url: string;
const UA = `HydroJudge/${version} (${Math.random().toString(36).substring(2, 8)})`;
export class Stream extends EventEmitter {
private ws: WebSocket;
@@ -16,7 +18,7 @@ export class Stream extends EventEmitter {
constructor(httpUrl: string, req: SandboxRequest) {
super();
const wsUrl = `${httpUrl.replace('http', 'ws')}/stream`;
this.ws = new WebSocket(wsUrl);
this.ws = new WebSocket(wsUrl, { headers: { 'User-Agent': UA } });
this.ws.onmessage = (e) => {
const data: Buffer = e.data as Buffer;
switch (data[0]) {
@@ -81,28 +83,38 @@ export class Stream extends EventEmitter {
}
}
function _call(method: 'post' | 'get' | 'delete', endpoint: string, trace?: string) {
return superagent[method](`${url}/${endpoint}`).set('User-Agent', trace ? `${UA} (${trace})` : UA);
}
const client = new Proxy({
run(req: SandboxRequest): Promise<SandboxResult[]> {
return superagent.post(`${url}/run`).send(req).then((res) => res.body);
async run(req: SandboxRequest, trace?: string): Promise<SandboxResult[]> {
const res = await _call('post', 'run', trace).send(req);
return res.body;
},
getFile(fileId: string, dest?: string): Promise<Buffer> {
async getFile(fileId: string, dest?: string): Promise<Buffer> {
const req = _call('get', `file/${fileId}`);
if (dest) {
const w = fs.createWriteStream(dest);
return pipeRequest(superagent.get(`${url}/file/${fileId}`), w, 60000, fileId) as any;
return await pipeRequest(req, w, 60000, fileId) as any;
}
return superagent.get(`${url}/file/${fileId}`).responseType('arraybuffer').then((res) => res.body);
const res = await req.responseType('arraybuffer');
return res.body;
},
deleteFile(fileId: string): Promise<void> {
return superagent.delete(`${url}/file/${fileId}`).then((res) => res.body);
async deleteFile(fileId: string): Promise<void> {
const res = await _call('delete', `file/${fileId}`);
return res.body;
},
listFiles(): Promise<Record<string, string>> {
return superagent.get(`${url}/file`).then((res) => res.body);
async listFiles(): Promise<Record<string, string>> {
const res = await _call('get', 'file');
return res.body;
},
version(): Promise<SandboxVersion> {
return superagent.get(`${url}/version`).then((res) => res.body);
async version(): Promise<SandboxVersion> {
const res = await _call('get', 'version');
return res.body;
},
config(): Promise<Record<string, any>> {
return superagent.get(`${url}/config`).then((res) => res.body);
async config(): Promise<Record<string, any>> {
const res = await _call('get', 'config');
return res.body;
},
stream(req: SandboxRequest): Stream {
return new Stream(url, req);
@@ -7,6 +7,7 @@ export interface SandboxVersion {
pipeProxy?: boolean;
addressSpaceLimit?: boolean;
stream?: boolean;
procPeak?: boolean;
}
export interface LocalFile {
@@ -157,6 +158,8 @@ export interface SandboxResult {
memory: number;
/** wall clock time in ns */
runTime: number;
/** peak count of processes (thread) during execution */
procPeak?: number;
/** copyOut file name to content (UTF-8 encoded and invalid character replaced) */
files?: Record<string, string>;
/** copyOutCached file name to fileId */
+124 -19
View File
@@ -1,12 +1,11 @@
import { basename, join } from 'path';
import { fs } from '@hydrooj/utils';
import { STATUS } from '@hydrooj/utils/lib/status';
import type {
FileInfo, JudgeMeta, JudgeResultBody, TestCase,
} from 'hydrooj';
import {
CompilableSource, FileInfo, JudgeMeta, JudgeResultBody, STATUS, TestCase,
} from '@hydrooj/common';
import { findFileSync, fs } from '@hydrooj/utils';
import readCases from './cases';
import checkers from './checkers';
import compile, { compileLocalFile } from './compile';
import compile from './compile';
import { getConfig } from './config';
import { CompileError, FormatError } from './error';
import {
@@ -14,11 +13,17 @@ import {
} from './interface';
import judge from './judge';
import { Logger } from './log';
import { CopyIn, CopyInFile, runQueued } from './sandbox';
import { compilerText, md5 } from './utils';
import {
CopyIn, CopyInFile, get, PreparedFile, runQueued,
} from './sandbox';
import { compilerText, Lock, md5 } from './utils';
const logger = new Logger('judge');
const testlibFile = {
src: findFileSync('@hydrooj/hydrojudge/vendor/testlib/testlib.h'),
};
export class JudgeTask {
stat: Record<string, Date> = Object.create(null);
source: string;
@@ -26,6 +31,7 @@ export class JudgeTask {
lang: string;
code: CopyInFile;
input?: string;
finished: boolean = false;
clean: (() => Promise<any>)[] = [];
data: FileInfo[];
folder: string;
@@ -36,6 +42,7 @@ export class JudgeTask {
end: (data: Partial<JudgeResultBody>) => void;
env: Record<string, string>;
callbackCache?: TestCase[];
compileCache: Record<string, Pick<Execute, 'execute' | 'copyIn' | typeof Symbol.asyncDispose>> = {};
constructor(public session: Session, public request: JudgeRequest) {
this.stat.receive = new Date();
@@ -63,8 +70,7 @@ export class JudgeTask {
HYDRO_USER: (this.request.uid || 0).toString(),
HYDRO_CONTEST: tid,
};
this.next = this.session.getNext(this);
this.end = this.session.getEnd(this);
Object.assign(this, this.session.getReporter(this));
logger.info('Submission: %s/%s/%s', host, this.source, this.rid);
await this.doSubmission();
} catch (e) {
@@ -87,16 +93,59 @@ export class JudgeTask {
});
}
} finally {
this.finished = true;
// eslint-disable-next-line no-await-in-loop
for (const clean of this.clean) await clean()?.catch(() => null);
}
}
async cacheOpen(source: string, files: FileInfo[]) {
// Backward compatibility for vj4
if ((this.session as any).cacheOpen) return (this.session as any).cacheOpen(source, files);
const host = this.session.config?.host || 'local';
const filePath = join(getConfig('cache_dir'), host, source);
await Lock.acquire(filePath);
try {
await fs.ensureDir(filePath);
if (!files?.length) throw new FormatError('Problem data not found.');
let etags: Record<string, string> = {};
try {
etags = JSON.parse(await fs.readFile(join(filePath, 'etags'), 'utf-8'));
} catch (e) { /* ignore */ }
this.compileCache = etags['*cache'] as any || {};
delete etags['*cache'];
const version = {};
const filenames = [];
const allFiles = new Set<string>();
for (const file of files) {
allFiles.add(file.name);
version[file.name] = file.etag + file.lastModified;
if (etags[file.name] !== file.etag + file.lastModified) filenames.push(file.name);
}
const allFilesToRemove = Object.keys(etags).filter((name) => !allFiles.has(name) && fs.existsSync(join(filePath, name)));
await Promise.all(allFilesToRemove.map((name) => fs.remove(join(filePath, name))));
if (filenames.length) {
logger.info(`Getting problem data: ${this.session?.config.host || 'local'}/${source}`);
this.next({ message: 'Syncing testdata, please wait...' });
await this.session.fetchFile(source, Object.fromEntries(files.map((i) => [i.name, join(filePath, i.name)])));
await fs.writeFile(join(filePath, 'etags'), JSON.stringify(version));
this.compileCache = {};
}
await fs.writeFile(join(filePath, 'lastUsage'), Date.now().toString());
return filePath;
} catch (e) {
logger.warn('CacheOpen Fail: %s %o %o', source, files, e);
throw e;
} finally {
Lock.release(filePath);
}
}
async doSubmission() {
this.stat.cache_start = new Date();
this.folder = await this.session.cacheOpen(this.source, this.data, this.next);
this.folder = await this.cacheOpen(this.source, this.data);
if (this.files?.code) {
const target = await this.session.fetchFile(this.files?.code);
const target = await this.session.fetchFile(null, { [this.files.code]: '' });
this.code = { src: target };
this.clean.push(() => fs.remove(target));
}
@@ -126,29 +175,85 @@ export class JudgeTask {
await judge[type].judge(this);
}
async pushClean(f: () => any | Promise<any>) {
if (this.finished) await f().catch(() => null);
else this.clean.push(f);
}
async compile(lang: string, code: CopyInFile) {
const copyIn = Object.fromEntries(
(this.config.user_extra_files || []).map((i) => [basename(i), { src: i }]),
) as CopyIn;
const result = await compile(this.session.getLang(lang), code, copyIn, this.next);
this.clean.push(result.clean);
await this.pushClean(result.clean);
return result;
}
async compileLocalFile(type: 'interactor' | 'validator' | 'checker' | 'generator' | 'std', file: string, checkerType?: string) {
if (type === 'checker' && ['default', 'strict'].includes(checkerType)) return { execute: '', copyIn: {}, clean: () => Promise.resolve(null) };
async compileLocalFile(
type: 'interactor' | 'validator' | 'checker' | 'generator' | 'manager' | 'std',
source: CompilableSource, checkerType?: string,
): Promise<Execute> {
if (type === 'checker' && ['default', 'strict'].includes(checkerType)) {
return {
execute: '',
copyIn: {},
clean: () => Promise.resolve(null),
[Symbol.asyncDispose]: () => Promise.resolve(null),
};
}
if (type === 'checker' && !checkers[checkerType]) throw new FormatError('Unknown checker type {0}.', [checkerType]);
if (this.compileCache?.[type]) {
return {
...this.compileCache[type],
clean: () => Promise.resolve(null),
};
}
const withTestlib = type !== 'std' && (type !== 'checker' || checkerType === 'testlib');
const extra = type === 'std' ? this.config.user_extra_files : this.config.judge_extra_files;
const copyIn = {
user_code: this.code,
...Object.fromEntries(
(extra || []).map((i) => [basename(i), { src: i }]),
),
...(withTestlib ? { 'testlib.h': testlibFile } : {}),
} as CopyIn;
let [file, langId] = typeof source === 'string' ? [source, 'auto'] : [source.file, source.lang];
if (!file.startsWith('/')) file = join(this.folder, file);
const result = await compileLocalFile(file, type, this.session.getLang, copyIn, withTestlib, this.next);
this.clean.push(result.clean);
let lang;
if (langId === 'auto') {
const s = file.replace('@', '.').split('.');
langId = s.pop();
while (s.length) {
lang = this.session.getLang(langId, false);
if (lang) break;
langId = `${s.pop()}.${langId}`;
}
} else lang = this.session.getLang(langId, false);
if (!lang) throw new FormatError(`Unknown ${type} language.`);
// TODO cache compiled binary
const result = await compile(lang, { src: file }, copyIn);
if (!result._cacheable) {
await this.pushClean(result.clean);
return result;
}
await Lock.acquire(this.folder);
try {
const loc = join(this.folder, `_${type}.cache`);
const newCopyIn = { ...result.copyIn, [result._cacheable]: { src: loc } };
// compiled checker should no longer need header file
// delete this copyIn as it's shipped with hydrojudge and may disappear after upgrade
delete newCopyIn['testlib.h'];
this.compileCache[type] = {
execute: result.execute,
copyIn: newCopyIn,
[Symbol.asyncDispose]: () => Promise.resolve(null),
};
await get((result.copyIn[result._cacheable] as PreparedFile).fileId, loc);
const currEtag = await fs.readFile(join(this.folder, 'etags'), 'utf-8');
await fs.writeFile(join(this.folder, 'etags'), JSON.stringify({ ...JSON.parse(currEtag), '*cache': this.compileCache }));
} finally {
await this.pushClean(result.clean);
Lock.release(this.folder);
}
return result;
}
@@ -167,7 +272,7 @@ export class JudgeTask {
env: this.env,
time: 5000,
memory: 256,
});
}, `analysis[${this.lang}]<${this.rid}>`, 5);
const out = r.stdout.toString();
if (out.length) this.next({ compilerText: out.substring(0, 1024) });
if (process.env.DEV) console.log(r);
+3 -3
View File
@@ -1,8 +1,8 @@
import { STATUS } from '@hydrooj/utils/lib/status';
import { DetailType, STATUS } from '@hydrooj/common';
const operation = /^\s*(status|score)\((\d+)\)\s*(.*)$/m;
export function parse(output: string, fullscore: number) {
export function parse(output: string, fullscore: number, detail: DetailType) {
let status = STATUS.STATUS_WRONG_ANSWER;
let score = 0;
let builder = (msg: string) => msg;
@@ -51,5 +51,5 @@ export function parse(output: string, fullscore: number) {
score = +val;
}
}
return { status, score, message: builder(message) };
return { status, score, message: builder(detail === 'full' ? message : '') };
}
+5 -6
View File
@@ -1,5 +1,6 @@
import path from 'path';
import { parse } from 'shell-quote';
import { CompilableSource } from '@hydrooj/common';
import { fs } from '@hydrooj/utils';
import { FormatError } from './error';
@@ -26,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) {
@@ -40,7 +38,8 @@ function restrictFile(p: string) {
}
export function ensureFile(folder: string) {
return (file: string, message: string) => {
return (src: CompilableSource, message: string) => {
const file = typeof src === 'string' ? src : src?.file;
if (file === '/dev/null') return file;
// Historical issue
if (file.includes('/')) {
+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;
Loaded 100 of 418 files, more files were not shown because too many files have changed in this diff. Show more