diff --git a/packages/hydrooj/locales/en.yaml b/packages/hydrooj/locales/en.yaml index c78311e8d..43cca839f 100644 --- a/packages/hydrooj/locales/en.yaml +++ b/packages/hydrooj/locales/en.yaml @@ -6,6 +6,7 @@ contest_detail_problem_submit: Submit Contest Problem contest_detail_problem: Problem Detail contest_detail: Contest Detail contest_edit: Contest Edit +contest_files: Contest Files contest_main: Contest contest_scoreboard: Scoreboard discussion_create: Create Discussion diff --git a/packages/hydrooj/locales/zh.yaml b/packages/hydrooj/locales/zh.yaml index a4bf95afa..eeb18bf02 100644 --- a/packages/hydrooj/locales/zh.yaml +++ b/packages/hydrooj/locales/zh.yaml @@ -157,6 +157,7 @@ contest_detail_problem_submit: 递交比赛题目 contest_detail_problem: 题目详情 contest_detail: 比赛详情 contest_edit: 编辑比赛 +contest_files: 比赛文件 contest_main: 比赛 contest_scoreboard: 比赛成绩表 Contest: 比赛 diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index e624f066c..f7601c1db 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "3.14.6", + "version": "3.14.7", "bin": "bin/hydrooj.js", "main": "src/loader", "module": "src/loader", diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index 8c33d536e..43e00d22d 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -1,7 +1,9 @@ import AdmZip from 'adm-zip'; +import { statSync } from 'fs-extra'; +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, ContestNotLiveError, ForbiddenError, InvalidTokenError, PermissionError, ValidationError, @@ -11,13 +13,15 @@ import paginate from '../lib/paginate'; import { PERM, PRIV } from '../model/builtin'; import * as contest from '../model/contest'; import message from '../model/message'; +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 TaskModel from '../model/task'; import user from '../model/user'; import { - Handler, param, Route, Types, + Handler, param, post, Route, Types, } from '../service/server'; import { registerResolver, registerValue } from './api'; @@ -363,6 +367,93 @@ export class ContestCodeHandler extends Handler { } } +export class ContestFilesHandler extends ContestDetailHandler { + @param('tid', Types.ObjectID) + @param('pjax', Types.Boolean) + // @ts-ignore + async get(domainId: string, tid: ObjectID, pjax = false) { + const tdoc = await contest.get(domainId, tid); + if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST); + const body = { + tdoc, + owner_udoc: await user.getById(domainId, tdoc.owner), + files: sortFiles(tdoc.files || []), + urlForFile: (filename: string) => this.url('contest_file_download', { tid, filename }), + }; + if (pjax) { + this.response.body = { + fragments: (await Promise.all([ + this.renderHTML('partials/files.html', body), + ])).map((i) => ({ html: i })), + }; + this.response.template = ''; + } else { + this.response.template = 'contest_files.html'; + this.response.body = body; + } + } + + @param('tid', Types.ObjectID) + async post(domainId: string, tid: ObjectID) { + const tdoc = await contest.get(domainId, tid); + if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST); + } + + @param('tid', Types.ObjectID) + @post('filename', Types.Name, true) + async postUploadFile(domainId: string, tid: ObjectID, filename: string) { + const tdoc = await contest.get(domainId, tid); + if ((tdoc.files?.length || 0) >= system.get('limit.contest_files')) { + throw new ForbiddenError('File limit exceeded.'); + } + const file = this.request.files?.file; + if (!file) throw new ValidationError('file'); + const f = statSync(file.filepath); + const size = Math.sum((tdoc.files || []).map((i) => i.size)) + f.size; + if (size >= system.get('limit.contest_files_size')) { + throw new ForbiddenError('File size limit exceeded.'); + } + if (!filename) filename = file.originalFilename || String.random(16); + if (filename.includes('/') || filename.includes('..')) throw new ValidationError('filename', null, 'Bad filename'); + await storage.put(`contest/${domainId}/${tid}/${filename}`, file.filepath, this.user._id); + const meta = await storage.getMeta(`contest/${domainId}/${tid}/${filename}`); + const payload = { name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) }; + if (!meta) throw new Error('Upload failed'); + await contest.edit(domainId, tid, { files: [...(tdoc.files || []), payload] }); + this.back(); + } + + @param('tid', Types.ObjectID) + @post('files', Types.Array) + async postDeleteFiles(domainId: string, tid: ObjectID, files: string[]) { + const tdoc = await contest.get(domainId, tid); + await Promise.all([ + storage.del(files.map((t) => `contest/${domainId}/${tid}/${t}`), this.user._id), + contest.edit(domainId, tid, { files: tdoc.files.filter((i) => !files.includes(i.name)) }), + ]); + this.back(); + } +} + +export class ContestFileDownloadHandler extends ContestDetailHandler { + @param('tid', Types.ObjectID) + @param('filename', Types.Name) + @param('noDisposition', Types.Boolean) + // @ts-ignore + 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() { Route('contest_create', '/contest/create', ContestEditHandler); Route('contest_main', '/contest', ContestListHandler, PERM.PERM_VIEW_CONTEST); @@ -372,6 +463,8 @@ export async function apply() { Route('contest_scoreboard', '/contest/:tid/scoreboard', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST); Route('contest_scoreboard_download', '/contest/:tid/export/:ext', ContestScoreboardDownloadHandler, PERM.PERM_VIEW_CONTEST); Route('contest_code', '/contest/:tid/code', ContestCodeHandler, PERM.PERM_VIEW_CONTEST); + Route('contest_files', '/contest/:tid/file', ContestFilesHandler, PERM.PERM_VIEW_CONTEST); + Route('contest_file_download', '/contest/:tid/file/:filename', ContestFileDownloadHandler, PERM.PERM_VIEW_CONTEST); } global.Hydro.handler.contest = apply; diff --git a/packages/hydrooj/src/handler/misc.ts b/packages/hydrooj/src/handler/misc.ts index f2776ca0f..879de6b3f 100644 --- a/packages/hydrooj/src/handler/misc.ts +++ b/packages/hydrooj/src/handler/misc.ts @@ -29,17 +29,20 @@ export class FilesHandler extends Handler { @param('pjax', Types.Boolean) async get(domainId: string, pjax = false) { if (!this.user._files?.length) this.checkPriv(PRIV.PRIV_CREATE_FILE); - const files = sortFiles(this.user._files); + const body = { + files: sortFiles(this.user._files), + urlForFile: (filename: string) => this.url('fs_download', { uid: this.user._id, filename }), + }; if (pjax) { this.response.body = { fragments: (await Promise.all([ - this.renderHTML('partials/home_files.html', { files }), + this.renderHTML('partials/files.html', body), ])).map((i) => ({ html: i })), }; this.response.template = ''; } else { this.response.template = 'home_files.html'; - this.response.body = { files }; + this.response.body = body; } } @@ -68,13 +71,6 @@ export class FilesHandler extends Handler { this.back(); } - @post('from', Types.String) - @post('to', Types.String) - async postRename(domainId: string, from: string, to: string) { - await storage.rename(`user/${this.user._id}/${from}`, `user/${this.user._id}/${to}`, this.user._id); - this.back(); - } - @post('files', Types.Array) async postDeleteFiles(domainId: string, files: string[]) { await Promise.all([ diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index 37f398a27..0add56fe0 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -338,6 +338,7 @@ export interface Tdoc", "license": "AGPL-3.0", "main": "hydro.js", diff --git a/packages/ui-default/pages/home_files.page.js b/packages/ui-default/pages/files.page.js similarity index 90% rename from packages/ui-default/pages/home_files.page.js rename to packages/ui-default/pages/files.page.js index e2f3ee0c2..740d55d66 100644 --- a/packages/ui-default/pages/home_files.page.js +++ b/packages/ui-default/pages/files.page.js @@ -14,7 +14,7 @@ function onBeforeUnload(e) { function ensureAndGetSelectedFiles() { const files = _.map( - $('.home-files tbody [data-checkbox-group="user_files"]:checked'), + $('.files tbody [data-checkbox-group="files"]:checked'), (ch) => $(ch).closest('tr').attr('data-filename'), ); if (files.length === 0) { @@ -152,12 +152,9 @@ function handleDrop(ev) { handleClickUpload(files); } -const page = new NamedPage('home_files', () => { - const clip = new Clipboard('.home-files .col--name', { - text: (trigger) => { - const filename = trigger.closest('[data-filename]').getAttribute('data-filename'); - return new URL(`/file/${UserContext._id}/${filename}`, window.location.href).toString(); - }, +const page = new NamedPage(['home_files', 'contest_files'], () => { + const clip = new Clipboard('.files .col--name', { + text: (trigger) => new URL(trigger.children[0].href, window.location.href).toString(), }); clip.on('success', () => { Notification.success(i18n('Download link copied to clipboard!'), 1000); @@ -165,11 +162,11 @@ const page = new NamedPage('home_files', () => { clip.on('error', () => { Notification.error(i18n('Copy failed :(')); }); - $(document).on('click', '.home-files .col--name', (ev) => ev.preventDefault()); + $(document).on('click', '.files .col--name', (ev) => ev.preventDefault()); $(document).on('click', '[name="upload_file"]', () => handleClickUpload()); $(document).on('click', '[name="remove_selected"]', () => handleClickRemoveSelected()); - $(document).on('dragover', '.home-files', (ev) => handleDragOver(ev)); - $(document).on('drop', '.home-files', (ev) => handleDrop(ev)); + $(document).on('dragover', '.files', (ev) => handleDragOver(ev)); + $(document).on('drop', '.files', (ev) => handleDrop(ev)); }); export default page; diff --git a/packages/ui-default/templates/contest_files.html b/packages/ui-default/templates/contest_files.html new file mode 100644 index 000000000..2e2e515bf --- /dev/null +++ b/packages/ui-default/templates/contest_files.html @@ -0,0 +1,27 @@ +{% extends "layout/basic.html" %} +{% import "components/contest.html" as contest with context %} +{% import "components/record.html" as record with context %} +{% import "components/problem.html" as problem with context %} +{% block content %} +{{ set(UiContext, 'tdoc', tdoc) }} +
+
+
+
+

{{ _('Files') }}

+
+ +
+
+ {{ noscript_note.render() }} + {% include "partials/files.html" %} +
+ +
+
+
+
+ {% include "partials/contest_sidebar.html" %} +
+
+{% endblock %} diff --git a/packages/ui-default/templates/home_files.html b/packages/ui-default/templates/home_files.html index 1fdaff326..de0f3b514 100644 --- a/packages/ui-default/templates/home_files.html +++ b/packages/ui-default/templates/home_files.html @@ -8,7 +8,7 @@ {{ noscript_note.render() }} - {% include "partials/home_files.html" %} + {% include "partials/files.html" %}
diff --git a/packages/ui-default/templates/partials/contest_sidebar.html b/packages/ui-default/templates/partials/contest_sidebar.html index 7ef47d829..06417febb 100644 --- a/packages/ui-default/templates/partials/contest_sidebar.html +++ b/packages/ui-default/templates/partials/contest_sidebar.html @@ -72,6 +72,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) %} {% if model.contest.canShowRecord.call(handler, tdoc, true) %} diff --git a/packages/ui-default/templates/partials/home_files.html b/packages/ui-default/templates/partials/files.html similarity index 77% rename from packages/ui-default/templates/partials/home_files.html rename to packages/ui-default/templates/partials/files.html index 6e9de3520..ee343fb86 100644 --- a/packages/ui-default/templates/partials/home_files.html +++ b/packages/ui-default/templates/partials/files.html @@ -1,5 +1,5 @@ {% import "components/nothing.html" as nothing with context %} -
+
{% if files.length %} @@ -11,7 +11,7 @@ @@ -23,11 +23,11 @@ diff --git a/packages/utils/lib/utils.ts b/packages/utils/lib/utils.ts index 696bfa502..0872fe915 100644 --- a/packages/utils/lib/utils.ts +++ b/packages/utils/lib/utils.ts @@ -250,7 +250,7 @@ export function sortFiles(files: { _id: string }[] | string[]) { if (!files?.length) return []; const isString = typeof files[0] === 'string'; const result = files - .map((i) => (isString ? { name: i, weights: i.match(fSortR) } : { ...i, weights: i._id.match(fSortR) })) + .map((i) => (isString ? { name: i, weights: i.match(fSortR) } : { ...i, weights: (i._id || i.name).match(fSortR) })) .sort((a, b) => { let pos = 0; const weightsA = a.weights; diff --git a/packages/utils/package.json b/packages/utils/package.json index 315530fd8..ed861ae24 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/utils", - "version": "1.3.0", + "version": "1.3.1", "description": "hydrooj utils", "main": "package.json", "repository": "https://github.com/hydro-dev/Hydro.git",
{{ _('Filename') }}
- {{ file.name }} + {{ file.name }} {{ size(file.size) }}