From f0219116f3b94acf82b5eeda5e286ac9f1c9b4e5 Mon Sep 17 00:00:00 2001 From: panda Date: Fri, 29 Mar 2024 10:36:55 +0800 Subject: [PATCH] core&ui: add homework files & enhance delete operation (#755) --- packages/hydrooj/src/handler/contest.ts | 1 + packages/hydrooj/src/handler/homework.ts | 102 ++++++++++++++++-- packages/hydrooj/src/handler/training.ts | 7 +- packages/hydrooj/src/model/contest.ts | 1 - packages/hydrooj/src/upgrade.ts | 18 ++++ .../ui-default/pages/contest_edit.page.ts | 2 +- packages/ui-default/pages/files.page.js | 2 +- packages/ui-default/pages/files.page.styl | 2 +- .../ui-default/pages/homework_edit.page.ts | 24 +++++ .../ui-default/pages/training_edit.page.ts | 2 +- .../ui-default/templates/homework_files.html | 24 +++++ .../templates/partials/homework_sidebar.html | 3 + 12 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 packages/ui-default/pages/homework_edit.page.ts create mode 100644 packages/ui-default/templates/homework_files.html diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index 23024b145..5e903993e 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -515,6 +515,7 @@ 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), ])); this.response.redirect = this.url('contest_main'); } diff --git a/packages/hydrooj/src/handler/homework.ts b/packages/hydrooj/src/handler/homework.ts index e69741c89..db71c3776 100644 --- a/packages/hydrooj/src/handler/homework.ts +++ b/packages/hydrooj/src/handler/homework.ts @@ -1,18 +1,25 @@ +import { statSync } from 'fs-extra'; import yaml from 'js-yaml'; +import { pick } from 'lodash'; import moment from 'moment-timezone'; import { ObjectId } from 'mongodb'; -import { Time } from '@hydrooj/utils/lib/utils'; +import { sortFiles, Time } from '@hydrooj/utils/lib/utils'; import { - ContestNotFoundError, HomeworkNotLiveError, NotAssignedError, ValidationError, + ContestNotFoundError, FileLimitExceededError, FileUploadError, HomeworkNotLiveError, NotAssignedError, ValidationError, } from '../error'; -import { PenaltyRules } from '../interface'; +import { PenaltyRules, Tdoc } from '../interface'; import { PERM } from '../model/builtin'; import * as contest from '../model/contest'; import * as discussion from '../model/discussion'; +import * as oplog from '../model/oplog'; import problem from '../model/problem'; import record from '../model/record'; +import storage from '../model/storage'; +import * as system from '../model/system'; import user from '../model/user'; -import { Handler, param, Types } from '../service/server'; +import { + Handler, param, post, Types, +} from '../service/server'; import { ContestCodeHandler, ContestScoreboardHandler } from './contest'; const validatePenaltyRules = (input: string) => yaml.load(input); @@ -22,7 +29,7 @@ class HomeworkMainHandler extends Handler { @param('group', Types.Name, true) @param('page', Types.PositiveInt, true) async get(domainId: string, group = '', page = 1) { - const groups = (await user.listGroup(domainId, this.user.hasPerm(PERM.PERM_VIEW_HIDDEN_CONTEST) ? undefined : this.user._id)) + const groups = (await user.listGroup(domainId, this.user.hasPerm(PERM.PERM_VIEW_HIDDEN_HOMEWORK) ? undefined : this.user._id)) .map((i) => i.name); if (group && !groups.includes(group)) throw new NotAssignedError(group); const cursor = contest.getMulti(domainId, { @@ -93,6 +100,9 @@ class HomeworkDetailHandler extends Handler { this.response.body = { tdoc, tsdoc, udict, ddocs, page, dpcount, dcount, }; + this.response.body.tdoc.content = this.response.body.tdoc.content + .replace(/\(file:\/\//g, `(./${tdoc.docId}/file/`) + .replace(/="file:\/\//g, `="./${tdoc.docId}/file/`); if ( (contest.isNotStarted(tdoc) || (!tsdoc?.attend && !contest.isDone(tdoc))) && !this.user.own(tdoc) @@ -226,11 +236,89 @@ class HomeworkEditHandler extends Handler { async postDelete(domainId: string, tid: ObjectId) { const tdoc = await contest.get(domainId, tid); if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_EDIT_HOMEWORK); - await contest.del(domainId, tid); + await Promise.all([ + record.updateMulti(domainId, { domainId, contest: tid }, undefined, undefined, { contest: '' }), + contest.del(domainId, tid), + storage.del(tdoc.files?.map((i) => `contest/${domainId}/${tid}/${i.name}`) || [], this.user._id), + ]); this.response.redirect = this.url('homework_main'); } } +export class HomeworkFilesHandler extends Handler { + tdoc: Tdoc; + + @param('tid', Types.ObjectId) + async prepare(domainId: string, tid: ObjectId) { + this.tdoc = await contest.get(domainId, tid); + if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_HOMEWORK); + else this.checkPerm(PERM.PERM_EDIT_HOMEWORK_SELF); + } + + @param('tid', Types.ObjectId) + async get(domainId: string, tid: ObjectId) { + if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_HOMEWORK); + this.response.body = { + tdoc: this.tdoc, + tsdoc: await contest.getStatus(domainId, this.tdoc.docId, this.user._id), + udoc: await user.getById(domainId, this.tdoc.owner), + files: sortFiles(this.tdoc.files || []), + urlForFile: (filename: string) => this.url('homework_file_download', { tid, filename }), + }; + this.response.pjax = 'partials/files.html'; + this.response.template = 'homework_files.html'; + } + + @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 f = statSync(file.filepath); + const size = Math.sum((this.tdoc.files || []).map((i) => i.size)) + f.size; + if (size >= system.get('limit.contest_files_size')) { + throw new FileLimitExceededError('size'); + } + 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(); + } +} + +export class HomeworkFileDownloadHandler extends Handler { + @param('tid', Types.ObjectId) + @param('filename', Types.Filename) + @param('noDisposition', Types.Boolean) + async get(domainId: string, tid: ObjectId, filename: string, noDisposition = false) { + this.response.addHeader('Cache-Control', 'public'); + const target = `contest/${domainId}/${tid}/${filename}`; + const file = await storage.getMeta(target); + await oplog.log(this, 'download.file.contest', { + target, + size: file?.size || 0, + }); + this.response.redirect = await storage.signDownloadLink( + target, noDisposition ? undefined : filename, false, 'user', + ); + } +} + export async function apply(ctx) { ctx.Route('homework_main', '/homework', HomeworkMainHandler, PERM.PERM_VIEW_HOMEWORK); ctx.Route('homework_create', '/homework/create', HomeworkEditHandler); @@ -242,4 +330,6 @@ export async function apply(ctx) { ); ctx.Route('homework_code', '/homework/:tid/code', ContestCodeHandler, PERM.PERM_VIEW_HOMEWORK); ctx.Route('homework_edit', '/homework/:tid/edit', HomeworkEditHandler); + ctx.Route('homework_files', '/homework/:tid/file', HomeworkFilesHandler, PERM.PERM_VIEW_HOMEWORK); + ctx.Route('homework_file_download', '/homework/:tid/file/:filename', HomeworkFileDownloadHandler, PERM.PERM_VIEW_HOMEWORK); } diff --git a/packages/hydrooj/src/handler/training.ts b/packages/hydrooj/src/handler/training.ts index a72a2979f..8c074c16e 100644 --- a/packages/hydrooj/src/handler/training.ts +++ b/packages/hydrooj/src/handler/training.ts @@ -173,7 +173,10 @@ class TrainingDetailHandler extends Handler { async postDelete(domainId: string, tid: ObjectId) { const tdoc = await training.get(domainId, tid); if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_EDIT_TRAINING); - await training.del(domainId, tid); + await Promise.all([ + training.del(domainId, tid), + storage.del(tdoc.files?.map((i) => `training/${domainId}/${tid}/${i.name}`) || [], this.user._id), + ]); this.response.redirect = this.url('training_main'); } } @@ -275,7 +278,7 @@ export class TrainingFilesHandler extends Handler { @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), + storage.del(files.map((t) => `training/${domainId}/${tid}/${t}`), this.user._id), training.edit(domainId, tid, { files: this.tdoc.files.filter((i) => !files.includes(i.name)) }), ]); this.back(); diff --git a/packages/hydrooj/src/model/contest.ts b/packages/hydrooj/src/model/contest.ts index fde8d9e9c..af232245a 100644 --- a/packages/hydrooj/src/model/contest.ts +++ b/packages/hydrooj/src/model/contest.ts @@ -760,7 +760,6 @@ export async function del(domainId: string, tid: ObjectId) { await Promise.all([ document.deleteOne(domainId, document.TYPE_CONTEST, tid), document.deleteMultiStatus(domainId, document.TYPE_CONTEST, { docId: tid }), - document.deleteMulti(domainId, document.TYPE_DISCUSSION, { parentType: document.TYPE_CONTEST, parentId: tid }), ]); } diff --git a/packages/hydrooj/src/upgrade.ts b/packages/hydrooj/src/upgrade.ts index 3ca5cc01d..c366143a6 100644 --- a/packages/hydrooj/src/upgrade.ts +++ b/packages/hydrooj/src/upgrade.ts @@ -20,6 +20,7 @@ import ScheduleModel from './model/schedule'; import StorageModel from './model/storage'; import * as system from './model/system'; import TaskModel from './model/task'; +import * as training from './model/training'; import user from './model/user'; import { iterateAllContest, @@ -639,6 +640,23 @@ const scripts: UpgradeScript[] = [ await domain.setRoles(ddoc._id, ddoc.roles); }); }, + async function _86_87() { + logger.info('Removing unused files...'); + return await iterateAllDomain(async ({ _id }) => { + logger.info('Processing domain %s', _id); + const contestFilesList = await StorageModel.list(`contest/${_id}`); + const trainingFilesList = await StorageModel.list(`training/${_id}`); + const tdocs = await contest.getMulti(_id, {}).toArray(); + const trdocs = await training.getMulti(_id, {}).toArray(); + let existsFiles = []; + for (const tdoc of tdocs) existsFiles = existsFiles.concat((tdoc.files || []).map((i) => `contest/${_id}/${tdoc.docId}/${i.name}`)); + await StorageModel.del(contestFilesList.filter((i) => !existsFiles.includes(i.name)).map((i) => i.name)); + existsFiles = []; + for (const tdoc of trdocs) existsFiles = existsFiles.concat((tdoc.files || []).map((i) => `training/${_id}/${tdoc.docId}/${i.name}`)); + await StorageModel.del(trainingFilesList.filter((i) => !existsFiles.includes(i.name)).map((i) => i.name)); + logger.info('Domain %s done', _id); + }); + }, ]; export default scripts; diff --git a/packages/ui-default/pages/contest_edit.page.ts b/packages/ui-default/pages/contest_edit.page.ts index f8cd68865..12e941991 100644 --- a/packages/ui-default/pages/contest_edit.page.ts +++ b/packages/ui-default/pages/contest_edit.page.ts @@ -39,7 +39,7 @@ const page = new NamedPage(['contest_edit', 'contest_create', 'homework_create', window.location.href = res.url; }); } - const message = `Confirm deleting this ${pagename.split('_')[0]}? Its status will be deleted as well.`; + const message = `Confirm deleting this ${pagename.split('_')[0]}? Its files and status will be deleted as well.`; return new ConfirmDialog({ $body: tpl.typoMsg(i18n(message)), }).open().then((action) => { diff --git a/packages/ui-default/pages/files.page.js b/packages/ui-default/pages/files.page.js index 78b80f93d..48c6f3ddf 100644 --- a/packages/ui-default/pages/files.page.js +++ b/packages/ui-default/pages/files.page.js @@ -92,7 +92,7 @@ function handleDrop(ev) { handleClickUpload(files); } -const page = new NamedPage(['home_files', 'contest_manage', 'training_files'], () => { +const page = new NamedPage(['home_files', 'contest_manage', 'training_files', 'homework_files'], () => { $(document).on('click', '[name="upload_file"]', () => handleClickUpload()); $(document).on('click', '[name="remove_selected"]', () => handleClickRemoveSelected()); $(document).on('dragover', '.files', (ev) => handleDragOver(ev)); diff --git a/packages/ui-default/pages/files.page.styl b/packages/ui-default/pages/files.page.styl index af7fb3ccb..49be07a05 100644 --- a/packages/ui-default/pages/files.page.styl +++ b/packages/ui-default/pages/files.page.styl @@ -1,4 +1,4 @@ -.page--home_files, .page--training_files, .page--contest_detail, .page--contest_manage +.page--home_files, .page--training_files, .page--contest_detail, .page--contest_manage, .page--homework_files .col--checkbox width: 60px diff --git a/packages/ui-default/pages/homework_edit.page.ts b/packages/ui-default/pages/homework_edit.page.ts new file mode 100644 index 000000000..a5fab1ff5 --- /dev/null +++ b/packages/ui-default/pages/homework_edit.page.ts @@ -0,0 +1,24 @@ +import $ from 'jquery'; +import { ConfirmDialog } from 'vj/components/dialog'; +import { NamedPage } from 'vj/misc/Page'; +import { i18n, request, tpl } from 'vj/utils'; + +export default new NamedPage('homework_edit', () => { + let confirmed = false; + $(document).on('click', '[value="delete"]', (ev) => { + ev.preventDefault(); + if (confirmed) { + return request.post('', { operation: 'delete' }).then((res) => { + window.location.href = res.url; + }); + } + const message = 'Confirm deleting this homework? Its files and status will be deleted as well.'; + return new ConfirmDialog({ + $body: tpl.typoMsg(i18n(message)), + }).open().then((action) => { + if (action !== 'yes') return; + confirmed = true; + ev.target.click(); + }); + }); +}); diff --git a/packages/ui-default/pages/training_edit.page.ts b/packages/ui-default/pages/training_edit.page.ts index 8610a7534..e5fc7760b 100644 --- a/packages/ui-default/pages/training_edit.page.ts +++ b/packages/ui-default/pages/training_edit.page.ts @@ -12,7 +12,7 @@ export default new NamedPage('training_edit', () => { window.location.href = res.url; }); } - const message = 'Confirm deleting this training? Its status will be deleted as well.'; + const message = 'Confirm deleting this training? Its files and status will be deleted as well.'; return new ConfirmDialog({ $body: tpl.typoMsg(i18n(message)), }).open().then((action) => { diff --git a/packages/ui-default/templates/homework_files.html b/packages/ui-default/templates/homework_files.html new file mode 100644 index 000000000..d51bf0ac7 --- /dev/null +++ b/packages/ui-default/templates/homework_files.html @@ -0,0 +1,24 @@ +{% extends "layout/basic.html" %} +{% block content %} +{{ set(UiContext, 'tdoc', tdoc) }} +
+
+
+
+

{{ _('Files') }}

+
+ +
+
+ {{ noscript_note.render() }} + {% include "partials/files.html" %} +
+ +
+
+
+
+ {% include "partials/homework_sidebar.html" %} +
+
+{% endblock %} diff --git a/packages/ui-default/templates/partials/homework_sidebar.html b/packages/ui-default/templates/partials/homework_sidebar.html index bc67ba88b..bf55b7b62 100644 --- a/packages/ui-default/templates/partials/homework_sidebar.html +++ b/packages/ui-default/templates/partials/homework_sidebar.html @@ -55,6 +55,9 @@ + {% endif %} {% if handler.user.own(tdoc) or handler.user.hasPriv(PRIV.PRIV_READ_RECORD_CODE) or handler.user.hasPerm(perm.PERM_READ_RECORD_CODE) %}