Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
44 changes: 38 additions & 6 deletions packages/hydrooj/src/handler/contest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,17 @@ export class ContestDetailBaseHandler extends Handler {
}
}
if (this.tdoc.duration && this.tsdoc?.startAt) {
const endAt = moment(this.tsdoc.startAt).add(this.tdoc.duration, 'hours').toDate();
this.tsdoc.endAt = endAt < this.tdoc.endAt ? endAt : this.tdoc.endAt;
this.tsdoc.endAt = moment.min([
moment(this.tsdoc.startAt).add(this.tdoc.duration, 'hours'),
moment(this.tdoc.endAt),
...(this.tsdoc.endAt ? [moment(this.tsdoc.endAt)] : []),
]).toDate();
}
Comment on lines 95 to 101

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 | 🟠 Major

Mutating this.tsdoc.endAt conflates persisted early-end with derived end-time.

After this block, callers can no longer distinguish a user who actually invoked early-end (where endAt was persisted in the DB) from a regular duration-based attendee (where endAt was just derived from startAt + duration). The same mutation also happens in ContestUserHandler.get at lines 716–722, and the template contest_user.html (line 13) gates the Resume link on this mutated value—so the Resume link shows up for every duration-limited attendee even when there is nothing to "resume" (postResume then $unsets an endAt field that never existed and silently no-ops).

