diff --git a/framework/eslint-config/package.json b/framework/eslint-config/package.json index 0f2e520f3..68eb61c9f 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.7", - "@typescript-eslint/eslint-plugin": "^8.47.0", - "@typescript-eslint/parser": "^8.47.0", + "@eslint-react/eslint-plugin": "^2.3.9", + "@typescript-eslint/eslint-plugin": "^8.48.0", + "@typescript-eslint/parser": "^8.48.0", "eslint-plugin-de-morgan": "^2.0.0", "eslint-plugin-github": "^6.0.0", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/package.json b/package.json index 0d31410bf..de4044032 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.29.0", + "oxlint": "^1.30.0", "package-json": "^10.0.1", "semver": "^7.7.3", "simple-git": "^3.30.0", diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index 530d14c6e..19e13596f 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -36,13 +36,13 @@ "koa-proxies": "^0.12.4", "koa-static-cache": "^5.1.4", "lodash": "^4.17.21", - "lru-cache": "^11.2.2", + "lru-cache": "^11.2.4", "mime-types": "^3.0.2", "moment-timezone": "^0.6.0", "mongodb": "^7.0.0", "mongodb-uri": "^0.9.7", "nanoid": "^5.1.6", - "nodemailer": "^7.0.10", + "nodemailer": "^7.0.11", "notp": "^2.0.3", "p-queue": "^9.0.1", "sanitize-filename": "^1.6.3", diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index 3a20b5b10..3299b354d 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -485,8 +485,11 @@ export class ContestCodeHandler extends Handler { async get(domainId: string, tid: ObjectId, all: boolean) { await this.limitRate('contest_code', 60, 10); const [tdoc, tsdocs] = await contest.getAndListStatus(domainId, tid); - if (!this.user.own(tdoc) && !this.user.hasPriv(PRIV.PRIV_READ_RECORD_CODE)) { - this.checkPerm(PERM.PERM_READ_RECORD_CODE); + if (!this.user.own(tdoc)) { + if (!this.user.hasPriv(PRIV.PRIV_READ_RECORD_CODE)) { + this.checkPerm(PERM.PERM_READ_RECORD_CODE); + } + if (!contest.isDone(tdoc)) throw new ContestNotEndedError(domainId, tid); } if (!contest.canShowRecord.call(this, tdoc as any, true)) { throw new PermissionError(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); @@ -754,7 +757,7 @@ export class ContestBalloonHandler extends ContestManagementBaseHandler { async postDone(domainId: string, tid: ObjectId, bid: ObjectId) { const balloon = await contest.getBalloon(domainId, tid, bid); if (!balloon) throw new ValidationError('balloon'); - if (balloon.sent) throw new ValidationError('balloon', null, 'Balloon already sent'); + if (balloon.sent) throw new ValidationError('Balloon already sent'); await contest.updateBalloon(domainId, tid, bid, { sent: this.user._id, sentAt: new Date() }); this.back(); } diff --git a/packages/hydrooj/src/handler/homework.ts b/packages/hydrooj/src/handler/homework.ts index f1aaeed33..fdd553440 100644 --- a/packages/hydrooj/src/handler/homework.ts +++ b/packages/hydrooj/src/handler/homework.ts @@ -124,15 +124,13 @@ class HomeworkDetailHandler extends Handler { await contest.setStatus(domainId, tid, this.user._id, { startAt: new Date() }); tsdoc.startAt = new Date(); } - for (const pdetail of tsdoc.journal || []) { + const valid = (tsdoc.journal || []).filter((p) => this.tdoc.pids.includes(p.pid)); + for (const pdetail of valid) { psdict[pdetail.pid] = pdetail; rdict[pdetail.rid] = { _id: pdetail.rid }; } - if (contest.canShowSelfRecord.call(this, this.tdoc) && tsdoc.journal) { - rdict = await record.getList( - domainId, - tsdoc.journal.map((pdetail) => pdetail.rid), - ); + if (contest.canShowSelfRecord.call(this, this.tdoc) && valid.length) { + rdict = await record.getList(domainId, valid.map((pdetail) => pdetail.rid)); } } Object.assign(this.response.body, { pdict, psdict, rdict }); diff --git a/packages/hydrooj/src/handler/record.ts b/packages/hydrooj/src/handler/record.ts index 471b0f569..f88a2347d 100644 --- a/packages/hydrooj/src/handler/record.ts +++ b/packages/hydrooj/src/handler/record.ts @@ -331,6 +331,7 @@ class RecordMainConnectionHandler extends ConnectionHandler { if (rdoc.domainId !== this.args.domainId) return; if (!this.pretest && typeof rdoc.input === 'string') return; if (!this.all) { + if (!rdoc.contest && this.tid) return; if (rdoc.contest && ![this.tid, '000000000000000000000000'].includes(rdoc.contest.toString())) return; if (this.tid && rdoc.contest?.toString() !== '0'.repeat(24)) { if (rdoc.uid !== this.user._id && !contest.canShowRecord.call(this, this.tdoc, true)) return; diff --git a/packages/onsite-toolkit/index.ts b/packages/onsite-toolkit/index.ts index efee050a8..aba29f988 100644 --- a/packages/onsite-toolkit/index.ts +++ b/packages/onsite-toolkit/index.ts @@ -2,10 +2,10 @@ import { LRUCache } from 'lru-cache'; import moment from 'moment'; import { - _, avatar, ContestModel, ContestNotEndedError, Context, db, findFileSync, + _, avatar, BadRequestError, 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, + SystemModel, Time, Types, UserModel, Zip, } from 'hydrooj'; import { ResolverInput } from './interface'; @@ -118,7 +118,7 @@ export function apply(ctx: Context, config: ReturnType) { exclude: t.unrank, })), submissions: submissions.map((i) => ({ - team: i.uid.toString(), + team: udict[i.uid]?.seat || i.uid.toString(), problem: i.pid.toString(), verdict: STATUS_SHORT_TEXTS[i.status], time: time(i.rid), @@ -134,6 +134,11 @@ export function apply(ctx: Context, config: ReturnType) { async display({ tdoc }) { if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST); if (!ContestModel.isDone(tdoc)) throw new ContestNotEndedError(); + try { + new URL(SystemModel.get('server.url')); // eslint-disable-line no-new + } 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++}`, @@ -146,7 +151,7 @@ export function apply(ctx: Context, config: ReturnType) { const teams = tsdocs.map((i) => { const udoc = udict[i.uid]; return { - team_id: `team-${udoc._id}`, + team_id: udoc.seat || `team-${udoc._id}`, name: udoc.uname, displayName: (i.unrank ? '⭐' : '') + (udoc.displayName || udoc.uname), organization: udoc.school || udoc.uname, @@ -239,6 +244,13 @@ export function apply(ctx: Context, config: ReturnType) { 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}`, @@ -265,7 +277,7 @@ export function apply(ctx: Context, config: ReturnType) { const judgeAt = moment().startOf('day').seconds(judgeDelta).format('HH:mm:ss.SSS'); result.push(getFeed('submissions', { id: s.rid, - team_id: `team-${i.uid}`, + team_id: udict[i.uid]?.seat || `team-${i.uid}`, problem_id: `${s.pid}`, language_id: s.lang?.split('.')[0] || 'cpp', files: [], @@ -305,13 +317,13 @@ export function apply(ctx: Context, config: ReturnType) { ]); 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.download.txt`, new Zip.TextReader(i.avatar)); + 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.download.txt`, new Zip.TextReader(avatar(avatarSrc))); + await zip.add(`organizations/${orgId[i]}/photo.url`, new Zip.TextReader(`URL=${avatar(avatarSrc)}`)); })); this.binary(await zip.close(), `contest-${tdoc._id}-cdp.zip`); }, diff --git a/packages/onsite-toolkit/package.json b/packages/onsite-toolkit/package.json index eb605ec6d..b68864277 100644 --- a/packages/onsite-toolkit/package.json +++ b/packages/onsite-toolkit/package.json @@ -4,7 +4,7 @@ "repository": "https://github.com/hydro-dev/Hydro", "dependencies": { "@react-spring/web": "^10.0.3", - "lru-cache": "^11.2.2", + "lru-cache": "^11.2.4", "react-use": "^17.6.0" } } diff --git a/packages/scoreboard-xcpcio/index.ts b/packages/scoreboard-xcpcio/index.ts index c00c456c0..d8d693c50 100644 --- a/packages/scoreboard-xcpcio/index.ts +++ b/packages/scoreboard-xcpcio/index.ts @@ -1,10 +1,12 @@ import path from 'path'; import { LRUCache } from 'lru-cache'; import { - avatar, ContestModel, Context, fs, getAlphabeticId, ObjectId, PERM, - RecordDoc, Schema, STATUS, Tdoc, Types, UserModel, + avatar, ContestModel, Context, fs, getAlphabeticId, Logger, ObjectId, + PERM, RecordDoc, Schema, STATUS, superagent, Tdoc, Types, UserModel, } from 'hydrooj'; +const logger = new Logger('scoreboard-xcpcio'); + const file = fs.readFileSync(path.join(__dirname, 'public/assets/board.html'), 'utf8'); const indexJs = file.match(/index-([\w-]+)\.js"/)?.[1]; const indexCss = file.match(/index-([\w-]+)\.css"/)?.[1]; @@ -88,11 +90,28 @@ async function loadContestState(tdoc: Tdoc, realtime: boolean) { } export const name = 'scoreboard-xcpcio'; + +const PublishConfig = Schema.object({ + domainId: Schema.string().required(), + contestId: Schema.string().required(), + publishToken: Schema.string().required(), + publishPath: Schema.string().required(), + publishEndpoint: Schema.string().default('https://scoreboard.hydrooj.com/_publish'), + medals: Schema.object({ + gold: Schema.number().required(), + silver: Schema.number().required(), + bronze: Schema.number().required(), + }), + banner: Schema.string().default(''), + badge: Schema.boolean().default(false), + override: Schema.any().default({}).description('Scoreboard contest override'), +}); export const Config = Schema.object({ cacheTTL: Schema.number().default(0).description('Cache TTL in milliseconds'), cacheSize: Schema.number().default(100).description('Cache size'), asDefault: Schema.boolean().default(false).description('As default scoreboard'), override: Schema.any().default({}).description('Scoreboard contest override'), + publish: Schema.array(PublishConfig).description('Scoreboard publish config'), }); export async function apply(ctx: Context, config: ReturnType) { @@ -138,6 +157,80 @@ export async function apply(ctx: Context, config: ReturnType) { }); } + const getJson = async (tdoc, realtime: boolean, cfg: Partial>) => { + const isLocked = ContestModel.isLocked(tdoc); + const cacheKey = `${tdoc.docId.toHexString()}/${(isLocked && realtime) ? 'realtime' : 'public'}`; + const state = lru.get(cacheKey) || await loadContestState(tdoc, realtime); + if (cfg.cacheTTL) lru.set(cacheKey, state); + const relatedGroups = state.teams.flatMap((i) => i.group); + return { + contest: { + contest_name: tdoc.title, + start_time: Math.floor(tdoc.beginAt.getTime() / 1000), + end_time: Math.floor(tdoc.endAt.getTime() / 1000), + frozen_time: tdoc.lockAt ? Math.floor((tdoc.endAt.getTime() - tdoc.lockAt.getTime()) / 1000) : 0, + penalty: 1200, + problem_quantity: tdoc.pids.length, + problem_id: tdoc.pids.map((i, idx) => getAlphabeticId(idx)), + group: { + official: '正式队伍', + unofficial: '打星队伍', + ...Object.fromEntries(cfg.groups?.filter((i) => relatedGroups.includes(i.name)).map((i) => [i.name, i.name]) || []), + }, + ...(cfg.badge ? { badge: 'Badge' } : {}), + organization: 'School', + status_time_display: { + correct: true, + incorrect: true, + pending: true, + }, + medal: { + official: cfg.medals, + }, + balloon_color: tdoc.balloon + ? tdoc.pids.filter((i) => tdoc.balloon[i]).map((i) => ({ + color: '#000', + background_color: typeof tdoc.balloon[i] === 'string' ? tdoc.balloon[i] : tdoc.balloon[i].color, + })) + : [], + logo: { + preset: 'ICPC', + }, + ...(cfg.banner ? { banner: { url: 'banner.jpg' } } : {}), + options: { + submission_timestamp_unit: 'millisecond', + }, + ...(typeof cfg.override === 'object' ? cfg.override || {} : {}), + }, + ...state, + }; + }; + + if (config.publish?.length && process.env.NODE_APP_INSTANCE === '0') { + const done = []; + const unlocked = []; + logger.debug('Will publish scoreboards', config.publish); + ctx.effect(() => ctx.setInterval(() => { + Promise.allSettled(config.publish.map(async (i) => { + const key = `${i.domainId}/${i.contestId}`; + if (unlocked.includes(key)) return; + const tdoc = await ContestModel.get(i.domainId, new ObjectId(i.contestId)); + if (ContestModel.isDone(tdoc) && ContestModel.isLocked(tdoc) && done.includes(key)) return; + if (ContestModel.isDone(tdoc) && !ContestModel.isLocked(tdoc)) unlocked.push(key); + if (ContestModel.isDone(tdoc)) done.push(key); + const groups = await UserModel.listGroup(i.domainId); + const json = await getJson(tdoc, false, { ...i, groups }); + logger.info(`Publishing scoreboard ${i.domainId}/${i.contestId} to ${i.publishEndpoint}`); + const res = await superagent.post(i.publishEndpoint).send({ + path: i.publishPath, + token: i.publishToken, + json, + }); + logger.info(`Published scoreboard ${i.domainId}/${i.contestId} to ${i.publishEndpoint}`, res.body); + })).catch(console.error); + }, 30000)); + } + ctx.inject(['scoreboard'], ({ scoreboard }) => { scoreboard.addView('xcpcio', 'XCPCIO', { tdoc: 'tdoc', @@ -155,56 +248,7 @@ export async function apply(ctx: Context, config: ReturnType) { }) { if (realtime && !this.user.own(tdoc)) this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); if (json || this.request.json) { - const isLocked = ContestModel.isLocked(tdoc); - const cacheKey = `${tdoc.docId.toHexString()}/${(isLocked && realtime) ? 'realtime' : 'public'}`; - const state = lru.get(cacheKey) || await loadContestState(tdoc, realtime); - if (config.cacheTTL) lru.set(cacheKey, state); - const relatedGroups = state.teams.flatMap((i) => i.group); - this.response.body = { - contest: { - contest_name: tdoc.title, - start_time: Math.floor(tdoc.beginAt.getTime() / 1000), - end_time: Math.floor(tdoc.endAt.getTime() / 1000), - frozen_time: tdoc.lockAt ? Math.floor((tdoc.endAt.getTime() - tdoc.lockAt.getTime()) / 1000) : 0, - penalty: 1200, - problem_quantity: tdoc.pids.length, - problem_id: tdoc.pids.map((i, idx) => getAlphabeticId(idx)), - group: { - official: '正式队伍', - 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, - incorrect: true, - pending: true, - }, - medal: { - official: { - gold, - silver, - bronze, - }, - }, - balloon_color: tdoc.balloon - ? tdoc.pids.filter((i) => tdoc.balloon[i]).map((i) => ({ - color: '#000', - background_color: typeof tdoc.balloon[i] === 'string' ? tdoc.balloon[i] : tdoc.balloon[i].color, - })) - : [], - logo: { - preset: 'ICPC', - }, - ...(banner ? { banner: { url: 'banner.jpg' } } : {}), - options: { - submission_timestamp_unit: 'millisecond', - }, - ...(typeof config.override === 'object' ? config.override || {} : {}), - }, - ...state, - }; + this.response.body = await getJson(tdoc, realtime, { badge, banner, groups, medals: { gold, silver, bronze } }); } else { this.response.template = 'xcpcio_board.html'; let query = ''; diff --git a/packages/scoreboard-xcpcio/package.json b/packages/scoreboard-xcpcio/package.json index 7ed422931..bc52c5aaf 100644 --- a/packages/scoreboard-xcpcio/package.json +++ b/packages/scoreboard-xcpcio/package.json @@ -1,16 +1,16 @@ { "name": "@hydrooj/scoreboard-xcpcio", "license": "AGPL-3.0-or-later", - "version": "0.0.5-beta.7", + "version": "0.0.5-beta.8", "main": "index.ts", "repository": "https://github.com/hydro-dev/Hydro.git", "scripts": { "postinstall": "node -r @hydrooj/register install.ts" }, "dependencies": { - "@xcpcio/board-app": "^0.75.1" + "@xcpcio/board-app": "^0.76.0" }, "devDependencies": { - "@xcpcio/types": "^0.75.1" + "@xcpcio/types": "^0.76.0" } } diff --git a/packages/scoreboard-xcpcio/templates/xcpcio_board.html b/packages/scoreboard-xcpcio/templates/xcpcio_board.html index 5803d5f8c..11db898a5 100644 --- a/packages/scoreboard-xcpcio/templates/xcpcio_board.html +++ b/packages/scoreboard-xcpcio/templates/xcpcio_board.html @@ -12,6 +12,7 @@ window.DATA_REGION = "Hydro"; window.DEFAULT_LANG = "{{ 'zh-CN' if _('__id').startsWith('zh') else 'en' }}"; window.DATA_SOURCE = "{{ dataSource|safe }}"; + {% if refreshInterval %}window.REFRESH_INTERVAL = {{ refreshInterval }};{% endif %} if (document.documentElement.classList.contains("theme--dark")) document.documentElement.classList.toggle("dark", !0); setInterval(() => { const location = window.location.href; @@ -30,7 +31,7 @@ -{% if realtime and model.contest.isLocked(tdoc) and model.contest.isDone(tdoc) and handler.user.own(tdoc) %} +{% if tdoc and realtime and model.contest.isLocked(tdoc) and model.contest.isDone(tdoc) and handler.user.own(tdoc) %}
diff --git a/packages/ui-default/locales/zh.yaml b/packages/ui-default/locales/zh.yaml index df6774218..f1ed0074c 100644 --- a/packages/ui-default/locales/zh.yaml +++ b/packages/ui-default/locales/zh.yaml @@ -587,6 +587,7 @@ My Files: 我的文件 My Profile: 我的资料 My Recent Submissions: 我的最近递交记录 My Role: 我的角色 +My Submissions: 我的递交 name: 名称 Name: 名称 Nav Logo Dark: 左上角小图标(暗) diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 8acac8655..bfd113048 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -23,7 +23,7 @@ "@mantine/core": "^8.3.9", "@mantine/hooks": "^8.3.9", "@mantine/notifications": "^8.3.9", - "@sentry/browser": "^10.26.0", + "@sentry/browser": "^10.27.0", "@sentry/webpack-plugin": "^4.6.1", "@simplewebauthn/browser": "13.2.2", "@svgr/webpack": "^8.1.0", @@ -36,7 +36,7 @@ "@types/nunjucks": "^3.2.6", "@types/pickadate": "^3.5.35", "@types/qrcode": "^1.5.6", - "@types/react": "^19.2.6", + "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@types/redux-logger": "^3.0.13", "@types/uuid": "^11.0.0", @@ -75,7 +75,7 @@ "monaco-editor": "0.52.2", "monaco-editor-nls": "^3.1.0", "monaco-editor-webpack-plugin": "^7.1.1", - "monaco-themes": "^0.4.7", + "monaco-themes": "0.4.7", "monaco-yaml": "5.1.1", "nanoid": "^5.1.6", "normalize.css": "^8.0.1", diff --git a/packages/ui-default/pages/domain_user.page.js b/packages/ui-default/pages/domain_user.page.js index 302d839a4..ec0ba862f 100644 --- a/packages/ui-default/pages/domain_user.page.js +++ b/packages/ui-default/pages/domain_user.page.js @@ -38,7 +38,7 @@ const page = new NamedPage('domain_user', () => { options: UiContext.roles.filter((i) => !['default', 'guest'].includes(i)), columns: -6, }, - ...(UiContext.canForceJoin ? { + ...((UiContext.canForceJoin && UiContext.domain._id !== 'system') ? { join: { type: 'checkbox', label: i18n('Mark user as joined using admin privilege'), diff --git a/packages/ui-default/templates/bsod.html b/packages/ui-default/templates/bsod.html index c24033e83..8aaf00ee4 100644 --- a/packages/ui-default/templates/bsod.html +++ b/packages/ui-default/templates/bsod.html @@ -25,6 +25,7 @@ return 0; } /* +{{ handler.request.hostname }}{{ handler.request.originalPath|default(handler.request.path) }} {{ error.message }} {{ error.stack }} */ diff --git a/packages/ui-next/package.json b/packages/ui-next/package.json index 7560b6bf1..822d47188 100644 --- a/packages/ui-next/package.json +++ b/packages/ui-next/package.json @@ -13,9 +13,9 @@ "react-dom": "^19.2.0" }, "devDependencies": { - "@types/react": "^19.2.6", + "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react-swc": "^4.2.2", - "vite": "^7.2.4" + "vite": "^7.2.6" } }