mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-19 17:38:19 +08:00
Merge branch 'master' into problem-content
This commit is contained in:
418 files changed
+11249
-7675
No files matched your search
@@ -1,3 +1,5 @@
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import { Context, Schema, UserModel } from 'hydrooj';
|
||||
import { startPerformanceTest } from './performance-test';
|
||||
|
||||
@@ -7,4 +9,17 @@ export async function apply(ctx: Context) {
|
||||
if (that.session.uid === 2) await UserModel.setSuperAdmin(2);
|
||||
});
|
||||
ctx.addScript('performance-test', 'test', Schema.object({ enable5: Schema.boolean().default(false) }), startPerformanceTest);
|
||||
const installerFileExists = fs.existsSync('/etc/HYDRO_INSTALLER');
|
||||
if (!installerFileExists) return;
|
||||
const content = fs.readFileSync('/etc/HYDRO_INSTALLER', 'utf-8');
|
||||
const val: Record<string, string> = {};
|
||||
for (const line of content.split('\n')) {
|
||||
const [key, value] = line.split('=').map((i) => i?.trim());
|
||||
if (key && value) val[key] = value;
|
||||
}
|
||||
if (val.layout === '1') {
|
||||
execSync('nix-env -iA nixpkgs.gawk');
|
||||
execSync('pm2 restart hydro-sandbox');
|
||||
fs.writeFileSync('/etc/HYDRO_INSTALLER', content.replace('layout=1', 'layout=2'));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"name": "@hydrooj/a11y",
|
||||
"version": "0.0.2"
|
||||
"version": "0.0.4",
|
||||
"repository": "https://github.com/hydro-dev/Hydro"
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 [
|
||||
@@ -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;
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
+20
-71
@@ -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;
|
||||
+17
-17
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
url:
|
||||
value: http://127.0.0.1:9200
|
||||
indexSize:
|
||||
value: 10000
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
@@ -1,5 +1,3 @@
|
||||
/* eslint-disable import/no-duplicates */
|
||||
/* eslint-disable no-constant-condition */
|
||||
/* eslint-disable no-await-in-loop */
|
||||
/* ..
|
||||
.' @`._
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { STATUS_TEXTS } from '@hydrooj/utils/lib/status';
|
||||
import { STATUS_TEXTS } from '@hydrooj/common';
|
||||
|
||||
interface CompileErrorInfo {
|
||||
stdout?: string,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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> }>;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 : '') };
|
||||
}
|
||||
@@ -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('/')) {
|
||||
|
||||
Vendored
+1
-1
Submodule packages/hydrojudge/vendor/testlib updated: d0a0161b20...9ecb11126c.
@@ -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')) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
__flag: 🇬🇧
|
||||
__id: en
|
||||
__interface: yes
|
||||
__langname: English
|
||||
Can be Extended For: Extension
|
||||
contest_create: Contest Create
|
||||
|
||||
@@ -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.: 그룹 생성이 끝나지 않았습니다. "계속"을 눌러 생성을 완료하십시오.
|
||||
|
||||
@@ -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.: 您有未完成的创建域操作。您可以点击“继续”来完成创建。
|
||||
|
||||
@@ -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.: 您還沒有參加本次比賽。
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: ['*'],
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user