From 7bdf580e7d6d1fe68bfc7024d3734519a5bb12ba Mon Sep 17 00:00:00 2001 From: undefined Date: Sun, 27 Jun 2021 23:29:34 +0800 Subject: [PATCH] core: allow user file upload --- packages/hydrooj/locales/zh.yaml | 2 + packages/hydrooj/package.json | 2 +- packages/hydrooj/src/handler/misc.ts | 70 ++++++- packages/hydrooj/src/model/builtin.ts | 9 +- packages/hydrooj/src/model/setting.ts | 2 + packages/hydrooj/src/model/user.ts | 6 +- packages/ui-default/package.json | 2 +- packages/ui-default/pages/home_files.page.js | 176 ++++++++++++++++++ .../ui-default/pages/home_files.page.styl | 6 + .../ui-default/templates/components/home.html | 5 + packages/ui-default/templates/home_files.html | 16 ++ .../templates/partials/home_files.html | 40 ++++ .../ui-default/templates/partials/nav.html | 8 + 13 files changed, 333 insertions(+), 11 deletions(-) create mode 100644 packages/ui-default/pages/home_files.page.js create mode 100644 packages/ui-default/pages/home_files.page.styl create mode 100644 packages/ui-default/templates/home_files.html create mode 100644 packages/ui-default/templates/partials/home_files.html diff --git a/packages/hydrooj/locales/zh.yaml b/packages/hydrooj/locales/zh.yaml index 54b30c0dc..e0baeb11c 100644 --- a/packages/hydrooj/locales/zh.yaml +++ b/packages/hydrooj/locales/zh.yaml @@ -269,6 +269,7 @@ Domain: 域 Don't have an account?: 还没有账户? Done: 已结束 Download Dataset: 下载数据集 +Download link copied to clipboard!: 下载链接已复制到剪贴板。 Download: 下载 Downvote: 差评 Duration (hours): 持续时间 (小时) @@ -350,6 +351,7 @@ home_account: 账户设置 home_domain_account: 当前域的设置 home_domain_create: 创建域 home_domain: 我的域 +home_files: 我的文件 home_messages: 站内消息 home_preference: 偏好设置 home_security: 安全设置 diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index 02d6b4a50..cba5b7324 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "2.28.56", + "version": "2.28.57", "bin": "bin/hydrooj.js", "main": "dist/loader.js", "typings": "dist/loader.d.ts", diff --git a/packages/hydrooj/src/handler/misc.ts b/packages/hydrooj/src/handler/misc.ts index ba42112db..cc24576cc 100644 --- a/packages/hydrooj/src/handler/misc.ts +++ b/packages/hydrooj/src/handler/misc.ts @@ -1,10 +1,14 @@ /* eslint-disable camelcase */ -import { BadRequestError } from '../error'; +import { pick } from 'lodash'; +import { BadRequestError, ForbiddenError, ValidationError } from '../error'; import { PRIV } from '../model/builtin'; +import storage from '../model/storage'; +import * as system from '../model/system'; import user from '../model/user'; import { - Route, Handler, Types, param, + Route, Handler, Types, param, post, } from '../service/server'; +import { sortFiles } from '../utils'; class CheckInHandler extends Handler { async prepare() { @@ -46,9 +50,71 @@ class SwitchLanguageHandler extends Handler { } } +export class FilesHandler extends Handler { + @param('pjax', Types.Boolean) + async get(domainId: string, pjax = false) { + const files = sortFiles(this.user._files); + if (pjax) { + this.response.body = { + fragments: (await Promise.all([ + this.renderHTML('partials/home_files.html', { files }), + ])).map((i) => ({ html: i })), + }; + this.response.template = ''; + } else { + this.response.template = 'home_files.html'; + this.response.body = { files }; + } + } + + @post('filename', Types.Name, true) + async postUploadFile(domainId: string, filename: string) { + if ((this.user._files?.length || 0) >= system.get('limit.user_files')) { + throw new ForbiddenError('File limit exceeded.'); + } + if (!this.request.files.file) throw new ValidationError('file'); + const size = Math.sum((this.user._files || []).map((i) => i.size)); + if (size >= system.get('limit.user_files_size')) { + throw new ForbiddenError('File size limit exceeded.'); + } + if (!filename) filename = this.request.files.file.name || String.random(16); + if (filename.includes('/') || filename.includes('..')) throw new ValidationError('filename', 'Bad filename'); + if (this.user._files.filter((i) => i.name === filename).length) throw new BadRequestError('file exists'); + await storage.put(`user/${this.user._id}/${filename}`, this.request.files.file.path); + const meta = await storage.getMeta(`user/${this.user._id}/${filename}`); + const payload = { name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) }; + if (!meta) throw new Error('Upload failed'); + this.user._files.push({ _id: filename, ...payload }); + await user.setById(this.user._id, { _files: this.user._files }); + this.back(); + } + + @post('files', Types.Array) + async postDeleteFiles(domainId: string, files: string[]) { + await Promise.all([ + storage.del(files.map((t) => `user/${this.user._id}/${t}`)), + user.setById(this.user._id, { _files: this.user._files.filter((i) => !files.includes(i.name)) }), + ]); + this.back(); + } +} + +export class FSDownloadHandler extends Handler { + @param('uid', Types.Int) + @param('filename', Types.Name) + @param('noDisposition', Types.Boolean) + async get(domainId: string, uid: number, filename: string, noDisposition = false) { + this.response.redirect = await storage.signDownloadLink( + `user/${uid}/${filename}`, noDisposition ? undefined : filename, false, 'user', + ); + } +} + export async function apply() { Route('check_in', '/checkin', CheckInHandler, PRIV.PRIV_USER_PROFILE); Route('switch_language', '/language/:lang', SwitchLanguageHandler); + Route('home_file', '/file', FilesHandler, PRIV.PRIV_CREATE_FILE); + Route('fs_download', '/file/:uid/:filename', FSDownloadHandler); } global.Hydro.handler.misc = apply; diff --git a/packages/hydrooj/src/model/builtin.ts b/packages/hydrooj/src/model/builtin.ts index b65fc7499..20d4d642a 100644 --- a/packages/hydrooj/src/model/builtin.ts +++ b/packages/hydrooj/src/model/builtin.ts @@ -242,6 +242,9 @@ export const PRIV = { PRIV_UNLIMITED_ACCESS: 1 << 22, PRIV_VIEW_SYSTEM_NOTIFICATION: 1 << 23, PRIV_SEND_MESSAGE: 1 << 24, + PRIV_CREATE_FILE: 1 << 16, + PRIV_UNLIMITED_QUOTA: 1 << 17, + PRIV_DELETE_FILE: 1 << 18, PRIV_ALL: -1, PRIV_DEFAULT: 0, @@ -252,12 +255,6 @@ export const PRIV = { /** @deprecated */ PRIV_READ_PRETEST_DATA_SELF: 1 << 6, /** @deprecated */ - PRIV_CREATE_FILE: 1 << 16, - /** @deprecated */ - PRIV_UNLIMITED_QUOTA: 1 << 17, - /** @deprecated */ - PRIV_DELETE_FILE: 1 << 18, - /** @deprecated */ PRIV_DELETE_FILE_SELF: 1 << 19, }; diff --git a/packages/hydrooj/src/model/setting.ts b/packages/hydrooj/src/model/setting.ts index 2584cedde..84207285b 100644 --- a/packages/hydrooj/src/model/setting.ts +++ b/packages/hydrooj/src/model/setting.ts @@ -184,6 +184,8 @@ SystemSetting( Setting('setting_server', 'server.login', true, 'boolean', 'server.login', 'Allow builtin-login', FLAG_PRO), Setting('setting_limits', 'limit.problem_files_max', 100, 'number', 'limit.problem_files_max', 'Max files per problem'), Setting('setting_limits', 'limit.problem_files_size_max', 128 * 1024 * 1024, 'number', 'limit.problem_files_size_max', 'Max files size per problem'), + Setting('setting_limits', 'limit.user_files', 100, 'number', 'limit.user_files', 'Max files for user'), + Setting('setting_limits', 'limit.user_files_size', 128 * 1024 * 1024, 'number', 'limit.user_files_size', 'Max total file size for user'), Setting('setting_basic', 'default.priv', builtin.PRIV.PRIV_DEFAULT, 'number', 'default.priv', 'Default Privilege', FLAG_PRO), Setting('setting_basic', 'problem.categories', builtin.CATEGORIES, 'yaml', 'problem.categories', 'Problem Categories'), Setting('setting_basic', 'pagination.problem', 100, 'number', 'pagination.problem', 'Problems per page'), diff --git a/packages/hydrooj/src/model/user.ts b/packages/hydrooj/src/model/user.ts index bd1c21914..6ac154f54 100644 --- a/packages/hydrooj/src/model/user.ts +++ b/packages/hydrooj/src/model/user.ts @@ -8,7 +8,9 @@ import domain from './domain'; import { PERM, PRIV } from './builtin'; import { ArgMethod } from '../utils'; import { UserNotFoundError, UserAlreadyExistError, LoginError } from '../error'; -import { User as _User, Udoc, Udict } from '../interface'; +import { + User as _User, Udoc, Udict, FileInfo, +} from '../interface'; import { Value } from '../typeutils'; import { Logger } from '../logger'; import pwhash from '../lib/hash.hydro'; @@ -51,6 +53,7 @@ class User implements _User { perm: bigint; role: string; scope: bigint; + _files: FileInfo[]; [key: string]: any; constructor(udoc: Udoc, dudoc, scope = PERM.PERM_ALL) { @@ -62,6 +65,7 @@ class User implements _User { this._hash = udoc.hash; this._regip = udoc.regip; this._loginip = udoc.loginip; + this._files = udoc._files || []; this.mail = udoc.mail; this.uname = udoc.uname; diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 3e8b2df53..05b1dadf6 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.11.39", + "version": "4.11.40", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", diff --git a/packages/ui-default/pages/home_files.page.js b/packages/ui-default/pages/home_files.page.js new file mode 100644 index 000000000..dc95b11a0 --- /dev/null +++ b/packages/ui-default/pages/home_files.page.js @@ -0,0 +1,176 @@ +import _ from 'lodash'; +import Clipboard from 'clipboard'; +import { NamedPage } from 'vj/misc/Page'; +import Notification from 'vj/components/notification'; +import { ConfirmDialog, Dialog } from 'vj/components/dialog/index'; +import request from 'vj/utils/request'; +import pjax from 'vj/utils/pjax'; +import tpl from 'vj/utils/tpl'; +import i18n from 'vj/utils/i18n'; + +function onBeforeUnload(e) { + e.returnValue = ''; +} + +const page = new NamedPage('home_files', () => { + function ensureAndGetSelectedFiles() { + const files = _.map( + $('.home-files tbody [data-checkbox-group="user_files"]:checked'), + (ch) => $(ch).closest('tr').attr('data-filename'), + ); + if (files.length === 0) { + Notification.error(i18n('Please select at least one file to perform this operation.')); + return null; + } + return files; + } + + async function handleClickUpload(files) { + if (!files) { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = true; + input.click(); + await new Promise((resolve) => { input.onchange = resolve; }); + files = input.files; + } + if (!files.length) { + Notification.warn(i18n('No file selected.')); + return; + } + const dialog = new Dialog({ + $body: ` +
+
+
+
+
+
+
+
`, + }); + try { + Notification.info(i18n('Uploading files...')); + window.addEventListener('beforeunload', onBeforeUnload); + dialog.open(); + const $uploadLabel = dialog.$dom.find('.dialog__body .upload-label'); + const $uploadProgress = dialog.$dom.find('.dialog__body .upload-progress'); + const $fileLabel = dialog.$dom.find('.dialog__body .file-label'); + const $fileProgress = dialog.$dom.find('.dialog__body .file-progress'); + for (const i in files) { + if (Number.isNaN(+i)) continue; + const file = files[i]; + const data = new FormData(); + data.append('filename', file.name); + data.append('file', file); + data.append('operation', 'upload_file'); + await request.postFile('', data, { + xhr() { + const xhr = new XMLHttpRequest(); + xhr.upload.addEventListener('loadstart', () => { + $fileLabel.text(`[${+i + 1}/${files.length}] ${file.name}`); + $fileProgress.width(`${Math.round((+i + 1) / files.length * 100)}%`); + $uploadLabel.text(i18n('Uploading... ({0}%)', 0)); + $uploadProgress.width(0); + }); + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const percentComplete = Math.round((e.loaded / e.total) * 100); + if (percentComplete === 100) $uploadLabel.text(i18n('Processing...')); + else $uploadLabel.text(i18n('Uploading... ({0}%)', percentComplete)); + $uploadProgress.width(`${percentComplete}%`); + } + }, false); + return xhr; + }, + }); + } + window.removeEventListener('beforeunload', onBeforeUnload); + Notification.success(i18n('File uploaded successfully.')); + await pjax.request({ push: false }); + } catch (e) { + console.error(e); + Notification.error(i18n('File upload failed: {0}', e.toString())); + } finally { + dialog.close(); + } + } + + async function handleClickRemoveSelected() { + const selectedFiles = ensureAndGetSelectedFiles(); + if (selectedFiles === null) return; + const action = await new ConfirmDialog({ + $body: tpl` +
+

