core: simplify contest & homework logic

This commit is contained in:
undefined committed 2022-07-10 22:12:18 +08:00
1 parent 81518ab424
commit 08462cf870
14 files changed
+143 -218

No files matched your search

+4 -4
View File
@@ -33,7 +33,7 @@
"@types/autocannon": "^7.6.1", "@types/autocannon": "^7.6.1",
"@types/cross-spawn": "^6.0.2", "@types/cross-spawn": "^6.0.2",
"@types/mocha": "^9.1.1", "@types/mocha": "^9.1.1",
"@types/node": "^18.0.0", "@types/node": "^18.0.3",
"@types/semver": "^7.3.10", "@types/semver": "^7.3.10",
"@types/supertest": "^2.0.12", "@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^5.29.0", "@typescript-eslint/eslint-plugin": "^5.29.0",
@@ -57,11 +57,11 @@
"latest-version": "6.0.0", "latest-version": "6.0.0",
"mocha": "^10.0.0", "mocha": "^10.0.0",
"mongodb": "^3.7.3", "mongodb": "^3.7.3",
"mongodb-memory-server": "^8.7.1", "mongodb-memory-server": "^8.7.2",
"ora": "5.4.1", "ora": "5.4.1",
"semver": "^7.3.7", "semver": "^7.3.7",
"supertest": "^6.2.3", "supertest": "^6.2.4",
"typedoc": "^0.22.18", "typedoc": "^0.23.7",
"typescript": "4.6.4" "typescript": "4.6.4"
}, },
"resolutions": { "resolutions": {
+6 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "hydrooj", "name": "hydrooj",
"version": "3.14.10", "version": "3.14.11",
"bin": "bin/hydrooj.js", "bin": "bin/hydrooj.js",
"main": "src/loader", "main": "src/loader",
"module": "src/loader", "module": "src/loader",
@@ -12,7 +12,7 @@
}, },
"preferUnplugged": true, "preferUnplugged": true,
"dependencies": { "dependencies": {
"@graphql-tools/schema": "^8.4.0", "@graphql-tools/schema": "^8.5.0",
"@hydrooj/utils": "workspace:*", "@hydrooj/utils": "workspace:*",
"adm-zip": "0.5.5", "adm-zip": "0.5.5",
"ajv": "^8.11.0", "ajv": "^8.11.0",
@@ -35,13 +35,13 @@
"koa-router": "^10.1.1", "koa-router": "^10.1.1",
"koa-static-cache": "^5.1.4", "koa-static-cache": "^5.1.4",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lru-cache": "7.9.0", "lru-cache": "7.12.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"minio": "7.0.25", "minio": "7.0.25",
"moment-timezone": "^0.5.34", "moment-timezone": "^0.5.34",
"mongodb": "^3.7.3", "mongodb": "^3.7.3",
"nanoid": "^3.3.4", "nanoid": "^3.3.4",
"nodemailer": "^6.7.5", "nodemailer": "^6.7.7",
"notp": "^2.0.3", "notp": "^2.0.3",
"p-queue": "^7.2.0", "p-queue": "^7.2.0",
"path-to-regexp": "^6.2.1", "path-to-regexp": "^6.2.1",
@@ -62,7 +62,7 @@
"@types/koa-router": "^7.4.4", "@types/koa-router": "^7.4.4",
"@types/koa-static-cache": "^5.1.1", "@types/koa-static-cache": "^5.1.1",
"@types/lodash": "^4.14.182", "@types/lodash": "^4.14.182",
"@types/lru-cache": "^7.6.1", "@types/lru-cache": "^7.10.10",
"@types/mime-types": "^2.1.1", "@types/mime-types": "^2.1.1",
"@types/minio": "^7.0.13", "@types/minio": "^7.0.13",
"@types/mongodb": "^3.6.20", "@types/mongodb": "^3.6.20",
@@ -72,7 +72,7 @@
"@types/serialize-javascript": "^5.0.2", "@types/serialize-javascript": "^5.0.2",
"@types/source-map-support": "^0.5.4", "@types/source-map-support": "^0.5.4",
"@types/superagent": "^4.1.15", "@types/superagent": "^4.1.15",
"moment": "^2.29.3", "moment": "^2.29.4",
"pm2": "^5.2.0" "pm2": "^5.2.0"
}, },
"peerDependencies": { "peerDependencies": {
+79 -87
View File
@@ -71,7 +71,7 @@ export class ContestListHandler extends Handler {
async get(domainId: string, rule = '', page = 1) { async get(domainId: string, rule = '', page = 1) {
const cursor = contest.getMulti(domainId, rule ? { rule } : { rule: { $ne: 'homework' } }); const cursor = contest.getMulti(domainId, rule ? { rule } : { rule: { $ne: 'homework' } });
const qs = rule ? `rule=${rule}` : ''; const qs = rule ? `rule=${rule}` : '';
const [tdocs, tpcount] = await paginate(cursor, page, system.get('pagination.contest')); const [tdocs, tpcount] = await paginate<Tdoc>(cursor, page, system.get('pagination.contest'));
const tids = []; const tids = [];
for (const tdoc of tdocs) tids.push(tdoc.docId); for (const tdoc of tdocs) tids.push(tdoc.docId);
const tsdict = await contest.getListStatus(domainId, this.user._id, tids); const tsdict = await contest.getListStatus(domainId, this.user._id, tids);
@@ -82,47 +82,58 @@ export class ContestListHandler extends Handler {
} }
} }
export class ContestDetailHandler extends Handler { export class ContestDetailBaseHandler extends Handler {
@param('tid', Types.ObjectID) tdoc?: Tdoc<30>;
@param('page', Types.PositiveInt, true) tsdoc?: any;
async prepare(domainId: string, tid: ObjectID) {
const tdoc = await contest.get(domainId, tid);
if (tdoc.assign?.length && !this.user.own(tdoc)) {
const groups = await user.listGroup(domainId, this.user._id);
if (!Set.intersection(tdoc.assign, groups.map((i) => i.name)).size) {
throw new ForbiddenError('You are not assigned.');
}
}
}
@param('tid', Types.ObjectID) @param('tid', Types.ObjectID, true)
@param('page', Types.PositiveInt, true) async __prepare(domainId: string, tid: ObjectID) {
async get(domainId: string, tid: ObjectID, page = 1) { if (!tid) return; // ProblemDetailHandler also extends from ContestDetailBaseHandler
const [tdoc, tsdoc] = await Promise.all([ [this.tdoc, this.tsdoc] = await Promise.all([
contest.get(domainId, tid), contest.get(domainId, tid),
contest.getStatus(domainId, tid, this.user._id), contest.getStatus(domainId, tid, this.user._id),
]); ]);
if (this.tdoc.assign?.length && !this.user.own(this.tdoc)) {
const groups = await user.listGroup(domainId, this.user._id);
if (!Set.intersection(this.tdoc.assign, groups.map((i) => i.name)).size) {
throw new ForbiddenError('You are not assigned.');
}
}
if (this.tdoc.duration && this.tsdoc?.attend) {
this.tsdoc.endAt = moment(this.tsdoc.startAt).add(this.tdoc.duration, 'hours').toDate();
}
}
}
export class ContestDetailHandler extends ContestDetailBaseHandler {
@param('tid', Types.ObjectID)
async prepare(domainId: string, tid: ObjectID) {
if (contest.RULES[this.tdoc.rule].hidden) throw new ContestNotFoundError(domainId, tid);
}
@param('tid', Types.ObjectID)
async get(domainId: string, tid: ObjectID) {
this.response.template = 'contest_detail.html'; this.response.template = 'contest_detail.html';
const udict = await user.getList(domainId, [tdoc.owner]); const udict = await user.getList(domainId, [this.tdoc.owner]);
this.response.body = { this.response.body = {
tdoc, tsdoc, udict, page, tdoc: this.tdoc, tsdoc: this.tsdoc, udict,
}; };
if (contest.isNotStarted(tdoc)) return; if (contest.isNotStarted(this.tdoc)) return;
if (!this.request.json) { if (!this.request.json) {
this.response.body.tdoc.content = this.response.body.tdoc.content this.response.body.tdoc.content = this.response.body.tdoc.content
.replace(/\(file:\/\//g, `(./${tdoc.docId}/file/`) .replace(/\(file:\/\//g, `(./${this.tdoc.docId}/file/`)
.replace(/="file:\/\//g, `="./${tdoc.docId}/file/`); .replace(/="file:\/\//g, `="./${this.tdoc.docId}/file/`);
} }
const pdict = await problem.getList(domainId, tdoc.pids, true, undefined, undefined, problem.PROJECTION_CONTEST_LIST); const pdict = await problem.getList(domainId, this.tdoc.pids, true, undefined, undefined, problem.PROJECTION_CONTEST_LIST);
const psdict = {}; const psdict = {};
let rdict = {}; let rdict = {};
if (tsdoc) { if (this.tsdoc) {
if (tsdoc.attend && !tsdoc.startAt && contest.isOngoing(tdoc)) { if (this.tsdoc.attend && !this.tsdoc.startAt && contest.isOngoing(this.tdoc)) {
await contest.setStatus(domainId, tid, this.user._id, { startAt: new Date() }); await contest.setStatus(domainId, tid, this.user._id, { startAt: new Date() });
tsdoc.startAt = new Date(); this.tsdoc.startAt = new Date();
} }
for (const pdetail of tsdoc.journal || []) psdict[pdetail.pid] = pdetail; for (const pdetail of this.tsdoc.journal || []) psdict[pdetail.pid] = pdetail;
if (contest.canShowSelfRecord.call(this, tdoc)) { if (contest.canShowSelfRecord.call(this, this.tdoc)) {
rdict = await record.getList(domainId, Object.values(psdict).map((i: any) => i.rid)); rdict = await record.getList(domainId, Object.values(psdict).map((i: any) => i.rid));
} else { } else {
for (const i in psdict) rdict[psdict[i].rid] = { _id: psdict[i].rid }; for (const i in psdict) rdict[psdict[i].rid] = { _id: psdict[i].rid };
@@ -134,17 +145,15 @@ export class ContestDetailHandler extends Handler {
@param('tid', Types.ObjectID) @param('tid', Types.ObjectID)
@param('code', Types.String, true) @param('code', Types.String, true)
async postAttend(domainId: string, tid: ObjectID, code = '') { async postAttend(domainId: string, tid: ObjectID, code = '') {
const tdoc = await contest.get(domainId, tid); if (contest.isDone(this.tdoc)) throw new ContestNotLiveError(tid);
if (contest.isDone(tdoc)) throw new ContestNotLiveError(tid); if (this.tdoc._code && code !== this.tdoc._code) throw new InvalidTokenError(code);
if (tdoc._code && code !== tdoc._code) throw new InvalidTokenError(code);
await contest.attend(domainId, tid, this.user._id); await contest.attend(domainId, tid, this.user._id);
this.back(); this.back();
} }
@param('tid', Types.ObjectID) @param('tid', Types.ObjectID)
async postDelete(domainId: string, tid: ObjectID) { async postDelete(domainId: string, tid: ObjectID) {
const tdoc = await contest.get(domainId, tid); if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST);
if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST);
await contest.del(domainId, tid); await contest.del(domainId, tid);
await record.updateMulti(domainId, { domainId, contest: tid }, undefined, undefined, { contest: '' }); await record.updateMulti(domainId, { domainId, contest: tid }, undefined, undefined, { contest: '' });
await TaskModel.deleteMany({ await TaskModel.deleteMany({
@@ -154,26 +163,18 @@ export class ContestDetailHandler extends Handler {
} }
} }
export class ContestBroadcastHandler extends Handler { export class ContestBroadcastHandler extends ContestDetailBaseHandler {
@param('tid', Types.ObjectID) async prepare() {
async get(domainId: string, tid: ObjectID) { if (!this.user.own(this.tdoc)) throw new PermissionError('Boardcast Message');
const tdoc = await contest.get(domainId, tid); }
if (!this.user.own(tdoc)) throw new PermissionError('Boardcast Message');
const path = [ async get() {
['Hydro', 'homepage'],
['contest_main', 'contest_main'],
[tdoc.title, 'contest_detail', { tid }, true],
['contest_broadcast'],
];
this.response.template = 'contest_broadcast.html'; this.response.template = 'contest_broadcast.html';
this.response.body = path;
} }
@param('tid', Types.ObjectID) @param('tid', Types.ObjectID)
@param('content', Types.Content) @param('content', Types.Content)
async post(domainId: string, tid: ObjectID, content: string) { async post(domainId: string, tid: ObjectID, content: string) {
const tdoc = await contest.get(domainId, tid);
if (!this.user.own(tdoc)) throw new PermissionError('Broadcast Message');
const tsdocs = await contest.getMultiStatus(domainId, { docId: tid }).toArray(); const tsdocs = await contest.getMultiStatus(domainId, { docId: tid }).toArray();
const uids: number[] = Array.from(new Set(tsdocs.map((tsdoc) => tsdoc.uid))); const uids: number[] = Array.from(new Set(tsdocs.map((tsdoc) => tsdoc.uid)));
await Promise.all( await Promise.all(
@@ -183,49 +184,44 @@ export class ContestBroadcastHandler extends Handler {
} }
} }
export class ContestScoreboardHandler extends Handler { export class ContestScoreboardHandler extends ContestDetailBaseHandler {
@param('tid', Types.ObjectID) @param('tid', Types.ObjectID)
@param('page', Types.PositiveInt, true) @param('page', Types.PositiveInt, true)
@param('ext', Types.Range(['csv', 'html']), true)
@param('ignoreLock', Types.Boolean, true) @param('ignoreLock', Types.Boolean, true)
async get(domainId: string, tid: ObjectID, page = 1, ignoreLock = false) { async get(domainId: string, tid: ObjectID, page = 1, ext = '', ignoreLock = false) {
const tdoc = await contest.get(domainId, tid); if (ignoreLock && !this.user.own(this.tdoc)) {
if (ignoreLock && !this.user.own(tdoc)) { this.checkPerm(this.tdoc.rule === 'homework'
this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); ? PERM.PERM_VIEW_HOMEWORK_HIDDEN_SCOREBOARD
: PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
} }
const pdict = await problem.getList(domainId, tdoc.pids, true, undefined, false, [ if (ext) {
await this.exportScoreboard(domainId, tid, ext, ignoreLock);
return;
}
const pdict = await problem.getList(domainId, this.tdoc.pids, true, undefined, false, [
// Problem statistics display is allowed as we can view submission info in scoreboard. // Problem statistics display is allowed as we can view submission info in scoreboard.
...problem.PROJECTION_CONTEST_LIST, 'nSubmit', 'nAccept', ...problem.PROJECTION_CONTEST_LIST, 'nSubmit', 'nAccept',
]); ]);
const [, rows, udict, , nPages] = await contest.getScoreboard.call(this, domainId, tid, false, page, ignoreLock); const [, rows, udict, , nPages] = await contest.getScoreboard.call(this, domainId, tid, false, page, ignoreLock);
const path = [
['Hydro', 'homepage'],
['contest_main', 'contest_main'],
[tdoc.title, 'contest_detail', { tid }, true],
['contest_scoreboard', null],
];
this.response.template = 'contest_scoreboard.html'; this.response.template = 'contest_scoreboard.html';
// eslint-disable-next-line @typescript-eslint/naming-convention
const page_name = this.tdoc.rule === 'homework'
? 'homework_scoreboard'
: 'contest_scoreboard';
this.response.body = { this.response.body = {
tdoc, rows, path, udict, nPages, page, pdict, tdoc: this.tdoc, rows, udict, nPages, page, pdict, page_name,
}; };
} }
}
export class ContestScoreboardDownloadHandler extends Handler { async exportScoreboard(domainId: string, tid: ObjectID, ext: string, ignoreLock: boolean) {
@param('tid', Types.ObjectID)
@param('ext', Types.Range(['csv', 'html']))
@param('ignoreLock', Types.Boolean, true)
async get(domainId: string, tid: ObjectID, ext: string, ignoreLock = false) {
await this.limitRate('scoreboard_download', 120, 3); await this.limitRate('scoreboard_download', 120, 3);
const getContent = { const getContent = {
csv: async (rows) => `\uFEFF${rows.map((c) => (c.map((i) => i.value?.toString().replace(/\n/g, ' ')).join(','))).join('\n')}`, csv: async (rows) => `\uFEFF${rows.map((c) => (c.map((i) => i.value?.toString().replace(/\n/g, ' ')).join(','))).join('\n')}`,
html: (rows, tdoc) => this.renderHTML('contest_scoreboard_download_html.html', { rows, tdoc }), html: (rows, tdoc) => this.renderHTML('contest_scoreboard_download_html.html', { rows, tdoc }),
}; };
const tdoc = await contest.get(domainId, tid);
if (ignoreLock && !this.user.own(tdoc)) {
this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
}
const [, rows] = await contest.getScoreboard.call(this, domainId, tid, true, 0, ignoreLock); const [, rows] = await contest.getScoreboard.call(this, domainId, tid, true, 0, ignoreLock);
this.binary(await getContent[ext](rows, tdoc), `${tdoc.title}.${ext}`); this.binary(await getContent[ext](rows, this.tdoc), `${this.tdoc.title}.${ext}`);
} }
} }
@@ -237,6 +233,7 @@ export class ContestEditHandler extends Handler {
if (tid) { if (tid) {
this.tdoc = await contest.get(domainId, tid); this.tdoc = await contest.get(domainId, tid);
if (!this.tdoc) throw new ContestNotFoundError(domainId, tid); if (!this.tdoc) throw new ContestNotFoundError(domainId, tid);
if (contest.RULES[this.tdoc.rule].hidden) throw new ContestNotFoundError(domainId, tid);
if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST); if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST);
else this.checkPerm(PERM.PERM_EDIT_CONTEST_SELF); else this.checkPerm(PERM.PERM_EDIT_CONTEST_SELF);
} else this.checkPerm(PERM.PERM_CREATE_CONTEST); } else this.checkPerm(PERM.PERM_CREATE_CONTEST);
@@ -372,17 +369,15 @@ export class ContestCodeHandler extends Handler {
} }
} }
export class ContestFilesHandler extends ContestDetailHandler { export class ContestFilesHandler extends ContestDetailBaseHandler {
@param('tid', Types.ObjectID) @param('tid', Types.ObjectID)
@param('pjax', Types.Boolean) @param('pjax', Types.Boolean)
// @ts-ignore
async get(domainId: string, tid: ObjectID, pjax = false) { async get(domainId: string, tid: ObjectID, pjax = false) {
const tdoc = await contest.get(domainId, tid); if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST);
if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST);
const body = { const body = {
tdoc, tdoc: this.tdoc,
owner_udoc: await user.getById(domainId, tdoc.owner), owner_udoc: await user.getById(domainId, this.tdoc.owner),
files: sortFiles(tdoc.files || []), files: sortFiles(this.tdoc.files || []),
urlForFile: (filename: string) => this.url('contest_file_download', { tid, filename }), urlForFile: (filename: string) => this.url('contest_file_download', { tid, filename }),
}; };
if (pjax) { if (pjax) {
@@ -407,14 +402,13 @@ export class ContestFilesHandler extends ContestDetailHandler {
@param('tid', Types.ObjectID) @param('tid', Types.ObjectID)
@post('filename', Types.Name, true) @post('filename', Types.Name, true)
async postUploadFile(domainId: string, tid: ObjectID, filename: string) { async postUploadFile(domainId: string, tid: ObjectID, filename: string) {
const tdoc = await contest.get(domainId, tid); if ((this.tdoc.files?.length || 0) >= system.get('limit.contest_files')) {
if ((tdoc.files?.length || 0) >= system.get('limit.contest_files')) {
throw new ForbiddenError('File limit exceeded.'); throw new ForbiddenError('File limit exceeded.');
} }
const file = this.request.files?.file; const file = this.request.files?.file;
if (!file) throw new ValidationError('file'); if (!file) throw new ValidationError('file');
const f = statSync(file.filepath); const f = statSync(file.filepath);
const size = Math.sum((tdoc.files || []).map((i) => i.size)) + f.size; const size = Math.sum((this.tdoc.files || []).map((i) => i.size)) + f.size;
if (size >= system.get('limit.contest_files_size')) { if (size >= system.get('limit.contest_files_size')) {
throw new ForbiddenError('File size limit exceeded.'); throw new ForbiddenError('File size limit exceeded.');
} }
@@ -424,27 +418,25 @@ export class ContestFilesHandler extends ContestDetailHandler {
const meta = await storage.getMeta(`contest/${domainId}/${tid}/${filename}`); const meta = await storage.getMeta(`contest/${domainId}/${tid}/${filename}`);
const payload = { name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) }; const payload = { name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) };
if (!meta) throw new Error('Upload failed'); if (!meta) throw new Error('Upload failed');
await contest.edit(domainId, tid, { files: [...(tdoc.files || []), payload] }); await contest.edit(domainId, tid, { files: [...(this.tdoc.files || []), payload] });
this.back(); this.back();
} }
@param('tid', Types.ObjectID) @param('tid', Types.ObjectID)
@post('files', Types.Array) @post('files', Types.Array)
async postDeleteFiles(domainId: string, tid: ObjectID, files: string[]) { async postDeleteFiles(domainId: string, tid: ObjectID, files: string[]) {
const tdoc = await contest.get(domainId, tid);
await Promise.all([ await Promise.all([
storage.del(files.map((t) => `contest/${domainId}/${tid}/${t}`), this.user._id), storage.del(files.map((t) => `contest/${domainId}/${tid}/${t}`), this.user._id),
contest.edit(domainId, tid, { files: tdoc.files.filter((i) => !files.includes(i.name)) }), contest.edit(domainId, tid, { files: this.tdoc.files.filter((i) => !files.includes(i.name)) }),
]); ]);
this.back(); this.back();
} }
} }
export class ContestFileDownloadHandler extends ContestDetailHandler { export class ContestFileDownloadHandler extends ContestDetailBaseHandler {
@param('tid', Types.ObjectID) @param('tid', Types.ObjectID)
@param('filename', Types.Name) @param('filename', Types.Name)
@param('noDisposition', Types.Boolean) @param('noDisposition', Types.Boolean)
// @ts-ignore
async get(domainId: string, tid: ObjectID, filename: string, noDisposition = false) { async get(domainId: string, tid: ObjectID, filename: string, noDisposition = false) {
this.response.addHeader('Cache-Control', 'public'); this.response.addHeader('Cache-Control', 'public');
const target = `contest/${domainId}/${tid}/${filename}`; const target = `contest/${domainId}/${tid}/${filename}`;
@@ -466,7 +458,7 @@ export async function apply() {
Route('contest_broadcast', '/contest/:tid/broadcast', ContestBroadcastHandler); Route('contest_broadcast', '/contest/:tid/broadcast', ContestBroadcastHandler);
Route('contest_edit', '/contest/:tid/edit', ContestEditHandler, PERM.PERM_VIEW_CONTEST); Route('contest_edit', '/contest/:tid/edit', ContestEditHandler, PERM.PERM_VIEW_CONTEST);
Route('contest_scoreboard', '/contest/:tid/scoreboard', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST); Route('contest_scoreboard', '/contest/:tid/scoreboard', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST);
Route('contest_scoreboard_download', '/contest/:tid/export/:ext', ContestScoreboardDownloadHandler, PERM.PERM_VIEW_CONTEST); Route('contest_scoreboard_download', '/contest/:tid/export/:ext', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST);
Route('contest_code', '/contest/:tid/code', ContestCodeHandler, PERM.PERM_VIEW_CONTEST); Route('contest_code', '/contest/:tid/code', ContestCodeHandler, PERM.PERM_VIEW_CONTEST);
Route('contest_files', '/contest/:tid/file', ContestFilesHandler, PERM.PERM_VIEW_CONTEST); Route('contest_files', '/contest/:tid/file', ContestFilesHandler, PERM.PERM_VIEW_CONTEST);
Route('contest_file_download', '/contest/:tid/file/:filename', ContestFileDownloadHandler, PERM.PERM_VIEW_CONTEST); Route('contest_file_download', '/contest/:tid/file/:filename', ContestFileDownloadHandler, PERM.PERM_VIEW_CONTEST);
+5 -69
View File
@@ -1,4 +1,3 @@
import AdmZip from 'adm-zip';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import moment from 'moment-timezone'; import moment from 'moment-timezone';
import { ObjectID } from 'mongodb'; import { ObjectID } from 'mongodb';
@@ -9,7 +8,7 @@ import {
} from '../error'; } from '../error';
import { PenaltyRules } from '../interface'; import { PenaltyRules } from '../interface';
import paginate from '../lib/paginate'; import paginate from '../lib/paginate';
import { PERM, PRIV } from '../model/builtin'; import { PERM } from '../model/builtin';
import * as contest from '../model/contest'; import * as contest from '../model/contest';
import * as discussion from '../model/discussion'; import * as discussion from '../model/discussion';
import problem from '../model/problem'; import problem from '../model/problem';
@@ -19,6 +18,7 @@ import user from '../model/user';
import { import {
Handler, param, Route, Types, Handler, param, Route, Types,
} from '../service/server'; } from '../service/server';
import { ContestCodeHandler, ContestScoreboardHandler } from './contest';
const validatePenaltyRules = (input: string) => yaml.load(input); const validatePenaltyRules = (input: string) => yaml.load(input);
const convertPenaltyRules = validatePenaltyRules; const convertPenaltyRules = validatePenaltyRules;
@@ -207,80 +207,16 @@ class HomeworkEditHandler extends Handler {
} }
} }
class HomeworkScoreboardHandler extends Handler {
@param('tid', Types.ObjectID)
@param('page', Types.PositiveInt, true)
async get(domainId: string, tid: ObjectID, page = 1) {
const [tdoc, rows, udict, , nPages] = await contest.getScoreboard.call(
this, domainId, tid, false, page,
);
const pdict = await problem.getList(domainId, tdoc.pids, true, undefined, false, [
// Problem statistics display is allowed as we can view submission info in scoreboard.
...problem.PROJECTION_CONTEST_LIST, 'nSubmit', 'nAccept',
]);
const path = [
['Hydro', 'homepage'],
['homework_main', 'homework_main'],
[tdoc.title, 'homework_detail', { tid }, true],
['homework_scoreboard', null],
];
this.response.template = 'contest_scoreboard.html';
this.response.body = {
tdoc, rows, path, udict, pdict, page, nPages, page_name: 'homework_scoreboard',
};
}
}
class HomeworkScoreboardDownloadHandler extends Handler {
@param('tid', Types.ObjectID)
@param('ext', Types.Name)
async get(domainId: string, tid: ObjectID, ext: string) {
await this.limitRate('scoreboard_download', 120, 3);
const getContent = {
csv: (rows) => `\uFEFF${rows.map((c) => (c.map((i) => i.value).join(','))).join('\n')}`,
html: (rows) => this.renderHTML('contest_scoreboard_download_html.html', { rows }),
};
if (!getContent[ext]) throw new ValidationError('ext', null, 'Unknown file extension');
const [tdoc, rows] = await contest.getScoreboard.call(this, domainId, tid, true, 0);
this.binary(await getContent[ext](rows), `${tdoc.title}.${ext}`);
}
}
class HomeworkCodeHandler extends Handler {
@param('tid', Types.ObjectID)
async get(domainId: string, tid: ObjectID) {
await this.limitRate('contest_code', 3600, 60);
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);
}
const rnames = {};
for (const tsdoc of tsdocs) {
for (const pid in tsdoc.detail || {}) {
rnames[tsdoc.detail[pid].rid] = `U${tsdoc.uid}_P${pid}_R${tsdoc.detail[pid].rid}`;
}
}
const zip = new AdmZip();
const rdocs = await record.getMulti(domainId, {
_id: { $in: Array.from(Object.keys(rnames)).map((id) => new ObjectID(id)) },
}).toArray();
for (const rdoc of rdocs) {
zip.addFile(`${rnames[rdoc._id.toHexString()]}.${rdoc.lang}`, Buffer.from(rdoc.code));
}
this.binary(zip.toBuffer(), `${tdoc.title}.zip`);
}
}
export async function apply() { export async function apply() {
Route('homework_main', '/homework', HomeworkMainHandler, PERM.PERM_VIEW_HOMEWORK); Route('homework_main', '/homework', HomeworkMainHandler, PERM.PERM_VIEW_HOMEWORK);
Route('homework_create', '/homework/create', HomeworkEditHandler); Route('homework_create', '/homework/create', HomeworkEditHandler);
Route('homework_detail', '/homework/:tid', HomeworkDetailHandler, PERM.PERM_VIEW_HOMEWORK); Route('homework_detail', '/homework/:tid', HomeworkDetailHandler, PERM.PERM_VIEW_HOMEWORK);
Route('homework_scoreboard', '/homework/:tid/scoreboard', HomeworkScoreboardHandler, PERM.PERM_VIEW_HOMEWORK_SCOREBOARD); Route('homework_scoreboard', '/homework/:tid/scoreboard', ContestScoreboardHandler, PERM.PERM_VIEW_HOMEWORK_SCOREBOARD);
Route( Route(
'homework_scoreboard_download', '/homework/:tid/scoreboard/download/:ext', 'homework_scoreboard_download', '/homework/:tid/scoreboard/download/:ext',
HomeworkScoreboardDownloadHandler, PERM.PERM_VIEW_HOMEWORK_SCOREBOARD, ContestScoreboardHandler, PERM.PERM_VIEW_HOMEWORK_SCOREBOARD,
); );
Route('homework_code', '/homework/:tid/code', HomeworkCodeHandler, PERM.PERM_VIEW_HOMEWORK); Route('homework_code', '/homework/:tid/code', ContestCodeHandler, PERM.PERM_VIEW_HOMEWORK);
Route('homework_edit', '/homework/:tid/edit', HomeworkEditHandler); Route('homework_edit', '/homework/:tid/edit', HomeworkEditHandler);
} }
+31 -42
View File
@@ -13,8 +13,7 @@ import {
ValidationError, ValidationError,
} from '../error'; } from '../error';
import { import {
ProblemDoc, ProblemStatusDoc, Tdoc, ProblemDoc, ProblemStatusDoc, User,
User,
} from '../interface'; } from '../interface';
import paginate from '../lib/paginate'; import paginate from '../lib/paginate';
import { isPid, parsePid as convertPid } from '../lib/validator'; import { isPid, parsePid as convertPid } from '../lib/validator';
@@ -35,6 +34,7 @@ import {
Handler, param, post, query, Route, route, Types, Handler, param, post, query, Route, route, Types,
} from '../service/server'; } from '../service/server';
import { registerResolver, registerValue } from './api'; import { registerResolver, registerValue } from './api';
import { ContestDetailBaseHandler } from './contest';
export const parseCategory = (value: string) => value.replace(/,/g, ',').split(',').map((e) => e.trim()); export const parseCategory = (value: string) => value.replace(/,/g, ',').split(',').map((e) => e.trim());
export const parsePid = (value: string) => (isSafeInteger(value) ? +value : value); export const parsePid = (value: string) => (isSafeInteger(value) ? +value : value);
@@ -112,30 +112,7 @@ function buildQuery(udoc: User) {
return q; return q;
} }
export class ProblemHandler extends Handler { export class ProblemMainHandler extends Handler {
async cleanup() {
if (this.response.template === 'problem_main.html' && this.request.json) {
const {
page, pcount, pcountRelation, ppcount, pdocs, psdict, category, qs,
} = this.response.body;
this.response.body = {
title: this.renderTitle(this.translate('problem_main')),
fragments: (await Promise.all([
this.renderHTML('partials/problem_list.html', {
page, ppcount, pcount, pdocs, psdict, qs,
}),
this.renderHTML('partials/problem_stat.html', { pcount, pcountRelation }),
this.renderHTML('partials/problem_lucky.html', { qs }),
])).map((i) => ({ html: i })),
raw: {
page, pcount, ppcount, pdocs, psdict, category,
},
};
}
}
}
export class ProblemMainHandler extends ProblemHandler {
@param('page', Types.PositiveInt, true) @param('page', Types.PositiveInt, true)
@param('q', Types.Content, true) @param('q', Types.Content, true)
async get(domainId: string, page = 1, q = '') { async get(domainId: string, page = 1, q = '') {
@@ -204,16 +181,32 @@ export class ProblemMainHandler extends ProblemHandler {
).then((res) => Object.assign(psdict, res))), ).then((res) => Object.assign(psdict, res))),
); );
} }
this.response.body = { if (this.request.json) {
page, this.response.body = {
pcount, title: this.renderTitle(this.translate('problem_main')),
ppcount, fragments: (await Promise.all([
pcountRelation, this.renderHTML('partials/problem_list.html', {
pdocs, page, ppcount, pcount, pdocs, psdict, qs: q,
psdict, }),
category: category.join(','), this.renderHTML('partials/problem_stat.html', { pcount, pcountRelation }),
qs: q, this.renderHTML('partials/problem_lucky.html', { qs: q }),
}; ])).map((i) => ({ html: i })),
raw: {
page, pcount, ppcount, pdocs, psdict, category,
},
};
} else {
this.response.body = {
page,
pcount,
ppcount,
pcountRelation,
pdocs,
psdict,
category: category.join(','),
qs: q,
};
}
} }
@param('pid', Types.UnsignedInt) @param('pid', Types.UnsignedInt)
@@ -283,7 +276,7 @@ export class ProblemMainHandler extends ProblemHandler {
} }
} }
export class ProblemRandomHandler extends ProblemHandler { export class ProblemRandomHandler extends Handler {
@param('q', Types.Content, true) @param('q', Types.Content, true)
async get(domainId: string, qs = '') { async get(domainId: string, qs = '') {
const category = flattenDeep(decodeURIComponent(qs).split(' ') const category = flattenDeep(decodeURIComponent(qs).split(' ')
@@ -299,10 +292,8 @@ export class ProblemRandomHandler extends ProblemHandler {
} }
} }
export class ProblemDetailHandler extends ProblemHandler { export class ProblemDetailHandler extends ContestDetailBaseHandler {
pdoc: ProblemDoc; pdoc: ProblemDoc;
tdoc?: Tdoc<30>;
tsdoc?: any;
udoc: User; udoc: User;
psdoc: ProblemStatusDoc; psdoc: ProblemStatusDoc;
@@ -312,9 +303,7 @@ export class ProblemDetailHandler extends ProblemHandler {
this.pdoc = await problem.get(domainId, pid); this.pdoc = await problem.get(domainId, pid);
if (!this.pdoc) throw new ProblemNotFoundError(domainId, pid); if (!this.pdoc) throw new ProblemNotFoundError(domainId, pid);
if (tid) { if (tid) {
this.tdoc = await contest.get(domainId, tid);
if (!this.tdoc?.pids?.includes(this.pdoc.docId)) throw new ContestNotFoundError(domainId, tid); if (!this.tdoc?.pids?.includes(this.pdoc.docId)) throw new ContestNotFoundError(domainId, tid);
this.tsdoc = await contest.getStatus(domainId, tid, this.user._id);
if (contest.isNotStarted(this.tdoc)) throw new ContestNotLiveError(tid); if (contest.isNotStarted(this.tdoc)) throw new ContestNotLiveError(tid);
if (!contest.isDone(this.tdoc, this.tsdoc) && (!this.tsdoc?.attend || !this.tsdoc.startAt)) throw new ContestNotAttendedError(tid); if (!contest.isDone(this.tdoc, this.tsdoc) && (!this.tsdoc?.attend || !this.tsdoc.startAt)) throw new ContestNotAttendedError(tid);
this.pdoc.tag.length = 0; this.pdoc.tag.length = 0;
+1
View File
@@ -26,6 +26,7 @@ export const TYPE_DISCUSSION: 21 = 21;
export const TYPE_DISCUSSION_REPLY: 22 = 22; export const TYPE_DISCUSSION_REPLY: 22 = 22;
export const TYPE_CONTEST: 30 = 30; export const TYPE_CONTEST: 30 = 30;
export const TYPE_TRAINING: 40 = 40; export const TYPE_TRAINING: 40 = 40;
/** @deprecated use `TYPE_CONTEST` with rule `homework` instead. */
export const TYPE_HOMEWORK: 60 = 60; export const TYPE_HOMEWORK: 60 = 60;
export const TYPE_BLOG: 70 = 70; export const TYPE_BLOG: 70 = 70;
+1
View File
@@ -291,6 +291,7 @@ async function handle(ctx: KoaContext, HandlerClass, checker) {
await bail(`handler/before-prepare/${HandlerClass.name.replace(/Handler$/, '')}`, h); await bail(`handler/before-prepare/${HandlerClass.name.replace(/Handler$/, '')}`, h);
await bail('handler/before-prepare', h); await bail('handler/before-prepare', h);
h.args.__prepare = Date.now(); h.args.__prepare = Date.now();
if (h.__prepare) await h.__prepare(args);
if (h._prepare) await h._prepare(args); if (h._prepare) await h._prepare(args);
if (h.prepare) await h.prepare(args); if (h.prepare) await h.prepare(args);
h.args.__prepareDone = Date.now(); h.args.__prepareDone = Date.now();
+1
View File
@@ -782,6 +782,7 @@ Testdata Config: 测试数据配置
Testdata: 测试数据 Testdata: 测试数据
Text: 文本 Text: 文本
The 'default' role applies to ALL REGISTERED USER.: default 角色作用于<b>所有已注册用户</b> The 'default' role applies to ALL REGISTERED USER.: default 角色作用于<b>所有已注册用户</b>
The contest is a flexible time contest. You need to complete the contest within a specified time after you attended.: 本场比赛采用灵活时间模式,你需要在参加后的指定时间内完成比赛。
The homework's deadline is due but in extension. You can still submit for this problem but your score will be penalized.: 作业已超过截止时间,但仍在延期时间内。您递交题目将无法获得全部分数。 The homework's deadline is due but in extension. You can still submit for this problem but your score will be penalized.: 作业已超过截止时间,但仍在延期时间内。您递交题目将无法获得全部分数。
The invitation code to enter to successfully join the domain. You can only use letters and numbers in the code and it should not be longer than 64 characters.: 加入此域的邀请码。您只能使用字母和数字,并且不能长于 64 个字符。 The invitation code to enter to successfully join the domain. You can only use letters and numbers in the code and it should not be longer than 64 characters.: 加入此域的邀请码。您只能使用字母和数字,并且不能长于 64 个字符。
The invitation code you provided is invalid.: 您提供的邀请码无效。 The invitation code you provided is invalid.: 您提供的邀请码无效。
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@hydrooj/ui-default", "name": "@hydrooj/ui-default",
"version": "4.38.11", "version": "4.38.12",
"author": "undefined <i@undefined.moe>", "author": "undefined <i@undefined.moe>",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"main": "hydro.js", "main": "hydro.js",
@@ -10,7 +10,7 @@
"lint": "eslint" "lint": "eslint"
}, },
"devDependencies": { "devDependencies": {
"@blueprintjs/core": "^4.5.1", "@blueprintjs/core": "^4.6.0",
"@fontsource/dm-mono": "^4.5.8", "@fontsource/dm-mono": "^4.5.8",
"@fontsource/fira-code": "^4.5.10", "@fontsource/fira-code": "^4.5.10",
"@fontsource/inconsolata": "^4.5.6", "@fontsource/inconsolata": "^4.5.6",
@@ -27,7 +27,7 @@
"@types/markdown-it": "^12.2.3", "@types/markdown-it": "^12.2.3",
"@types/pickadate": "^3.5.32", "@types/pickadate": "^3.5.32",
"@types/qrcode": "^1.4.2", "@types/qrcode": "^1.4.2",
"@types/react-dom": "^18.0.5", "@types/react-dom": "^18.0.6",
"@types/redux-logger": "^3.0.9", "@types/redux-logger": "^3.0.9",
"ansi_up": "^5.1.0", "ansi_up": "^5.1.0",
"autoprefixer": "^9.8.8", "autoprefixer": "^9.8.8",
@@ -61,7 +61,7 @@
"jquery.transit": "^0.9.12", "jquery.transit": "^0.9.12",
"matchmedia-polyfill": "^0.3.2", "matchmedia-polyfill": "^0.3.2",
"mini-css-extract-plugin": "^1.6.2", "mini-css-extract-plugin": "^1.6.2",
"moment": "^2.29.3", "moment": "^2.29.4",
"monaco-editor": "^0.33.0", "monaco-editor": "^0.33.0",
"monaco-editor-nls": "^2.0.0", "monaco-editor-nls": "^2.0.0",
"monaco-editor-webpack-plugin": "^7.0.1", "monaco-editor-webpack-plugin": "^7.0.1",
@@ -30,6 +30,11 @@
<p>{{ _('We will check code similarity after this contest.') }}</p> <p>{{ _('We will check code similarity after this contest.') }}</p>
</blockquote> </blockquote>
{% endif %} {% endif %}
{% if tdoc.duration %}
<blockquote class="note">
<p>{{ _('The contest is a flexible time contest. You need to complete the contest within a specified time after you attended.') }}</p>
</blockquote>
{% endif %}
</div> </div>
</div> </div>
{% if model.contest.isOngoing(tdoc, tsdoc) or model.contest.isDone(tdoc, tsdoc) %} {% if model.contest.isOngoing(tdoc, tsdoc) or model.contest.isDone(tdoc, tsdoc) %}
@@ -1,6 +1,6 @@
{% import "components/paginator.html" as paginator with context %} {% import "components/paginator.html" as paginator with context %}
{% extends "layout/basic.html" %} {% extends "layout/basic.html" %}
{% set type = 'homework' if tdoc.docType == 60 else 'contest' %} {% set type = 'homework' if tdoc.rule == 'homework' else 'contest' %}
{% block content %} {% block content %}
<div class="row"><div class="medium-12 columns scoreboard--{{ tdoc.rule }}"> <div class="row"><div class="medium-12 columns scoreboard--{{ tdoc.rule }}">
<div class="section visible"> <div class="section visible">
@@ -109,7 +109,7 @@
<dt>{{ _('Rule') }}</dt><dd>{{ model.contest.RULES[tdoc.rule].TEXT }}</dd> <dt>{{ _('Rule') }}</dt><dd>{{ model.contest.RULES[tdoc.rule].TEXT }}</dd>
<dt>{{ _('Problem') }}</dt><dd>{{ tdoc.pids.length }}</dd> <dt>{{ _('Problem') }}</dt><dd>{{ tdoc.pids.length }}</dd>
<dt>{{ _('Start at') }}</dt><dd>{{ contest.render_time(tsdoc.startAt or tdoc.beginAt) }}</dd> <dt>{{ _('Start at') }}</dt><dd>{{ contest.render_time(tsdoc.startAt or tdoc.beginAt) }}</dd>
<dt>{{ _('End at') }}</dt><dd>{{ contest.render_time(tdoc.endAt) }}</dd> <dt>{{ _('End at') }}</dt><dd>{{ contest.render_time(tsdoc.endAt or tdoc.endAt) }}</dd>
<dt>{{ _('Duration') }}</dt><dd>{{ contest.render_duration(tdoc) }} {{ _('hour(s)') }}</dd> <dt>{{ _('Duration') }}</dt><dd>{{ contest.render_duration(tdoc) }} {{ _('hour(s)') }}</dd>
<dt>{{ _('Host') }}</dt><dd>{{ user.render_inline(owner_udoc, badge=false) }}</dd> <dt>{{ _('Host') }}</dt><dd>{{ user.render_inline(owner_udoc, badge=false) }}</dd>
<dt>{{ _('Partic.') }}</dt><dd>{{ tdoc['attend']|default(0) }}</dd> <dt>{{ _('Partic.') }}</dt><dd>{{ tdoc['attend']|default(0) }}</dd>
+1 -1
View File
@@ -12,7 +12,7 @@
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"moment-timezone": "^0.5.34", "moment-timezone": "^0.5.34",
"mongodb": "^3.7.3", "mongodb": "^3.7.3",
"systeminformation": "^5.11.22" "systeminformation": "^5.11.24"
}, },
"devDependencies": { "devDependencies": {
"@types/fs-extra": "^9.0.13" "@types/fs-extra": "^9.0.13"
+3 -3
View File
@@ -12,10 +12,10 @@
"chrome-finder": "^1.0.7", "chrome-finder": "^1.0.7",
"jsdom": "^19.0.0", "jsdom": "^19.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"puppeteer-core": "^15.1.1", "puppeteer-core": "^15.3.2",
"puppeteer-extra": "3.3.0", "puppeteer-extra": "3.3.0",
"puppeteer-extra-plugin-portal": "^3.1.0", "puppeteer-extra-plugin-portal": "3.1.0",
"puppeteer-extra-plugin-stealth": "^2.10.1", "puppeteer-extra-plugin-stealth": "2.10.1",
"superagent": "^7.1.5", "superagent": "^7.1.5",
"superagent-proxy": "^3.0.0" "superagent-proxy": "^3.0.0"
}, },