import AdmZip from 'adm-zip'; import { statSync } from 'fs-extra'; import { intersection, isSafeInteger } from 'lodash'; import { FilterQuery, ObjectID } from 'mongodb'; import { sortFiles } from '@hydrooj/utils/lib/utils'; import { BadRequestError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError, ForbiddenError, NoProblemError, NotFoundError, PermissionError, ProblemNotFoundError, SolutionNotFoundError, ValidationError, } from '../error'; import { ProblemDoc, ProblemStatusDoc, Tdoc, User, } from '../interface'; import paginate from '../lib/paginate'; import { isPid, parsePid as convertPid } from '../lib/validator'; import { PERM, PRIV, STATUS } from '../model/builtin'; import * as contest from '../model/contest'; import * as discussion from '../model/discussion'; import domain from '../model/domain'; import * as oplog from '../model/oplog'; import problem from '../model/problem'; import record from '../model/record'; import * as setting from '../model/setting'; import solution from '../model/solution'; import storage from '../model/storage'; import * as system from '../model/system'; import user from '../model/user'; import * as bus from '../service/bus'; import { Handler, param, post, query, Route, route, Types, } from '../service/server'; import { registerResolver, registerValue } from './api'; export const parseCategory = (value: string) => value.replace(/,/g, ',').split(',').map((e) => e.trim()); export const parsePid = (value: string) => (isSafeInteger(value) ? +value : value); registerValue('FileInfo', [ ['_id', 'String!'], ['name', 'String!'], ['size', 'Int'], ['lastModified', 'Date'], ]); registerValue('Problem', [ ['_id', 'ObjectID!'], ['owner', 'Int!'], ['domainId', 'String!'], ['docId', 'Int!'], ['docType', 'Int!'], ['pid', 'String'], ['title', 'String!'], ['content', 'String!'], ['config', 'String!'], ['data', '[FileInfo]'], ['additional_file', '[FileInfo]'], ['nSubmit', 'Int'], ['nAccept', 'Int'], ['difficulty', 'Int'], ['tag', '[String]'], ['hidden', 'Boolean'], ['assign', '[String]'], ]); registerResolver( 'Query', 'problem(id: Int, pid: String)', 'Problem', async (arg, ctx) => { const pdoc = await problem.get(ctx.domainId, arg.pid || arg.id); if (!pdoc) return null; if (pdoc.hidden) ctx.checkPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN); if (pdoc.assign.length && !ctx.user.own(pdoc)) { if (!Set.intersection(new Set(pdoc.assign), new Set(ctx.user.group)).size) { throw new PermissionError(PERM.PERM_VIEW_PROBLEM_HIDDEN); } } ctx.pdoc = pdoc; return pdoc; }, ); registerResolver( 'Problem', 'manage', 'ProblemManage', (arg, ctx) => { if (!ctx.user.own(ctx.pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) ctx.checkPerm(PERM.PERM_EDIT_PROBLEM); return {}; }, ); registerResolver( 'ProblemManage', 'delete', 'Boolean!', async (arg, ctx) => { const tdocs = await contest.getRelated(ctx.domainId, ctx.pdoc.docId); if (tdocs.length) throw new BadRequestError('Problem already used by contest {0}', tdocs[0]._id); return problem.del(ctx.pdoc.domainId, ctx.pdoc.docId); }, ); registerResolver( 'ProblemManage', 'edit(title: String, content: String, tag: [String], hidden: Boolean, assign: [String])', 'Problem!', (arg, ctx) => problem.edit(ctx.domainId, ctx.pdoc.docId, arg), ); function buildQuery(udoc: User) { const q: FilterQuery = {}; if (!udoc.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN)) { q.$or = [ { hidden: false }, { owner: udoc._id }, { maintainer: udoc._id }, ]; } return q; } export class ProblemHandler extends Handler { async cleanup() { if (this.response.template === 'problem_main.html' && this.request.json) { const { page, pcount, ppcount, pdocs, psdict, category, } = 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, }), this.renderHTML('partials/problem_stat.html', { pcount }), this.renderHTML('partials/problem_lucky.html', { category }), ])).map((i) => ({ html: i })), raw: { page, pcount, ppcount, pdocs, psdict, category, }, }; } } } export class ProblemMainHandler extends ProblemHandler { @param('page', Types.PositiveInt, true) @param('q', Types.Content, true) @param('category', Types.Name, true, null, parseCategory) async get(domainId: string, page = 1, q = '', category = []) { this.response.template = 'problem_main.html'; // eslint-disable-next-line @typescript-eslint/no-shadow const query = buildQuery(this.user); const psdict = {}; const search = global.Hydro.lib.problemSearch; let sort: string[]; let fail = false; if (category.length) query.$and = category.map((tag) => ({ tag })); if (q) category.push(q); if (category.length) this.extraTitleContent = category.join(','); if (q) { if (search) { const result = await search(domainId, q); if (!result.length) fail = true; if (!query.$and) query.$and = []; query.$and.push({ $or: result.map((i) => { const [did, docId] = i.split('/'); return { domainId: did, docId: +docId }; }), }); sort = result; } else query.$text = { $search: q }; } await bus.serial('problem/list', query, this); // eslint-disable-next-line prefer-const let [pdocs, ppcount, pcount] = fail ? [[], 0, 0] : await problem.list( domainId, query, page, system.get('pagination.problem'), undefined, this.user._id, ); if (sort) pdocs = pdocs.sort((a, b) => sort.indexOf(`${a.domainId}/${a.docId}`) - sort.indexOf(`${b.domainId}/${b.docId}`)); if (q) { const pdoc = await problem.get(domainId, +q || q, problem.PROJECTION_LIST); if (pdoc && problem.canViewBy(pdoc, this.user)) { const count = pdocs.length; pdocs = pdocs.filter((doc) => doc.docId !== pdoc.docId); pdocs.unshift(pdoc); pcount = pcount - count + pdocs.length; } } if (this.user.hasPriv(PRIV.PRIV_USER_PROFILE)) { const domainIds = Array.from(new Set(pdocs.map((i) => i.domainId))); await Promise.all( domainIds.map((did) => problem.getListStatus( did, this.user._id, pdocs.filter((i) => i.domainId === did).map((i) => i.docId), ).then((res) => Object.assign(psdict, res))), ); } this.response.body = { page, pcount, ppcount, pdocs, psdict, category: category.join(','), }; } @param('pid', Types.UnsignedInt) async postStar(domainId: string, pid: number) { await problem.setStar(domainId, pid, this.user._id, true); this.back({ star: true }); } @param('pid', Types.UnsignedInt) async postUnstar(domainId: string, pid: number) { await problem.setStar(domainId, pid, this.user._id, false); this.back({ star: false }); } @param('pids', Types.NumericArray) @param('target', Types.String) async postCopy(domainId: string, pids: number[], target: string) { const t = `,${this.domain.share || ''},`; if (t !== ',*,' && !t.includes(`,${target},`)) throw new PermissionError(target); const ddoc = await domain.get(target); if (!ddoc) throw new NotFoundError(target); const dudoc = await user.getById(target, this.user._id); if (!dudoc.hasPerm(PERM.PERM_CREATE_PROBLEM)) throw new PermissionError(PERM.PERM_CREATE_PROBLEM); const ids = []; for (const pid of pids) { // eslint-disable-next-line no-await-in-loop ids.push(await problem.copy(domainId, pid, target)); } this.response.body = ids; } @param('pids', Types.NumericArray) async postDelete(domainId: string, pids: number[]) { for (const pid of pids) { // eslint-disable-next-line no-await-in-loop const pdoc = await problem.get(domainId, pid); if (!pdoc) continue; if (!this.user.own(pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) this.checkPerm(PERM.PERM_EDIT_PROBLEM); // eslint-disable-next-line no-await-in-loop await problem.del(domainId, pid); } this.back(); } @param('pids', Types.NumericArray) async postHide(domainId: string, pids: number[]) { for (const pid of pids) { // eslint-disable-next-line no-await-in-loop const pdoc = await problem.get(domainId, pid); if (!this.user.own(pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) this.checkPerm(PERM.PERM_EDIT_PROBLEM); // eslint-disable-next-line no-await-in-loop await problem.edit(domainId, pid, { hidden: true }); } this.back(); } @param('pids', Types.NumericArray) async postUnhide(domainId: string, pids: number[]) { for (const pid of pids) { // eslint-disable-next-line no-await-in-loop const pdoc = await problem.get(domainId, pid); if (!this.user.own(pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) this.checkPerm(PERM.PERM_EDIT_PROBLEM); // eslint-disable-next-line no-await-in-loop await problem.edit(domainId, pid, { hidden: false }); } this.back(); } } export class ProblemRandomHandler extends ProblemHandler { @param('category', Types.Name, true, null, parseCategory) async get(domainId: string, category: string[] = []) { const q = buildQuery(this.user); if (category.length) q.$and = category.map((tag) => ({ tag })); await bus.serial('problem/list', q, this); const pid = await problem.random(domainId, q); if (!pid) throw new NoProblemError(); this.response.body = { pid }; this.response.redirect = this.url('problem_detail', { pid }); } } export class ProblemDetailHandler extends ProblemHandler { pdoc: ProblemDoc; tdoc?: Tdoc<30>; tsdoc?: any; udoc: User; psdoc: ProblemStatusDoc; @route('pid', Types.Name, true, null, parsePid) @query('tid', Types.ObjectID, true) async _prepare(domainId: string, pid: number | string, tid?: ObjectID) { this.pdoc = await problem.get(domainId, pid); if (!this.pdoc) throw new ProblemNotFoundError(domainId, pid); if (tid) { this.tdoc = await contest.get(domainId, tid); if (!this.tdoc) 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.isDone(this.tdoc) && !this.tsdoc?.attend) throw new ContestNotAttendedError(tid); this.pdoc.tag.length = 0; if (!contest.canShowScoreboard.call(this, this.tdoc) || !contest.isLocked(this.tdoc)) { delete this.pdoc.nAccept; delete this.pdoc.nSubmit; delete this.pdoc.difficulty; delete this.pdoc.stats; } } else if (!problem.canViewBy(this.pdoc, this.user)) { throw new PermissionError(PERM.PERM_VIEW_PROBLEM_HIDDEN); } let ddoc = this.domain; if (this.pdoc.reference) { ddoc = await domain.get(this.pdoc.reference.domainId); const pdoc = await problem.get(this.pdoc.reference.domainId, this.pdoc.reference.pid); if (!ddoc || !pdoc) throw new ProblemNotFoundError(this.pdoc.reference.domainId, this.pdoc.reference.pid); this.pdoc.config = pdoc.config; } if (typeof this.pdoc.config !== 'string') { const t = []; if (this.pdoc.config.langs) t.push(this.pdoc.config.langs); if (ddoc.langs) t.push(ddoc.langs.split(',').map((i) => i.trim()).filter((i) => i)); if (this.domain.langs) t.push(this.domain.langs.split(',').map((i) => i.trim()).filter((i) => i)); if (this.pdoc.config.type === 'remote_judge') { const p = this.pdoc.config.subType; const dl = [p, ...Object.keys(setting.langs).filter((i) => i.startsWith(`${p}.`))]; t.push(dl); } if (t.length) this.pdoc.config.langs = intersection(...t); } await bus.serial('problem/get', this.pdoc, this); [this.psdoc, this.udoc] = await Promise.all([ problem.getStatus(domainId, this.pdoc.docId, this.user._id), user.getById(domainId, this.pdoc.owner), ]); const [scnt, dcnt] = await Promise.all([ solution.count(domainId, { parentId: this.pdoc.docId }), discussion.count(domainId, { parentId: this.pdoc.docId }), ]); this.response.body = { pdoc: this.pdoc, udoc: this.udoc, psdoc: this.psdoc, title: this.pdoc.title, solutionCount: scnt, discussionCount: dcnt, tdoc: this.tdoc, tsdoc: this.tsdoc, }; this.response.template = 'problem_detail.html'; this.extraTitleContent = this.pdoc.title; } @query('tid', Types.ObjectID, true) async get(...args: any[]) { // Navigate to current additional file download // e.g. ![img](a.jpg) will navigate to ![img](./pid/file/a.jpg) if (!this.request.json) { if (args[1]) { this.response.body.pdoc.content = this.response.body.pdoc.content .replace(/\(file:\/\/(.+?)\)/g, (str) => { const info = str.match(/\(file:\/\/(.+?)\)/); return `(./${this.pdoc.docId}/file/${info[1]}${info[1].includes('?') ? '&' : '?'}tid=${args[1]})`; }) .replace(/="file:\/\/(.+?)"/g, (str) => { const info = str.match(/="file:\/\/(.+?)"/); return `="./${this.pdoc.docId}/file/${info[1]}${info[1].includes('?') ? '&' : '?'}tid=${args[1]}"`; }); } else { this.response.body.pdoc.content = this.response.body.pdoc.content .replace(/\(file:\/\//g, `(./${this.pdoc.docId}/file/`) .replace(/="file:\/\//g, `="./${this.pdoc.docId}/file/`); } } this.response.body.page_name = this.tdoc ? this.tdoc.rule === 'homework' ? 'homework_detail_problem' : 'contest_detail_problem' : 'problem_detail'; if (!this.response.body.tdoc) { if (this.psdoc?.rid) { this.response.body.rdoc = await record.get(this.domainId, this.psdoc.rid); } this.response.body.ctdocs = await contest.getRelated(this.domainId, this.pdoc.docId); } } @param('pid', Types.UnsignedInt) async postRejudge(domainId: string, pid: number) { this.checkPerm(PERM.PERM_REJUDGE_PROBLEM); // TODO maybe async? const rdocs = await record.getMulti(domainId, { pid, contest: { $ne: new ObjectID('0'.repeat(24)) } }) .project({ _id: 1 }).toArray(); const priority = await record.submissionPriority(this.user._id, -rdocs.length * 5 - 50); await Promise.all(rdocs.map( (doc) => record.reset(domainId, doc._id, true) .then(() => record.judge(domainId, doc._id, priority)), )); this.back(); } @param('target', Types.String) async postCopy(domainId: string, target: string) { const t = `,${this.domain.share || ''},`; if (t !== ',*,' && !t.includes(`,${target},`)) throw new PermissionError(target); const ddoc = await domain.get(target); if (!ddoc) throw new NotFoundError(target); const dudoc = await user.getById(target, this.user._id); if (!dudoc.hasPerm(PERM.PERM_CREATE_PROBLEM)) throw new PermissionError(PERM.PERM_CREATE_PROBLEM); const docId = await problem.copy(domainId, this.pdoc.docId, target); this.response.redirect = this.url('problem_detail', { domainId: target, pid: docId }); } async postDelete() { if (!this.user.own(this.pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) this.checkPerm(PERM.PERM_EDIT_PROBLEM); const tdocs = await contest.getRelated(this.domainId, this.pdoc.docId); if (tdocs.length) throw new BadRequestError('Problem already used by contest {0}', tdocs[0]._id); await problem.del(this.pdoc.domainId, this.pdoc.docId); this.response.redirect = this.url('problem_main'); } } export class ProblemSubmitHandler extends ProblemDetailHandler { @param('tid', Types.ObjectID, true) async prepare(domainId: string, tid?: ObjectID) { if (tid && !contest.isOngoing(this.tdoc)) throw new ContestNotLiveError(this.tdoc.docId); } async get() { this.response.template = 'problem_submit.html'; this.response.body = { pdoc: this.pdoc, udoc: this.udoc, title: this.pdoc.title, page_name: this.tdoc ? this.tdoc.rule === 'homework' ? 'homework_detail_problem_submit' : 'contest_detail_problem_submit' : 'problem_submit', }; } @param('lang', Types.Name) @param('code', Types.Content) @param('pretest', Types.Boolean) @param('input', Types.String, true) @param('tid', Types.ObjectID, true) async post(domainId: string, lang: string, code: string, pretest = false, input = '', tid?: ObjectID) { if (this.response.body.pdoc.config?.langs && !this.response.body.pdoc.config.langs.includes(lang)) { throw new BadRequestError('Language not allowed.'); } if (pretest && setting.langs[lang]?.pretest) lang = setting.langs[lang].pretest; if (pretest && setting.langs[lang]?.pretest === false) throw new BadRequestError('Cannot run pretest for this language.'); if (pretest && !['default', 'fileio', 'remote_judge'].includes(this.response.body.pdoc.config?.type)) { throw new BadRequestError('unable to run pretest'); } await this.limitRate('add_record', 60, system.get('limit.submission')); const rid = await record.add(domainId, this.pdoc.docId, this.user._id, lang, code, true, pretest ? input : tid, tid && !pretest); const rdoc = await record.get(domainId, rid); if (!pretest) { await Promise.all([ problem.inc(this.domainId, this.pdoc.docId, 'nSubmit', 1), problem.incStatus(domainId, this.pdoc.docId, this.user._id, 'nSubmit', 1), domain.incUserInDomain(domainId, this.user._id, 'nSubmit'), tid && contest.updateStatus(domainId, tid, this.user._id, rid, this.pdoc.docId), ]); } bus.broadcast('record/change', rdoc); if (tid && !pretest && !contest.canShowSelfRecord.call(this, this.tdoc)) { this.response.body = { tid }; this.response.redirect = this.url(this.tdoc.rule === 'homework' ? 'homework_detail' : 'contest_detail', { tid }); } else { this.response.body = { rid }; this.response.redirect = this.url('record_detail', { rid }); } } } export class ProblemManageHandler extends ProblemDetailHandler { async prepare() { if (!this.user.own(this.pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) this.checkPerm(PERM.PERM_EDIT_PROBLEM); } } export class ProblemEditHandler extends ProblemManageHandler { async get() { this.response.template = 'problem_edit.html'; } @route('pid', Types.Name, null, parsePid) @post('title', Types.Title) @post('content', Types.Content) @post('pid', Types.Name, isPid, convertPid, true) @post('hidden', Types.Boolean) @post('tag', Types.Content, true, null, parseCategory) @post('difficulty', Types.PositiveInt, (i) => +i <= 10, true) @post('assign', Types.CommaSeperatedArray, true) async post( domainId: string, pid: string | number, title: string, content: string, newPid: string = '', hidden = false, tag: string[] = [], difficulty = 0, assign: string[] = [], ) { if (newPid !== this.pdoc.pid && await problem.get(domainId, newPid)) throw new BadRequestError('new pid exists'); const $update: Partial = { title, content, pid: newPid, hidden, assign, tag: tag ?? [], difficulty, }; let pdoc = await problem.get(domainId, pid); pdoc = await problem.edit(domainId, pdoc.docId, $update); this.response.redirect = this.url('problem_detail', { pid: newPid || pdoc.docId }); } } export class ProblemFilesHandler extends ProblemDetailHandler { notUsage = true; @param('testdata', Types.Boolean) @param('additional_file', Types.Boolean) @param('pjax', Types.Boolean) async get(domainId: string, getTestdata = true, getAdditionalFile = true, pjax = false) { this.response.body.testdata = getTestdata ? sortFiles(this.pdoc.data || []) : []; this.response.body.reference = getTestdata ? this.pdoc.reference : ''; this.response.body.additional_file = getAdditionalFile ? sortFiles(this.pdoc.additional_file || []) : []; if (pjax) { const { testdata, additional_file } = this.response.body; const owner = await user.getById(domainId, this.pdoc.owner); this.response.body = { fragments: (await Promise.all([ this.renderHTML('partials/problem_files-testdata.html', { testdata, pdoc: this.pdoc }), this.renderHTML('partials/problem_files-additional_file.html', { additional_file, pdoc: this.pdoc }), this.renderHTML('partials/problem-sidebar-information.html', { pdoc: this.pdoc, owner_udoc: owner }), ])).map((i) => ({ html: i })), }; this.response.template = ''; } else this.response.template = 'problem_files.html'; } @post('files', Types.Set) @post('type', Types.Range(['testdata', 'additional_file']), true) async postGetLinks(domainId: string, files: Set, type = 'testdata') { if (type === 'testdata' && !this.user.own(this.pdoc)) { if (!this.user.hasPriv(PRIV.PRIV_READ_PROBLEM_DATA)) this.checkPerm(PERM.PERM_READ_PROBLEM_DATA); if (this.tdoc && !contest.isDone(this.tdoc)) throw new ContestNotEndedError(this.tdoc.domainId, this.tdoc.docId); } if (this.pdoc.reference) this.pdoc = await problem.get(this.pdoc.reference.domainId, this.pdoc.reference.pid); const links = {}; const size = Math.sum( this.pdoc[type === 'testdata' ? 'data' : 'additional_file'] ?.filter((i) => files.has(i.name)) ?.map((i) => i.size), ) || 0; await oplog.log(this, 'download.problem.bulk', { target: Array.from(files).map((file) => `problem/${this.pdoc.domainId}/${this.pdoc.docId}/${type}/${file}`), size, }); for (const file of files) { // eslint-disable-next-line no-await-in-loop links[file] = await storage.signDownloadLink( `problem/${this.pdoc.domainId}/${this.pdoc.docId}/${type}/${file}`, file, false, 'user', ); } this.response.body.links = links; } @post('filename', Types.Name, true) @post('type', Types.Range(['testdata', 'additional_file']), true) async postUploadFile(domainId: string, filename: string, type = 'testdata') { if (this.pdoc.reference) throw new ForbiddenError('Cannot delete files of a referenced problem.'); if (!this.request.files.file) throw new ValidationError('file'); if (!filename) filename = this.request.files.file.name || String.random(16); if (filename.includes('/') || filename.includes('..')) throw new ValidationError('filename', null, 'Bad filename'); if (!this.user.own(this.pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) this.checkPerm(PERM.PERM_EDIT_PROBLEM); const files = []; if (filename.endsWith('.zip')) { const zip = new AdmZip(this.request.files.file.path); const entries = zip.getEntries(); for (const entry of entries) { if (!entry.name) continue; files.push({ type, name: entry.name, size: entry.header.size, data: () => entry.getData(), }); } } else { files.push({ type, name: filename, size: statSync(this.request.files.file.path).size, data: () => this.request.files.file.path, }); } if (!this.user.hasPriv(PRIV.PRIV_EDIT_SYSTEM)) { if ((this.pdoc.data?.length || 0) + (this.pdoc.additional_file?.length || 0) + files.length >= system.get('limit.problem_files_max')) { throw new ForbiddenError('File limit exceeded.'); } const size = Math.sum( (this.pdoc.data || []).map((i) => i.size), (this.pdoc.additional_file || []).map((i) => i.size), files.map((i) => i.size), ); if (size >= system.get('limit.problem_files_max_size')) { throw new ForbiddenError('File size limit exceeded.'); } } for (const entry of files) { if (entry.type === 'testdata') { // eslint-disable-next-line no-await-in-loop await problem.addTestdata(domainId, this.pdoc.docId, entry.name, entry.data()); } else { // eslint-disable-next-line no-await-in-loop await problem.addAdditionalFile(domainId, this.pdoc.docId, entry.name, entry.data()); } } this.back(); } @post('files', Types.Array) @post('type', Types.Range(['testdata', 'additional_file']), true) async postDeleteFiles(domainId: string, files: string[], type = 'testdata') { if (this.pdoc.reference) throw new ForbiddenError('Cannot delete files of a referenced problem.'); if (!this.user.own(this.pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) this.checkPerm(PERM.PERM_EDIT_PROBLEM); if (type === 'testdata') await problem.delTestdata(domainId, this.pdoc.docId, files); else await problem.delAdditionalFile(domainId, this.pdoc.docId, files); this.back(); } } export class ProblemFileDownloadHandler extends ProblemDetailHandler { @query('type', Types.Range(['additional_file', 'testdata']), true) @param('filename', Types.Name) @param('noDisposition', Types.Boolean) async get(domainId: string, type = 'additional_file', filename: string, noDisposition = false) { if (this.pdoc.reference) { if (type === 'testdata') throw new ForbiddenError('Cannot download testdata'); this.pdoc = await problem.get(this.pdoc.reference.domainId, this.pdoc.reference.pid); if (!this.pdoc) throw new ProblemNotFoundError(); } if (type === 'testdata' && !this.user.own(this.pdoc)) { if (!this.user.hasPriv(PRIV.PRIV_READ_PROBLEM_DATA)) this.checkPerm(PERM.PERM_READ_PROBLEM_DATA); if (this.tdoc && !contest.isDone(this.tdoc)) throw new ContestNotEndedError(this.tdoc.domainId, this.tdoc.docId); } const target = `problem/${this.pdoc.domainId}/${this.pdoc.docId}/${type}/${filename}`; const file = await storage.getMeta(target); await oplog.log(this, 'download.problem.single', { target, size: file?.size || 0, }); this.response.redirect = await storage.signDownloadLink( target, noDisposition ? undefined : filename, false, 'user', ); } } export class ProblemSolutionHandler extends ProblemDetailHandler { @param('page', Types.PositiveInt, true) @param('tid', Types.ObjectID, true) async get(domainId: string, page = 1, tid?: ObjectID) { if (tid) throw new PermissionError(PERM.PERM_VIEW_PROBLEM_SOLUTION); this.response.template = 'problem_solution.html'; const accepted = this.tsdoc?.status === STATUS.STATUS_ACCEPTED; if (!accepted || !this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_SOLUTION_ACCEPT)) { this.checkPerm(PERM.PERM_VIEW_PROBLEM_SOLUTION); } const [psdocs, pcount, pscount] = await paginate( solution.getMulti(domainId, this.pdoc.docId), page, system.get('pagination.solution'), ); const uids = [this.pdoc.owner]; const docids = []; for (const psdoc of psdocs) { docids.push(psdoc.docId); uids.push(psdoc.owner); if (psdoc.reply.length) { for (const psrdoc of psdoc.reply) uids.push(psrdoc.owner); } } const udict = await user.getList(domainId, uids); const pssdict = await solution.getListStatus(domainId, docids, this.user._id); this.response.body = { psdocs, page, pcount, pscount, udict, pssdict, pdoc: this.pdoc, }; await bus.serial('handler/solution/get', this); } @param('content', Types.Content) async postSubmit(domainId: string, content: string) { this.checkPerm(PERM.PERM_CREATE_PROBLEM_SOLUTION); await solution.add(domainId, this.pdoc.docId, this.user._id, content); this.back(); } @param('content', Types.Content) @param('psid', Types.ObjectID) async postEditSolution(domainId: string, content: string, psid: ObjectID) { let psdoc = await solution.get(domainId, psid); if (!this.user.own(psdoc)) this.checkPerm(PERM.PERM_EDIT_PROBLEM_SOLUTION); else this.checkPerm(PERM.PERM_EDIT_PROBLEM_SOLUTION_SELF); psdoc = await solution.edit(domainId, psdoc.docId, content); this.back({ psdoc }); } @param('psid', Types.ObjectID) async postDeleteSolution(domainId: string, psid: ObjectID) { const psdoc = await solution.get(domainId, psid); if (!this.user.own(psdoc)) this.checkPerm(PERM.PERM_DELETE_PROBLEM_SOLUTION); else this.checkPerm(PERM.PERM_DELETE_PROBLEM_SOLUTION_SELF); await solution.del(domainId, psdoc.docId); this.back(); } @param('psid', Types.ObjectID) @param('content', Types.Content) async postReply(domainId: string, psid: ObjectID, content: string) { this.checkPerm(PERM.PERM_REPLY_PROBLEM_SOLUTION); const psdoc = await solution.get(domainId, psid); await solution.reply(domainId, psdoc.docId, this.user._id, content); this.back(); } @param('psid', Types.ObjectID) @param('psrid', Types.ObjectID) @param('content', Types.Content) async postEditReply(domainId: string, psid: ObjectID, psrid: ObjectID, content: string) { const [psdoc, psrdoc] = await solution.getReply(domainId, psid, psrid); if ((!psdoc) || psdoc.parentId !== this.pdoc.docId) throw new SolutionNotFoundError(domainId, psid); if (!(!this.user.own(psrdoc) && this.user.hasPerm(PERM.PERM_EDIT_PROBLEM_SOLUTION_REPLY_SELF))) { throw new PermissionError(PERM.PERM_EDIT_PROBLEM_SOLUTION_REPLY_SELF); } await solution.editReply(domainId, psid, psrid, content); this.back(); } @param('psid', Types.ObjectID) @param('psrid', Types.ObjectID) async postDeleteReply(domainId: string, psid: ObjectID, psrid: ObjectID) { const [psdoc, psrdoc] = await solution.getReply(domainId, psid, psrid); if ((!psdoc) || psdoc.parentId !== this.pdoc.docId) throw new SolutionNotFoundError(psid); if (!(!this.user.own(psrdoc) && this.user.hasPerm(PERM.PERM_DELETE_PROBLEM_SOLUTION_REPLY_SELF))) { this.checkPerm(PERM.PERM_DELETE_PROBLEM_SOLUTION_REPLY); } await solution.delReply(domainId, psid, psrid); this.back(); } @param('psid', Types.ObjectID) async postUpvote(domainId: string, psid: ObjectID) { const [psdoc, pssdoc] = await solution.vote(domainId, psid, this.user._id, 1); this.back({ vote: psdoc.vote, user_vote: pssdoc.vote }); } @param('psid', Types.ObjectID) async postDownvote(domainId: string, psid: ObjectID) { const [psdoc, pssdoc] = await solution.vote(domainId, psid, this.user._id, -1); this.back({ vote: psdoc.vote, user_vote: pssdoc.vote }); } } export class ProblemSolutionRawHandler extends ProblemDetailHandler { @param('psid', Types.ObjectID) @route('psrid', Types.ObjectID, true) @param('tid', Types.ObjectID, true) async get(domainId: string, psid: ObjectID, psrid?: ObjectID, tid?: ObjectID) { if (tid) throw new PermissionError(PERM.PERM_VIEW_PROBLEM_SOLUTION); const accepted = this.tsdoc?.status === STATUS.STATUS_ACCEPTED; if (!accepted || !this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_SOLUTION_ACCEPT)) { this.checkPerm(PERM.PERM_VIEW_PROBLEM_SOLUTION); } if (psrid) { const [psdoc, psrdoc] = await solution.getReply(domainId, psid, psrid); if ((!psdoc) || psdoc.parentId !== this.pdoc.docId) throw new SolutionNotFoundError(psid, psrid); this.response.body = psrdoc.content; } else { const psdoc = await solution.get(domainId, psid); this.response.body = psdoc.content; } this.response.type = 'text/markdown'; } } export class ProblemCreateHandler extends Handler { async get() { this.response.template = 'problem_edit.html'; this.response.body = { page_name: 'problem_create', }; } @post('title', Types.Title) @post('content', Types.Content) @post('pid', Types.Name, true, isPid, convertPid) @post('hidden', Types.Boolean) @post('difficulty', Types.PositiveInt, (i) => +i <= 10, true) @post('tag', Types.Content, true, null, parseCategory) @post('assign', Types.CommaSeperatedArray, true) async post( domainId: string, title: string, content: string, pid: string, hidden = false, difficulty = 0, tag: string[] = [], assign: string[] = [], ) { if (pid && await problem.get(domainId, pid)) throw new BadRequestError('ProblemID already exists'); const docId = await problem.add(domainId, pid, title, content, this.user._id, tag ?? [], hidden, assign); const files = new Set(Array.from(content.matchAll(/file:\/\/([a-zA-Z0-9_-]+\.[a-zA-Z0-9]+)/g)).map((i) => i[1])); const tasks = []; for (const file of files) { if (this.user._files.find((i) => i.name === file)) { tasks.push( storage.rename(`user/${this.user._id}/${file}`, `problem/${domainId}/${docId}/additional_file/${file}`) .then(() => problem.addAdditionalFile(domainId, docId, file, '', true)), user.setById(this.user._id, { _files: this.user._files.filter((i) => i.name !== file) }), ); } } await Promise.all(tasks); if (difficulty) await problem.edit(domainId, docId, { difficulty }); this.response.body = { pid: pid || docId }; this.response.redirect = this.url('problem_files', { pid: pid || docId }); } } export class ProblemPrefixListHandler extends Handler { @param('prefix', Types.Name) async get(domainId: string, prefix: string) { const pdocs = await problem.getPrefixList(domainId, prefix); if (!Number.isNaN(+prefix)) { const pdoc = await problem.get(domainId, +prefix, ['domainId', 'docId', 'pid', 'title']); if (pdoc) pdocs.unshift(pdoc); } const search = global.Hydro.lib.problemSearch; if (pdocs.length < 20) { if (search) { const result = await search(domainId, prefix, 20 - pdocs.length); const docs = await problem.getMulti(domainId, { docId: { $in: result } }).toArray(); pdocs.push(...docs); } } this.response.body = pdocs; } } export async function apply() { Route('problem_main', '/p', ProblemMainHandler, PERM.PERM_VIEW_PROBLEM); Route('problem_category', '/p/category/:category', ProblemMainHandler, PERM.PERM_VIEW_PROBLEM); Route('problem_random', '/problem/random', ProblemRandomHandler, PERM.PERM_VIEW_PROBLEM); Route('problem_detail', '/p/:pid', ProblemDetailHandler, PERM.PERM_VIEW_PROBLEM); Route('problem_submit', '/p/:pid/submit', ProblemSubmitHandler, PERM.PERM_SUBMIT_PROBLEM); Route('problem_edit', '/p/:pid/edit', ProblemEditHandler); Route('problem_files', '/p/:pid/files', ProblemFilesHandler, PERM.PERM_VIEW_PROBLEM); Route('problem_file_download', '/p/:pid/file/:filename', ProblemFileDownloadHandler, PERM.PERM_VIEW_PROBLEM); Route('problem_solution', '/p/:pid/solution', ProblemSolutionHandler, PERM.PERM_VIEW_PROBLEM); Route('problem_solution_raw', '/p/:pid/solution/:psid/raw', ProblemSolutionRawHandler, PERM.PERM_VIEW_PROBLEM); Route('problem_solution_reply_raw', '/p/:pid/solution/:psid/:psrid/raw', ProblemSolutionRawHandler, PERM.PERM_VIEW_PROBLEM); Route('problem_create', '/problem/create', ProblemCreateHandler, PERM.PERM_CREATE_PROBLEM); Route('problem_prefix_list', '/problem/list', ProblemPrefixListHandler, PERM.PERM_VIEW_PROBLEM); } global.Hydro.handler.problem = apply;