${i18n('Confirm to delete the selected files?')}

+
`, + }).open(); + if (action !== 'yes') return; + try { + await request.post('', { + operation: 'delete_files', + files: selectedFiles, + }); + Notification.success(i18n('Selected files have been deleted.')); + await pjax.request({ push: false }); + } catch (error) { + Notification.error(error.message); + } + } + + /** + * @param {string} type + * @param {JQuery.DragOverEvent} ev + */ + function handleDragOver(type, ev) { + ev.preventDefault(); + // TODO display a drag-drop allowed hint + } + + /** + * @param {string} type + * @param {JQuery.DropEvent} ev + */ + function handleDrop(type, ev) { + ev.preventDefault(); + if (!$('[name="upload_testdata"]').length) { + Notification.error(i18n("You don't have permission to upload file.")); + return; + } + ev = ev.originalEvent; + const files = []; + if (ev.dataTransfer.items) { + for (let i = 0; i < ev.dataTransfer.items.length; i++) { + if (ev.dataTransfer.items[i].kind === 'file') { + const file = ev.dataTransfer.items[i].getAsFile(); + files.push(file); + } + } + } else { + for (let i = 0; i < ev.dataTransfer.files.length; i++) { + files.push(ev.dataTransfer.files[i]); + } + } + handleClickUpload(type, 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(); + }, + }); + clip.on('success', () => { + Notification.success(i18n('Download link copied to clipboard!'), 1000); + }); + clip.on('error', () => { + Notification.error(i18n('Copy failed :(')); + }); + $(document).on('click', '.home-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)); +}); + +export default page; diff --git a/packages/ui-default/pages/home_files.page.styl b/packages/ui-default/pages/home_files.page.styl new file mode 100644 index 000000000..49bd0b7e3 --- /dev/null +++ b/packages/ui-default/pages/home_files.page.styl @@ -0,0 +1,6 @@ +.page--home_files + .col--checkbox + width: 60px + + .col--size + width: 120px diff --git a/packages/ui-default/templates/components/home.html b/packages/ui-default/templates/components/home.html index e0683b5f2..6c3421b55 100644 --- a/packages/ui-default/templates/components/home.html +++ b/packages/ui-default/templates/components/home.html @@ -6,6 +6,11 @@ {{ sidemenu.render_item('comment--multiple', 'home_messages') }} {{ sidemenu.render_item('web', 'home_settings', {category:'domain'}, label='Domain Settings') }} + {{ sidemenu.render_item('web', 'home_domain', label='My Domains') }} + {% if handler.user.hasPriv(PRIV.PRIV_CREATE_FILE) %} + + {{ sidemenu.render_item('file', 'home_file', label='My Files') }} + {% endif %} {{ sidemenu.render_item('wrench', 'home_settings', {category:'account'}, label='Account Settings') }} {{ sidemenu.render_item('sliders', 'home_settings', {category:'preference'}, label='Preference Settings')}} diff --git a/packages/ui-default/templates/home_files.html b/packages/ui-default/templates/home_files.html new file mode 100644 index 000000000..1fdaff326 --- /dev/null +++ b/packages/ui-default/templates/home_files.html @@ -0,0 +1,16 @@ +{% extends "layout/home_base.html" %} +{% block home_content %} +
+
+

{{ _('Files') }}

+
+ +
+
+ {{ noscript_note.render() }} + {% include "partials/home_files.html" %} +
+ +
+
+{% endblock %} \ No newline at end of file diff --git a/packages/ui-default/templates/partials/home_files.html b/packages/ui-default/templates/partials/home_files.html new file mode 100644 index 000000000..6e9de3520 --- /dev/null +++ b/packages/ui-default/templates/partials/home_files.html @@ -0,0 +1,40 @@ +{% import "components/nothing.html" as nothing with context %} +
+ {% if files.length %} + + + + + + + + + + + + + + + {%- for file in files -%} + + + + + + {%- endfor -%} + +
+ + {{ _('Filename') }}{{ _('Size') }}
+ + + {{ file.name }} + {{ size(file.size) }}
+ {% else %} + {{ nothing.render('No file at current.') }} + {% endif %} +
\ No newline at end of file diff --git a/packages/ui-default/templates/partials/nav.html b/packages/ui-default/templates/partials/nav.html index 42faaaa66..83691bee0 100644 --- a/packages/ui-default/templates/partials/nav.html +++ b/packages/ui-default/templates/partials/nav.html @@ -62,6 +62,14 @@ {{ _('My Domains') }} + {% if handler.user.hasPriv(PRIV.PRIV_CREATE_FILE) %} + + + {% endif %}