Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions framework/framework/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,12 @@ export default (logger, xff, xhost) => async (ctx: KoaContext, next: Next) => {
}
if (!response.type) {
if (response.pjax && args.pjax) {
const html = await handler.renderHTML(response.pjax, response.body);
response.body = { fragments: [{ html }] };
const pjax = typeof response.pjax === 'string' ? [[response.pjax, {}]] : response.pjax;
response.body = {
fragments: (await Promise.all(
pjax.map(async ([template, extra]) => handler.renderHTML(template, { ...response.body, ...extra })),
)).map((i) => ({ html: i })),
};
response.type = 'application/json';
} else if (
request.json || response.redirect
Expand Down
2 changes: 1 addition & 1 deletion framework/framework/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export interface HydroResponse {
* If set, and pjax content was request from client,
* The template will be used for rendering.
*/
pjax?: string;
pjax?: string | (readonly [string, Record<string, any>])[];
redirect?: string;
disposition?: string;
etag?: string;
Expand Down
155 changes: 98 additions & 57 deletions packages/hydrooj/src/handler/contest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError,
InvalidTokenError, NotAssignedError, NotFoundError, PermissionError, ValidationError,
} from '../error';
import { ScoreboardConfig, Tdoc } from '../interface';
import { FileInfo, ScoreboardConfig, Tdoc } from '../interface';
import { PERM, PRIV, STATUS } from '../model/builtin';
import * as contest from '../model/contest';
import * as discussion from '../model/discussion';
Expand All @@ -25,7 +25,6 @@ import problem from '../model/problem';
import record from '../model/record';
import ScheduleModel from '../model/schedule';
import storage from '../model/storage';
import * as system from '../model/system';
import user from '../model/user';
import {
Handler, param, post, Type, Types,
Expand Down Expand Up @@ -155,13 +154,13 @@ export class ContestDetailHandler extends ContestDetailBaseHandler {
tdoc: this.tdoc,
tsdoc: this.tsdocAsPublic(),
udict,
files: sortFiles(this.tdoc.files || []),
urlForFile: (filename: string) => this.url('contest_file_download', { tid, filename }),
files: (this.tsdoc?.attend && contest.isNotStarted(this.tdoc)) ? sortFiles(this.tdoc.privateFiles || []) : [],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
urlForFile: (filename: string) => this.url('contest_file_download', { tid, filename, type: 'private' }),
};
if (this.request.json) return;
this.response.body.tdoc.content = this.response.body.tdoc.content
.replace(/\(file:\/\//g, `(./${this.tdoc.docId}/file/`)
.replace(/="file:\/\//g, `="./${this.tdoc.docId}/file/`);
.replace(/\(file:\/\//g, `(./${this.tdoc.docId}/file/public/`)
.replace(/="file:\/\//g, `="./${this.tdoc.docId}/file/public/`);
}

@param('tid', Types.ObjectId)
Expand Down Expand Up @@ -443,7 +442,11 @@ export class ContestEditHandler extends Handler {
ScheduleModel.deleteMany({
type: 'schedule', subType: 'contest', domainId, tid,
}),
storage.del(this.tdoc.files?.map((i) => `contest/${domainId}/${tid}/${i.name}`) || [], this.user._id),
storage.del(
(this.tdoc.files?.map((i) => `contest/${domainId}/${tid}/public/${i.name}`) || [])
.concat(this.tdoc.privateFiles?.map((i) => `contest/${domainId}/${tid}/private/${i.name}`) || []),
this.user._id,
),
]));
this.response.redirect = this.url('contest_main');
}
Expand Down Expand Up @@ -506,22 +509,95 @@ export class ContestCodeHandler extends Handler {
export class ContestManagementHandler extends ContestManagementBaseHandler {
@param('tid', Types.ObjectId)
async get(domainId: string, tid: ObjectId) {
const tcdocs = await contest.getMultiClarification(domainId, tid);
this.response.body = {
tdoc: this.tdoc,
tsdoc: this.tsdoc,
owner_udoc: await user.getById(domainId, this.tdoc.owner),
pdict: await problem.getList(domainId, this.tdoc.pids, true, true, [...problem.PROJECTION_CONTEST_LIST, 'tag']),
files: sortFiles(this.tdoc.files || []),
privateFiles: sortFiles(this.tdoc.privateFiles || []),
urlForFile: (filename: string, type: string) => this.url('contest_file_download', { tid, filename, type }),
};
this.response.pjax = [
['partials/files.html', { filetype: 'public' }],
['partials/files.html', {
files: this.response.body.privateFiles,
filetype: 'private',
}],
];
this.response.template = 'contest_manage.html';
}

@param('tid', Types.ObjectId)
@post('filename', Types.Filename, true)
@post('type', Types.Boolean, true)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
async postUploadFile(domainId: string, tid: ObjectId, filename: string, type: 'private' | 'public' = 'private') {
const allFiles = [...(this.tdoc.files || []), ...(this.tdoc.privateFiles || [])];
if (allFiles.length >= this.ctx.setting.get('limit.contest_files')) {
throw new FileLimitExceededError('count');
}
const file = this.request.files?.file;
if (!file) throw new ValidationError('file');
if (Math.sum(allFiles.map((i) => i.size)) + file.size >= this.ctx.setting.get('limit.contest_files_size')) {
throw new FileLimitExceededError('size');
}
Comment thread
undefined-moe marked this conversation as resolved.
filename ||= file.originalFilename || randomstring(16);
const target = `contest/${domainId}/${tid}/${type}/${filename}`;
await storage.put(target, file.filepath, this.user._id);
const meta = await storage.getMeta(target);
const payload = { _id: filename, name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) };
if (!meta) throw new FileUploadError();
const updateList = (files: FileInfo[], newFile: FileInfo) => (files || []).filter((i) => i._id !== newFile._id).concat(newFile);
await contest.edit(domainId, tid, {
files: type === 'private' ? this.tdoc.files : updateList(this.tdoc.files, payload),
privateFiles: type === 'private' ? updateList(this.tdoc.privateFiles, payload) : this.tdoc.privateFiles,
});
this.back();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@param('tid', Types.ObjectId)
@post('files', Types.ArrayOf(Types.Filename))
@post('type', Types.Range(['public', 'private']), true)
async postDeleteFiles(domainId: string, tid: ObjectId, files: string[], type = 'public') {
await Promise.all([
storage.del(files.map((t) => `contest/${domainId}/${tid}/${type}/${t}`), this.user._id),
contest.edit(domainId, tid, type === 'private'
? { privateFiles: this.tdoc.privateFiles.filter((i) => !files.includes(i.name)) }
: { files: this.tdoc.files.filter((i) => !files.includes(i.name)) },
Comment thread
undefined-moe marked this conversation as resolved.
Outdated
),
]);
this.back();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@param('pid', Types.PositiveInt)
@param('score', Types.PositiveInt)
async postSetScore(domainId: string, pid: number, score: number) {
if (!this.tdoc.pids.includes(pid)) throw new ValidationError('pid');
this.tdoc.score ||= {};
this.tdoc.score[pid] = score;
await contest.edit(domainId, this.tdoc.docId, { score: this.tdoc.score });
await contest.recalcStatus(domainId, this.tdoc.docId);
this.back();
}
}

class ContestClarificationHandler extends ContestManagementBaseHandler {
@param('tid', Types.ObjectId)
async get(domainId: string, tid: ObjectId) {
const tcdocs = await contest.getMultiClarification(domainId, tid);
this.response.body = {
tdoc: this.tdoc,
tsdoc: this.tsdoc,
owner_udoc: await user.getById(domainId, this.tdoc.owner),
pdict: await problem.getList(domainId, this.tdoc.pids, true, true, [...problem.PROJECTION_CONTEST_LIST, 'tag']),
tcdocs,
udict: await user.getListForRender(
domainId, tcdocs.map((i) => i.owner),
this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME) ? ['displayName'] : [],
),
tcdocs,
urlForFile: (filename: string) => this.url('contest_file_download', { tid, filename }),
};
this.response.pjax = 'partials/files.html';
this.response.template = 'contest_manage.html';
this.response.pjax = 'partials/contest_clarification.html';
this.response.template = 'contest_clarification.html';
}

@param('tid', Types.ObjectId)
Expand Down Expand Up @@ -554,57 +630,21 @@ export class ContestManagementHandler extends ContestManagementBaseHandler {
}
this.back();
}

@param('tid', Types.ObjectId)
@post('filename', Types.Filename, true)
async postUploadFile(domainId: string, tid: ObjectId, filename: string) {
if ((this.tdoc.files?.length || 0) >= system.get('limit.contest_files')) {
throw new FileLimitExceededError('count');
}
const file = this.request.files?.file;
if (!file) throw new ValidationError('file');
const size = Math.sum((this.tdoc.files || []).map((i) => i.size)) + file.size;
if (size >= system.get('limit.contest_files_size')) {
throw new FileLimitExceededError('size');
}
filename ||= file.originalFilename || randomstring(16);
await storage.put(`contest/${domainId}/${tid}/${filename}`, file.filepath, this.user._id);
const meta = await storage.getMeta(`contest/${domainId}/${tid}/${filename}`);
const payload = { _id: filename, name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) };
if (!meta) throw new FileUploadError();
await contest.edit(domainId, tid, { files: [...(this.tdoc.files || []), payload] });
this.back();
}

@param('tid', Types.ObjectId)
@post('files', Types.ArrayOf(Types.Filename))
async postDeleteFiles(domainId: string, tid: ObjectId, files: string[]) {
await Promise.all([
storage.del(files.map((t) => `contest/${domainId}/${tid}/${t}`), this.user._id),
contest.edit(domainId, tid, { files: this.tdoc.files.filter((i) => !files.includes(i.name)) }),
]);
this.back();
}

@param('pid', Types.PositiveInt)
@param('score', Types.PositiveInt)
async postSetScore(domainId: string, pid: number, score: number) {
if (!this.tdoc.pids.includes(pid)) throw new ValidationError('pid');
this.tdoc.score ||= {};
this.tdoc.score[pid] = score;
await contest.edit(domainId, this.tdoc.docId, { score: this.tdoc.score });
await contest.recalcStatus(domainId, this.tdoc.docId);
this.back();
}
}

export class ContestFileDownloadHandler extends ContestDetailBaseHandler {
@param('tid', Types.ObjectId)
@param('filename', Types.Filename)
@param('noDisposition', Types.Boolean)
async get(domainId: string, tid: ObjectId, filename: string, noDisposition = false) {
@param('type', Types.Range(['public', 'private']), true)
async get(domainId: string, tid: ObjectId, filename: string, noDisposition = false, type = 'private') {
if (type === 'private' && !this.user.own(this.tdoc)) {
if (!this.tsdoc?.attend) throw new ContestNotAttendedError(domainId, tid);
if (!contest.isOngoing(this.tdoc) && !contest.isDone(this.tdoc)) throw new ContestNotLiveError(domainId, tid);
if (!this.tsdoc.startAt) await contest.setStatus(domainId, tid, this.user._id, { startAt: new Date() });
}
this.response.addHeader('Cache-Control', 'public');
const target = `contest/${domainId}/${tid}/${filename}`;
const target = `contest/${domainId}/${tid}/${type}/${filename}`;
const file = await storage.getMeta(target);
await oplog.log(this, 'download.file.contest', {
target,
Expand Down Expand Up @@ -809,8 +849,9 @@ export async function apply(ctx: Context) {
ctx.Route('contest_edit', '/contest/:tid/edit', ContestEditHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_print', '/contest/:tid/print', ContestPrintHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_manage', '/contest/:tid/management', ContestManagementHandler);
ctx.Route('contest_clarification', '/contest/:tid/clarification', ContestClarificationHandler);
ctx.Route('contest_code', '/contest/:tid/code', ContestCodeHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_file_download', '/contest/:tid/file/:filename', ContestFileDownloadHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_file_download', '/contest/:tid/file/:type/:filename', ContestFileDownloadHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_user', '/contest/:tid/user', ContestUserHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_balloon', '/contest/:tid/balloon', ContestBalloonHandler, PERM.PERM_VIEW_CONTEST);
ctx.worker.addHandler('contest', async (doc) => {
Expand Down
25 changes: 6 additions & 19 deletions packages/hydrooj/src/handler/problem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,28 +646,15 @@ export class ProblemFilesHandler extends ProblemDetailHandler {
notUsage = true;

@param('d', Types.CommaSeperatedArray, true)
@param('pjax', Types.Boolean)
@param('sidebar', Types.Boolean)
async get(domainId: string, d = ['testdata', 'additional_file'], pjax = false, sidebar = false) {
async get({ }, d = ['testdata', 'additional_file'], sidebar = false) {
Comment thread
undefined-moe marked this conversation as resolved.
if (this.tdoc) throw new ContestNotEndedError();
this.response.body.testdata = d.includes('testdata') ? sortFiles(this.pdoc.data || []) : [];
this.response.body.testdata = sortFiles(this.pdoc.data || []);
this.response.body.additional_file = sortFiles(this.pdoc.additional_file || []);
this.response.body.reference = this.pdoc.reference;
this.response.body.additional_file = d.includes('additional_file') ? sortFiles(this.pdoc.additional_file || []) : [];
if (pjax) {
const { testdata, additional_file } = this.response.body;
const owner = await user.getById(domainId, this.pdoc.owner);
const args = {
testdata, additional_file, pdoc: this.pdoc, owner_udoc: owner, sidebar, can_edit: true,
};
const tasks = [];
if (d.includes('testdata')) tasks.push(this.renderHTML('partials/problem_files.html', { ...args, filetype: 'testdata' }));
if (d.includes('additional_file')) tasks.push(this.renderHTML('partials/problem_files.html', { ...args, filetype: 'additional_file' }));
if (!sidebar) tasks.push(this.renderHTML('partials/problem-sidebar-information.html', args));
this.response.body = {
fragments: (await Promise.all(tasks)).map((i) => ({ html: i })),
};
this.response.template = '';
} else this.response.template = 'problem_files.html';
this.response.pjax = d.map((i) => ['partials/problem_files.html', { filetype: i, sidebar, can_edit: true }]);
if (!sidebar) this.response.pjax.push(['partials/problem-sidebar-information.html', {}]);
this.response.template = 'problem_files.html';
Comment thread
undefined-moe marked this conversation as resolved.
}

async post() {
Expand Down
1 change: 1 addition & 0 deletions packages/hydrooj/src/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ export interface Tdoc extends Document {
_code?: string;
assign?: string[];
files?: FileInfo[];
privateFiles?: FileInfo[];
allowViewCode?: boolean;
allowPrint?: boolean;

Expand Down
2 changes: 2 additions & 0 deletions packages/ui-default/pages/contest.page.styl
Original file line number Diff line number Diff line change
Expand Up @@ -217,5 +217,7 @@ $highlight-button-color = #F6DF45
width: rem(130px)

.page--contest_manage
.col--id
width: rem(80px)
.col--score
width: rem(80px)
25 changes: 25 additions & 0 deletions packages/ui-default/pages/contest_clarification.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import $ from 'jquery';
import { NamedPage } from 'vj/misc/Page';

function handleReplyOrBroadcast(ev) {
const title = $(ev.currentTarget).data('title');
const did = $(ev.currentTarget).data('did');

$('#reply_or_broadcast .section_title').text(title);
$('#reply_or_broadcast [name="did"]').val(did ?? '');
const $item = $(`#clarification_${did} .media`);
if ($item.length) {
$('#reply_or_broadcast .form__item_subject').hide();
$('#reply_or_broadcast .clarification-container').empty().append($item.clone());
} else {
$('#reply_or_broadcast .form__item_subject').show();
$('#reply_or_broadcast .clarification-container').empty();
}
}
Comment on lines +4 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Sanitize or validate did before DOM selector construction.

Line 10 constructs a selector using an unsanitized did value from a data attribute. If did contains special characters (quotes, brackets, etc.), the selector will fail. While did is likely a numeric document ID from the backend, defensive coding would escape it or validate the format.

Consider applying this defensive pattern:

 function handleReplyOrBroadcast(ev) {
   const title = $(ev.currentTarget).data('title');
   const did = $(ev.currentTarget).data('did');
 
   $('#reply_or_broadcast .section_title').text(title);
   $('#reply_or_broadcast [name="did"]').val(did ?? '');
-  const $item = $(`#clarification_${did} .media`);
+  const $item = did ? $(`#clarification_${CSS.escape(did)} .media`) : $();
   if ($item.length) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function handleReplyOrBroadcast(ev) {
const title = $(ev.currentTarget).data('title');
const did = $(ev.currentTarget).data('did');
$('#reply_or_broadcast .section_title').text(title);
$('#reply_or_broadcast [name="did"]').val(did ?? '');
const $item = $(`#clarification_${did} .media`);
if ($item.length) {
$('#reply_or_broadcast .form__item_subject').hide();
$('#reply_or_broadcast .clarification-container').empty().append($item.clone());
} else {
$('#reply_or_broadcast .form__item_subject').show();
$('#reply_or_broadcast .clarification-container').empty();
}
}
function handleReplyOrBroadcast(ev) {
const title = $(ev.currentTarget).data('title');
const did = $(ev.currentTarget).data('did');
$('#reply_or_broadcast .section_title').text(title);
$('#reply_or_broadcast [name="did"]').val(did ?? '');
const $item = did ? $(`#clarification_${CSS.escape(did)} .media`) : $();
if ($item.length) {
$('#reply_or_broadcast .form__item_subject').hide();
$('#reply_or_broadcast .clarification-container').empty().append($item.clone());
} else {
$('#reply_or_broadcast .form__item_subject').show();
$('#reply_or_broadcast .clarification-container').empty();
}
}
🤖 Prompt for AI Agents
In packages/ui-default/pages/contest_clarification.page.tsx around lines 4 to
18, the handler builds a jQuery selector using an unsanitized did from data
attributes which can break or be unsafe if did contains special characters;
validate that did matches the expected format (e.g., /^\d+$/ for numeric IDs)
and only use it if valid, otherwise fallback to a safe empty path, or escape it
with a proper CSS selector escaper (e.g., use CSS.escape(did) before
interpolating into `#clarification_${...}`) so the DOM query never receives raw
unvalidated input.


const page = new NamedPage('contest_clarification', () => {
$(document).on('click', '[name="broadcast"]', handleReplyOrBroadcast);
$(document).on('click', '[name="reply"]', handleReplyOrBroadcast);
});

export default page;
18 changes: 0 additions & 18 deletions packages/ui-default/pages/contest_manage.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,7 @@ import Notification from 'vj/components/notification';
import { NamedPage } from 'vj/misc/Page';
import { i18n, request, tpl } from 'vj/utils';

function handleReplyOrBroadcast(ev) {
const title = $(ev.currentTarget).data('title');
const did = $(ev.currentTarget).data('did');

$('#reply_or_broadcast .section_title').text(title);
$('#reply_or_broadcast [name="did"]').val(did ?? '');
const $item = $(`#clarification_${did} .media`);
if ($item.length) {
$('#reply_or_broadcast .form__item_subject').hide();
$('#reply_or_broadcast .clarification-container').empty().append($item.clone());
} else {
$('#reply_or_broadcast .form__item_subject').show();
$('#reply_or_broadcast .clarification-container').empty();
}
}

const page = new NamedPage('contest_manage', () => {
$(document).on('click', '[name="broadcast"]', handleReplyOrBroadcast);
$(document).on('click', '[name="reply"]', handleReplyOrBroadcast);
$(document).on('click', '[name="set_score"]', async (ev) => {
const pid = $(ev.currentTarget).data('pid');
const op = await new ActionDialog({
Expand Down
Loading
Loading