diff --git a/packages/hydrojudge/src/flow.ts b/packages/hydrojudge/src/flow.ts index 7aec406a9..3c6e59d0c 100644 --- a/packages/hydrojudge/src/flow.ts +++ b/packages/hydrojudge/src/flow.ts @@ -72,7 +72,6 @@ export const runFlow = async (ctx: Context, task: Task) => { ctx.next({ status: STATUS.STATUS_COMPILING }); await task.compile(); ctx.next({ status: STATUS.STATUS_JUDGING, progress: 0 }); - const tasks = []; ctx.total_status = 0; ctx.total_score = 0; ctx.total_memory = 0; @@ -80,21 +79,38 @@ export const runFlow = async (ctx: Context, task: Task) => { ctx.rerun = getConfig('rerun') || 0; ctx.queue = new Queue({ concurrency: getConfig('singleTaskParallelism') }); ctx.failed = {}; - for (const sid in ctx.config.subtasks) tasks.push(judgeSubtask(ctx.config.subtasks[sid], sid, task.judgeCase)(ctx)); - const scores = await Promise.all(tasks); - for (const sid in ctx.config.subtasks) { - let effective = true; - for (const required of ctx.config.subtasks[sid].if || []) { - if (ctx.failed[required.toString()]) effective = false; + if (ctx.meta.hackRejudge) { + const subtask = ctx.config.subtasks.find((i) => i.cases.find((j) => j.input === ctx.meta.hackRejudge)); + const ctxSubtask = { + subtask, + status: STATUS.STATUS_ACCEPTED, + score: subtask.type === 'min' ? subtask.score : 0, + }; + const runner = task.judgeCase(subtask.cases.find((i) => i.input === ctx.meta.hackRejudge)); + const res = await runner(ctx, ctxSubtask, runner); + if (res) ctx.next({ case: res }); + ctx.end({ + status: ctxSubtask.status === STATUS.STATUS_ACCEPTED ? STATUS.STATUS_ACCEPTED : STATUS.STATUS_HACKED, + score: 97, + }); + } else { + const tasks = []; + for (const sid in ctx.config.subtasks) tasks.push(judgeSubtask(ctx.config.subtasks[sid], sid, task.judgeCase)(ctx)); + const scores = await Promise.all(tasks); + for (const sid in ctx.config.subtasks) { + let effective = true; + for (const required of ctx.config.subtasks[sid].if || []) { + if (ctx.failed[required.toString()]) effective = false; + } + if (effective) ctx.total_score += scores[sid]; } - if (effective) ctx.total_score += scores[sid]; + ctx.end({ + status: ctx.total_status, + score: ctx.total_score, + time: Math.floor(ctx.total_time * 1000000) / 1000000, + memory: ctx.total_memory, + }); } ctx.stat.done = new Date(); if (process.env.DEV) ctx.next({ message: JSON.stringify(ctx.stat) }); - ctx.end({ - status: ctx.total_status, - score: ctx.total_score, - time: Math.floor(ctx.total_time * 1000000) / 1000000, - memory: ctx.total_memory, - }); }; diff --git a/packages/hydrooj/src/handler/judge.ts b/packages/hydrooj/src/handler/judge.ts index 723208f2f..959748657 100644 --- a/packages/hydrooj/src/handler/judge.ts +++ b/packages/hydrooj/src/handler/judge.ts @@ -1,7 +1,12 @@ +import assert from 'assert'; +import yaml from 'js-yaml'; import { ObjectID } from 'mongodb'; -import { JudgeResultBody, RecordDoc, TestCase } from '../interface'; +import { + JudgeResultBody, ProblemConfigFile, RecordDoc, TestCase, +} from '../interface'; import { Logger } from '../logger'; import * as builtin from '../model/builtin'; +import { STATUS } from '../model/builtin'; import * as contest from '../model/contest'; import domain from '../model/domain'; import problem from '../model/problem'; @@ -18,28 +23,6 @@ import { sleep } from '../utils'; const logger = new Logger('judge'); -export async function postJudge(rdoc: RecordDoc) { - if (typeof rdoc.input === 'string') 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.status, rdoc.score, - ); - } else if (accept && updated) await domain.incUserInDomain(rdoc.domainId, rdoc.uid, 'nAccept', 1); - const pdoc = (accept && updated) - ? await problem.inc(rdoc.domainId, rdoc.pid, 'nAccept', 1) - : await problem.get(rdoc.domainId, rdoc.pid); - if (pdoc) { - 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${rdoc.score}`, 1), - ]); - } - await bus.serial('record/judge', rdoc, updated); -} - export async function next(body: Partial) { body.rid = new ObjectID(body.rid); let rdoc = await record.get(body.rid); @@ -74,6 +57,58 @@ export async function next(body: Partial) { bus.broadcast('record/change', rdoc!, $set, $push); } +export async function postJudge(rdoc: RecordDoc) { + if (typeof rdoc.input === 'string') 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.status, rdoc.score, + ); + } else if (accept && updated) await domain.incUserInDomain(rdoc.domainId, rdoc.uid, 'nAccept', 1); + const pdoc = (accept && updated) + ? await problem.inc(rdoc.domainId, rdoc.pid, 'nAccept', 1) + : await problem.get(rdoc.domainId, rdoc.pid, undefined, true); + if (pdoc) { + 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${rdoc.score}`, 1), + ]); + if (rdoc.status === STATUS.STATUS_HACK_SUCCESSFUL) { + 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}`); + 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: { $ne: new ObjectID('0'.repeat(24)) }, + }).project({ _id: 1, contest: 1 }).toArray(); + const priority = await record.submissionPriority(this.user._id, -rdocs.length * 5 - 50); + await Promise.all(rdocs.map( + (r) => record.judge(r.domainId, r._id, priority, r.contest ? { detail: false } : {}, { hackRejudge: input }), + )); + } catch (e) { + next({ + rid: rdoc._id, domainId: rdoc.domainId, key: 'next', message: { message: 'Unable to apply hack: {0}', params: [e.message] }, + }); + } + } + } + await bus.serial('record/judge', rdoc, updated); +} + export async function end(body: Partial) { if (body.rid) body.rid = new ObjectID(body.rid); let rdoc = await record.get(body.rid); @@ -90,9 +125,9 @@ export async function end(body: Partial) { $push.compilerTexts = body.compilerText; } if (body.status) $set.status = body.status; - if (body.score !== undefined) $set.score = body.score; - if (body.time !== undefined) $set.time = body.time; - if (body.memory !== undefined) $set.memory = body.memory; + if (Number.isSafeInteger(body.score)) $set.score = body.score; + if (Number.isFinite(body.time)) $set.time = body.time; + if (Number.isFinite(body.memory)) $set.memory = body.memory; $set.judgeAt = new Date(); $set.judger = body.judger ?? 1; await sleep(100); // Make sure that all 'next' event already triggered diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index 9791b773d..cf67d6516 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -555,7 +555,7 @@ export class ProblemHackHandler extends ProblemDetailHandler { if (!contest.isOngoing(this.tdoc, this.tsdoc)) throw new ContestNotLiveError(this.tdoc.docId); } if (this.rdoc.uid === this.user._id) throw new BadRequestError('You cannot hack your own submission'); - if (this.psdoc.status !== STATUS.STATUS_ACCEPTED) throw new ForbiddenError('You must accept this problem before hacking.'); + if (this.psdoc?.status !== STATUS.STATUS_ACCEPTED) throw new ForbiddenError('You must accept this problem before hacking.'); if (this.rdoc.status !== STATUS.STATUS_ACCEPTED) throw new ForbiddenError('You cannot hack a unsuccessful submission.'); } @@ -575,7 +575,6 @@ export class ProblemHackHandler extends ProblemDetailHandler { await this.limitRate('add_record', 60, system.get('limit.submission_user'), true); await this.limitRate('add_record', 60, system.get('limit.submission')); const id = `${this.user._id}/${nanoid()}`; - console.log(input); if (this.request.files?.file?.size > 0) { const file = this.request.files.file; if (!file || file.size > 128 * 1024 * 1024) throw new ValidationError('input'); diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index e2d821fdf..5340d5106 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -181,7 +181,7 @@ export interface ProblemConfig { type: string; subType?: string; target?: string; - hackable?:boolean; + hackable?: boolean; } export interface PlainContentNode { @@ -304,6 +304,7 @@ export interface RecordDoc { export interface JudgeMeta { problemOwner: number; + hackRejudge?: string; } export interface JudgeRequest extends Omit { diff --git a/packages/hydrooj/src/model/record.ts b/packages/hydrooj/src/model/record.ts index 0be067aaa..ce2bb0b99 100644 --- a/packages/hydrooj/src/model/record.ts +++ b/packages/hydrooj/src/model/record.ts @@ -62,12 +62,12 @@ class RecordModel { }; } - static async judge(domainId: string, rid: ObjectID, priority = 0, config: ProblemConfigFile = {}) { + static async judge(domainId: string, rid: ObjectID, priority = 0, config: ProblemConfigFile = {}, meta: Partial = {}) { const rdoc = await RecordModel.get(domainId, rid); if (!rdoc) return null; let data: FileInfo[] = []; let source = `${domainId}/${rdoc.pid}`; - const meta: JudgeMeta = { problemOwner: 1 }; + meta = { ...meta, problemOwner: 1 }; if (rdoc.pid) { let pdoc = await problem.get(rdoc.domainId, rdoc.pid); if (!pdoc) throw new ProblemNotFoundError(rdoc.domainId, rdoc.pid); diff --git a/packages/hydrooj/src/model/storage.ts b/packages/hydrooj/src/model/storage.ts index 13ceac7e0..c2f48eee5 100644 --- a/packages/hydrooj/src/model/storage.ts +++ b/packages/hydrooj/src/model/storage.ts @@ -90,6 +90,29 @@ export class StorageModel { ); return await storage.signDownloadLink(res.value?._id || target, filename, noExpire, useAlternativeEndpointFor); } + + static async copy(src: string, dst: string) { + const { value } = await StorageModel.coll.findOneAndUpdate( + { path: src, autoDelete: null }, + { $set: { lastUsage: new Date() } }, + { returnDocument: 'after' }, + ); + const meta = {}; + await StorageModel.del([dst]); + meta['Content-Type'] = (dst.endsWith('.ans') || dst.endsWith('.out')) + ? 'text/plain' + : lookup(dst) || 'application/octet-stream'; + let _id = `${nanoid(3)}/${nanoid()}${extname(dst)}`; + // Make sure id is not used + // eslint-disable-next-line no-await-in-loop + while (await StorageModel.coll.findOne({ _id })) _id = `${nanoid(3)}/${nanoid()}${extname(dst)}`; + const result = await storage.copy(value._id, dst); + const { metaData, size, etag } = await storage.getMeta(_id); + await StorageModel.coll.insertOne({ + _id, meta: metaData, path: dst, size, etag, lastModified: new Date(), owner: value.owner || 1, + }); + return result; + } } async function cleanFiles() { diff --git a/packages/hydrooj/src/service/storage.ts b/packages/hydrooj/src/service/storage.ts index 166ae338c..1bcfd7cf0 100644 --- a/packages/hydrooj/src/service/storage.ts +++ b/packages/hydrooj/src/service/storage.ts @@ -6,7 +6,9 @@ import { remove, stat, writeFile, } from 'fs-extra'; import { lookup } from 'mime-types'; -import { BucketItem, Client, ItemBucketMetadata } from 'minio'; +import { + BucketItem, Client, CopyConditions, ItemBucketMetadata, +} from 'minio'; import { Logger } from '../logger'; import { builtinConfig } from '../settings'; import { MaybeArray } from '../typeutils'; @@ -237,6 +239,18 @@ class RemoteStorageService { extraFormData: policyResult.formData, }; } + + async copy(src: string, target: string) { + if (src.includes('..') || src.includes('//')) throw new Error('Invalid path'); + if (target.includes('..') || target.includes('//')) throw new Error('Invalid path'); + try { + const result = await this.client.copyObject(this.opts.bucket, target, src, new CopyConditions()); + return result; + } catch (e) { + e.stack = new Error().stack; + throw e; + } + } } class LocalStorageService { @@ -314,6 +328,15 @@ class LocalStorageService { async list() { throw new Error('deprecated'); } + + async copy(src: string, target: string) { + if (src.includes('..') || src.includes('//')) throw new Error('Invalid path'); + if (target.includes('..') || target.includes('//')) throw new Error('Invalid path'); + src = resolve(this.dir, src); + target = resolve(this.dir, target); + await copyFile(src, target); + return { etag: target, lastModified: new Date() }; + } } let service; // eslint-disable-line import/no-mutable-exports