diff --git a/packages/hydrojudge/src/compile.ts b/packages/hydrojudge/src/compile.ts index 132c019dc..e72122879 100644 --- a/packages/hydrojudge/src/compile.ts +++ b/packages/hydrojudge/src/compile.ts @@ -50,8 +50,8 @@ const testlibFile = { src: findFileSync('@hydrooj/hydrojudge/vendor/testlib/testlib.h'), }; -export async function compileWithTestlib( - src: string, type: 'checker' | 'validator' | 'interactor', +export async function compileLocalFile( + src: string, type: 'checker' | 'validator' | 'interactor' | 'generator' | 'std', getLang, copyIn: CopyIn, withTestlib = true, next?: any, ) { const s = src.replace('@', '.').split('.'); diff --git a/packages/hydrojudge/src/hosts/builtin.ts b/packages/hydrojudge/src/hosts/builtin.ts index ad3bd24fc..275742d7a 100644 --- a/packages/hydrojudge/src/hosts/builtin.ts +++ b/packages/hydrojudge/src/hosts/builtin.ts @@ -6,7 +6,7 @@ import { JudgeResultBody, ObjectId, RecordModel, SettingModel, StorageModel, SystemModel, TaskModel, } from 'hydrooj'; -import { end, next } from 'hydrooj/src/handler/judge'; +import { end, next, processJudgeFileCallback } from 'hydrooj/src/handler/judge'; import { getConfig } from '../config'; import { FormatError, SystemError } from '../error'; import { Context } from '../judge/interface'; @@ -46,6 +46,9 @@ const session = { if (doThrow) throw new SystemError('Unsupported language {0}.', [lang]); return null; }, + async postFile(target: string, filename: string, filepath: string) { + return await processJudgeFileCallback(new ObjectId(target), filename, filepath); + }, async cacheOpen(source: string, files: any[]) { const filePath = path.join(getConfig('cache_dir'), source); await fs.ensureDir(filePath); @@ -84,9 +87,10 @@ export async function postInit(ctx) { logger.debug('Record not found: %o', t); return; } - await (new JudgeTask(session, Object.assign(rdoc, t))).handle().catch(logger.error); + await (new JudgeTask(session, JSON.parse(JSON.stringify(Object.assign(rdoc, t))))).handle().catch(logger.error); }; const parallelism = Math.max(getConfig('parallelism'), 2); for (let i = 1; i < parallelism; i++) TaskModel.consume({ type: 'judge' }, handle); TaskModel.consume({ type: 'judge', priority: { $gt: -50 } }, handle); + TaskModel.consume({ type: 'generate' }, handle); } diff --git a/packages/hydrojudge/src/hosts/hydro.ts b/packages/hydrojudge/src/hosts/hydro.ts index 0f6651a04..286aefc18 100644 --- a/packages/hydrojudge/src/hosts/hydro.ts +++ b/packages/hydrojudge/src/hosts/hydro.ts @@ -10,6 +10,7 @@ import * as sysinfo from '@hydrooj/utils/lib/sysinfo'; import type { JudgeResultBody } from 'hydrooj'; import { getConfig } from '../config'; import { FormatError, SystemError } from '../error'; +import { Session } from '../interface'; import log from '../log'; import { JudgeTask } from '../task'; import { Lock } from '../utils'; @@ -18,7 +19,7 @@ function removeNixPath(text: string) { return text.replace(/\/nix\/store\/[a-z0-9]{32}-/g, '/nix/'); } -export default class Hydro { +export default class Hydro implements Session { ws: WebSocket; language: Record; @@ -134,6 +135,10 @@ export default class Hydro { return target; } + async postFile(target: string, file: string) { + await this.post('judge/upload', { target }).attach('file', fs.createReadStream(file)); + } + getLang(name: string, doThrow = true) { if (this.language[name]) return this.language[name]; if (name === 'cpp' && this.language.cc) return this.language.cc; diff --git a/packages/hydrojudge/src/interface.ts b/packages/hydrojudge/src/interface.ts index 9e936f88d..6f0427a46 100644 --- a/packages/hydrojudge/src/interface.ts +++ b/packages/hydrojudge/src/interface.ts @@ -1,6 +1,9 @@ +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 { CopyInFile } from './sandbox'; +import type { JudgeTask } from './task'; export interface Execute { execute: string; @@ -16,3 +19,18 @@ export interface ParsedConfig extends Omit LangConfig; + getNext: (task: JudgeTask) => NextFunction; + getEnd: (task: JudgeTask) => NextFunction; + cacheOpen: (source: string, files: any[], next?: NextFunction) => Promise; + fetchFile: (target: string) => Promise; + postFile: (target: string, filename: string, file: string) => Promise; + config: { detail: boolean, host?: string }; +} diff --git a/packages/hydrojudge/src/judge/default.ts b/packages/hydrojudge/src/judge/default.ts index a6eedf810..f411d17df 100644 --- a/packages/hydrojudge/src/judge/default.ts +++ b/packages/hydrojudge/src/judge/default.ts @@ -75,7 +75,7 @@ export const judge = async (ctx: Context) => await runFlow(ctx, { compile: async () => { [ctx.execute, ctx.checker] = await Promise.all([ ctx.compile(ctx.lang, ctx.code), - ctx.compileWithTestlib('checker', ctx.config.checker, ctx.config.checker_type), + ctx.compileLocalFile('checker', ctx.config.checker, ctx.config.checker_type), ]); }, judgeCase, diff --git a/packages/hydrojudge/src/judge/generate.ts b/packages/hydrojudge/src/judge/generate.ts new file mode 100644 index 000000000..f3f9db5a2 --- /dev/null +++ b/packages/hydrojudge/src/judge/generate.ts @@ -0,0 +1,144 @@ +/* eslint-disable no-await-in-loop */ +import { tmpdir } from 'os'; +import path from 'path'; +import fs from 'fs-extra'; +import { STATUS } from '@hydrooj/utils/lib/status'; +import { SystemError } from 'hydrooj'; +import { CopyInFile, runQueued } from '../sandbox'; +import client from '../sandbox/client'; +import signals from '../signals'; +import { JudgeTask } from '../task'; +import { parseMemoryMB, parseTimeMS } from '../utils'; + +export const judge = async (ctx: JudgeTask) => { + ctx.stat.judge = new Date(); + ctx.next({ status: STATUS.STATUS_COMPILING }); + if (!('content' in ctx.code)) throw new SystemError('Unsupported input'); + const [generator, std] = ctx.code.content.toString().split('\n').map((i) => i.trim()); + if (generator.includes('/') || generator === '..') throw new SystemError('Invalid input'); + if (std.includes('/') || std === '..') throw new SystemError('Invalid input'); + const [executeGenerator, executeStd] = await Promise.all([ + ctx.compileLocalFile('generator', generator), + ctx.compileLocalFile('std', std), + ]); + ctx.next({ status: STATUS.STATUS_JUDGING, progress: 0 }); + let totalTime = 0; + let totalMemory = 0; + let totalStatus = 0; + + async function runGenerator(i: number) { + const res = await runQueued( + `${executeGenerator.execute} ${i}`, + { + stdin: { content: ctx.input || '' }, + copyIn: executeGenerator.copyIn, + copyOut: ['stdout'], + time: parseTimeMS('2s'), + memory: parseMemoryMB('256m'), + }, + 1, + ); + const tmp = path.join(tmpdir(), `${ctx.request.rid}.${i}.in`); + ctx.clean.push(() => { + if (fs.existsSync(tmp)) fs.removeSync(tmp); + return res.fileIds['stdout'] ? client.deleteFile(res.fileIds['stdout']) : Promise.resolve(); + }); + const { + code, signalled, time, memory, fileIds, + } = res; + let { status } = res; + const message: string[] = []; + if (time > parseTimeMS(ctx.config.time || '2s')) { + status = STATUS.STATUS_TIME_LIMIT_EXCEEDED; + } else if (memory > parseMemoryMB('256m') * 1024) { + status = STATUS.STATUS_MEMORY_LIMIT_EXCEEDED; + } else if (code) { + status = STATUS.STATUS_RUNTIME_ERROR; + if (code < 32 && signalled) message.push(`ExitCode: ${code} (${signals[code]})`); + else message.push(`ExitCode: ${code}`); + } + totalTime += time; + totalMemory = Math.max(memory, totalMemory); + totalStatus = Math.max(status, totalStatus); + if (status === STATUS.STATUS_ACCEPTED) { + await client.getFile(fileIds['output'], tmp); + await ctx.session.postFile(ctx.request.rid.toString(), `${i}.in`, tmp); + } + ctx.next({ + case: { + id: i, + subtaskId: 1, + status, + score: 0, + time, + memory, + message: message.join('\n').substring(0, 102400), + }, + }); + return status === STATUS.STATUS_ACCEPTED ? tmp : null; + } + async function runStd(i: number, stdin: CopyInFile) { + const res = await runQueued( + `${executeStd.execute} ${i}`, + { + stdin, + copyIn: executeStd.copyIn, + copyOut: ['stdout'], + time: parseTimeMS('2s'), + memory: parseMemoryMB('256m'), + }, + 1, + ); + const tmp = path.join(tmpdir(), `${ctx.request.rid}.${i}.out`); + ctx.clean.push(() => { + if (fs.existsSync(tmp)) fs.removeSync(tmp); + return res.fileIds['stdout'] ? client.deleteFile(res.fileIds['stdout']) : Promise.resolve(); + }); + const { + code, signalled, time, memory, fileIds, + } = res; + let { status } = res; + const message: string[] = []; + if (time > parseTimeMS(ctx.config.time || '2s')) { + status = STATUS.STATUS_TIME_LIMIT_EXCEEDED; + } else if (memory > parseMemoryMB('256m') * 1024) { + status = STATUS.STATUS_MEMORY_LIMIT_EXCEEDED; + } else if (code) { + status = STATUS.STATUS_RUNTIME_ERROR; + if (code < 32 && signalled) message.push(`ExitCode: ${code} (${signals[code]})`); + else message.push(`ExitCode: ${code}`); + } + totalTime += time; + totalMemory = Math.max(memory, totalMemory); + totalStatus = Math.max(status, totalStatus); + if (status === STATUS.STATUS_ACCEPTED) { + await client.getFile(fileIds['output'], tmp); + await ctx.session.postFile(ctx.request.rid.toString(), `${i}.out`, tmp); + } + ctx.next({ + case: { + id: i, + subtaskId: 2, + status, + score: 0, + time, + memory, + message: message.join('\n').substring(0, 102400), + }, + }); + return status === STATUS.STATUS_ACCEPTED; + } + + for (let i = 1; i <= 10; i++) { + const result = await runGenerator(i); + if (result) await runStd(i, { src: result }); + } + ctx.stat.done = new Date(); + if (process.env.DEV) ctx.next({ message: JSON.stringify(ctx.stat) }); + ctx.end({ + status: totalStatus, + score: totalStatus === STATUS.STATUS_ACCEPTED ? 100 : 0, + time: totalTime, + memory: totalMemory, + }); +}; diff --git a/packages/hydrojudge/src/judge/hack.ts b/packages/hydrojudge/src/judge/hack.ts index 6f14a2561..bf86969f9 100644 --- a/packages/hydrojudge/src/judge/hack.ts +++ b/packages/hydrojudge/src/judge/hack.ts @@ -11,8 +11,8 @@ export async function judge(ctx: Context) { ctx.next({ status: STATUS.STATUS_COMPILING, progress: 0 }); const [execute, checker, validator, input] = await Promise.all([ ctx.compile(ctx.lang, ctx.code), - ctx.compileWithTestlib('checker', ctx.config.checker, ctx.config.checker_type), - ctx.compileWithTestlib('validator', ctx.config.validator), + ctx.compileLocalFile('checker', ctx.config.checker, ctx.config.checker_type), + ctx.compileLocalFile('validator', ctx.config.validator), ctx.session.fetchFile(ctx.files.hack), ]); ctx.clean.push(() => fs.unlink(input)); diff --git a/packages/hydrojudge/src/judge/index.ts b/packages/hydrojudge/src/judge/index.ts index b188af12e..67e8e8ee3 100644 --- a/packages/hydrojudge/src/judge/index.ts +++ b/packages/hydrojudge/src/judge/index.ts @@ -1,4 +1,5 @@ import * as def from './default'; +import * as generate from './generate'; import * as hack from './hack'; import * as interactive from './interactive'; import { Context } from './interface'; @@ -7,5 +8,5 @@ import * as run from './run'; import * as submit_answer from './submit_answer'; export = { - default: def, interactive, run, submit_answer, objective, hack, + default: def, generate, interactive, run, submit_answer, objective, hack, } as Record }>; diff --git a/packages/hydrojudge/src/judge/interactive.ts b/packages/hydrojudge/src/judge/interactive.ts index 02fc3229d..d60413847 100644 --- a/packages/hydrojudge/src/judge/interactive.ts +++ b/packages/hydrojudge/src/judge/interactive.ts @@ -68,7 +68,7 @@ export const judge = async (ctx: Context) => await runFlow(ctx, { compile: async () => { [ctx.executeUser, ctx.executeInteractor] = await Promise.all([ ctx.compile(ctx.lang, ctx.code), - ctx.compileWithTestlib('interactor', ctx.config.interactor), + ctx.compileLocalFile('interactor', ctx.config.interactor), ]); }, judgeCase, diff --git a/packages/hydrojudge/src/judge/submit_answer.ts b/packages/hydrojudge/src/judge/submit_answer.ts index a2b5db4d1..ab7febdbb 100644 --- a/packages/hydrojudge/src/judge/submit_answer.ts +++ b/packages/hydrojudge/src/judge/submit_answer.ts @@ -67,7 +67,7 @@ function judgeCase(c: NormalizedCase) { export const judge = async (ctx: Context) => { await runFlow(ctx, { compile: async () => { - ctx.checker = await ctx.compileWithTestlib('checker', ctx.config.checker, ctx.config.checker_type); + ctx.checker = await ctx.compileLocalFile('checker', ctx.config.checker, ctx.config.checker_type); }, judgeCase, }); diff --git a/packages/hydrojudge/src/sandbox/client.ts b/packages/hydrojudge/src/sandbox/client.ts index 54b6bbd04..1dc63c789 100644 --- a/packages/hydrojudge/src/sandbox/client.ts +++ b/packages/hydrojudge/src/sandbox/client.ts @@ -1,3 +1,4 @@ +import fs from 'fs'; import superagent from 'superagent'; import { getConfig } from '../config'; import { SandboxRequest, SandboxResult, SandboxVersion } from './interface'; @@ -8,7 +9,15 @@ const client = new Proxy({ run(req: SandboxRequest): Promise { return superagent.post(`${url}/run`).send(req).then((res) => res.body); }, - getFile(fileId: string): Promise { + getFile(fileId: string, dest?: string): Promise { + if (dest) { + const w = fs.createWriteStream(dest); + superagent.get(`${url}/file/${fileId}`).pipe(w); + return new Promise((resolve, reject) => { + w.on('finish', () => resolve(null)); + w.on('error', reject); + }); + } return superagent.get(`${url}/file/${fileId}`).responseType('arraybuffer').then((res) => res.body); }, deleteFile(fileId: string): Promise { diff --git a/packages/hydrojudge/src/task.ts b/packages/hydrojudge/src/task.ts index 1e6dfa499..1a2cbbc30 100644 --- a/packages/hydrojudge/src/task.ts +++ b/packages/hydrojudge/src/task.ts @@ -1,30 +1,22 @@ -import { basename } from 'path'; +import { basename, join } from 'path'; import { fs } from '@hydrooj/utils'; -import { LangConfig } from '@hydrooj/utils/lib/lang'; import { STATUS } from '@hydrooj/utils/lib/status'; import type { - FileInfo, JudgeMeta, JudgeRequest, JudgeResultBody, TestCase, + FileInfo, JudgeMeta, JudgeResultBody, TestCase, } from 'hydrooj'; import readCases from './cases'; import checkers from './checkers'; -import compile, { compileWithTestlib } from './compile'; +import compile, { compileLocalFile } from './compile'; import { getConfig } from './config'; import { CompileError, FormatError } from './error'; -import { Execute, NextFunction, ParsedConfig } from './interface'; +import { + Execute, JudgeRequest, ParsedConfig, Session, +} from './interface'; import judge from './judge'; import { Logger } from './log'; import { CopyIn, CopyInFile, runQueued } from './sandbox'; import { compilerText, md5 } from './utils'; -interface Session { - getLang: (name: string) => LangConfig; - getNext: (task: JudgeTask) => NextFunction; - getEnd: (task: JudgeTask) => NextFunction; - cacheOpen: (source: string, files: any[], next?: NextFunction) => Promise; - fetchFile: (target: string) => Promise; - config: { detail: boolean, host?: string }; -} - const logger = new Logger('judge'); export class JudgeTask { @@ -121,14 +113,15 @@ export class JudgeTask { isSelfSubmission: this.meta.problemOwner === this.request.uid, key: md5(`${this.source}/${getConfig('secret')}`), lang: this.lang, - langConfig: ['objective', 'submit_answer'].includes(this.request.config.type) ? null : this.session.getLang(this.lang), + langConfig: (this.request.type === 'generate' || ['objective', 'submit_answer'].includes(this.request.config.type)) + ? null : this.session.getLang(this.lang), }, ); this.stat.judge = new Date(); const type = this.request.contest?.toString() === '000000000000000000000000' ? 'run' - : this.files?.hack - ? 'hack' - : this.config.type || 'default'; + : this.request.type === 'generate' ? 'generate' + : this.files?.hack ? 'hack' + : this.config.type || 'default'; if (!judge[type]) throw new FormatError('Unrecognized problemType: {0}', [type]); await judge[type].judge(this); } @@ -142,17 +135,19 @@ export class JudgeTask { return result; } - async compileWithTestlib(type: 'interactor' | 'validator' | 'checker', file: string, checkerType?: string) { + 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) }; - if (!checkers[checkerType]) throw new FormatError('Unknown checker type {0}.', [checkerType]); - const withTestlib = type !== 'checker' || checkerType === 'testlib'; + if (type === 'checker' && !checkers[checkerType]) throw new FormatError('Unknown checker type {0}.', [checkerType]); + 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( - (this.config.judge_extra_files || []).map((i) => [basename(i), { src: i }]), + (extra || []).map((i) => [basename(i), { src: i }]), ), } as CopyIn; - const result = await compileWithTestlib(file, type, this.session.getLang, copyIn, withTestlib, this.next); + 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); return result; } diff --git a/packages/hydrooj/src/handler/judge.ts b/packages/hydrooj/src/handler/judge.ts index 83c96a03a..8ac0b3d99 100644 --- a/packages/hydrooj/src/handler/judge.ts +++ b/packages/hydrooj/src/handler/judge.ts @@ -1,20 +1,27 @@ import assert from 'assert'; +import fs from 'fs-extra'; import yaml from 'js-yaml'; import { omit } from 'lodash'; import { ObjectId } from 'mongodb'; +import sanitize from 'sanitize-filename'; +import { + FileLimitExceededError, ForbiddenError, ProblemIsReferencedError, ValidationError, +} from '../error'; import { JudgeResultBody, ProblemConfigFile, RecordDoc, Task, TestCase, } from '../interface'; import { Logger } from '../logger'; import * as builtin from '../model/builtin'; -import { STATUS } from '../model/builtin'; +import { PERM, STATUS } from '../model/builtin'; import * as contest from '../model/contest'; import domain from '../model/domain'; import problem from '../model/problem'; import record from '../model/record'; import * as setting from '../model/setting'; import storage from '../model/storage'; +import * as system from '../model/system'; import task from '../model/task'; +import user from '../model/user'; import * as bus from '../service/bus'; import { updateJudge } from '../service/monitor'; import { @@ -189,11 +196,46 @@ export class SubmissionDataDownloadHandler extends Handler { } } +export async function processJudgeFileCallback(rid: ObjectId, filename: string, filePath: string) { + const rdoc = await record.get(rid); + const [pdoc, udoc] = await Promise.all([ + problem.get(rdoc.domainId, rdoc.pid), + user.getById(rdoc.domainId, rdoc.uid), + ]); + if (!udoc.own(pdoc, PERM.PERM_EDIT_PROBLEM_SELF) && !udoc.hasPerm(PERM.PERM_EDIT_PROBLEM)) throw new ForbiddenError(); + if (pdoc.reference) throw new ProblemIsReferencedError('edit files'); + const stat = await fs.stat(filePath); + if ((pdoc.data?.length || 0) + + (pdoc.additional_file?.length || 0) + >= system.get('limit.problem_files_max')) { + throw new FileLimitExceededError('count'); + } + const size = Math.sum( + (pdoc.data || []).map((i) => i.size), + (pdoc.additional_file || []).map((i) => i.size), + stat.size, + ); + if (size >= system.get('limit.problem_files_max_size')) { + throw new FileLimitExceededError('size'); + } + await problem.addTestdata(pdoc.domainId, pdoc.docId, sanitize(filename), fs.createReadStream(filePath), udoc._id); +} + +export class JudgeFileUpdateHandler extends Handler { + @post('rid', Types.ObjectId) + @post('name', Types.Filename) + async post(domainId: string, rid: ObjectId, filename: string) { + if (!this.request.files.file) throw new ValidationError('file'); + await processJudgeFileCallback(rid, filename, this.request.files.file.filepath); + this.response.body = { ok: 1 }; + } +} + class JudgeConnectionHandler extends ConnectionHandler { category = '#judge'; processing: Task[] = []; closed = false; - query: any = { type: 'judge' }; + query: any = { type: { $in: ['judge', 'generate'] } }; rdocs: Record = {}; ip: string; concurrency = 1; @@ -282,6 +324,7 @@ class JudgeConnectionHandler extends ConnectionHandler { export async function apply(ctx) { 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.Route('judge_submission_download', '/judge/code', SubmissionDataDownloadHandler, builtin.PRIV.PRIV_JUDGE); ctx.Connection('judge_conn', '/judge/conn', JudgeConnectionHandler, builtin.PRIV.PRIV_JUDGE); } diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index cfa3a1dd9..82c7f202c 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -519,7 +519,7 @@ export class ProblemSubmitHandler extends ProblemDetailHandler { } if (pretest) { if (setting.langs[lang]?.pretest) lang = setting.langs[lang].pretest as string; - if (!['default', 'fileio', 'remote_judge'].includes(this.response.body.pdoc.config?.type)) { + if (!['default', 'remote_judge'].includes(this.response.body.pdoc.config?.type)) { throw new ProblemNotAllowPretestError('type'); } } @@ -543,7 +543,7 @@ export class ProblemSubmitHandler extends ProblemDetailHandler { } const rid = await record.add( domainId, this.pdoc.docId, this.user._id, lang, code, true, - pretest ? { input, type: 'pretest' } : { contest: tid, files, type: tid ? 'contest' : 'judge' }, + pretest ? { input, type: 'pretest' } : { contest: tid, files, type: 'judge' }, ); const rdoc = await record.get(domainId, rid); if (!pretest) { @@ -698,6 +698,12 @@ export class ProblemFilesHandler extends ProblemDetailHandler { } else this.response.template = 'problem_files.html'; } + async post() { + if (this.args.operation === 'get_links') return; + if (this.pdoc.reference) throw new ProblemIsReferencedError('edit files'); + if (!this.user.own(this.pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) this.checkPerm(PERM.PERM_EDIT_PROBLEM); + } + @post('files', Types.Set) @post('type', Types.Range(['testdata', 'additional_file']), true) async postGetLinks(domainId: string, files: Set, type = 'testdata') { @@ -730,10 +736,8 @@ 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.pdoc.reference) throw new ProblemIsReferencedError('edit files'); if (!this.request.files.file) throw new ValidationError('file'); filename ||= this.request.files.file.originalFilename || String.random(16); - if (!this.user.own(this.pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) this.checkPerm(PERM.PERM_EDIT_PROBLEM); const files = []; if (filename.endsWith('.zip') && type === 'testdata') { let zip: AdmZip; @@ -744,7 +748,7 @@ export class ProblemFilesHandler extends ProblemDetailHandler { } const entries = zip.getEntries(); for (const entry of entries) { - if (!entry.name) continue; + if (!entry.name || entry.isDirectory) continue; files.push({ type, name: sanitize(entry.name), @@ -788,9 +792,7 @@ export class ProblemFilesHandler extends ProblemDetailHandler { @post('newNames', Types.ArrayOf(Types.Filename)) @post('type', Types.Range(['testdata', 'additional_file']), true) async postRenameFiles(domainId: string, files: string[], newNames: string[], type = 'testdata') { - if (this.pdoc.reference) throw new ProblemIsReferencedError('rename files'); if (files.length !== newNames.length) throw new ValidationError('files', 'newNames'); - if (!this.user.own(this.pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) this.checkPerm(PERM.PERM_EDIT_PROBLEM); await Promise.all(files.map(async (file, index) => { const newName = newNames[index]; if (type === 'testdata') await problem.renameTestdata(domainId, this.pdoc.docId, file, newName, this.user._id); @@ -802,12 +804,21 @@ export class ProblemFilesHandler extends ProblemDetailHandler { @post('files', Types.ArrayOf(Types.Filename)) @post('type', Types.Range(['testdata', 'additional_file']), true) async postDeleteFiles(domainId: string, files: string[], type = 'testdata') { - if (this.pdoc.reference) throw new ProblemIsReferencedError('delete files'); - if (!this.user.own(this.pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) this.checkPerm(PERM.PERM_EDIT_PROBLEM); if (type === 'testdata') await problem.delTestdata(domainId, this.pdoc.docId, files, this.user._id); else await problem.delAdditionalFile(domainId, this.pdoc.docId, files, this.user._id); this.back(); } + + @post('std', Types.Filename) + @post('gen', Types.Filename) + async postGenerateTestdata(domainId: string, std: string, gen: string) { + if (!this.pdoc.data.find((i) => i.name === std)) throw new BadRequestError(); + if (!this.pdoc.data.find((i) => i.name === gen)) throw new BadRequestError(); + const rid = await record.add(domainId, this.pdoc.docId, this.user._id, '_', `${gen}\n${std}`, true, { + type: 'generate', + }); + this.response.redirect = this.url('record_detail', { rid }); + } } export class ProblemFileDownloadHandler extends ProblemDetailHandler { diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index c8021da4f..c61e7a04d 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -342,11 +342,13 @@ export interface JudgeMeta { problemOwner: number; hackRejudge?: string; rejudge?: boolean; + // FIXME stricter types + type?: string; } export interface JudgeRequest extends Omit { priority: number; - type: 'judge'; + type: 'judge' | 'generate'; rid: ObjectId; config: ProblemConfigFile; meta: JudgeMeta; diff --git a/packages/hydrooj/src/lib/testdataConfig.ts b/packages/hydrooj/src/lib/testdataConfig.ts index 1c46cc038..93da3181e 100644 --- a/packages/hydrooj/src/lib/testdataConfig.ts +++ b/packages/hydrooj/src/lib/testdataConfig.ts @@ -33,10 +33,7 @@ export async function parseConfig(config: string | ProblemConfigFile = {}) { if (result.timeMax < result.timeMin) result.timeMax = result.timeMin = 1000; if (cfg.langs) result.langs = cfg.langs; if (cfg.redirect) result.redirect = cfg.redirect.split('/', 2) as any; - if (cfg.filename && result.type === 'default') { - result.type = 'fileio'; - result.subType = cfg.filename; - } + if (cfg.filename && result.type === 'default') result.subType = cfg.filename; return result; } diff --git a/packages/hydrooj/src/model/record.ts b/packages/hydrooj/src/model/record.ts index 7dfa77a75..6112a4e7a 100644 --- a/packages/hydrooj/src/model/record.ts +++ b/packages/hydrooj/src/model/record.ts @@ -26,6 +26,9 @@ export default class RecordModel { 'files', ]; + static RECORD_PRETEST = new ObjectId('000000000000000000000000'); + static RECORD_GENERATE = new ObjectId('000000000000000000000001'); + static async submissionPriority(uid: number, base: number = 0) { const timeRecent = await RecordModel.coll .find({ _id: { $gte: Time.getObjectID(moment().add(-30, 'minutes')) }, uid, rejudged: { $ne: true } }) @@ -82,8 +85,9 @@ export default class RecordModel { } meta = { ...meta, problemOwner: pdoc.owner }; if (typeof pdoc.config === 'string') throw new Error(pdoc.config); - const type = (pdoc.config.type === 'remote_judge' && rdoc.contest?.toHexString() !== '0'.repeat(24)) ? 'remotejudge' : 'judge'; - config.type = pdoc.config.type === 'fileio' ? 'default' : pdoc.config.type as any; + let type = 'judge'; + if (pdoc.config.type === 'remote_judge' && rdoc.contest?.toHexString() !== '0'.repeat(24)) type = 'remotejudge'; + else if (meta?.type === 'generate') type = 'generate'; return await task.addMany(rids.map((rid) => ({ ...(pdoc.config as any), priority, @@ -104,7 +108,7 @@ export default class RecordModel { contest?: ObjectId, input?: string, files?: Record, - type: 'judge' | 'rejudge' | 'contest' | 'pretest' | 'hack', + type: 'judge' | 'rejudge' | 'pretest' | 'hack' | 'generate', } = { type: 'judge' }, ) { const data: RecordDoc = { @@ -125,6 +129,7 @@ export default class RecordModel { judgeAt: null, rejudged: false, }; + let isContest = !!args.contest; if (args.contest) data.contest = args.contest; if (args.files) data.files = args.files; if (args.type === 'rejudge') { @@ -132,12 +137,16 @@ export default class RecordModel { data.rejudged = true; } else if (args.type === 'pretest') { data.input = args.input || ''; - data.contest = new ObjectId('000000000000000000000000'); + isContest = false; + data.contest = RecordModel.RECORD_PRETEST; } const res = await RecordModel.coll.insertOne(data); if (addTask) { - const priority = await RecordModel.submissionPriority(uid, args.type === 'pretest' ? -20 : (args.type === 'contest' ? 50 : 0)); - await RecordModel.judge(domainId, res.insertedId, priority, args.type === 'contest' ? { detail: false } : {}, { rejudge: data.rejudged }); + const priority = await RecordModel.submissionPriority(uid, args.type === 'pretest' ? -20 : (isContest ? 50 : 0)); + await RecordModel.judge(domainId, res.insertedId, priority, isContest ? { detail: false } : {}, { + type: args.type, + rejudge: data.rejudged, + }); } return res.insertedId; } diff --git a/packages/hydrooj/src/model/schedule.ts b/packages/hydrooj/src/model/schedule.ts index f671c9aee..ef71f779a 100644 --- a/packages/hydrooj/src/model/schedule.ts +++ b/packages/hydrooj/src/model/schedule.ts @@ -6,6 +6,7 @@ import { Schedule } from '../interface'; import { Logger } from '../logger'; import * as bus from '../service/bus'; import db from '../service/db'; +import RecordModel from './record'; const logger = new Logger('model/schedule'); const coll = db.collection('schedule'); @@ -136,7 +137,7 @@ export async function apply(ctx: Context) { ctx.worker = Worker; Worker.addHandler('task.daily', async () => { - await global.Hydro.model.record.coll.deleteMany({ contest: new ObjectId('000000000000000000000000') }); + await RecordModel.coll.deleteMany({ contest: { $in: [RecordModel.RECORD_PRETEST, RecordModel.RECORD_GENERATE] } }); await global.Hydro.script.rp?.run({}, new Logger('task/rp').debug); await global.Hydro.script.problemStat?.run({}, new Logger('task/problem').debug); if (global.Hydro.model.system.get('server.checkUpdate') && !(new Date().getDay() % 3)) { diff --git a/packages/hydrooj/src/script/problemStat.ts b/packages/hydrooj/src/script/problemStat.ts index 72b759e80..b47eed464 100644 --- a/packages/hydrooj/src/script/problemStat.ts +++ b/packages/hydrooj/src/script/problemStat.ts @@ -1,8 +1,8 @@ /* eslint-disable no-await-in-loop */ -import { ObjectId } from 'mongodb'; import Schema from 'schemastery'; import { STATUS } from '../model/builtin'; import * as document from '../model/document'; +import RecordModel from '../model/record'; import db from '../service/db'; const sumStatus = (status) => ({ $sum: { $cond: [{ $eq: ['$status', status] }, 1, 0] } }); @@ -10,7 +10,7 @@ const sumStatus = (status) => ({ $sum: { $cond: [{ $eq: ['$status', status] }, 1 export async function udoc(report) { report({ message: 'Udoc' }); const pipeline = [ - { $match: { contest: { $ne: new ObjectId('000000000000000000000000') } } }, + { $match: { contest: { $nin: [RecordModel.RECORD_PRETEST, RecordModel.RECORD_GENERATE] } } }, { $group: { _id: { domainId: '$domainId', pid: '$pid', uid: '$uid' }, @@ -49,7 +49,7 @@ export async function udoc(report) { export async function psdoc(report) { report({ message: 'Psdoc' }); const pipeline = [ - { $match: { contest: { $ne: new ObjectId('000000000000000000000000') } } }, + { $match: { contest: { $nin: [RecordModel.RECORD_PRETEST, RecordModel.RECORD_GENERATE] } } }, { $group: { _id: { domainId: '$domainId', pid: '$pid', uid: '$uid' }, @@ -68,7 +68,7 @@ export async function pdoc(report) { const pipeline = [ { $match: { - contest: { $ne: new ObjectId('000000000000000000000000') }, + contest: { $nin: [RecordModel.RECORD_PRETEST, RecordModel.RECORD_GENERATE] }, status: { $ne: STATUS.STATUS_CANCELED }, }, }, diff --git a/packages/ui-default/components/autocomplete/FileSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/FileSelectAutoComplete.tsx new file mode 100644 index 000000000..2d8814fda --- /dev/null +++ b/packages/ui-default/components/autocomplete/FileSelectAutoComplete.tsx @@ -0,0 +1,63 @@ +import { assign } from 'lodash'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import DOMAttachedObject from 'vj/components/DOMAttachedObject'; +import AutoComplete, { AutoCompleteOptions } from '.'; +import FileSelectAutoCompleteFC from './components/FileSelectAutoComplete'; + +interface FileSelectOptions { + data: { + id: string; + name: string; + }[]; +} + +const Component = React.forwardRef((props, ref) => { + const [value, setValue] = React.useState(props.value); + return ( + i.trim()).filter((i) => i)} + onChange={(v) => { + setValue(v); + props.onChange(v); + }} + data={props.data} + multi={props.multi} + /> + ); +}); + +export default class FileSelectAutoComplete extends AutoComplete { + static DOMAttachKey = 'ucwFileSelectAutoCompleteInstance'; + options: FileSelectOptions & AutoCompleteOptions; + + constructor($dom, options: FileSelectOptions & AutoCompleteOptions) { + super($dom, { + classes: 'custom-select', + ...options, + }); + } + + value(): Multi extends true ? number[] : string { + if (this.options.multi) return this.ref?.getSelectedItemKeys().map((i) => +i) ?? this.$dom.val(); + return this.ref?.getSelectedItems()[0] ?? null; + } + + attach() { + const value = this.$dom.val(); + ReactDOM.createRoot(this.container).render( + { this.ref = ref; }} + data={this.options.data} + value={value} + multi={this.options.multi} + onChange={this.onChange} + />, + ); + } +} + +assign(FileSelectAutoComplete, DOMAttachedObject); +window.Hydro.components.FileSelectAutoComplete = FileSelectAutoComplete; diff --git a/packages/ui-default/components/scratchpad/ScratchpadToolbarContainer.jsx b/packages/ui-default/components/scratchpad/ScratchpadToolbarContainer.jsx index 2d6bbf798..5935a0766 100644 --- a/packages/ui-default/components/scratchpad/ScratchpadToolbarContainer.jsx +++ b/packages/ui-default/components/scratchpad/ScratchpadToolbarContainer.jsx @@ -100,7 +100,7 @@ export default connect(mapStateToProps, mapDispatchToProps)(class ScratchpadTool } render() { - let canUsePretest = ['default', 'fileio'].includes(UiContext.pdoc.config?.type); + let canUsePretest = UiContext.pdoc.config?.type === 'default'; const langInfo = availableLangs[this.props.editorLang]; if (UiContext.pdoc.config?.type === 'remote_judge' && langInfo) { if (langInfo.pretest) canUsePretest = true; diff --git a/packages/ui-default/components/scratchpad/reducers/ui.ts b/packages/ui-default/components/scratchpad/reducers/ui.ts index a6240535b..bd12fb202 100644 --- a/packages/ui-default/components/scratchpad/reducers/ui.ts +++ b/packages/ui-default/components/scratchpad/reducers/ui.ts @@ -3,7 +3,7 @@ import { i18n } from 'vj/utils'; export default function reducer(state = { pretest: { - visible: ['default', 'fileio'].includes(UiContext.pdoc.config?.type) + visible: UiContext.pdoc.config?.type === 'default' ? localStorage.getItem('scratchpad/pretest') === 'true' : false, }, diff --git a/packages/ui-default/locales/en.yaml b/packages/ui-default/locales/en.yaml index 9b4e5b6b8..e580f9b71 100644 --- a/packages/ui-default/locales/en.yaml +++ b/packages/ui-default/locales/en.yaml @@ -1,4 +1,5 @@ __langname: English +'File IO: {0}': 'File IO: {0}' Can be Extended For: Extension Current Status: Status Handled Requests: Req @@ -27,7 +28,6 @@ problem_main: Problem Set problem_solution: Problem Solution problem_submit: Problem Submit problem_type.default: 'Type: Default' -problem_type.fileio: 'Type: FileIO ({0})' problem_type.interactive: 'Type: Interactive' problem_type.objective: 'Type: Objective' problem_type.remote_judge: 'Type: RemoteJudge' diff --git a/packages/ui-default/locales/zh.yaml b/packages/ui-default/locales/zh.yaml index beb1b743d..2f04e03cb 100644 --- a/packages/ui-default/locales/zh.yaml +++ b/packages/ui-default/locales/zh.yaml @@ -27,7 +27,9 @@ __langname: 简体中文 'Current dataset: {0}': '当前测试数据: {0}' 'Discussion: Terms Of Service': 讨论区服务条款 'Drag and drop testcases here:': '拖拽测试点到此处:' +'Enter a new name for the file: ': 请输入新的文件名: 'Failed to get credential: {0}': '获取认证数据失败: {0}' +'File IO: {0}': 文件IO:{0} 'File upload failed: {0}': 文件上传失败:{0} 'Format: category 1, category 2, ..., category n': 格式:分类1, 分类2, ..., 分类n 'Hello, {0}! You can click following link to active your new email of your {1} account:': 您好,{0}!您可以点击以下链接来激活您 {1} 账户的新电子邮件地址: @@ -50,6 +52,8 @@ __langname: 简体中文 (None): (无) (Not changed): 未更改 6-Digit Code: 6位代码 +A data generator is a program that generates testdata. It should output result to stdout.: 数据生成器是一个生成测试数据的程序。它应该将结果输出到标准输出。 +A standard program is a program that solves the problem. It should output answer to stdout.: 标准程序是一个解决问题的程序。它应该将答案输出到标准输出。 Aborted: 已放弃 About {0}: 关于 {0} About Markdown: 关于 Markdown @@ -245,6 +249,7 @@ Current Status: 当前状态 currently offline: 目前离线 Daily update check: 每日自动检查更新 Dark: 暗色 +Data Generator: 数据生成器 Data of Problem {0} not found.: 题目 {0} 的数据缺失。 Data of problem {1} not found.: 题目 {1} 的数据未找到。 Data of record {0} not found.: 记录 {0} 的数据未找到。 @@ -358,7 +363,6 @@ End Time: 结束时间 Enroll Training: 参加训练 Enrolled: 已参加 Enrollees: 参加人数 -'Enter a new name for the file: ': 请输入新的文件名: error: 错误 Errors detected in the config. Confirm save?: 配置中存在错误。确认保存? Evaluated difficulty: 估计的难度 @@ -398,6 +402,9 @@ Freeze scoreboard with N minutes remaining: 在赛事剩余N分钟时封榜 fs_upload: 上传文件 Gender Visibility: 性别可见性 Gender: 性别 +Generate testdata is not supported for this problem type.: 此题目类型不支持生成测试数据。 +Generate Testdata: 生成测试数据 +Generate: 生成 Gravatar URL Prefix: Gravatar地址前缀 Group / UID: 小组或 UID Group Name: 名称 @@ -686,7 +693,6 @@ problem_main: 题库 problem_solution: 题解 problem_submit: 递交代码 problem_type.default: 传统题 -problem_type.fileio: '文件IO ({0})' problem_type.interactive: 交互题 problem_type.objective: 客观题 problem_type.remote_judge: 远端评测题 @@ -845,6 +851,7 @@ Sorry: 对不起 Sort by: 排序 Source type: 来源类型 Standard mode: 标准模式 +Standard Program: 标准程序 Star Topic: 收藏讨论 Star: 收藏 Starred Users: 关注用户 diff --git a/packages/ui-default/pages/problem_files.page.tsx b/packages/ui-default/pages/problem_files.page.tsx index 02d607eb8..030877c6a 100644 --- a/packages/ui-default/pages/problem_files.page.tsx +++ b/packages/ui-default/pages/problem_files.page.tsx @@ -2,7 +2,8 @@ import $ from 'jquery'; import { map } from 'lodash'; import React from 'react'; import ReactDOM from 'react-dom/client'; -import { ActionDialog, ConfirmDialog } from 'vj/components/dialog/index'; +import FileSelectAutoComplete from 'vj/components/autocomplete/FileSelectAutoComplete'; +import { ActionDialog, ConfirmDialog, InfoDialog } from 'vj/components/dialog/index'; import createHint from 'vj/components/hint'; import Notification from 'vj/components/notification'; import { previewFile } from 'vj/components/preview/preview.page'; @@ -318,6 +319,48 @@ const page = new NamedPage('problem_files', () => { handleClickUpload(type, files); } + async function handleGenerateTestdata(ev) { + ev.preventDefault(); + const gen = $('[name=gen]').val(); + const std = $('[name=std]').val(); + if (!gen) { + Notification.error(i18n('Please input the generator.')); + return; + } + if (!std) { + Notification.error(i18n('Please input the standard program.')); + return; + } + try { + const res = await request.post('', { + operation: 'generate_testdata', + gen, + std, + }); + if (res.error) Notification.error(res.error); + else { + Notification.success(i18n('Generating...')); + await new InfoDialog({ + $body: tpl` +
+ +
`, + width: `${window.innerWidth - 200}px`, + height: `${window.innerHeight - 100}px`, + }).open(); + } + } catch (error) { + Notification.error([error.message, ...error.params].join(' ')); + } + } + + if (UiContext.pdoc) { + FileSelectAutoComplete.getOrConstruct($('[name=gen]'), { data: UiContext.pdoc.data }); + FileSelectAutoComplete.getOrConstruct($('[name=std]'), { data: UiContext.pdoc.data }); + $('[name="generate_testdata"]').on('click', handleGenerateTestdata); + } + if ($('[name="upload_testdata"]').length) { $(document).on('click', '[name="upload_testdata"]', () => handleClickUpload('testdata')); $(document).on('click', '[name="upload_file"]', () => handleClickUpload('additional_file')); diff --git a/packages/ui-default/templates/problem_detail.html b/packages/ui-default/templates/problem_detail.html index 76b004a1a..31513d204 100644 --- a/packages/ui-default/templates/problem_detail.html +++ b/packages/ui-default/templates/problem_detail.html @@ -69,6 +69,9 @@ {{ _('ID') }}: {{ pdoc.docId }} {% endif %} {{ _('problem_type.' + pdoc.config.type).format(pdoc.config.subType) }} + {% if pdoc.config.type == 'default' and pdoc.config.subType %} + {{ _('File IO: {0}').format(pdoc.config.subType) }} + {% endif %} {% if pdoc.config.type != 'objective' and pdoc.config.type != 'submit_answer' %} {{ pdoc.config.timeMax if pdoc.config.timeMax == pdoc.config.timeMin else pdoc.config.timeMin+'~'+pdoc.config.timeMax }}ms {{ pdoc.config.memoryMax if pdoc.config.memoryMax == pdoc.config.memoryMin else pdoc.config.memoryMin+'~'+pdoc.config.memoryMax }}MiB diff --git a/packages/ui-default/templates/problem_files.html b/packages/ui-default/templates/problem_files.html index cd4151cab..8aae9d814 100644 --- a/packages/ui-default/templates/problem_files.html +++ b/packages/ui-default/templates/problem_files.html @@ -1,3 +1,4 @@ +{% import "components/nothing.html" as nothing with context %} {% extends "layout/basic.html" %} {% block content %}
@@ -27,6 +28,36 @@
{% endif %} + {% if not reference and (handler.user.own(pdoc, perm.PERM_EDIT_PROBLEM_SELF) or handler.user.hasPerm(perm.PERM_EDIT_PROBLEM)) %} + {{ set(UiContext, { + pdoc: pdoc + }) }} +
+
+

{{ _('Generate Testdata') }}(Beta)

+
+ {{ noscript_note.render() }} +
+ {% if pdoc.config.type === 'default' %} + {{ form.form_text({ + columns: 12, + label:'Data Generator', + name:'gen', + help_text:'A data generator is a program that generates testdata. It should output result to stdout.' + }) }} + {{ form.form_text({ + columns: 12, + label:'Standard Program', + name:'std', + help_text:'A standard program is a program that solves the problem. It should output answer to stdout.' + }) }} + + {% else %} + {{ nothing.render('Generate testdata is not supported for this problem type.') }} + {% endif %} +
+
+ {% endif %}