core: allow user file upload

This commit is contained in:
undefined committed 2021-06-27 23:29:34 +08:00
1 parent b13b8835ec
commit 7bdf580e7d
13 files changed
+333 -11

No files matched your search

+2
View File
@@ -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: 安全设置
+1 -1
View File
@@ -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",
+68 -2
View File
@@ -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;
+3 -6
View File
@@ -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,
};
+2
View File
@@ -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'),
+5 -1
View File
@@ -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;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hydrooj/ui-default",
"version": "4.11.39",
"version": "4.11.40",
"author": "undefined <i@undefined.moe>",
"license": "AGPL-3.0",
"main": "hydro.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: `
<div class="file-label" style="text-align: center; margin-bottom: 5px; color: gray; font-size: small;"></div>
<div class="bp3-progress-bar bp3-intent-primary bp3-no-stripes">
<div class="file-progress bp3-progress-meter" style="width: 0"></div>
</div>
<div class="upload-label" style="text-align: center; margin: 5px 0; color: gray; font-size: small;"></div>
<div class="bp3-progress-bar bp3-intent-primary bp3-no-stripes">
<div class="upload-progress bp3-progress-meter" style="width: 0"></div>
</div>`,
});
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`
<div class="typo">
<p>${i18n('Confirm to delete the selected files?')}</p>
</div>`,
}).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<HTMLElement, undefined, HTMLElement, HTMLElement>} ev
*/
function handleDragOver(type, ev) {
ev.preventDefault();
// TODO display a drag-drop allowed hint
}
/**
* @param {string} type
* @param {JQuery.DropEvent<HTMLElement, undefined, HTMLElement, HTMLElement>} 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;
@@ -0,0 +1,6 @@
.page--home_files
.col--checkbox
width: 60px
.col--size
width: 120px
@@ -6,6 +6,11 @@
{{ sidemenu.render_item('comment--multiple', 'home_messages') }}
<li class="menu__seperator"></li>
{{ 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) %}
<li class="menu__seperator"></li>
{{ sidemenu.render_item('file', 'home_file', label='My Files') }}
{% endif %}
<li class="menu__seperator"></li>
{{ sidemenu.render_item('wrench', 'home_settings', {category:'account'}, label='Account Settings') }}
{{ sidemenu.render_item('sliders', 'home_settings', {category:'preference'}, label='Preference Settings')}}
@@ -0,0 +1,16 @@
{% extends "layout/home_base.html" %}
{% block home_content %}
<div class="section">
<div class="section__header">
<h1 class="section__title">{{ _('Files') }}</h1>
<div class="section__tools">
<button class="primary rounded button" name="upload_file">{{ _('Upload File') }}</button>
</div>
</div>
{{ noscript_note.render() }}
{% include "partials/home_files.html" %}
<div class="section__body">
<button class="rounded button" name="remove_selected">{{ _('Remove Selected') }}</button>
</div>
</div>
{% endblock %}
@@ -0,0 +1,40 @@
{% import "components/nothing.html" as nothing with context %}
<div class="section__body no-padding home-files" data-fragment-id="home-files">
{% if files.length %}
<table class="data-table">
<colgroup>
<col class="col--checkbox">
<col class="col--name">
<col class="col--size">
</colgroup>
<thead>
<tr>
<th class="col--checkbox">
<label class="compact checkbox">
<input type="checkbox" name="select_all" data-checkbox-toggle="user_files">
</label>
</th>
<th class="col--name">{{ _('Filename') }}</th>
<th class="col--size">{{ _('Size') }}</th>
</tr>
</thead>
<tbody>
{%- for file in files -%}
<tr data-filename="{{ file.name }}" data-size="{{ file.size }}">
<td class="col--checkbox">
<label class="compact checkbox">
<input type="checkbox" data-checkbox-group="user_files" data-checkbox-range>
</label>
</td>
<td class="col--name">
<a href="{{ url('fs_download', {uid: handler.user._id, filename: file.name}) }}">{{ file.name }}</a>
</td>
<td class="col--size">{{ size(file.size) }}</td>
</tr>
{%- endfor -%}
</tbody>
</table>
{% else %}
{{ nothing.render('No file at current.') }}
{% endif %}
</div>
@@ -62,6 +62,14 @@
<span class="icon icon-web"></span> {{ _('My Domains') }}
</a>
</li>
{% if handler.user.hasPriv(PRIV.PRIV_CREATE_FILE) %}
<li class="menu__seperator"></li>
<li class="menu__item">
<a href="{{ url('home_files') }}" class="menu__link">
<span class="icon icon-file"></span> {{ _('My Files') }}
</a>
</li>
{% endif %}
<li class="menu__seperator"></li>
<li class="menu__item">
<a href="{{ url('user_logout') }}" class="menu__link" name="nav_logout">