Skip to content

Commit f5ef8b7

Browse files
core&ui: add contest private files (#1053)
Co-authored-by: panda <panda_dtdyy@outlook.com>
1 parent 9729346 commit f5ef8b7

35 files changed

Lines changed: 781 additions & 843 deletions

framework/framework/base.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,12 @@ export default (logger, xff, xhost) => async (ctx: KoaContext, next: Next) => {
6464
}
6565
if (!response.type) {
6666
if (response.pjax && args.pjax) {
67-
const html = await handler.renderHTML(response.pjax, response.body);
68-
response.body = { fragments: [{ html }] };
67+
const pjax = typeof response.pjax === 'string' ? [[response.pjax, {}]] : response.pjax;
68+
response.body = {
69+
fragments: (await Promise.all(
70+
pjax.map(async ([template, extra]) => handler.renderHTML(template, { ...response.body, ...extra })),
71+
)).map((i) => ({ html: i })),
72+
};
6973
response.type = 'application/json';
7074
} else if (
7175
request.json || response.redirect

framework/framework/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export interface HydroResponse {
8282
* If set, and pjax content was request from client,
8383
* The template will be used for rendering.
8484
*/
85-
pjax?: string;
85+
pjax?: string | (readonly [string, Record<string, any>])[];
8686
redirect?: string;
8787
disposition?: string;
8888
etag?: string;

packages/hydrooj/locales/ko.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,6 @@ name: 이름
428428
Name: 이름
429429
Network error: Network error
430430
Never expire: 영원히 만료되지 않음
431-
New dataset: 새 데이터셋
432431
New Email: 새 이메일
433432
New file: 새 파일
434433
New Password: 새 비밀번호

packages/hydrooj/locales/zh.yaml

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -153,13 +153,14 @@ Contact Us: 联系我们
153153
Contact: 联系
154154
content: 内容
155155
Content: 内容
156-
Contest {0} has a new clarification about {1}, please go to contest management to reply.: 比赛 {0} 有一条关于 {1} 的新问答,请前往比赛管理页面回复
157-
Contest {0} jury replied to your clarification, please go to contest page to view.: 比赛 {0} 的裁判回复了您的问答,请前往比赛页面查看。
158-
Contest Clarifications: 问答
156+
Contest {0} has a new clarification about {1}, please go to contest clarifications page to reply.: 比赛 {0} 有一条关于 {1} 的新答疑,请前往比赛答疑页面回复
157+
Contest {0} jury replied to your clarification, please go to contest page to view.: 比赛 {0} 的裁判回复了您的答疑,请前往比赛页面查看。
158+
Contest Clarifications: 赛时答疑
159159
Contest Management: 比赛管理
160160
Contest scoreboard is not visible.: 当前比赛成绩表隐藏,暂不可显示。
161161
Contest Scoreboard: 比赛成绩表
162162
contest_balloon: 气球状态
163+
contest_clarification: 赛时答疑
163164
contest_create: 创建比赛
164165
contest_detail_problem_submit: 递交比赛题目
165166
contest_detail_problem: 题目详情
@@ -476,6 +477,7 @@ Manage: 管理
476477
Manager: 管理
477478
Mark Green: 标绿
478479
Mark Yellow: 标黄
480+
Materials: 材料
479481
Max files for user: 单用户最大文件数量
480482
Max files per problem: 单题最大文件数量
481483
Max files size per problem: 单题最大总文件大小
@@ -501,7 +503,6 @@ name: 名称
501503
Name: 名称
502504
Network error: 网络错误
503505
Never expire: 从不过期
504-
New dataset: 新测试数据
505506
New Email: 新电子邮件
506507
New file: 新文件
507508
New Password: 新密码
@@ -532,10 +533,11 @@ Oh, the user doesn't have any contributions!: 啊哦,这个用户还没贡献
532533
Oh, the user hasn't created any discussions yet!: 这个用户还没有发布过讨论
533534
Oh, the user hasn't submitted yet!: 这个用户还没有交过题 _(:зゝ∠)_
534535
Oh, there are no tasks that match the filter!: 喔,目前没有符合过滤条件的任务。
535-
Oh, there is no clarification!: 喔,目前没有提问
536+
Oh, there is no clarification!: 喔,目前没有答疑
536537
Oh, there is no task in the queue!: 喔,队列中目前没有任务。
537538
Ok: 确定
538539
Only A-Z, a-z, 0-9 and _ are accepted: 只接受 A-Z, a-z, 0-9 和 _
540+
Only Partic.: 仅参赛者
539541
Oops, there are no results.: 呀,没有结果。
540542
Oops!: Oops!
541543
Open in Problem Set: 在题库中打开

packages/hydrooj/src/handler/contest.ts

Lines changed: 102 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError,
1515
InvalidTokenError, NotAssignedError, NotFoundError, PermissionError, ValidationError,
1616
} from '../error';
17-
import { ScoreboardConfig, Tdoc } from '../interface';
17+
import { FileInfo, ScoreboardConfig, Tdoc } from '../interface';
1818
import { PERM, PRIV, STATUS } from '../model/builtin';
1919
import * as contest from '../model/contest';
2020
import * as discussion from '../model/discussion';
@@ -25,7 +25,6 @@ import problem from '../model/problem';
2525
import record from '../model/record';
2626
import ScheduleModel from '../model/schedule';
2727
import storage from '../model/storage';
28-
import * as system from '../model/system';
2928
import user from '../model/user';
3029
import {
3130
Handler, param, post, Type, Types,
@@ -156,13 +155,13 @@ export class ContestDetailHandler extends ContestDetailBaseHandler {
156155
tdoc: this.tdoc,
157156
tsdoc: this.tsdocAsPublic(),
158157
udict,
159-
files: sortFiles(this.tdoc.files || []),
160-
urlForFile: (filename: string) => this.url('contest_file_download', { tid, filename }),
158+
files: (this.tsdoc?.attend && !contest.isNotStarted(this.tdoc)) ? sortFiles(this.tdoc.privateFiles || []) : [],
159+
urlForFile: (filename: string) => this.url('contest_file_download', { tid, filename, type: 'private' }),
161160
};
162161
if (this.request.json) return;
163162
this.response.body.tdoc.content = this.response.body.tdoc.content
164-
.replace(/\(file:\/\//g, `(./${this.tdoc.docId}/file/`)
165-
.replace(/="file:\/\//g, `="./${this.tdoc.docId}/file/`);
163+
.replace(/\(file:\/\//g, `(./${this.tdoc.docId}/file/public/`)
164+
.replace(/="file:\/\//g, `="./${this.tdoc.docId}/file/public/`);
166165
}
167166

168167
@param('tid', Types.ObjectId)
@@ -312,9 +311,9 @@ export class ContestProblemListHandler extends ContestDetailBaseHandler {
312311
await contest.addClarification(domainId, tid, this.user._id, content, this.request.ip, subject);
313312
if (!this.user.own(this.tdoc)) {
314313
await message.send(1, this.tdoc.maintainer.concat(this.tdoc.owner), JSON.stringify({
315-
message: 'Contest {0} has a new clarification about {1}, please go to contest management to reply.',
314+
message: 'Contest {0} has a new clarification about {1}, please go to contest clarifications page to reply.',
316315
params: [this.tdoc.title, subject > 0 ? `#${this.tdoc.pids.indexOf(subject) + 1}` : 'the contest'],
317-
url: this.url('contest_manage', { tid }),
316+
url: this.url('contest_clarification', { tid }),
318317
}), message.FLAG_I18N | message.FLAG_UNREAD);
319318
}
320319
this.back();
@@ -354,6 +353,8 @@ export class ContestEditHandler extends Handler {
354353
pids: tid ? this.tdoc.pids.join(',') : '',
355354
beginAt,
356355
page_name: tid ? 'contest_edit' : 'contest_create',
356+
files: tid ? this.tdoc.files : [],
357+
urlForFile: (filename: string) => this.url('contest_file_download', { tid, filename, type: 'public' }),
357358
};
358359
}
359360

@@ -439,7 +440,11 @@ export class ContestEditHandler extends Handler {
439440
ScheduleModel.deleteMany({
440441
type: 'schedule', subType: 'contest', domainId, tid,
441442
}),
442-
storage.del(this.tdoc.files?.map((i) => `contest/${domainId}/${tid}/${i.name}`) || [], this.user._id),
443+
storage.del(
444+
(this.tdoc.files?.map((i) => `contest/${domainId}/${tid}/public/${i.name}`) || [])
445+
.concat(this.tdoc.privateFiles?.map((i) => `contest/${domainId}/${tid}/private/${i.name}`) || []),
446+
this.user._id,
447+
),
443448
]));
444449
this.response.redirect = this.url('contest_main');
445450
}
@@ -502,22 +507,95 @@ export class ContestCodeHandler extends Handler {
502507
export class ContestManagementHandler extends ContestManagementBaseHandler {
503508
@param('tid', Types.ObjectId)
504509
async get(domainId: string, tid: ObjectId) {
505-
const tcdocs = await contest.getMultiClarification(domainId, tid);
506510
this.response.body = {
507511
tdoc: this.tdoc,
508512
tsdoc: this.tsdoc,
509513
owner_udoc: await user.getById(domainId, this.tdoc.owner),
510514
pdict: await problem.getList(domainId, this.tdoc.pids, true, true, [...problem.PROJECTION_CONTEST_LIST, 'tag']),
511515
files: sortFiles(this.tdoc.files || []),
516+
privateFiles: sortFiles(this.tdoc.privateFiles || []),
517+
urlForFile: (filename: string, type: string) => this.url('contest_file_download', { tid, filename, type }),
518+
};
519+
this.response.pjax = [
520+
['partials/files.html', { filetype: 'public' }],
521+
['partials/files.html', {
522+
files: this.response.body.privateFiles,
523+
filetype: 'private',
524+
}],
525+
];
526+
this.response.template = 'contest_manage.html';
527+
}
528+
529+
@param('tid', Types.ObjectId)
530+
@post('filename', Types.Filename, true)
531+
@post('type', Types.Range(['private', 'public']), true)
532+
async postUploadFile(domainId: string, tid: ObjectId, filename: string, type: 'private' | 'public' = 'private') {
533+
const allFiles = [...(this.tdoc.files || []), ...(this.tdoc.privateFiles || [])];
534+
if (allFiles.length >= this.ctx.setting.get('limit.contest_files')) {
535+
throw new FileLimitExceededError('count');
536+
}
537+
const file = this.request.files?.file;
538+
if (!file) throw new ValidationError('file');
539+
if (Math.sum(allFiles.map((i) => i.size)) + file.size >= this.ctx.setting.get('limit.contest_files_size')) {
540+
throw new FileLimitExceededError('size');
541+
}
542+
filename ||= file.originalFilename || randomstring(16);
543+
const target = `contest/${domainId}/${tid}/${type}/${filename}`;
544+
await storage.put(target, file.filepath, this.user._id);
545+
const meta = await storage.getMeta(target);
546+
const payload = { _id: filename, name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) };
547+
if (!meta) throw new FileUploadError();
548+
const updateList = (files: FileInfo[], newFile: FileInfo) => (files || []).filter((i) => i._id !== newFile._id).concat(newFile);
549+
await contest.edit(domainId, tid, {
550+
files: type === 'private' ? this.tdoc.files : updateList(this.tdoc.files, payload),
551+
privateFiles: type === 'private' ? updateList(this.tdoc.privateFiles, payload) : this.tdoc.privateFiles,
552+
});
553+
this.back();
554+
}
555+
556+
@param('tid', Types.ObjectId)
557+
@post('files', Types.ArrayOf(Types.Filename))
558+
@post('type', Types.Range(['public', 'private']), true)
559+
async postDeleteFiles(domainId: string, tid: ObjectId, files: string[], type = 'private') {
560+
await Promise.all([
561+
storage.del(files.map((t) => `contest/${domainId}/${tid}/${type}/${t}`), this.user._id),
562+
contest.edit(domainId, tid, type === 'private'
563+
? { privateFiles: this.tdoc.privateFiles?.filter((i) => !files.includes(i.name)) }
564+
: { files: this.tdoc.files?.filter((i) => !files.includes(i.name)) },
565+
),
566+
]);
567+
this.back();
568+
}
569+
570+
@param('pid', Types.PositiveInt)
571+
@param('score', Types.PositiveInt)
572+
async postSetScore(domainId: string, pid: number, score: number) {
573+
if (!this.tdoc.pids.includes(pid)) throw new ValidationError('pid');
574+
this.tdoc.score ||= {};
575+
this.tdoc.score[pid] = score;
576+
await contest.edit(domainId, this.tdoc.docId, { score: this.tdoc.score });
577+
await contest.recalcStatus(domainId, this.tdoc.docId);
578+
this.back();
579+
}
580+
}
581+
582+
class ContestClarificationHandler extends ContestManagementBaseHandler {
583+
@param('tid', Types.ObjectId)
584+
async get(domainId: string, tid: ObjectId) {
585+
const tcdocs = await contest.getMultiClarification(domainId, tid);
586+
this.response.body = {
587+
tdoc: this.tdoc,
588+
tsdoc: this.tsdoc,
589+
owner_udoc: await user.getById(domainId, this.tdoc.owner),
590+
pdict: await problem.getList(domainId, this.tdoc.pids, true, true, [...problem.PROJECTION_CONTEST_LIST, 'tag']),
591+
tcdocs,
512592
udict: await user.getListForRender(
513593
domainId, tcdocs.map((i) => i.owner),
514594
this.user.hasPerm(PERM.PERM_VIEW_USER_PRIVATE_INFO),
515595
),
516-
tcdocs,
517-
urlForFile: (filename: string) => this.url('contest_file_download', { tid, filename }),
518596
};
519-
this.response.pjax = 'partials/files.html';
520-
this.response.template = 'contest_manage.html';
597+
this.response.pjax = 'partials/contest_clarification.html';
598+
this.response.template = 'contest_clarification.html';
521599
}
522600

523601
@param('tid', Types.ObjectId)
@@ -550,57 +628,21 @@ export class ContestManagementHandler extends ContestManagementBaseHandler {
550628
}
551629
this.back();
552630
}
553-
554-
@param('tid', Types.ObjectId)
555-
@post('filename', Types.Filename, true)
556-
async postUploadFile(domainId: string, tid: ObjectId, filename: string) {
557-
if ((this.tdoc.files?.length || 0) >= system.get('limit.contest_files')) {
558-
throw new FileLimitExceededError('count');
559-
}
560-
const file = this.request.files?.file;
561-
if (!file) throw new ValidationError('file');
562-
const size = Math.sum((this.tdoc.files || []).map((i) => i.size)) + file.size;
563-
if (size >= system.get('limit.contest_files_size')) {
564-
throw new FileLimitExceededError('size');
565-
}
566-
filename ||= file.originalFilename || randomstring(16);
567-
await storage.put(`contest/${domainId}/${tid}/${filename}`, file.filepath, this.user._id);
568-
const meta = await storage.getMeta(`contest/${domainId}/${tid}/${filename}`);
569-
const payload = { _id: filename, name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) };
570-
if (!meta) throw new FileUploadError();
571-
await contest.edit(domainId, tid, { files: [...(this.tdoc.files || []), payload] });
572-
this.back();
573-
}
574-
575-
@param('tid', Types.ObjectId)
576-
@post('files', Types.ArrayOf(Types.Filename))
577-
async postDeleteFiles(domainId: string, tid: ObjectId, files: string[]) {
578-
await Promise.all([
579-
storage.del(files.map((t) => `contest/${domainId}/${tid}/${t}`), this.user._id),
580-
contest.edit(domainId, tid, { files: this.tdoc.files.filter((i) => !files.includes(i.name)) }),
581-
]);
582-
this.back();
583-
}
584-
585-
@param('pid', Types.PositiveInt)
586-
@param('score', Types.PositiveInt)
587-
async postSetScore(domainId: string, pid: number, score: number) {
588-
if (!this.tdoc.pids.includes(pid)) throw new ValidationError('pid');
589-
this.tdoc.score ||= {};
590-
this.tdoc.score[pid] = score;
591-
await contest.edit(domainId, this.tdoc.docId, { score: this.tdoc.score });
592-
await contest.recalcStatus(domainId, this.tdoc.docId);
593-
this.back();
594-
}
595631
}
596632

597633
export class ContestFileDownloadHandler extends ContestDetailBaseHandler {
598634
@param('tid', Types.ObjectId)
599635
@param('filename', Types.Filename)
600636
@param('noDisposition', Types.Boolean)
601-
async get(domainId: string, tid: ObjectId, filename: string, noDisposition = false) {
637+
@param('type', Types.Range(['public', 'private']), true)
638+
async get(domainId: string, tid: ObjectId, filename: string, noDisposition = false, type = 'private') {
639+
if (type === 'private' && !this.user.own(this.tdoc)) {
640+
if (!this.tsdoc?.attend) throw new ContestNotAttendedError(domainId, tid);
641+
if (!contest.isOngoing(this.tdoc) && !contest.isDone(this.tdoc)) throw new ContestNotLiveError(domainId, tid);
642+
if (!this.tsdoc.startAt) await contest.setStatus(domainId, tid, this.user._id, { startAt: new Date() });
643+
}
602644
this.response.addHeader('Cache-Control', 'public');
603-
const target = `contest/${domainId}/${tid}/${filename}`;
645+
const target = `contest/${domainId}/${tid}/${type}/${filename}`;
604646
const file = await storage.getMeta(target);
605647
await oplog.log(this, 'download.file.contest', {
606648
target,
@@ -805,8 +847,9 @@ export async function apply(ctx: Context) {
805847
ctx.Route('contest_edit', '/contest/:tid/edit', ContestEditHandler, PERM.PERM_VIEW_CONTEST);
806848
ctx.Route('contest_print', '/contest/:tid/print', ContestPrintHandler, PERM.PERM_VIEW_CONTEST);
807849
ctx.Route('contest_manage', '/contest/:tid/management', ContestManagementHandler);
850+
ctx.Route('contest_clarification', '/contest/:tid/clarification', ContestClarificationHandler);
808851
ctx.Route('contest_code', '/contest/:tid/code', ContestCodeHandler, PERM.PERM_VIEW_CONTEST);
809-
ctx.Route('contest_file_download', '/contest/:tid/file/:filename', ContestFileDownloadHandler, PERM.PERM_VIEW_CONTEST);
852+
ctx.Route('contest_file_download', '/contest/:tid/file/:type/:filename', ContestFileDownloadHandler, PERM.PERM_VIEW_CONTEST);
810853
ctx.Route('contest_user', '/contest/:tid/user', ContestUserHandler, PERM.PERM_VIEW_CONTEST);
811854
ctx.Route('contest_balloon', '/contest/:tid/balloon', ContestBalloonHandler, PERM.PERM_VIEW_CONTEST);
812855
ctx.worker.addHandler('contest', async (doc) => {

packages/hydrooj/src/handler/homework.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ class HomeworkEditHandler extends Handler {
247247
await Promise.all([
248248
record.updateMulti(domainId, { domainId, contest: tid }, undefined, undefined, { contest: '' }),
249249
contest.del(domainId, tid),
250-
storage.del(tdoc.files?.map((i) => `contest/${domainId}/${tid}/${i.name}`) || [], this.user._id),
250+
storage.del(tdoc.files?.map((i) => `contest/${domainId}/${tid}/private/${i.name}`) || [], this.user._id),
251251
]);
252252
this.response.redirect = this.url('homework_main');
253253
}
@@ -289,8 +289,8 @@ export class HomeworkFilesHandler extends Handler {
289289
if (size >= system.get('limit.contest_files_size')) {
290290
throw new FileLimitExceededError('size');
291291
}
292-
await storage.put(`contest/${domainId}/${tid}/${filename}`, file.filepath, this.user._id);
293-
const meta = await storage.getMeta(`contest/${domainId}/${tid}/${filename}`);
292+
await storage.put(`contest/${domainId}/${tid}/private/${filename}`, file.filepath, this.user._id);
293+
const meta = await storage.getMeta(`contest/${domainId}/${tid}/private/${filename}`);
294294
const payload = { _id: filename, name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) };
295295
if (!meta) throw new FileUploadError();
296296
await contest.edit(domainId, tid, { files: [...(this.tdoc.files || []), payload] });
@@ -301,7 +301,7 @@ export class HomeworkFilesHandler extends Handler {
301301
@post('files', Types.ArrayOf(Types.Filename))
302302
async postDeleteFiles(domainId: string, tid: ObjectId, files: string[]) {
303303
await Promise.all([
304-
storage.del(files.map((t) => `contest/${domainId}/${tid}/${t}`), this.user._id),
304+
storage.del(files.map((t) => `contest/${domainId}/${tid}/private/${t}`), this.user._id),
305305
contest.edit(domainId, tid, { files: this.tdoc.files.filter((i) => !files.includes(i.name)) }),
306306
]);
307307
this.back();

0 commit comments

Comments
 (0)