diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index baed2d7d7..9a4b5611c 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -17,12 +17,12 @@ "@aws-sdk/s3-presigned-post": "3.726.1", "@aws-sdk/s3-request-presigner": "3.726.1", "@babel/code-frame": "^7.29.0", - "@cordisjs/plugin-logger": "^1.0.1", + "@cordisjs/plugin-logger": "^1.0.2", "@cordisjs/plugin-timer": "1.1.1", "@hydrooj/common": "workspace:^", "@hydrooj/framework": "workspace:^", "@hydrooj/utils": "workspace:^", - "@mongodb-js/saslprep": "^1.4.6", + "@mongodb-js/saslprep": "^1.4.8", "@simplewebauthn/server": "13.2.2", "@undefined-moe/require-resolve-hook": "^1.1.0", "@zip.js/zip.js": "^2.8.26", @@ -37,15 +37,15 @@ "koa-proxies": "^0.12.4", "koa-static-cache": "^5.1.4", "lodash": "^4.18.1", - "lru-cache": "^11.2.7", + "lru-cache": "^11.3.5", "mime-types": "^3.0.2", "moment-timezone": "^0.6.1", "mongodb": "^7.1.1", "mongodb-uri": "^0.9.7", - "nanoid": "^5.1.7", - "nodemailer": "^8.0.4", + "nanoid": "^5.1.9", + "nodemailer": "^8.0.5", "notp": "^2.0.3", - "p-queue": "^9.1.1", + "p-queue": "^9.1.2", "sanitize-filename": "^1.6.4", "schemastery": "^3.18.0", "semver": "^7.7.4", @@ -64,7 +64,7 @@ "@types/lodash": "^4.17.24", "@types/mime-types": "^3.0.1", "@types/mongodb-uri": "^0.9.4", - "@types/nodemailer": "^7.0.11", + "@types/nodemailer": "^8.0.0", "@types/notp": "^2.0.5", "@types/semver": "^7.7.1", "@types/superagent": "^8.1.9", diff --git a/packages/hydrooj/src/loader.ts b/packages/hydrooj/src/loader.ts index 9b8a64750..49d56b043 100644 --- a/packages/hydrooj/src/loader.ts +++ b/packages/hydrooj/src/loader.ts @@ -184,7 +184,7 @@ app.plugin(Loader); async function preload() { global.app = await new Promise((resolve) => { app.inject(['timer', 'i18n', 'logger', '$api'], (c) => { - resolve(c); + c.inject({ domain: { required: false } }, resolve); }); }); for (const a of [path.resolve(__dirname, '..'), ...getAddons()]) { diff --git a/packages/onsite-toolkit/frontend/resolver.page.tsx b/packages/onsite-toolkit/frontend/resolver.page.tsx deleted file mode 100644 index 91d595e62..000000000 --- a/packages/onsite-toolkit/frontend/resolver.page.tsx +++ /dev/null @@ -1,293 +0,0 @@ -import { - addPage, NamedPage, React, ReactDOM, -} from '@hydrooj/ui-default'; -import { animated, easings, useSprings } from '@react-spring/web'; -import useKey from 'react-use/lib/useKey'; -import { ResolverInput } from '../interface'; - -async function scrollTo(offset) { - const fixedOffset = offset.toFixed(); - await new Promise((resolve) => { - const onScroll = function () { - if (window.pageYOffset.toFixed() === fixedOffset) { - window.removeEventListener('scroll', onScroll); - resolve(null); - } - }; - - window.addEventListener('scroll', onScroll); - onScroll(); - window.scrollTo({ - top: offset, - behavior: 'smooth', - }); - }); -} -export interface DisplaySettings { - showAvatar: boolean; - showSchool: boolean; -} - -interface Props extends DisplaySettings { - data: ResolverInput; -} - -interface ProblemStatus { - old: number; - frozen: number; - pass: boolean; - time: number; - index: number; - id?: string; -} - -function status(problem: ProblemStatus) { - if (!problem) return 'untouched'; - if (problem.pass) return 'ac'; - if (!problem.old && !problem.frozen) return 'untouched'; - if (problem.frozen) return 'frozen'; - return 'failed'; -} - -function submissions(problem: ProblemStatus) { - const st = status(problem); - if (st === 'ac') { return `${problem.old} / ${problem.time}`; } - if (st === 'frozen') { return `${problem.old}+${problem.frozen}`; } - if (st === 'failed') { return problem.old; } - return String.fromCharCode('A'.charCodeAt(0) + problem.index); -} - -export function start(data: ResolverInput, options: DisplaySettings) { - $('title').text(`${data.name} - Resolver`); - $('.header .title').text(`${data.name}`); - const teams = data.teams.map((v) => ({ - id: v.id, - rank: 0, - score: 0, - penalty: 0, - ranked: !v.exclude, - total: 0, - problems: data.problems.map((problem, idx) => ({ - old: 0, - frozen: 0, - pass: false, - id: problem.id, - index: idx, - time: 0, - })), - })); - const allSubmissions = data.submissions.filter((i) => !['CE', 'SE', 'FE', 'IGN'].includes(i.verdict)).sort((a, b) => a.time - b.time); - const allAc = allSubmissions.filter((i) => i.verdict === 'AC'); - for (const submission of allSubmissions) { - const team = teams.find((v) => v.id === submission.team); - if (!team) continue; - const isFrozen = submission.time > data.frozen; - const problem = team.problems.find((i) => i.id === submission.problem); - if (!problem || problem.pass) continue; - team.total++; - if (isFrozen) problem.frozen += 1; - else { - if (submission.verdict === 'AC') { - problem.pass = true; - problem.time = Math.floor(submission.time / 60); - team.score += 1; - team.penalty += Math.floor(submission.time / 60) + problem.old * 20; - } - problem.old += 1; - } - } - - function MainList(props: Props) { - const [selectedTeam, setTeam] = React.useState(''); - const [selectedProblem, setP] = React.useState(null); - const [executeIdx, setExecuteIdx] = React.useState(0); - const [, setRenderC] = React.useState(0); - - function processRank(source = teams) { - const clone = [...source]; - clone.sort((a, b) => b.score - a.score || a.penalty - b.penalty || b.total - a.total); - let rank = 1; - for (const team of clone) { - if (team.ranked) { - team.rank = rank; - rank++; - } else { - team.rank = -1; - } - } - return clone.map((i) => source.indexOf(i)); - } - - const order = React.useRef(processRank()); - - const [springs, api] = useSprings(teams.length, (index) => ({ - y: order.current.indexOf(index) * 80 - index * 80, - scale: 1, - zIndex: 0, - shadow: 1, - immediate: (key: string) => key === 'y' || key === 'zIndex', - })); - - const operations = { - async highlightTeam(teamId: string, scrollIdx: number) { - setP(null); - setTeam(teamId); - await scrollTo(scrollIdx * 80 - window.innerHeight + 241 + 40); - }, - async highlightProblem(problemId: string) { - setP(problemId); - }, - async revealProblem(teamId: string, problemId: string) { - const team = teams.find((i) => i.id === teamId); - const problem = team?.problems.find((i) => i.id === problemId); - if (!team || !problem) return; - if (allAc.find((s) => s.team === teamId && s.problem === problemId)) { - const sub = allSubmissions.filter((s) => s.team === teamId && s.problem === problemId); - let penalty = 0; - for (const s of sub) { - problem.old++; - if (s.verdict !== 'AC') { - penalty += 20; - } else { - problem.time = Math.floor(s.time / 60); - penalty += Math.floor(s.time / 60); - break; - } - } - team.penalty += penalty; - team.score += 1; - problem.pass = true; - problem.frozen = 0; - } else { - problem.old += problem.frozen; - problem.frozen = 0; - } - setP(null); - }, - async updateRank() { - order.current = processRank(); - api.start((index) => ({ - y: order.current.indexOf(index) * 80 - index * 80, - scale: 1, - zIndex: 0, - shadow: 1, - config: { - easing: easings.steps(5), - }, - })); - }, - }; - - const calculated = React.useMemo(() => { - window.scrollTo(0, document.body.scrollHeight); - const clone = JSON.parse(JSON.stringify(teams)); - const ops: { name: string, args: any[] }[] = []; - function queueOperations(name: string, ...args: any[]) { - ops.push({ name, args }); - } - let orders = processRank(clone); - for (let i = clone.length - 1; i >= 0; i--) { - const team = clone[orders[i]]; - queueOperations('highlightTeam', team.id, i); - for (const pinfo of data.problems) { - const problem: ProblemStatus = team.problems.find((idx) => idx.id === pinfo.id); - if (!problem || !problem.frozen || problem.pass) continue; - queueOperations('highlightProblem', pinfo.id); - queueOperations('revealProblem', team.id, pinfo.id); - // scroll to selected line - if (allAc.find((s) => s.team === team.id && s.problem === problem.id)) { - const sub = allSubmissions.filter((s) => s.team === team.id && s.problem === problem.id); - let penalty = 0; - for (const s of sub) { - problem.old++; - if (s.verdict !== 'AC') { - penalty += 20; - } else { - problem.time = Math.floor(s.time / 60); - penalty += Math.floor(s.time / 60); - break; - } - } - team.penalty += penalty; - team.score += 1; - problem.pass = true; - problem.frozen = 0; - queueOperations('updateRank'); - const oldOrder = JSON.stringify(orders); - orders = processRank(clone); - if (oldOrder !== JSON.stringify(orders)) { - i++; - break; - } - } else { - problem.old += problem.frozen; - problem.frozen = 0; - } - } - } - console.log(ops.length, 'operations'); - return ops; - }, [data]); - - useKey((key) => ['n', 'ArrowRight'].includes(key.key), async () => { - const op = calculated[executeIdx]; - if (!op) return; - setExecuteIdx(executeIdx + 1); - await operations[op.name](...op.args); - setRenderC((i) => i + 1); - }, {}, [executeIdx, calculated]); - - return (<> - {springs.map(({ - zIndex, y, - }, i) => { - const team = teams[i]; - const teamInfo = data.teams.find((idx) => idx.id === team.id); - if (!teams[i]) return Team {i} not found; - if (!teamInfo) return Team info for id {team.id} not found; - return `rgba(0, 0, 0, 0.15) 0px ${s}px ${2 * s}px 0px`), - y, - ...(selectedTeam === team.id ? { - backgroundColor: '#406b82', - } : { - background: 'transparent', - }), - }} - > -
{team.rank === -1 ? '⭐' : team.rank}
- {props.showAvatar && } -
-
- {props.showSchool ? `${teamInfo.institution} - ` : ''}{teamInfo.name} -
-
- {data.problems.map((v) => { - const uncover = team.id === selectedTeam && selectedProblem === v.id; - const problemStatus = team.problems.find((idx) => idx.id === v.id); - if (!problemStatus) return ERR; - return - {submissions(problemStatus)} - ; - })} -
-
-
{team.score}
-
{team.penalty}
-
; - })} - ); - } - ReactDOM.createRoot(document.getElementById('rank-list')!).render(); -} - -addPage(new NamedPage(['resolver'], () => { - start(UiContext.payload, { - showAvatar: true, - showSchool: true, - }); -})); diff --git a/packages/onsite-toolkit/index.ts b/packages/onsite-toolkit/index.ts index 0db844dff..1b5f25ac8 100644 --- a/packages/onsite-toolkit/index.ts +++ b/packages/onsite-toolkit/index.ts @@ -3,11 +3,10 @@ import { LRUCache } from 'lru-cache'; import moment from 'moment'; import { _, avatar, BadRequestError, ContestModel, ContestNotEndedError, Context, db, findFileSync, - ForbiddenError, fs, ObjectId, parseTimeMS, PERM, ProblemConfig, ProblemModel, + ForbiddenError, fs, Handler, InvalidTokenError, ObjectId, param, parseTimeMS, PERM, ProblemConfig, ProblemModel, randomstring, Schema, SettingModel, STATUS, STATUS_SHORT_TEXTS, STATUS_TEXTS, - SystemModel, Time, Types, UserModel, Zip, + SystemModel, TokenModel, Types, UserModel, Zip, } from 'hydrooj'; -import { ResolverInput } from './interface'; interface IpLoginInfo { _id: string; @@ -89,245 +88,203 @@ export function apply(ctx: Context, config: ReturnType) { 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)) 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 = {}; - for (let i = 1; i <= teams.length; i++) teamIds[teams[i - 1].uid] = i; - const time = (t: ObjectId) => Math.floor((t.getTimestamp().getTime() - tdoc.beginAt.getTime()) / Time.second); - const pid = (i: number) => String.fromCharCode(65 + i); - const unknownSchool = this.translate('Unknown School'); - const submissions = teams.flatMap((i) => { - if (!i.journal) return []; - return i.journal.filter((s) => tdoc.pids.includes(s.pid)).map((s) => ({ ...s, uid: i.uid })); - }); - this.response.body = { - payload: { - name: tdoc.title, - duration: Math.floor((new Date(tdoc.endAt).getTime() - new Date(tdoc.beginAt).getTime()) / 1000), - frozen: Math.floor((new Date(tdoc.lockAt).getTime() - new Date(tdoc.beginAt).getTime()) / 1000), - problems: tdoc.pids.map((i, n) => ({ name: pid(n), id: i.toString() })), - teams: teams.map((t) => ({ - id: t.uid.toString(), - name: udict[t.uid].displayName || udict[t.uid].uname, - avatar: avatar(udict[t.uid].avatar), - institution: udict[t.uid].school || unknownSchool, - exclude: t.unrank, - })), - submissions: submissions.map((i) => ({ - team: udict[i.uid]?.seat || i.uid.toString(), - problem: i.pid.toString(), - verdict: STATUS_SHORT_TEXTS[i.status], - time: time(i.rid), - })), - } as ResolverInput, - }; - this.response.template = 'resolver.html'; - }, - supportedRules: ['acm'], + async function generateCdpZip(tdoc) { + const serverUrl = SystemModel.get('server.url'); + try { + new URL(serverUrl); + } catch (e) { + throw new BadRequestError('Server URL not set'); + } + let token = 0; + const getFeed = (type: string, data: any) => ({ + type, id: data.id, data, token: `t${token++}`, }); + const [pdict, tsdocs] = await Promise.all([ + ProblemModel.getList(tdoc.domainId, tdoc.pids, true, false, ProblemModel.PROJECTION_LIST.concat('config'), true), + ContestModel.getMultiStatus(tdoc.domainId, { docId: tdoc._id }).toArray(), + ]); + const udict = await UserModel.getList(tdoc.domainId, tsdocs.map((i) => i.uid)); + const teams = tsdocs.map((i) => { + const udoc = udict[i.uid]; + return { + team_id: udoc.seat || `team-${udoc._id}`, + name: udoc.uname, + displayName: (i.unrank ? '⭐' : '') + (udoc.displayName || udoc.uname), + organization: udoc.school || udoc.uname, + avatar: avatar(udoc.avatar), + group: [ + ...(udoc.group.filter((g) => g !== `${udoc._id}`)), + i.unrank ? 'observers' : 'participants', + ], + }; + }); + const relatedGroups = Array.from(new Set(teams.flatMap((i) => i.group))); + const groupId = {}; + let gid = 1; + for (const i of relatedGroups) groupId[i] = `group-${gid++}`; + const organizations = Array.from(new Set(teams.flatMap((i) => i.organization))); + const orgId = {}; + let oid = 1; + for (const i of organizations) orgId[i] = `org-${oid++}`; + const duration = moment(tdoc.endAt).diff(tdoc.beginAt, 'seconds'); + const lockDuration = tdoc.lockAt + ? moment(tdoc.endAt).diff(tdoc.lockAt, 'seconds') + : tdoc.rule === 'oi' ? duration : null; + const eventfeed: Record[] = [ + getFeed('contest', { + id: tdoc._id.toHexString(), + name: tdoc.title, + formal_name: tdoc.title, + start_time: moment(tdoc.beginAt).format('YYYY-MM-DDTHH:mm:ss.SSS+08:00'), + duration: moment().startOf('day').seconds(duration).format('HH:mm:ss.SSS'), + ...lockDuration && { + scoreboard_freeze_duration: moment().startOf('day').seconds(lockDuration).format('HH:mm:ss.SSS'), + }, + penalty_time: 20, + }), + ...Object.keys(STATUS_SHORT_TEXTS).map((i) => getFeed('judgement-types', { + id: STATUS_SHORT_TEXTS[i], + name: STATUS_TEXTS[i], + penalty: ![STATUS.STATUS_ACCEPTED, STATUS.STATUS_COMPILE_ERROR, STATUS.STATUS_SYSTEM_ERROR].includes(+i), + solved: +i === STATUS.STATUS_ACCEPTED, + })), + getFeed('languages', { id: 'c', name: 'C', entry_point_required: false, extensions: ['c'] }), + getFeed('languages', { id: 'cpp', name: 'C++', entry_point_required: false, extensions: ['cpp', 'cc', 'cxx', 'c++'] }), + getFeed('languages', { id: 'java', name: 'Java', entry_point_required: true, extensions: ['java'] }), + getFeed('languages', { id: 'python3', name: 'Python 3', entry_point_required: false, entry_point_name: 'Main file', extensions: ['py', 'py3'] }), + getFeed('languages', { id: 'kotlin', name: 'Kotlin', entry_point_required: true, extensions: ['kt'] }), + getFeed('languages', { id: 'rust', name: 'Rust', entry_point_required: true, extensions: ['rs'] }), + getFeed('languages', { id: 'go', name: 'Go', entry_point_required: true, extensions: ['go'] }), + getFeed('groups', { id: 'participants', name: '正式队伍' }), + getFeed('groups', { id: 'observers', name: '打星队伍' }), + ...relatedGroups.map((i) => getFeed('groups', { id: groupId[i], name: i })), + ...organizations.map((i) => getFeed('organizations', { + id: orgId[i], name: i, formal_name: i, + logo: [{ href: `contest/${tdoc._id}/organizations/${orgId[i]}/logo`, filename: 'logo.png', mime: 'image/png', width: 64, height: 64 }], + })), + ...teams.map((i) => getFeed('teams', { + id: i.team_id, label: i.team_id, name: i.name, + display_name: i.displayName || i.name, + group_ids: i.group.map((j) => groupId[j]), + organization_id: orgId[i.organization], + photo: [{ href: `contest/${tdoc._id}/teams/${i.team_id}/photo`, filename: 'photo.png', mime: 'image/png', width: 1920, height: 1080 }], + logo: [{ href: new URL(i.avatar, serverUrl).toString(), filename: 'logo.webp', mime: 'image/webp', width: 128, height: 128 }], + })), + ...tdoc.pids.map((i, idx) => getFeed('problems', { + id: `${i}`, label: String.fromCharCode(65 + idx), name: pdict[i].title, ordinal: idx, + color: (typeof (tdoc.balloon?.[idx]) === 'object' ? tdoc.balloon[idx].name : tdoc.balloon?.[idx]) || 'white', + rgb: (typeof (tdoc.balloon?.[idx]) === 'object' ? tdoc.balloon[idx].color : null) || '#ffffff', + time_limit: Math.floor(parseTimeMS((pdict[i].config as ProblemConfig).timeMax) / 100) / 10, + test_data_count: 20, + })), + ]; + let cntJudge = 0; + const submissions = tsdocs.flatMap((i) => { + if (!i.journal) return []; + const journal = i.journal.filter((s) => tdoc.pids.includes(s.pid)); + const result: any[] = []; + for (const s of journal) { + const submitTime = moment(s.rid.getTimestamp()); + const submitDelta = submitTime.diff(tdoc.beginAt, 'seconds'); + const submitAt = moment().startOf('day').seconds(submitDelta).format('HH:mm:ss.SSS'); + const judgeTime = submitTime.add(1, 'seconds'); + const judgeDelta = judgeTime.diff(tdoc.beginAt, 'seconds'); + const judgeAt = moment().startOf('day').seconds(judgeDelta).format('HH:mm:ss.SSS'); + result.push(getFeed('submissions', { + id: s.rid, team_id: udict[i.uid]?.seat || `team-${i.uid}`, + problem_id: `${s.pid}`, language_id: s.lang?.split('.')[0] || 'cpp', + files: [], contest_time: submitAt, + time: submitTime.format('YYYY-MM-DDTHH:mm:ss.SSS+08:00'), + })); + result.push(getFeed('judgements', { + id: `${cntJudge}`, submission_id: s.rid, + judgement_type_id: STATUS_SHORT_TEXTS[s.status], max_run_time: 0.1, + start_contest_time: submitAt, end_contest_time: judgeAt, + start_time: submitTime.format('YYYY-MM-DDTHH:mm:ss.SSS+08:00'), + end_time: judgeTime.format('YYYY-MM-DDTHH:mm:ss.SSS+08:00'), + ...s.score != null && { score: s.score }, + })); + cntJudge++; + } + return result; + }); + const endState = [ + getFeed('state', { + started: moment(tdoc.beginAt).format('YYYY-MM-DDTHH:mm:ss.SSS+08:00'), + ended: moment(tdoc.endAt).format('YYYY-MM-DDTHH:mm:ss.SSS+08:00'), + ...tdoc.lockAt && { frozen: moment(tdoc.lockAt).format('YYYY-MM-DDTHH:mm:ss.SSS+08:00') }, + finalized: moment().format('YYYY-MM-DDTHH:mm:ss.SSS+08:00'), + }), + ]; + const zip = new Zip.ZipWriter(new Zip.BlobWriter()); + await Promise.all([ + zip.add('event-feed.ndjson', new Zip.TextReader(eventfeed.concat(submissions).concat(endState).map((i) => JSON.stringify(i)).join('\n'))), + zip.add('contest/logo.png', new Zip.BlobReader(new Blob([fs.readFileSync(findFileSync('@hydrooj/onsite-toolkit/public/logo.png'))]))), + zip.add('teams/', null, { directory: true }), + zip.add('organizations/', null, { directory: true }), + ]); + await Promise.all(teams.map(async (i) => { + await zip.add(`teams/${i.team_id}/`, null, { directory: true }); + await zip.add(`teams/${i.team_id}/photo.url`, new Zip.TextReader(`URL=${i.avatar}`)); + })); + await Promise.all(organizations.map(async (i) => { + const avatarSrc = teams.find((j) => j.organization === i)?.avatar; + if (!avatarSrc) return; + await zip.add(`organizations/${orgId[i]}/`, null, { directory: true }); + await zip.add(`organizations/${orgId[i]}/photo.url`, new Zip.TextReader(`URL=${avatar(avatarSrc)}`)); + })); + return zip.close(); + } + class ContestResolverCdpHandler extends Handler { + allowCors = true; + @param('tid', Types.ObjectId) + @param('token', Types.String) + async get(domainId: string, tid: ObjectId, token: string) { + const tokenDoc = await TokenModel.get(token, TokenModel.TYPE_EXPORT); + if (!tokenDoc || tokenDoc.domainId !== domainId || tokenDoc.contestId?.toHexString() !== tid.toHexString()) { + throw new InvalidTokenError(token); + } + const tdoc = await ContestModel.get(domainId, tid); + if (!ContestModel.isDone(tdoc)) throw new ContestNotEndedError(); + this.response.addHeader('Access-Control-Allow-Origin', '*'); + this.binary(await generateCdpZip(tdoc), `contest-${tid}-cdp.zip`); + } + } + + ctx.Route('contest_resolver_cdp', '/contest/:tid/resolver-cdp/:token', ContestResolverCdpHandler); + + ctx.inject(['scoreboard'], ({ scoreboard }) => { scoreboard.addView('cdp', 'CDP', { tdoc: 'tdoc' }, { async display({ tdoc }) { if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST); if (!ContestModel.isDone(tdoc)) throw new ContestNotEndedError(); + this.binary(await generateCdpZip(tdoc), `contest-${tdoc._id}-cdp.zip`); + }, + supportedRules: ['acm', 'oi'], + }); + + scoreboard.addView('resolver', 'Resolver', { tdoc: 'tdoc' }, { + async display({ tdoc }) { + if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST); + if (!ContestModel.isDone(tdoc)) throw new ContestNotEndedError(); + const serverUrl = SystemModel.get('server.url'); try { - new URL(SystemModel.get('server.url')); // eslint-disable-line no-new + new URL(serverUrl); } catch (e) { throw new BadRequestError('Server URL not set'); } - let token = 0; - const getFeed = (type: string, data: any) => ({ - type, id: data.id, data, token: `t${token++}`, - }); - const [pdict, tsdocs] = await Promise.all([ - ProblemModel.getList(tdoc.domainId, tdoc.pids, true, false, ProblemModel.PROJECTION_LIST.concat('config'), true), - ContestModel.getMultiStatus(tdoc.domainId, { docId: tdoc._id }).toArray(), - ]); - const udict = await UserModel.getList(tdoc.domainId, tsdocs.map((i) => i.uid)); - const teams = tsdocs.map((i) => { - const udoc = udict[i.uid]; - return { - team_id: udoc.seat || `team-${udoc._id}`, - name: udoc.uname, - displayName: (i.unrank ? '⭐' : '') + (udoc.displayName || udoc.uname), - organization: udoc.school || udoc.uname, - avatar: avatar(udoc.avatar), - group: [ - ...(udoc.group.filter((g) => g !== `${udoc._id}`)), - i.unrank ? 'observers' : 'participants', - ], - }; - }); - const relatedGroups = Array.from(new Set(teams.flatMap((i) => i.group))); - const groupId = {}; - let gid = 1; - for (const i of relatedGroups) groupId[i] = `group-${gid++}`; - const organizations = Array.from(new Set(teams.flatMap((i) => i.organization))); - const orgId = {}; - let oid = 1; - for (const i of organizations) orgId[i] = `org-${oid++}`; - const duration = moment(tdoc.endAt).diff(tdoc.beginAt, 'seconds'); - const lockDuration = tdoc.lockAt ? moment(tdoc.endAt).diff(tdoc.lockAt, 'seconds') : null; - const eventfeed: Record[] = [ - getFeed('contest', { - id: tdoc._id.toHexString(), - name: tdoc.title, - formal_name: tdoc.title, - start_time: moment(tdoc.beginAt).format('YYYY-MM-DDTHH:mm:ss.SSS+08:00'), - // 1:00:00.000 - duration: moment().startOf('day').seconds(duration).format('HH:mm:ss.SSS'), - ...lockDuration && { - scoreboard_freeze_duration: moment().startOf('day').seconds(lockDuration).format('HH:mm:ss.SSS'), - }, - penalty_time: 20, - }), - ...Object.keys(STATUS_SHORT_TEXTS).map((i) => getFeed('judgement-types', { - id: STATUS_SHORT_TEXTS[i], - name: STATUS_TEXTS[i], - penalty: ![STATUS.STATUS_ACCEPTED, STATUS.STATUS_COMPILE_ERROR, STATUS.STATUS_SYSTEM_ERROR].includes(+i), - solved: +i === STATUS.STATUS_ACCEPTED, - })), - getFeed('languages', { - id: 'c', name: 'C', entry_point_required: false, extensions: ['c'], - }), - getFeed('languages', { - id: 'cpp', name: 'C++', entry_point_required: false, extensions: ['cpp', 'cc', 'cxx', 'c++'], - }), - getFeed('languages', { - id: 'java', name: 'Java', entry_point_required: true, extensions: ['java'], - }), - getFeed('languages', { - id: 'python3', name: 'Python 3', entry_point_required: false, entry_point_name: 'Main file', extensions: ['py', 'py3'], - }), - getFeed('languages', { - id: 'kotlin', name: 'Kotlin', entry_point_required: true, extensions: ['kt'], - }), - getFeed('languages', { - id: 'rust', name: 'Rust', entry_point_required: true, extensions: ['rs'], - }), - getFeed('languages', { - id: 'go', name: 'Go', entry_point_required: true, extensions: ['go'], - }), - getFeed('groups', { id: 'participants', name: '正式队伍' }), - getFeed('groups', { id: 'observers', name: '打星队伍' }), - ...relatedGroups.map((i) => getFeed('groups', { - id: groupId[i], - name: i, - })), - ...organizations.map((i) => getFeed('organizations', { - id: orgId[i], - name: i, - formal_name: i, - logo: [{ - href: `contest/${tdoc._id}/organizations/${orgId[i]}/logo`, - filename: 'logo.png', - mime: 'image/png', - width: 64, - height: 64, - }], - })), - ...teams.map((i) => getFeed('teams', { - id: i.team_id, - label: i.team_id, - name: i.name, - display_name: i.displayName || i.name, - group_ids: i.group.map((j) => groupId[j]), - organization_id: orgId[i.organization], - photo: [{ - href: `contest/${tdoc._id}/teams/${i.team_id}/photo`, - filename: 'photo.png', - mime: 'image/png', - width: 1920, - height: 1080, - }], - logo: [{ - href: new URL(i.avatar, SystemModel.get('server.url')).toString(), - filename: 'logo.webp', - mime: 'image/webp', - width: 128, - height: 128, - }], - })), - ...tdoc.pids.map((i, idx) => getFeed('problems', { - id: `${i}`, - label: String.fromCharCode(65 + idx), - name: pdict[i].title, - ordinal: idx, - color: (typeof (tdoc.balloon?.[idx]) === 'object' ? tdoc.balloon[idx].name : tdoc.balloon?.[idx]) || 'white', - rgb: (typeof (tdoc.balloon?.[idx]) === 'object' ? tdoc.balloon[idx].color : null) || '#ffffff', - time_limit: Math.floor(parseTimeMS((pdict[i].config as ProblemConfig).timeMax) / 100) / 10, - test_data_count: 20, - })), - ]; - let cntJudge = 0; - const submissions = tsdocs.flatMap((i) => { - if (!i.journal) return []; - const journal = i.journal.filter((s) => tdoc.pids.includes(s.pid)); - const result: any[] = []; - for (const s of journal) { - const submitTime = moment(s.rid.getTimestamp()); - const submitDelta = submitTime.diff(tdoc.beginAt, 'seconds'); - const submitAt = moment().startOf('day').seconds(submitDelta).format('HH:mm:ss.SSS'); - const judgeTime = submitTime.add(1, 'seconds'); - const judgeDelta = judgeTime.diff(tdoc.beginAt, 'seconds'); - const judgeAt = moment().startOf('day').seconds(judgeDelta).format('HH:mm:ss.SSS'); - result.push(getFeed('submissions', { - id: s.rid, - team_id: udict[i.uid]?.seat || `team-${i.uid}`, - problem_id: `${s.pid}`, - language_id: s.lang?.split('.')[0] || 'cpp', - files: [], - contest_time: submitAt, - time: submitTime.format('YYYY-MM-DDTHH:mm:ss.SSS+08:00'), - })); - result.push(getFeed('judgements', { - id: `${cntJudge}`, - submission_id: s.rid, - judgement_type_id: STATUS_SHORT_TEXTS[s.status], - max_run_time: 0.1, - start_contest_time: submitAt, - end_contest_time: judgeAt, - start_time: submitTime.format('YYYY-MM-DDTHH:mm:ss.SSS+08:00'), - end_time: judgeTime.format('YYYY-MM-DDTHH:mm:ss.SSS+08:00'), - })); - cntJudge++; - } - return result; - }); - const endState = [ - getFeed('state', { - started: moment(tdoc.beginAt).format('YYYY-MM-DDTHH:mm:ss.SSS+08:00'), - ended: moment(tdoc.endAt).format('YYYY-MM-DDTHH:mm:ss.SSS+08:00'), - ...tdoc.lockAt && { - frozen: moment(tdoc.lockAt).format('YYYY-MM-DDTHH:mm:ss.SSS+08:00'), - }, - finalized: moment().format('YYYY-MM-DDTHH:mm:ss.SSS+08:00'), - }), - ]; - const zip = new Zip.ZipWriter(new Zip.BlobWriter()); - await Promise.all([ - zip.add('event-feed.ndjson', new Zip.TextReader(eventfeed.concat(submissions).concat(endState).map((i) => JSON.stringify(i)).join('\n'))), - zip.add('contest/logo.png', new Zip.BlobReader(new Blob([fs.readFileSync(findFileSync('@hydrooj/onsite-toolkit/public/logo.png'))]))), - zip.add('teams/', null, { directory: true }), - zip.add('organizations/', null, { directory: true }), - ]); - await Promise.all(teams.map(async (i) => { - await zip.add(`teams/${i.team_id}/`, null, { directory: true }); - await zip.add(`teams/${i.team_id}/photo.url`, new Zip.TextReader(`URL=${i.avatar}`)); - })); - await Promise.all(organizations.map(async (i) => { - const avatarSrc = teams.find((j) => j.organization === i)?.avatar; - if (!avatarSrc) return; - await zip.add(`organizations/${orgId[i]}/`, null, { directory: true }); - await zip.add(`organizations/${orgId[i]}/photo.url`, new Zip.TextReader(`URL=${avatar(avatarSrc)}`)); - })); - this.binary(await zip.close(), `contest-${tdoc._id}-cdp.zip`); + const [tokenId] = await TokenModel.add( + TokenModel.TYPE_EXPORT, 600, + { domainId: tdoc.domainId, contestId: tdoc._id }, + ); + const source = new URL(`/d/${tdoc.domainId}/contest/${tdoc._id}/resolver-cdp/${tokenId}`, serverUrl).toString(); + const target = new URL('https://resolver.hydrooj.com'); + target.searchParams.set('source', source); + target.searchParams.set('mode', tdoc.rule === 'oi' ? 'oi' : 'acm'); + this.response.redirect = target.toString(); }, - supportedRules: ['acm'], + supportedRules: ['acm', 'oi'], }); }); diff --git a/packages/onsite-toolkit/interface.ts b/packages/onsite-toolkit/interface.ts deleted file mode 100644 index d7f02dc85..000000000 --- a/packages/onsite-toolkit/interface.ts +++ /dev/null @@ -1,33 +0,0 @@ -type Verdict = 'RJ' | 'AC' | 'NA'; -export interface ProblemInfo { - color?: string; // hex color - id: string; - name: string; // A, B, C, ... -} -export interface TeamInfo { - id: string; - name: string; - avatar?: string; - institution?: string; - exclude?: boolean; // false by default -} -export interface InstitiutionInfo { - id: string; - name: string; - avatar?: string; -} -export interface SubmissionInfo { - team: string; - problem: string; - verdict: Verdict; - time: number; // in seconds -} -export interface ResolverInput { - name: string; - duration: number; // in seconds - frozen: number; // in seconds - problems: ProblemInfo[]; - submissions: SubmissionInfo[]; - teams: TeamInfo[]; - institutions: Record; -} diff --git a/packages/onsite-toolkit/package.json b/packages/onsite-toolkit/package.json index dd21c391e..f890056c7 100644 --- a/packages/onsite-toolkit/package.json +++ b/packages/onsite-toolkit/package.json @@ -3,8 +3,6 @@ "version": "0.1.0-beta.6", "repository": "https://github.com/hydro-dev/Hydro", "dependencies": { - "@react-spring/web": "^10.0.3", - "lru-cache": "^11.2.7", - "react-use": "^17.6.0" + "lru-cache": "^11.3.5" } } diff --git a/packages/onsite-toolkit/public/resolver.css b/packages/onsite-toolkit/public/resolver.css deleted file mode 100644 index d324d43b8..000000000 --- a/packages/onsite-toolkit/public/resolver.css +++ /dev/null @@ -1,110 +0,0 @@ -body { - color: #eee; - background-color: #121212; -} -.rank-list { - background: repeating-linear-gradient( - 180deg, - #3e3e3e 0px, - #3e3e3e 80px, - #121212 80px, - #121212 160px - ); -} -.rank-list-item { - padding: 10px 0; - height: 80px; - position: relative; - background: transparent; - display: flex; - align-items: center; - vertical-align: middle; -} -.header { - display: flex; - align-items: center; - padding: 10px 0; - height: 40px; -} -.header .rank, .header .solved, .header .penalty { - width: 90px; - font-size: 20px; - text-align: center; -} -.header .content { - flex-grow: 1; - text-align: left; - display: flex; - justify-content: center; - align-items: center; -} -.header .content .title { - flex-grow: 1; - font-size: 20px; - text-align: left; - display: flex; -} -.header .content .copyright { - color: #8a919b; - font-size: 15px; -} -.rank-list-item .rank, .rank-list-item .solved, .rank-list-item .penalty { - padding: 10px 0px; - width: 90px; - font-size: 40px; - text-align: center; - vertical-align: middle; -} -.rank-list-item .avatar { - height: 60px; - width: 60px; - border-radius: 5px; - margin-right: 15px; -} -.rank-list-item .content { - flex-grow: 1; - height: 60px -} -.rank-list-item .content .name { - font-size: 30px; - margin-bottom: 6px; -} -.rank-list-item .content .problems { - display: flex; - align-items: center; - gap: 5px; -} -.rank-list-item .problems .item { - padding: 2px; - width: 95px; - text-align: center; - border-radius: 5px; - font-size: 20px; -} -.ac { - background-color: #009c00; -} -.failed, .WA { - background-color: #dd514c; -} -.frozen { - background-color: #3c6ef6; -} -.untouched { - background-color: #282828; - color: #8a919b; -} -.uncover { - animation: flashing 1000ms infinite; - -webkit-animation: flashing 1000ms infinite; /*Safari and Chrome*/ -} -@keyframes flashing { - from { background-color: #3c6ef6 } - 50% { background-color: #7699ea } - to { background-color: #3c6ef6 } -} -@-webkit-keyframes flashing {/*Safari and Chrome*/ - from { background-color: #3c6ef6 } - 50% { background-color: #7699ea } - to { background-color: #3c6ef6 } -} \ No newline at end of file diff --git a/packages/onsite-toolkit/templates/resolver.html b/packages/onsite-toolkit/templates/resolver.html deleted file mode 100644 index 9a0c437ec..000000000 --- a/packages/onsite-toolkit/templates/resolver.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends "layout/html5.html" %} -{% block body %} -{{ set(UiContext, 'payload', payload) }} -
- #Rank - - Contest - @Hydro/TinyResolver - - Solved - Penalty -
-
- -{% endblock %}