From 191353fbbcc55db60c450d58294df4804f70dda5 Mon Sep 17 00:00:00 2001 From: undefined Date: Tue, 18 Nov 2025 17:59:59 +0800 Subject: [PATCH] bug fixes & improvements for on site contest --- framework/eslint-config/package.json | 6 +- framework/utils/package.json | 2 +- package.json | 2 +- packages/components/package.json | 4 +- packages/hydrooj/package.json | 4 +- packages/hydrooj/src/handler/contest.ts | 33 +++- packages/hydrooj/src/handler/problem.ts | 5 +- packages/hydrooj/src/lib/testdataConfig.ts | 7 +- packages/hydrooj/src/model/contest.ts | 5 +- packages/hydrooj/src/model/problem.ts | 58 +++++-- packages/hydrooj/src/upgrade.ts | 7 +- packages/onsite-toolkit/index.ts | 147 ++++++++++++++++-- packages/onsite-toolkit/package.json | 1 + packages/onsite-toolkit/submit.ts | 92 +++++++++++ packages/scoreboard-xcpcio/index.ts | 41 +++-- .../templates/xcpcio_board.html | 16 ++ packages/ui-default/package.json | 12 +- .../templates/contest_problemlist.html | 7 + packages/ui-next/package.json | 2 +- 19 files changed, 383 insertions(+), 68 deletions(-) create mode 100644 packages/onsite-toolkit/submit.ts diff --git a/framework/eslint-config/package.json b/framework/eslint-config/package.json index e747bdaa8..0caebf19c 100644 --- a/framework/eslint-config/package.json +++ b/framework/eslint-config/package.json @@ -6,9 +6,9 @@ "repository": "https://github.com/hydro-dev/Hydro", "dependencies": { "@antfu/eslint-config": "^6.2.0", - "@eslint-react/eslint-plugin": "^2.3.4", - "@typescript-eslint/eslint-plugin": "^8.46.4", - "@typescript-eslint/parser": "^8.46.4", + "@eslint-react/eslint-plugin": "^2.3.5", + "@typescript-eslint/eslint-plugin": "^8.47.0", + "@typescript-eslint/parser": "^8.47.0", "eslint-plugin-de-morgan": "^2.0.0", "eslint-plugin-github": "^6.0.0", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/framework/utils/package.json b/framework/utils/package.json index fa42f29d7..5d76ab885 100644 --- a/framework/utils/package.json +++ b/framework/utils/package.json @@ -15,7 +15,7 @@ "@hydrooj/register": "workspace:^", "cac": "^6.7.14", "fs-extra": "^11.3.2", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "reggol": "^2.1.0", "search-query-parser": "^1.6.0", "systeminformation": "^5.27.11" diff --git a/package.json b/package.json index bbc84617e..3a77057c6 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "mongodb-memory-server": "10.3.0", "nyc": "^17.1.0", "ora": "^9.0.0", - "oxlint": "^1.28.0", + "oxlint": "^1.29.0", "package-json": "^10.0.1", "semver": "^7.7.3", "simple-git": "^3.30.0", diff --git a/packages/components/package.json b/packages/components/package.json index 0c9e5653a..f537393a0 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -10,7 +10,7 @@ "dependencies": { "allotment": "^1.20.4", "diff": "^8.0.2", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "lodash": "^4.17.21", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", @@ -21,7 +21,7 @@ "@types/diff": "^8.0.0", "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.20", - "sass": "^1.94.0" + "sass": "^1.94.1" }, "peerDependencies": { "react": "*", diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index f7ca80f2b..f74aa53a3 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -32,7 +32,7 @@ "emoji-regex": "^10.6.0", "emojis-list": "2.1.0", "fs-extra": "^11.3.2", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "koa-proxies": "^0.12.4", "koa-static-cache": "^5.1.4", "lodash": "^4.17.21", @@ -63,7 +63,7 @@ "@types/lodash": "^4.17.20", "@types/mime-types": "^3.0.1", "@types/mongodb-uri": "^0.9.4", - "@types/nodemailer": "^7.0.3", + "@types/nodemailer": "^7.0.4", "@types/notp": "^2.0.5", "@types/semver": "^7.7.1", "@types/superagent": "^8.1.9", diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index 305e0b9e8..d9e3a9fcd 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -12,7 +12,7 @@ import { Context, Service } from '../context'; import { BadRequestError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError, ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError, - InvalidTokenError, NotAssignedError, NotFoundError, PermissionError, ValidationError, + InvalidTokenError, MethodNotAllowedError, NotAssignedError, NotFoundError, PermissionError, ValidationError, } from '../error'; import { FileInfo, ScoreboardConfig, Tdoc } from '../interface'; import { PERM, PRIV, STATUS } from '../model/builtin'; @@ -126,6 +126,11 @@ export class ContestDetailBaseHandler extends Handler { args: { tid, prefix: 'contest_problemlist' }, checker: () => this.tsdoc?.attend || contest.isDone(this.tdoc), }, + { + name: 'contest_print', + args: { tid, prefix: 'contest_print' }, + checker: () => this.tdoc.allowPrint && (this.tsdoc?.attend || this.user.own(this.tdoc) || this.user.hasPerm(PERM.PERM_EDIT_CONTEST)), + }, { name: 'contest_scoreboard', args: { tid, prefix: 'contest_scoreboard' }, @@ -197,6 +202,24 @@ export class ContestPrintHandler extends ContestDetailBaseHandler { this.response.template = 'contest_print.html'; } + async post() { + if (this.args.operation) return; + if (this.args.file_contents && this.args.original_name) { + try { + await (this.postPrint as any)({ + ...this.args, + title: this.args.original_name, + content: Buffer.from(this.args.file_contents, 'base64').toString('utf-8'), + }); + this.response.body = { success: true, output: '' }; + } catch (e) { + this.response.body = { success: false, output: e.message }; + } finally { + delete this.response.redirect; + } + } else throw new MethodNotAllowedError('POST'); + } + @param('tid', Types.ObjectId) @param('title', Types.Title, true) @param('content', Types.Content, true) @@ -758,8 +781,10 @@ export class ContestScoreboardHandler extends ContestDetailBaseHandler { @param('tid', Types.ObjectId) @param('view', Types.String, true) async get(domainId: string, tid: ObjectId, viewId = 'default') { - if (!contest.canShowScoreboard.call(this, this.tdoc, true)) throw new ContestScoreboardHiddenError(tid); - if (contest.isNotStarted(this.tdoc)) throw new ContestNotLiveError(domainId, tid); + if (!this.user.own(this.tdoc)) { + if (!contest.canShowScoreboard.call(this, this.tdoc, true)) throw new ContestScoreboardHiddenError(tid); + if (contest.isNotStarted(this.tdoc)) throw new ContestNotLiveError(domainId, tid); + } const view = this.ctx.scoreboard.getView(viewId); if (!view) throw new NotFoundError(`View ${viewId} not found`); const args = {}; @@ -846,6 +871,8 @@ export async function apply(ctx: Context) { ctx.Route('contest_problemlist', '/contest/:tid/problems', ContestProblemListHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_edit', '/contest/:tid/edit', ContestEditHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_print', '/contest/:tid/print', ContestPrintHandler, PERM.PERM_VIEW_CONTEST); + // Support for DOMJudge printfile + ctx.Route('contest_print_alt', '/contest/:tid/api/printing/team', ContestPrintHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_manage', '/contest/:tid/management', ContestManagementHandler); ctx.Route('contest_clarification', '/contest/:tid/clarification', ContestClarificationHandler); ctx.Route('contest_code', '/contest/:tid/code', ContestCodeHandler, PERM.PERM_VIEW_CONTEST); diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index c4eff8bb5..6b4c9aefa 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -519,7 +519,10 @@ export class ProblemSubmitHandler extends ProblemDetailHandler { await storage.put(`submission/${this.user._id}/${id}`, file.filepath, this.user._id); files.code = `${this.user._id}/${id}#${file.originalFilename}`; } - } else if (code.length > lengthLimit) throw new ValidationError('code'); + } else { + code = code.replace(/\r\n/g, '\n'); + if (code.length > lengthLimit) throw new ValidationError('code'); + } const rid = await record.add( domainId, this.pdoc.docId, this.user._id, lang, code, true, pretest ? { input, type: 'pretest' } : { contest: tid, files, type: 'judge' }, diff --git a/packages/hydrooj/src/lib/testdataConfig.ts b/packages/hydrooj/src/lib/testdataConfig.ts index 512f0ce5f..683e83368 100644 --- a/packages/hydrooj/src/lib/testdataConfig.ts +++ b/packages/hydrooj/src/lib/testdataConfig.ts @@ -1,15 +1,15 @@ import { load } from 'js-yaml'; -import { normalizeSubtasks, ProblemConfigFile } from '@hydrooj/common'; +import { normalizeSubtasks, ProblemConfigFile, readSubtasksFromFiles } from '@hydrooj/common'; import { readYamlCases } from '@hydrooj/common/cases'; import { parseMemoryMB, parseTimeMS } from '@hydrooj/utils'; import type { ProblemConfig } from '../interface'; -export async function parseConfig(config: string | ProblemConfigFile = {}) { +export async function parseConfig(config: string | ProblemConfigFile = {}, files: string[]) { const cfg: ProblemConfigFile = typeof config === 'string' ? await readYamlCases(load(config) as Record) : await readYamlCases(config); const result: ProblemConfig = { - count: 0, + count: Object.keys(cfg.answers || {}).length || Math.sum((cfg.subtasks || []).map((s) => s.cases.length)), memoryMin: Number.MAX_SAFE_INTEGER, memoryMax: 0, timeMin: Number.MAX_SAFE_INTEGER, @@ -19,6 +19,7 @@ export async function parseConfig(config: string | ProblemConfigFile = {}) { }; if (cfg.subType) result.subType = cfg.subType; if (cfg.target) result.target = cfg.target; + result.count ||= Math.sum(readSubtasksFromFiles(files, cfg).map((i) => i.cases.length)); if (cfg.subtasks?.length) { for (const subtask of normalizeSubtasks(cfg.subtasks as any || [], (i) => i, cfg.time, cfg.memory)) { result.memoryMax = Math.max(result.memoryMax, ...subtask.cases.map((i) => parseMemoryMB(i.memory))); diff --git a/packages/hydrooj/src/model/contest.ts b/packages/hydrooj/src/model/contest.ts index 51d4bf4c2..a86628336 100644 --- a/packages/hydrooj/src/model/contest.ts +++ b/packages/hydrooj/src/model/contest.ts @@ -98,7 +98,7 @@ export function buildContestRule(def: Partial>, baseRule: Cont } const acm = buildContestRule({ - TEXT: 'ACM/ICPC', + TEXT: 'XCPC', check: () => { }, statusSort: { accept: -1, time: 1 }, submitAfterAccept: false, @@ -276,6 +276,7 @@ const acm = buildContestRule({ delete rdoc.memory; rdoc.testCases = []; rdoc.judgeTexts = []; + delete rdoc.progress; delete rdoc.subtasks; delete rdoc.score; return rdoc; @@ -908,7 +909,7 @@ export async function updateStatus( }: { status?: STATUS, score?: number, subtasks?: Record, lang?: string } = {}, ) { const tdoc = await get(domainId, tid); - if (tdoc.balloon && status === STATUS.STATUS_ACCEPTED) await addBalloon(domainId, tid, uid, rid, pid); + if (tdoc.balloon && status === STATUS.STATUS_ACCEPTED && !isLocked(tdoc)) await addBalloon(domainId, tid, uid, rid, pid); const tsdoc = await document.revPushStatus(tdoc.domainId, document.TYPE_CONTEST, tdoc.docId, uid, 'journal', { rid, pid, status, score, subtasks, lang, }, 'rid'); diff --git a/packages/hydrooj/src/model/problem.ts b/packages/hydrooj/src/model/problem.ts index 0f96f495f..7eb67adc6 100644 --- a/packages/hydrooj/src/model/problem.ts +++ b/packages/hydrooj/src/model/problem.ts @@ -75,21 +75,25 @@ interface ProblemCreateOptions { reference?: { domainId: string, pid: number }; } +const PROJECTION_BASE: Field[] = [ + '_id', 'domainId', 'docType', 'docId', 'pid', + 'owner', 'title', +]; + export class ProblemModel { static PROJECTION_CONTEST_LIST: Field[] = [ - '_id', 'domainId', 'docType', 'docId', 'pid', - 'owner', 'title', + ...PROJECTION_BASE, 'config', ]; static PROJECTION_LIST: Field[] = [ - ...ProblemModel.PROJECTION_CONTEST_LIST, + ...PROJECTION_BASE, 'nSubmit', 'nAccept', 'difficulty', 'tag', 'hidden', 'stats', ]; static PROJECTION_CONTEST_DETAIL: Field[] = [ ...ProblemModel.PROJECTION_CONTEST_LIST, - 'content', 'html', 'data', 'config', 'additional_file', + 'content', 'html', 'data', 'additional_file', 'reference', 'maintainer', ]; @@ -198,7 +202,7 @@ export class ProblemModel { .project(buildProjection(projection)).limit(1).toArray())[0]; if (!res) return null; try { - if (!rawConfig) res.config = await parseConfig(res.config); + if (!rawConfig) res.config = await parseConfig(res.config, res.data?.map((i) => i.name) || []); } catch (e) { res.config = `Cannot parse: ${e.message}`; } @@ -390,7 +394,7 @@ export class ProblemModel { } for (const pdoc of pdocs) { try { - pdoc.config = await parseConfig(pdoc.config as string); + pdoc.config = await parseConfig(pdoc.config as string, pdoc.data?.map((i) => i.name) || []); } catch (e) { pdoc.config = `Cannot parse: ${e.message}`; } @@ -552,16 +556,29 @@ export class ProblemModel { const tag = (pdoc.tag || []).map((t) => t.toString()); let configChanged = false; let config: ProblemConfigFile = {}; - if (await fs.exists('testdata/config.yaml')) { + if (await fs.exists(path.join(tmpdir, i, 'testdata/config.yaml'))) { try { - config = yaml.load(await fs.readFile('testdata/config.yaml', 'utf-8')); + config = yaml.load(await fs.readFile(path.join(tmpdir, i, 'testdata/config.yaml'), 'utf-8')); } catch (e) { // TODO: report this as a warning } } + if (await fs.exists(path.join(tmpdir, i, 'domjudge-problem.ini'))) { + const djConfig = (await fs.readFile(path.join(tmpdir, i, 'domjudge-problem.ini'), 'utf-8') as string) + .split('\n').map((line: string) => line.split('=').map((lines: string) => lines.trim())); + const djConfigJson: any = {}; + for (const [key, value] of djConfig) { + djConfigJson[key] = value; + } + if (djConfigJson.timelimit) { + config.time = `${djConfigJson.timelimit * 1000}ms`; + configChanged = true; + } + if (djConfigJson.externalid) pid = djConfigJson.externalid; + } if ((pdoc as any).limits) { - config.time = (pdoc as any).limits.time_limit; - config.memory = (pdoc as any).limits.memory; + config.time = (pdoc as any).limits.time_limit ? `${(pdoc as any).limits.time_limit * 1000}ms` : config.time || undefined; + config.memory = (pdoc as any).limits.memory ? `${(pdoc as any).limits.memory}m` : config.memory || undefined; configChanged = true; } const docId = overridePid @@ -587,9 +604,8 @@ export class ProblemModel { if (!f.isDirectory()) continue; const sub = await fs.readdir(loc); for (const file of sub) { - await (f.name === 'sample' ? ProblemModel.addAdditionalFile - : f.name === 'secret' ? ProblemModel.addTestdata - : null)?.(domainId, docId, file, path.join(loc, file)); + if (f.name === 'sample') await ProblemModel.addAdditionalFile(domainId, docId, file, path.join(loc, file)); + await ProblemModel.addTestdata(domainId, docId, file, path.join(loc, file)); } } for (const [f, loc] of await getFiles('output_validators')) { @@ -598,10 +614,10 @@ export class ProblemModel { for (const file of sub) { if (file === 'testlib.h') continue; await ProblemModel.addTestdata(domainId, docId, file, path.join(loc, file)); - if (file === 'checker') { + if (f.name === 'checker') { config.checker_type = 'testlib'; config.checker = file; - } else if (file === 'interactor') { + } else if (f.name === 'interactor') { config.type = ProblemType.Interactive; config.interactor = file; } @@ -624,6 +640,7 @@ export class ProblemModel { config.judge_extra_files = Array.from(new Set(config.judge_extra_files.concat(f.name))); configChanged = true; } + if (configChanged) await ProblemModel.addTestdata(domainId, docId, 'config.yaml', Buffer.from(yaml.dump(config))); let count = 0; for (const [f, loc] of await getFiles('std')) { if (!f.isFile()) continue; @@ -631,7 +648,16 @@ export class ProblemModel { if (count > 5) continue; await RecordModel.add(domainId, docId, operator, f.name.split('.')[1], await fs.readFile(loc, 'utf-8'), true); } - if (configChanged) await ProblemModel.addTestdata(domainId, docId, 'config.yaml', yaml.dump(config)); + for (const [f, loc] of await getFiles('submissions')) { + if (f.isFile()) continue; + const sub = await fs.readdir(loc); + for (const file of sub) { + if (file.endsWith('.zip')) continue; + const code = await fs.readFile(path.join(loc, file), 'utf-8'); + await RecordModel.add(domainId, docId, operator, file.split('.')[1], `// ${file}: ${loc}\n${code}`, true); + } + } + if (configChanged) await ProblemModel.addTestdata(domainId, docId, 'config.yaml', Buffer.from(yaml.dump(config))); const message = `${overridePid ? 'Updated' : 'Imported'} problem ${pdoc.pid || docId} (${title})`; (process.env.HYDRO_CLI ? logger.info : progress)?.(message); } catch (e) { diff --git a/packages/hydrooj/src/upgrade.ts b/packages/hydrooj/src/upgrade.ts index 3da95d86f..b18cf1722 100644 --- a/packages/hydrooj/src/upgrade.ts +++ b/packages/hydrooj/src/upgrade.ts @@ -627,7 +627,12 @@ export const coreScripts: MigrationScript[] = [ }; for (const file of files) { const [, domainId, tid, type, name] = file.path.split('/'); - const tdoc = await contest.get(domainId, new ObjectId(tid)); + let tdoc; + try { + tdoc = await contest.get(domainId, new ObjectId(tid)); + } catch (e) { + continue; + } if (!tdoc) console.error('Contest not found', file.path); if (tdoc.rule === 'homework' || !name) { await rename(file.path, `contest/${domainId}/${tid}/public/${name || type}`); diff --git a/packages/onsite-toolkit/index.ts b/packages/onsite-toolkit/index.ts index 74d31681a..5a773baa8 100644 --- a/packages/onsite-toolkit/index.ts +++ b/packages/onsite-toolkit/index.ts @@ -1,9 +1,11 @@ /* eslint-disable max-len */ +import { LRUCache } from 'lru-cache'; import moment from 'moment'; import { - avatar, ContestModel, ContestNotEndedError, Context, db, findFileSync, - ForbiddenError, fs, ObjectId, parseTimeMS, PERM, ProblemConfig, ProblemModel, - STATUS, STATUS_SHORT_TEXTS, STATUS_TEXTS, Time, UserModel, Zip, + _, avatar, ContestModel, ContestNotEndedError, Context, db, findFileSync, + ForbiddenError, fs, ObjectId, parseTimeMS, PERM, PRIV, ProblemConfig, ProblemModel, + randomstring, Schema, SettingModel, STATUS, STATUS_SHORT_TEXTS, STATUS_TEXTS, + Time, Types, UserModel, Zip, } from 'hydrooj'; import { ResolverInput } from './interface'; @@ -18,31 +20,79 @@ declare module 'hydrooj' { } } const coll = db.collection('iplogin'); +const ipLoginCache = new LRUCache({ max: 1000, ttl: 60 * 1000 }); function normalizeIp(ip: string) { if (ip.startsWith('::ffff:')) return ip.slice(7); return ip; } -export function apply(ctx: Context) { - ctx.on('handler/init', async (that) => { - const iplogin = await coll.findOne({ _id: normalizeIp(that.request.ip) }); - if (iplogin) { +const QuickImportSchema = Schema.array(Schema.object({ + id: Schema.union([Schema.string().required(), Schema.number().required()]), + name: Schema.string().required(), + password: Schema.string(), + school: Schema.string(), + members: Schema.array(Schema.string()).default([]), + member1: Schema.string(), + member2: Schema.string(), + member3: Schema.string(), + member4: Schema.string(), + coach: Schema.string(), + seat: Schema.string(), + rank: Schema.boolean(), + ip: Schema.string(), +})); + +export const Config = Schema.object({ + ipLogin: Schema.boolean().default(false), + extraFields: Schema.boolean().default(false), + submit: Schema.boolean().default(false).description('Enable submit script'), +}); + +export function apply(ctx: Context, config: ReturnType) { + if (config.ipLogin) { + ctx.on('handler/init', async (that) => { + const iplogin = ipLoginCache.get(normalizeIp(that.request.ip)) || await coll.findOne({ _id: normalizeIp(that.request.ip) }); + if (!iplogin) { + if (that.session.ipLoggedIn && that.session.ipLoggedIn !== normalizeIp(that.request.ip)) { + that.session.uid = 0; + that.session.user = await UserModel.getById(that.domain._id, 0); + } + return; + } + ipLoginCache.set(normalizeIp(that.request.ip), iplogin); that.user = await UserModel.getById(that.domain._id, iplogin.uid); if (!that.user) { that.user = await UserModel.getById(that.domain._id, 0); - throw new ForbiddenError(`User ${iplogin.uid}not found`); + throw new ForbiddenError(`User ${iplogin.uid} not found`); } - that.session.ipLoggedIn = true; + that.session.ipLoggedIn = that.request.ip; that.session.uid = iplogin.uid; that.session.user = that.user; - } - }); + }); + + const disable = (that) => { + if (!that.user.hasPriv(PRIV.PRIV_EDIT_SYSTEM)) throw new ForbiddenError('Not available'); + }; + ctx.on('handler/before/HomeDomain', disable); + ctx.on('handler/before/DomainUser', disable); + ctx.on('handler/before/HomeMessages#post', disable); + ctx.on('handler/before/Files', disable); + ctx.on('handler/before/HomeAvatar', disable); + ctx.on('handler/before/HomeSecurity', disable); + ctx.on('handler/before/UserDetail', disable); + ctx.on('handler/before/UserLostPass', disable); + + ctx.on('handler/before/HomeSettings', (that) => { + if (that.user.hasPriv(PRIV.PRIV_EDIT_SYSTEM)) return; + if (['domain', 'account'].includes(that.args.category)) throw new ForbiddenError('Not available'); + }); + } ctx.inject(['scoreboard'], ({ scoreboard }) => { scoreboard.addView('resolver-tiny', 'Resolver(Tiny)', { tdoc: 'tdoc' }, { async display({ tdoc }) { - if (!this.user.own(tdoc) && ContestModel.isLocked(tdoc)) this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); + if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); const teams = await ContestModel.getMultiStatus(tdoc.domainId, { docId: tdoc.docId }).toArray(); const udict = await UserModel.getList(tdoc.domainId, teams.map((i) => i.uid)); const teamIds: Record = {}; @@ -82,9 +132,7 @@ export function apply(ctx: Context) { scoreboard.addView('cdp', 'CDP', { tdoc: 'tdoc' }, { async display({ tdoc }) { - if (ContestModel.isLocked(tdoc) && !this.user.own(tdoc)) { - this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); - } + if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST); if (!ContestModel.isDone(tdoc)) throw new ContestNotEndedError(); let token = 0; const getFeed = (type: string, data: any) => ({ @@ -100,7 +148,7 @@ export function apply(ctx: Context) { return { team_id: `team-${udoc._id}`, name: udoc.uname, - displayName: (i.unrank ? '⭐' : '') + udoc.displayName, + displayName: (i.unrank ? '⭐' : '') + (udoc.displayName || udoc.uname), organization: udoc.school || udoc.uname, avatar: avatar(udoc.avatar), group: [ @@ -270,4 +318,71 @@ export function apply(ctx: Context) { supportedRules: ['acm'], }); }); + + /* eslint-disable no-await-in-loop */ + // @ts-ignore + Hydro.model.system.onsiteImport = async function (filepath: string, tidsInput: string, format = 'webp') { + const data = QuickImportSchema(JSON.parse(fs.readFileSync(filepath, 'utf-8'))); + const tids = tidsInput.split(',').map((i) => i.trim()).filter((i) => i).map((i) => new ObjectId(i)); + const tdocs = await Promise.all(tids.map((i) => ContestModel.get('system', i))); + const convertUname = Types.Username[0]; + for (const line of data) { + const uname = convertUname(line.name); + const email = `${line.id}@onsite.local`; + let team = await UserModel.getByEmail('system', email); + if (!team) { + const uid = await UserModel.create(email, uname, line.password || randomstring()); + team = await UserModel.getById('system', uid); + } else { + await UserModel.setById(team._id, { uname }); + if (line.password) await UserModel.setPassword(team._id, line.password); + } + if (line.member1) line.members.push(line.member1); + if (line.member2) line.members.push(line.member2); + if (line.member3) line.members.push(line.member3); + if (line.member4) line.members.push(line.member4); + const set: any = _.pick(line, 'school', 'members', 'coach', 'seat'); + if (line.school) set.avatar = `url:/avatars/${line.school.replace(/[ ()]/g, '')}.${format}`; + if (Object.keys(set).length) await UserModel.setById(team._id, set); + for (const tdoc of tdocs) { + const tsdoc = await ContestModel.getStatus('system', tdoc.docId, team._id); + if (!tsdoc?.attend) await ContestModel.attend('system', tdoc.docId, team._id, 'rank' in line ? { unrank: !line.rank } : {}); + else if ('rank' in line && tsdoc.unrank === line.rank) await ContestModel.setStatus('system', tdoc.docId, team._id, { unrank: !line.rank }); + } + if (line.ip) await coll.updateOne({ _id: normalizeIp(line.ip) }, { $set: { uid: team._id } }, { upsert: true }); + } + }; + + // @ts-ignore + Hydro.model.system.setIpLogin = async function (filepath: string) { + const data = fs.readFileSync(filepath, 'utf-8').split('\n').filter((i) => i); + for (const line of data) { + const [seat, ip] = line.split(',').map((i) => i.trim()); + const user = await UserModel.coll.findOne({ seat }); + if (!user) { + console.warn(`User ${seat} not found`); + continue; + } + await coll.updateOne({ _id: normalizeIp(ip) }, { $set: { uid: user._id } }, { upsert: true }); + } + }; + + if (config.submit) ctx.plugin(require('./submit')); + + if (config.extraFields) { + ctx.inject(['setting'], (c) => { + c.setting.AccountSetting( + SettingModel.Setting('setting_info', 'coach', null, 'text', 'coach', '', SettingModel.FLAG_DISABLED | SettingModel.FLAG_PUBLIC), + SettingModel.Setting('setting_info', 'members', null, 'text', 'members', '', SettingModel.FLAG_DISABLED | SettingModel.FLAG_PUBLIC), + SettingModel.Setting('setting_info', 'seat', null, 'text', 'seat', '', SettingModel.FLAG_DISABLED | SettingModel.FLAG_PUBLIC), + ); + }); + } + + ctx.on('handler/before/ContestScoreboard#get', (that) => { + if (that.request.path.endsWith('/scoreboard')) { + that.response.redirect = `${that.request.path}/xcpcio`; + return 'cleanup'; + } + }); } diff --git a/packages/onsite-toolkit/package.json b/packages/onsite-toolkit/package.json index 76be4d203..519ee01de 100644 --- a/packages/onsite-toolkit/package.json +++ b/packages/onsite-toolkit/package.json @@ -4,6 +4,7 @@ "repository": "https://github.com/hydro-dev/Hydro", "dependencies": { "@react-spring/web": "^10.0.3", + "lru-cache": "^11.2.2", "react-use": "^17.6.0" } } diff --git a/packages/onsite-toolkit/submit.ts b/packages/onsite-toolkit/submit.ts new file mode 100644 index 000000000..85082c654 --- /dev/null +++ b/packages/onsite-toolkit/submit.ts @@ -0,0 +1,92 @@ +import { + ContestModel, Context, DomainModel, fs, nanoid, ProblemDoc, ProblemModel, + RecordModel, SettingModel, StorageModel, SystemModel, +} from 'hydrooj'; +import { ContestDetailBaseHandler } from 'hydrooj/src/handler/contest'; + +class AutoSubmitHandler extends ContestDetailBaseHandler { + pdoc: ProblemDoc; + + async submit({ domainId, dryrun }: { domainId: string, dryrun: boolean }) { + if (!this.tdoc || !ContestModel.isOngoing(this.tdoc, this.tsdoc)) return { error: 'Contest not live' }; + if (!this.tdoc.allowPrint) return { error: 'Not a on-site contest' }; + const file = this.request.files?.file; + if (!file) return { error: 'No file uploaded' }; + const filename = file.originalFilename.split('/').pop(); + if (!/^[A-Z]\./i.test(filename.toUpperCase())) return { error: 'Unsupported file name' }; + const pids = this.tdoc.pids; + const pidIndex = filename.toUpperCase().charCodeAt(0) - 'A'.charCodeAt(0); + if (pidIndex < 0 || pidIndex >= pids.length) return { error: 'Unsupported file name' }; + const pid = pids[pidIndex]; + this.pdoc = await ProblemModel.get(domainId, pid); + + const ext = filename.split('.').pop(); + let lang = ext.toLowerCase(); + + const config = this.pdoc.config; + const lengthLimit = SystemModel.get('limit.codelength') || 128 * 1024; + if (typeof config === 'string' || config === null) return { error: 'system error' }; + const sizeLimit = config.type === 'submit_answer' ? 128 * 1024 * 1024 : lengthLimit; + if (file.size > sizeLimit) return { error: 'File too large' }; + + if (['submit_answer', 'objective'].includes(config.type)) { + lang = '_'; + } else if ((config.langs && !config.langs.includes(lang)) || !SettingModel.langs[lang] || SettingModel.langs[lang].disabled) { + return { error: 'Unsupported language' }; + } + const shouldReadFile = () => { + if (config.type === 'objective') return true; + if (lang === '_') return false; + return file.size < lengthLimit && !file.filepath.endsWith('.zip') && !SettingModel.langs[lang].isBinary; + }; + let code = ''; + const files: Record = {}; + if (shouldReadFile()) code = await fs.readFile(file.filepath, 'utf-8'); + else { + const id = nanoid(); + await StorageModel.put(`submission/${this.user._id}/${id}`, file.filepath, this.user._id); + files.code = `${this.user._id}/${id}#${file.originalFilename}`; + } + + if (!code) return { error: 'Cannot submit empty code' }; + + if (dryrun) { + return { + info: `Info + +Contest ${this.tdoc.title} +Problem ${this.pdoc.title} +User ${this.user.uname} +School ${this.user.school} +Language ${SettingModel.langs[lang].display} (${lang}) +`, + }; + } + + await this.limitRate('add_record', 60, SystemModel.get('limit.submission_user'), '{{user}}'); + await this.limitRate('add_record', 60, SystemModel.get('limit.submission')); + + const rid = await RecordModel.add( + domainId, this.pdoc.docId, this.user._id, lang, code, true, + { contest: this.tdoc.docId, files, type: 'judge' }, + ); + await Promise.all([ + ProblemModel.inc(domainId, this.pdoc.docId, 'nSubmit', 1), + DomainModel.incUserInDomain(domainId, this.user._id, 'nSubmit'), + ContestModel.updateStatus(domainId, this.tdoc.docId, this.user._id, rid, this.pdoc.docId), + ]); + return { rid }; + } + + async post({ domainId, dryrun }) { + try { + this.response.body = await this.submit({ domainId, dryrun }); + } catch (e) { + this.response.body = { error: e.message }; + } + } +} + +export function apply(ctx: Context) { + ctx.Route('contest_autosubmit', '/contest/:tid/autosubmit', AutoSubmitHandler); +} diff --git a/packages/scoreboard-xcpcio/index.ts b/packages/scoreboard-xcpcio/index.ts index 6910d5530..e8f15ae62 100644 --- a/packages/scoreboard-xcpcio/index.ts +++ b/packages/scoreboard-xcpcio/index.ts @@ -24,6 +24,22 @@ const status = { [STATUS.STATUS_ETC]: 'SYSTEM_ERROR', [STATUS.STATUS_CANCELED]: 'CANCELED', }; +const statusPrivate = { + [STATUS.STATUS_WRONG_ANSWER]: 'REJECTED', + [STATUS.STATUS_ACCEPTED]: 'CORRECT', + [STATUS.STATUS_COMPILING]: 'PENDING', + [STATUS.STATUS_WAITING]: 'PENDING', + [STATUS.STATUS_JUDGING]: 'PENDING', + [STATUS.STATUS_TIME_LIMIT_EXCEEDED]: 'REJECTED', + [STATUS.STATUS_MEMORY_LIMIT_EXCEEDED]: 'REJECTED', + [STATUS.STATUS_RUNTIME_ERROR]: 'REJECTED', + [STATUS.STATUS_SYSTEM_ERROR]: 'SYSTEM_ERROR', + [STATUS.STATUS_COMPILE_ERROR]: 'COMPILATION_ERROR', + [STATUS.STATUS_FETCHED]: 'PENDING', + [STATUS.STATUS_OUTPUT_LIMIT_EXCEEDED]: 'REJECTED', + [STATUS.STATUS_ETC]: 'SYSTEM_ERROR', + [STATUS.STATUS_CANCELED]: 'CANCELED', +}; function submissionBase(tdoc: Tdoc, rdoc: RecordDoc, uid?: number) { // NOTE: rdoc can be either record, or a tsdoc detail entry @@ -39,6 +55,7 @@ function submissionBase(tdoc: Tdoc, rdoc: RecordDoc, uid?: number) { async function loadContestState(tdoc: Tdoc, realtime: boolean) { const tsdocs = await ContestModel.getMultiStatus(tdoc.domainId, { docId: tdoc.docId }).toArray(); + const ended = ContestModel.isDone(tdoc); const udict = await UserModel.getList(tdoc.domainId, tsdocs.map((i) => i.uid)); const teams = tsdocs.map((i) => { const udoc = udict[i.uid]; @@ -46,11 +63,11 @@ async function loadContestState(tdoc: Tdoc, realtime: boolean) { team_id: `${udoc._id}`, name: udoc.uname, organization: udoc.school, - members: udoc.members?.split(',').filter((t) => t) || [], + members: 'members' in udoc ? (typeof udoc.members === 'string' ? udoc.members.split(',').filter((t) => t) : udoc.members) : [], coach: udoc.coach, badge: { url: avatar(udoc.avatar) }, group: [ - ...(udoc.group || []), + ...((udoc.group || []).filter((g) => !Number.isSafeInteger(+g))), i.unrank ? 'unofficial' : 'official', ], }; @@ -58,11 +75,11 @@ async function loadContestState(tdoc: Tdoc, realtime: boolean) { return { submissions: tsdocs.flatMap((i) => (i.journal || []).map((j) => { const submit = new ObjectId(j.rid as string).getTimestamp().getTime(); - const curStatus = status[j.status] || 'SYSTEM_ERROR'; + const curStatus = (ended ? status : statusPrivate)[j.status] || 'SYSTEM_ERROR'; return { ...submissionBase(tdoc, j, i.uid), status: (ContestModel.isLocked(tdoc) && submit > tdoc.lockAt.getTime() && !realtime) - ? 'PENDING' + ? 'FROZEN' : curStatus, }; })), @@ -95,7 +112,7 @@ export async function apply(ctx: Context, config: ReturnType) { const tdoc = await ContestModel.get(rdoc.domainId, rdoc.contest); const submit = new ObjectId(rdoc._id).getTimestamp().getTime(); const isLocked = ContestModel.isLocked(tdoc) && submit > tdoc.lockAt.getTime(); - const statusStr = status[rdoc.status] || 'SYSTEM_ERROR'; + const statusStr = statusPrivate[rdoc.status] || 'SYSTEM_ERROR'; if (realtime) { const found = realtime.submissions.find((i) => i.submission_id === rdoc._id.toHexString()); if (found) found.status = statusStr; @@ -104,7 +121,7 @@ export async function apply(ctx: Context, config: ReturnType) { if (pub) { const found = pub.submissions.find((i) => i.submission_id === rdoc._id.toHexString()); if (found && !isLocked) found.status = statusStr; - else pub.submissions.push({ ...submissionBase(tdoc, rdoc), status: isLocked ? 'PENDING' : statusStr }); + else pub.submissions.push({ ...submissionBase(tdoc, rdoc), status: isLocked ? 'FROZEN' : statusStr }); } }); } @@ -115,12 +132,14 @@ export async function apply(ctx: Context, config: ReturnType) { groups: 'groups', json: Types.Boolean, realtime: Types.Boolean, + badge: Types.Boolean, + banner: Types.Boolean, gold: Schema.transform(Schema.union([Schema.string(), Schema.number().step(1).min(0)]), (v) => +v).default(0), silver: Schema.transform(Schema.union([Schema.string(), Schema.number().step(1).min(0)]), (v) => +v).default(0), bronze: Schema.transform(Schema.union([Schema.string(), Schema.number().step(1).min(0)]), (v) => +v).default(0), }, { async display({ - tdoc, groups, json, realtime, gold, silver, bronze, + tdoc, groups, json, realtime, gold, silver, bronze, badge = true, banner = false, }) { if (realtime && !this.user.own(tdoc)) this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); if (json || this.request.json) { @@ -143,6 +162,7 @@ export async function apply(ctx: Context, config: ReturnType) { unofficial: '打星队伍', ...Object.fromEntries(groups.filter((i) => relatedGroups.includes(i.name)).map((i) => [i.name, i.name])), }, + ...(badge ? { badge: 'Badge' } : {}), organization: 'School', status_time_display: { correct: true, @@ -165,9 +185,7 @@ export async function apply(ctx: Context, config: ReturnType) { logo: { preset: 'ICPC', }, - // banner: { - // url: 'assets/banner.png', - // }, + ...(banner ? { banner: { url: 'banner.jpg' } } : {}), options: { submission_timestamp_unit: 'millisecond', }, @@ -178,12 +196,15 @@ export async function apply(ctx: Context, config: ReturnType) { this.response.template = 'xcpcio_board.html'; let query = ''; if (gold || silver || bronze) query = `&gold=${gold}&silver=${silver}&bronze=${bronze}`; + if (badge) query += '&badge=true'; + if (banner) query += '&banner=true'; if (realtime) query += '&realtime=true'; const endpoint = `/d/${tdoc.domainId}/contest/${tdoc.docId}/scoreboard/xcpcio`; this.response.body = { dataSource: `${endpoint}?json=true${query}#allInOne=true`, js: indexJs, css: indexCss, + realtime, }; } }, diff --git a/packages/scoreboard-xcpcio/templates/xcpcio_board.html b/packages/scoreboard-xcpcio/templates/xcpcio_board.html index 847cf32cd..5803d5f8c 100644 --- a/packages/scoreboard-xcpcio/templates/xcpcio_board.html +++ b/packages/scoreboard-xcpcio/templates/xcpcio_board.html @@ -30,6 +30,22 @@ +{% if realtime and model.contest.isLocked(tdoc) and model.contest.isDone(tdoc) and handler.user.own(tdoc) %} +
+
+
+ {{ _('You are viewing the realtime scoreboard while the scoreboard is currently frozen.') }}
+ {{ _('To allow contestants to view the scoreboard, you can unfreeze the scoreboard.') }}
+
+ + +
+
+
+
+{% endif %}
{% endblock %} \ No newline at end of file diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 341a2d346..3d801d8b9 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -20,9 +20,9 @@ "@fontsource/source-code-pro": "^5.2.7", "@fontsource/ubuntu-mono": "^5.2.8", "@hydrooj/utils": "workspace:^", - "@mantine/core": "^8.3.7", - "@mantine/hooks": "^8.3.7", - "@mantine/notifications": "^8.3.7", + "@mantine/core": "^8.3.8", + "@mantine/hooks": "^8.3.8", + "@mantine/notifications": "^8.3.8", "@sentry/browser": "^10.25.0", "@sentry/webpack-plugin": "^4.6.0", "@simplewebauthn/browser": "13.2.2", @@ -36,7 +36,7 @@ "@types/nunjucks": "^3.2.6", "@types/pickadate": "^3.5.35", "@types/qrcode": "^1.5.6", - "@types/react": "^19.2.4", + "@types/react": "^19.2.6", "@types/react-dom": "^19.2.3", "@types/redux-logger": "^3.0.13", "@types/uuid": "^11.0.0", @@ -67,7 +67,7 @@ "jquery-scroll-lock": "^3.1.3", "jquery.easing": "^1.4.1", "jquery.transit": "^0.9.12", - "mantine-contextmenu": "^8.3.7", + "mantine-contextmenu": "^8.3.8", "matchmedia-polyfill": "^0.3.2", "md-editor-rt": "^6.1.1", "mini-css-extract-plugin": "^2.9.4", @@ -101,7 +101,7 @@ "redux-promise-middleware": "^6.2.0", "redux-thunk": "^3.1.0", "rupture": "^0.7.1", - "sass": "^1.94.0", + "sass": "^1.94.1", "sass-loader": "^16.0.6", "schemastery": "^3.17.2", "shorty.js": "^1.0.1", diff --git a/packages/ui-default/templates/contest_problemlist.html b/packages/ui-default/templates/contest_problemlist.html index 6f27607a0..46314b3ae 100644 --- a/packages/ui-default/templates/contest_problemlist.html +++ b/packages/ui-default/templates/contest_problemlist.html @@ -74,6 +74,13 @@ {% endif %} {{ problem.render_problem_title(pdict[pid], tdoc, alphabetic=true) }} + {% if pdict[pid].config.type != 'objective' and pdict[pid].config.type != 'submit_answer' %} + {{ pdict[pid].config.timeMax if pdict[pid].config.timeMax == pdict[pid].config.timeMin else pdict[pid].config.timeMin+'~'+pdict[pid].config.timeMax }}ms + {{ pdict[pid].config.memoryMax if pdict[pid].config.memoryMax == pdict[pid].config.memoryMin else pdict[pid].config.memoryMin+'~'+pdict[pid].config.memoryMax }}MiB + {% endif %} + {% if pdict[pid].config.type != 'objective' %} + 提交 + {% endif %} {%- endfor -%} diff --git a/packages/ui-next/package.json b/packages/ui-next/package.json index 757b0105c..465ac8a8d 100644 --- a/packages/ui-next/package.json +++ b/packages/ui-next/package.json @@ -13,7 +13,7 @@ "react-dom": "^19.2.0" }, "devDependencies": { - "@types/react": "^19.2.3", + "@types/react": "^19.2.6", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react-swc": "^4.2.2", "vite": "^7.2.2"