Consider keeping the persisted value separate from the computed one, e.g. expose tsdoc.endAt as the raw DB field and compute an effectiveEndAt (or similar) for display/liveness logic, then gate UI/admin controls on the persisted field only.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/hydrooj/src/handler/contest.ts` around lines 95 - 101, The code
mutates the persisted this.tsdoc.endAt when deriving end time (using
this.tdoc.duration and startAt), which prevents distinguishing a real persisted
early-end from a computed deadline; change the logic to leave the raw DB field
this.tsdoc.endAt untouched and instead compute a separate derived value (e.g.
effectiveEndAt or computedEndAt) using the same moment.min(...) expression and
attach it to the handler or tsdoc object (e.g. this.tsdoc.effectiveEndAt) for
display/liveness checks; update ContestUserHandler.get (the same block at lines
~716–722) and templates (contest_user.html) to gate Resume/UI actions on the
presence of the raw persisted endAt field, not the derived effectiveEndAt, and
keep postResume ($unset) semantics unchanged so it only affects the real
persisted field.

}

tsdocAsPublic() {
if (!this.tsdoc) return null;
return pick(this.tsdoc, ['attend', 'subscribe', 'startAt', ...(this.tdoc.duration ? ['endAt'] : [])]);
return pick(this.tsdoc, ['attend', 'subscribe', 'startAt', ...(this.tdoc.duration || this.tsdoc.endAt ? ['endAt'] : [])]);
}

@param('tid', Types.ObjectId, true)
Expand Down Expand Up @@ -173,7 +176,7 @@ export class ContestDetailHandler extends ContestDetailBaseHandler {
@param('code', Types.String, true)
async postAttend(domainId: string, tid: ObjectId, code = '') {
this.checkPerm(PERM.PERM_ATTEND_CONTEST);
if (contest.isDone(this.tdoc)) throw new ContestNotLiveError(tid);
if (contest.isDone(this.tdoc)) throw new ContestNotLiveError(domainId, tid);
if (this.tdoc._code && code !== this.tdoc._code) throw new InvalidTokenError('Contest Invitation', code);
await contest.attend(domainId, tid, this.user._id, { subscribe: 1 });
this.back();
Expand All @@ -186,6 +189,15 @@ export class ContestDetailHandler extends ContestDetailBaseHandler {
await contest.setStatus(domainId, tid, this.user._id, { subscribe: subscribe ? 1 : 0 });
this.back();
}

@param('tid', Types.ObjectId)
async postEarlyEnd(domainId: string, tid: ObjectId) {
if (this.tdoc.rule === 'homework') throw new ContestNotFoundError(domainId, tid);
if (!this.tsdoc?.attend) throw new ContestNotAttendedError(domainId, tid);
if (!contest.isOngoing(this.tdoc, this.tsdoc)) throw new ContestNotLiveError(domainId, tid);
await contest.setStatus(domainId, tid, this.user._id, { endAt: new Date() });
this.back();
}
Comment thread
renbaoshuo marked this conversation as resolved.
}

export class ContestPrintHandler extends ContestDetailBaseHandler {
Expand Down Expand Up @@ -698,10 +710,16 @@ export class ContestUserHandler extends ContestManagementBaseHandler {
@param('tid', Types.ObjectId)
async get(domainId: string, tid: ObjectId) {
const tsdocs = await contest.getMultiStatus(domainId, { docId: tid }).project({
uid: 1, attend: 1, startAt: 1, unrank: 1,
uid: 1, attend: 1, startAt: 1, unrank: 1, endAt: 1,
}).toArray();
for (const tsdoc of tsdocs) {
tsdoc.endAt = (this.tdoc.duration && tsdoc.startAt) ? moment(tsdoc.startAt).add(this.tdoc.duration, 'hours').toDate() : null;
if (this.tdoc.duration && tsdoc.startAt) {
tsdoc.endAt = moment.min([
moment(tsdoc.startAt).add(this.tdoc.duration, 'hours'),
moment(this.tdoc.endAt),
...(tsdoc.endAt ? [moment(tsdoc.endAt)] : []),
]).toDate();
}
}
const udict = await user.getListForRender(
domainId, [this.tdoc.owner, ...tsdocs.map((i) => i.uid)],
Expand All @@ -728,6 +746,20 @@ export class ContestUserHandler extends ContestManagementBaseHandler {
await contest.setStatus(domainId, tid, uid, { unrank: !tsdoc.unrank });
this.back();
}

@param('tid', Types.ObjectId)
@param('uid', Types.PositiveInt)
async postResume(domainId: string, tid: ObjectId, uid: number) {
const tsdoc = await contest.getStatus(domainId, tid, uid);
if (!tsdoc?.attend) throw new ContestNotAttendedError(uid);
if (this.tdoc.endAt <= new Date()) throw new ContestNotLiveError(domainId, tid);
if (this.tdoc.duration && tsdoc.startAt) {
const durationEnd = moment(tsdoc.startAt).add(this.tdoc.duration, 'hours').toDate();
if (durationEnd <= new Date()) throw new ContestNotLiveError(domainId, tid);
}
await contest.setStatus(domainId, tid, uid, null, { endAt: '' });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
this.back();
}
}

export class ContestBalloonHandler extends ContestManagementBaseHandler {
Expand Down
6 changes: 4 additions & 2 deletions packages/hydrooj/src/model/contest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,14 @@ export function isNotStarted(tdoc: Tdoc) {

export function isOngoing(tdoc: Tdoc, tsdoc?: any) {
const now = new Date();
if (tsdoc?.endAt && tsdoc.endAt <= now) return false;
if (tsdoc && tdoc.duration && tsdoc.startAt <= new Date(Date.now() - Math.floor(tdoc.duration * Time.hour))) return false;
return (tdoc.beginAt <= now && now < tdoc.endAt);
}

export function isDone(tdoc: Tdoc, tsdoc?: any) {
if (tdoc.endAt <= new Date()) return true;
if (tsdoc?.endAt && tsdoc.endAt <= new Date()) return true;
if (tsdoc && tdoc.duration && tsdoc.startAt <= new Date(Date.now() - Math.floor(tdoc.duration * Time.hour))) return true;
return false;
}
Expand Down Expand Up @@ -940,8 +942,8 @@ export function getMultiStatus(domainId: string, query: any) {
return document.getMultiStatus(domainId, document.TYPE_CONTEST, query);
}

export function setStatus(domainId: string, tid: ObjectId, uid: number, $set: any) {
return document.setStatus(domainId, document.TYPE_CONTEST, tid, uid, $set);
export function setStatus(domainId: string, tid: ObjectId, uid: number, $set?: any, $unset?: any) {
return document.setStatus(domainId, document.TYPE_CONTEST, tid, uid, $set, $unset);
}

export function count(domainId: string, query: any) {
Expand Down
9 changes: 7 additions & 2 deletions packages/hydrooj/src/model/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,11 +295,16 @@ export function getMultiStatusWithoutDomain<K extends keyof DocStatusType>(

export async function setStatus<K extends keyof DocStatusType>(
domainId: string, docType: K, docId: DocStatusType[K]['docId'], uid: number,
args: UpdateFilter<DocStatusType[K]>['$set'], returnDocument: 'before' | 'after' = 'after',
$set: UpdateFilter<DocStatusType[K]>['$set'] | null,
$unset?: UpdateFilter<DocStatusType[K]>['$unset'] | null,
returnDocument: 'before' | 'after' = 'after',
): Promise<DocStatusType[K]> {
const op: UpdateFilter<DocStatusType[K]> = {};
if ($set && Object.keys($set).length) op.$set = $set;
if ($unset && Object.keys($unset).length) op.$unset = $unset;
return await collStatus.findOneAndUpdate(
{ domainId, docType, docId, uid },
{ $set: args },
op,
{
upsert: true,
returnDocument,
Expand Down
2 changes: 1 addition & 1 deletion packages/hydrooj/src/model/solution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ class SolutionModel {
if (!doc) throw new SolutionNotFoundError(domainId, psid);
const before = await document.setStatus(
domainId, document.TYPE_PROBLEM_SOLUTION, psid, uid,
{ vote: value }, 'before',
{ vote: value }, null, 'before',
);
let inc = value;
if (before?.vote) inc -= before.vote;
Expand Down
3 changes: 3 additions & 0 deletions packages/ui-default/locales/zh.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ Confirm deleting the selected roles?: 您确定删除所选角色吗?
Confirm deleting this comment? Its replies will be deleted as well.: 确认删除这个评论吗?回复会被同时删除。
Confirm deleting this domain? This action cannot be undone.: '确认删除此域?此操作无法撤销。'
Confirm deleting this reply?: 确认删除这个回复吗?
Confirm end contest early? You will not be able to submit after this.: 确认提前结束比赛吗?之后您将无法提交代码。
Confirm rejudge this problem?: 确定要重测这道题吗?
Confirm removing the selected users?: 您确定将所选用户移除吗?
Confirm to delete the file?: 确认删除此文件吗?
Expand All @@ -209,6 +210,7 @@ Content copied to clipboard!: 内容已复制到剪贴板!
content: 内容
Content: 内容
Contest Maintainer: 比赛管理员
Contest resumed.: 比赛已恢复。
Contest scoreboard is not visible.: 当前比赛成绩表隐藏,暂不可显示。
Contest Scoreboard: 比赛成绩表
Contest Settings: 比赛设置
Expand Down Expand Up @@ -376,6 +378,7 @@ Email Visibility: Email 可见性
Email: 电子邮件
Enabled: 开启
End at: 结束于
End Contest Early: 提前结束比赛
End Date: 结束日期
End Time: 结束时间
Enroll Training: 参加训练
Expand Down
2 changes: 1 addition & 1 deletion packages/ui-default/pages/contest.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ contestTimer.appendTo(document.body);

export default new NamedPage(['contest_detail', 'contest_problemlist', 'contest_detail_problem', 'contest_scoreboard'], () => {
const beginAt = new Date((UiContext.tdoc.duration && UiContext.tsdoc?.startAt) || UiContext.tdoc.beginAt).getTime();
const endAt = new Date((UiContext.tdoc.duration && UiContext.tsdoc?.endAt) || UiContext.tdoc.endAt).getTime();
const endAt = new Date(UiContext.tsdoc?.endAt || UiContext.tdoc.endAt).getTime();
NProgress.configure({ trickle: false, showSpinner: false, minimum: 0 });
function updateProgress() {
const now = Date.now();
Expand Down
52 changes: 28 additions & 24 deletions packages/ui-default/pages/contest_user.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,46 +52,50 @@ const page = new NamedPage('contest_user', () => {
return this;
};

async function handleClickAddUser() {
const action = await addUserDialog.clear().open();
if (action !== 'ok') return;
const unrank = addUserDialog.$dom.find('[name="unrank"]').prop('checked');
const uids = userSelect.value();
async function handlePostRequest(params, successMessage) {
try {
const res = await request.post('', {
operation: 'add_user',
uids: uids.join(','),
unrank,
});
const res = await request.post('', params);
if (res.url && res.url !== window.location.href) window.location.href = res.url;
else {
Notification.success(i18n('User added.'));
Notification.success(successMessage);
pjax.request({ push: false });
}
} catch (error) {
Notification.error([error.message, ...error.params].join(' '));
}
}

async function handleClickAddUser() {
const action = await addUserDialog.clear().open();
if (action !== 'ok') return;
const unrank = addUserDialog.$dom.find('[name="unrank"]').prop('checked');
const uids = userSelect.value();
await handlePostRequest({
operation: 'add_user',
uids: uids.join(','),
unrank,
}, i18n('User added.'));
}

async function handleEditRank(ev) {
const uid = $(ev.target).data('uid');
try {
const res = await request.post('', {
operation: 'rank',
uid,
});
if (res.url && res.url !== window.location.href) window.location.href = res.url;
else {
Notification.success(i18n('Ranking status updated.'));
pjax.request({ push: false });
}
} catch (error) {
Notification.error([error.message, ...error.params].join(' '));
}
await handlePostRequest({
operation: 'rank',
uid,
}, i18n('Ranking status updated.'));
}

async function handleResume(ev) {
const uid = $(ev.target).data('uid');
await handlePostRequest({
operation: 'resume',
uid,
}, i18n('Contest resumed.'));
}

$('[name="add_user"]').on('click', () => handleClickAddUser());
$(document).on('click', '[name="edit_rank"]', handleEditRank);
$(document).on('click', '[name="resume_contest"]', handleResume);
});

export default page;
10 changes: 10 additions & 0 deletions packages/ui-default/templates/partials/contest_sidebar.html
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ <h1>{{ tdoc.title }}</h1>
<li class="menu__item"><a class="menu__link" href="{{ url('contest_problemlist', tid=tdoc.docId) }}">
<span class="icon icon-unordered_list"></span> {{ _('Problem List') }}
</a></li>
{% if page_name == 'contest_detail' and tsdoc.attend and model.contest.isOngoing(tdoc, tsdoc) and tdoc.rule !== 'homework' %}
<li class="menu__item">
<form action="{{ url('contest_detail', tid=tdoc.docId) }}" method="POST" onsubmit='return confirm({{ _("Confirm end contest early? You will not be able to submit after this.")|json|safe }});'>
<input type="hidden" name="operation" value="early_end">
<button class="menu__link" type="submit">
<span class="icon icon-delete"></span> {{ _('End Contest Early') }}
</button>
</form>
</li>
{% endif %}
{% endif %}{# not attend and not done #}
{% if model.contest.canShowScoreboard.call(handler, tdoc, False) %}
<li class="menu__item"><a class="menu__link" href="{{ url('contest_scoreboard', tid=tdoc.docId) }}">
Expand Down
5 changes: 4 additions & 1 deletion packages/ui-default/templates/partials/contest_user.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
<td class="col--rank">{{ _('UnRank') if tsdoc.unrank else _('Rank') }}</td>
<td class="col--actions">
<a name="edit_rank" data-uid="{{ tsdoc.uid }}">{{ _('Rank') if tsdoc.unrank else _('UnRank') }}</a>
{% if tsdoc.endAt and tsdoc.endAt < tdoc.endAt and tdoc.endAt.getTime() > Date.now() and (not tdoc.duration or not tsdoc.startAt or tsdoc.startAt.getTime() + tdoc.duration * 3600000 > Date.now()) %}
| <a name="resume_contest" data-uid="{{ tsdoc.uid }}">{{ _('Resume') }}</a>
{% endif %}
</td>
</tr>
{%- endfor -%}
</tbody>
</tbody>
Loading