From 7000c1f36449410c60e408e18ea6e95c47535aa7 Mon Sep 17 00:00:00 2001 From: llj Date: Tue, 20 Oct 2020 16:24:58 +0800 Subject: [PATCH] Share link file upload (#4633) * [share dialog] fixed permission bugs; added 'download and upload' permission * share link: fixed permission bug for library * upload link: fixed permission bug * share link: added 'download and upload' permission for folder * [folder share link] added 'file upload' for folder share link with 'can_upload' permission --- .../components/dialog/generate-share-link.js | 5 +- .../src/components/dialog/share-dialog.js | 5 +- .../file-uploader.js | 700 ++++++++++++++++++ .../forbid-upload-list-item.js | 28 + .../upload-list-item.js | 144 ++++ .../upload-progress-dialog.js | 126 ++++ frontend/src/css/shared-dir-view.css | 9 +- frontend/src/shared-dir-view.js | 54 +- frontend/src/utils/utils.js | 77 +- seahub/templates/view_shared_dir_react.html | 5 +- seahub/views/repo.py | 6 +- 11 files changed, 1107 insertions(+), 52 deletions(-) create mode 100644 frontend/src/components/shared-link-file-uploader/file-uploader.js create mode 100644 frontend/src/components/shared-link-file-uploader/forbid-upload-list-item.js create mode 100644 frontend/src/components/shared-link-file-uploader/upload-list-item.js create mode 100644 frontend/src/components/shared-link-file-uploader/upload-progress-dialog.js diff --git a/frontend/src/components/dialog/generate-share-link.js b/frontend/src/components/dialog/generate-share-link.js index 9799103c0c..58b9fd1aba 100644 --- a/frontend/src/components/dialog/generate-share-link.js +++ b/frontend/src/components/dialog/generate-share-link.js @@ -83,8 +83,9 @@ class GenerateShareLink extends React.Component { }); if (isPro) { - if (this.props.itemType === 'library') { - let permissionOptions = Utils.getShareLinkPermissionList(this.props.itemType, '', path); + const { itemType, userPerm } = this.props; + if (itemType == 'library') { + let permissionOptions = Utils.getShareLinkPermissionList(itemType, userPerm, path); this.setState({ permissionOptions: permissionOptions, currentPermission: permissionOptions[0], diff --git a/frontend/src/components/dialog/share-dialog.js b/frontend/src/components/dialog/share-dialog.js index 6e0fe37b43..44f7a68003 100644 --- a/frontend/src/components/dialog/share-dialog.js +++ b/frontend/src/components/dialog/share-dialog.js @@ -54,7 +54,7 @@ class ShareDialog extends React.Component { getInitialActiveTab = () => { let { repoEncrypted, userPerm, enableDirPrivateShare, itemType } = this.props; const enableShareLink = !repoEncrypted && canGenerateShareLink; - const enableUploadLink = !repoEncrypted && canGenerateUploadLink && userPerm == 'rw'; + const enableUploadLink = !repoEncrypted && canGenerateUploadLink && (userPerm == 'rw' || userPerm == 'admin'); // for encrypted repo, 'dir private share' is only enabled for the repo itself, // not for the folders in it. @@ -88,7 +88,7 @@ class ShareDialog extends React.Component { let activeTab = this.state.activeTab; let { repoEncrypted, userPerm, enableDirPrivateShare, itemType } = this.props; const enableShareLink = !repoEncrypted && canGenerateShareLink; - const enableUploadLink = !repoEncrypted && canGenerateUploadLink && userPerm == 'rw'; + const enableUploadLink = !repoEncrypted && canGenerateUploadLink && (userPerm == 'rw' || userPerm == 'admin'); // for encrypted repo, 'dir private share' is only enabled for the repo itself, // not for the folders in it. @@ -160,6 +160,7 @@ class ShareDialog extends React.Component { repoID={this.props.repoID} closeShareDialog={this.props.toggleDialog} itemType={itemType} + userPerm={userPerm} /> } diff --git a/frontend/src/components/shared-link-file-uploader/file-uploader.js b/frontend/src/components/shared-link-file-uploader/file-uploader.js new file mode 100644 index 0000000000..b3bcfa3bdb --- /dev/null +++ b/frontend/src/components/shared-link-file-uploader/file-uploader.js @@ -0,0 +1,700 @@ +// the parent folder of this file is copied from components/file-uploader +// this file is modified according to the requirements of 'file upload' in 'shared dir link' +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import Resumablejs from '@seafile/resumablejs'; +import MD5 from 'MD5'; +import { resumableUploadFileBlockSize, maxUploadFileSize, maxNumberOfFilesForFileupload } from '../../utils/constants'; +import { seafileAPI } from '../../utils/seafile-api'; +import { Utils } from '../../utils/utils'; +import { gettext } from '../../utils/constants'; +import UploadProgressDialog from './upload-progress-dialog'; +import toaster from '../toast'; +import '../../css/file-uploader.css'; + +const propTypes = { + token: PropTypes.string.isRequired, + repoID: PropTypes.string.isRequired, + //direntList: PropTypes.array.isRequired, + filetypes: PropTypes.array, + chunkSize: PropTypes.number, + withCredentials: PropTypes.bool, + testMethod: PropTypes.string, + testChunks: PropTypes.number, + simultaneousUploads: PropTypes.number, + fileParameterName: PropTypes.string, + minFileSizeErrorCallback: PropTypes.func, + fileTypeErrorCallback: PropTypes.func, + dragAndDrop: PropTypes.bool.isRequired, + path: PropTypes.string.isRequired, + onFileUploadSuccess: PropTypes.func.isRequired +}; + +class FileUploader extends React.Component { + + constructor(props) { + super(props); + this.state = { + retryFileList: [], + uploadFileList: [], + forbidUploadFileList: [], + totalProgress: 0, + isUploadProgressDialogShow: false, + isUploadRemindDialogShow: false, + currentResumableFile: null, + uploadBitrate: 0, + allFilesUploaded: false, + }; + + this.uploadInput = React.createRef(); + + this.notifiedFolders = []; + + this.timestamp = null; + this.loaded = 0; + this.bitrateInterval = 500; // Interval in milliseconds to calculate the bitrate + window.onbeforeunload = this.onbeforeunload; + this.isUploadLinkLoaded = false; + } + + componentDidMount() { + this.resumable = new Resumablejs({ + target: '', + query: this.setQuery || {}, + fileType: this.props.filetypes, + maxFiles: maxNumberOfFilesForFileupload || undefined, + maxFileSize: maxUploadFileSize * 1000 * 1000 || undefined, + testMethod: this.props.testMethod || 'post', + testChunks: this.props.testChunks || false, + headers: this.setHeaders || {}, + withCredentials: this.props.withCredentials || false, + chunkSize: parseInt(resumableUploadFileBlockSize) * 1024 * 1024 || 1 * 1024 * 1024, + simultaneousUploads: this.props.simultaneousUploads || 1, + fileParameterName: this.props.fileParameterName, + generateUniqueIdentifier: this.generateUniqueIdentifier, + forceChunkSize: true, + maxChunkRetries: 3, + minFileSize: 0, + }); + + this.resumable.assignBrowse(this.uploadInput.current, true); + + //Enable or Disable DragAnd Drop + if (this.props.dragAndDrop === true) { + this.resumable.enableDropOnDocument(); + } + + this.bindCallbackHandler(); + this.bindEventHandler(); + } + + componentWillUnmount = () => { + window.onbeforeunload = null; + if (this.props.dragAndDrop === true) { + this.resumable.disableDropOnDocument(); + } + } + + onbeforeunload = () => { + if (window.uploader && + window.uploader.isUploadProgressDialogShow && + window.uploader.totalProgress !== 100) { + return ''; + } + } + + bindCallbackHandler = () => { + let { minFileSizeErrorCallback, fileTypeErrorCallback } = this.props; + + if (this.maxFilesErrorCallback) { + this.resumable.opts.maxFilesErrorCallback = this.maxFilesErrorCallback; + } + + if (minFileSizeErrorCallback) { + this.resumable.opts.minFileSizeErrorCallback = this.props.minFileSizeErrorCallback; + } + + if (this.maxFileSizeErrorCallback) { + this.resumable.opts.maxFileSizeErrorCallback = this.maxFileSizeErrorCallback; + } + + if (fileTypeErrorCallback) { + this.resumable.opts.fileTypeErrorCallback = this.props.fileTypeErrorCallback; + } + + } + + bindEventHandler = () => { + this.resumable.on('chunkingComplete', this.onChunkingComplete.bind(this)); + this.resumable.on('fileAdded', this.onFileAdded.bind(this)); + this.resumable.on('filesAddedComplete', this.filesAddedComplete.bind(this)); + this.resumable.on('fileProgress', this.onFileProgress.bind(this)); + this.resumable.on('fileSuccess', this.onFileUploadSuccess.bind(this)); + this.resumable.on('progress', this.onProgress.bind(this)); + this.resumable.on('complete', this.onComplete.bind(this)); + this.resumable.on('pause', this.onPause.bind(this)); + this.resumable.on('fileRetry', this.onFileRetry.bind(this)); + this.resumable.on('fileError', this.onFileError.bind(this)); + this.resumable.on('error', this.onError.bind(this)); + this.resumable.on('beforeCancel', this.onBeforeCancel.bind(this)); + this.resumable.on('cancel', this.onCancel.bind(this)); + this.resumable.on('dragstart', this.onDragStart.bind(this)); + } + + maxFilesErrorCallback = (files, errorCount) => { + let maxFiles = maxNumberOfFilesForFileupload; + let message = gettext('Please upload no more than {maxFiles} files at a time.'); + message = message.replace('{maxFiles}', maxFiles); + toaster.danger(message); + } + + maxFileSizeErrorCallback = (file) => { + let { forbidUploadFileList } = this.state; + forbidUploadFileList.push(file); + this.setState({forbidUploadFileList: forbidUploadFileList}); + } + + onChunkingComplete = (resumableFile) => { + + let allFilesUploaded = this.state.allFilesUploaded; + if (allFilesUploaded === true) { + this.setState({allFilesUploaded: false}); + } + + //get parent_dir relative_path + let path = this.props.path === '/' ? '/' : this.props.path + '/'; + let fileName = resumableFile.fileName; + let relativePath = resumableFile.relativePath; + let isFile = fileName === relativePath; + + //update formdata + resumableFile.formData = {}; + if (isFile) { // upload file + resumableFile.formData = { + parent_dir: path, + }; + } else { // upload folder + let relative_path = relativePath.slice(0, relativePath.lastIndexOf('/') + 1); + resumableFile.formData = { + parent_dir: path, + relative_path: relative_path + }; + } + } + + onFileAdded = (resumableFile, files) => { + let isFile = resumableFile.fileName === resumableFile.relativePath; + // uploading is file and only upload one file + if (isFile && files.length === 1) { + let hasRepetition = false; + /* + let direntList = this.props.direntList; + for (let i = 0; i < direntList.length; i++) { + if (direntList[i].type === 'file' && direntList[i].name === resumableFile.fileName) { + hasRepetition = true; + break; + } + } + */ + if (hasRepetition) { + this.setState({ + isUploadRemindDialogShow: true, + currentResumableFile: resumableFile, + }); + } else { + this.setUploadFileList(this.resumable.files); + let { token } = this.props; + seafileAPI.sharedLinkGetFileUploadUrl(token).then(res => { + this.resumable.opts.target = res.data.upload_link + '?ret-json=1'; + this.resumableUpload(resumableFile); + }).catch(error => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + } else { + this.setUploadFileList(this.resumable.files); + if (!this.isUploadLinkLoaded) { + this.isUploadLinkLoaded = true; + let { token } = this.props; + seafileAPI.sharedLinkGetFileUploadUrl(token).then(res => { + this.resumable.opts.target = res.data.upload_link + '?ret-json=1'; + this.resumable.upload(); + }).catch(error => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + } + } + + resumableUpload = (resumableFile) => { + let { repoID, path } = this.props; + seafileAPI.getFileUploadedBytes(repoID, path, resumableFile.fileName).then(res => { + let uploadedBytes = res.data.uploadedBytes; + let blockSize = parseInt(resumableUploadFileBlockSize) * 1024 * 1024 || 1024 * 1024; + let offset = Math.floor(uploadedBytes / blockSize); + resumableFile.markChunksCompleted(offset); + this.resumable.upload(); + }).catch(error => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + + filesAddedComplete = (resumable, files) => { + let { forbidUploadFileList } = this.state; + if (forbidUploadFileList.length > 0 && files.length === 0) { + this.setState({ + isUploadProgressDialogShow: true, + totalProgress: 100 + }); + } + } + + setUploadFileList = () => { + let uploadFileList = this.resumable.files; + this.setState({ + uploadFileList: uploadFileList, + isUploadProgressDialogShow: true, + }); + Utils.registerGlobalVariable('uploader', 'isUploadProgressDialogShow', true); + } + + onFileProgress = (resumableFile) => { + let uploadBitrate = this.getBitrate(); + let uploadFileList = this.state.uploadFileList.map(item => { + if (item.uniqueIdentifier === resumableFile.uniqueIdentifier) { + if (uploadBitrate) { + let lastSize = (item.size - (item.size * item.progress())) * 8; + let time = Math.floor(lastSize / uploadBitrate); + item.remainingTime = time; + } + } + return item; + }); + + this.setState({ + uploadBitrate: uploadBitrate, + uploadFileList: uploadFileList + }); + } + + getBitrate = () => { + let loaded = 0; + let uploadBitrate = 0; + let now = new Date().getTime(); + + this.resumable.files.forEach(file => { + loaded += file.progress() * file.size; + }); + + if (this.timestamp) { + let timeDiff = (now - this.timestamp); + if (timeDiff < this.bitrateInterval) { + return this.state.uploadBitrate; + } + + // 1. Cancel will produce loaded greater than this.loaded + // 2. reset can make this.loaded to be 0 + if (loaded < this.loaded || this.loaded === 0) { + this.loaded = loaded; // + } + + uploadBitrate = (loaded - this.loaded) * (1000 / timeDiff) * 8; + } + + this.timestamp = now; + this.loaded = loaded; + + return uploadBitrate; + } + + onProgress = () => { + let progress = Math.round(this.resumable.progress() * 100); + this.setState({totalProgress: progress}); + Utils.registerGlobalVariable('uploader', 'totalProgress', progress); + } + + onFileUploadSuccess = (resumableFile, message) => { + let formData = resumableFile.formData; + let currentTime = new Date().getTime()/1000; + message = formData.replace ? message : JSON.parse(message)[0]; + if (formData.relative_path) { // upload folder + // 'upload folder' is not supported + /* + let relative_path = formData.relative_path; + let dir_name = relative_path.slice(0, relative_path.indexOf('/')); + let dirent = { + id: message.id, + name: dir_name, + type: 'dir', + mtime: currentTime, + }; + + // update folders cache + let isExist = this.notifiedFolders.some(item => {return item.name === dirent.name;}); + if (!isExist) { + this.notifiedFolders.push(dirent); + this.props.onFileUploadSuccess(dirent); + } + + // update uploadFileList + let uploadFileList = this.state.uploadFileList.map(item => { + if (item.uniqueIdentifier === resumableFile.uniqueIdentifier) { + item.newFileName = relative_path + message.name; + item.isSaved = true; + } + return item; + }); + this.setState({uploadFileList: uploadFileList}); + + return; + */ + } + + // replacing file is not allowed in shared link with 'can_upload' permission + /* + if (formData.replace) { // upload file -- replace exist file + let fileName = resumableFile.fileName; + let dirent = { + id: message, + name: fileName, + type: 'file', + mtime: currentTime + }; + this.props.onFileUploadSuccess(dirent); // this contance: just one file + + let uploadFileList = this.state.uploadFileList.map(item => { + if (item.uniqueIdentifier === resumableFile.uniqueIdentifier) { + item.newFileName = fileName; + item.isSaved = true; + } + return item; + }); + this.setState({uploadFileList: uploadFileList}); + + return; + } + */ + + // upload file -- add files + let dirent = { + id: message.id, + type: 'file', + name: message.name, + size: message.size, + mtime: currentTime, + }; + this.props.onFileUploadSuccess(dirent); // this contance: no repetition file + + let uploadFileList = this.state.uploadFileList.map(item => { + if (item.uniqueIdentifier === resumableFile.uniqueIdentifier) { + item.newFileName = message.name; + item.isSaved = true; + } + return item; + }); + this.setState({uploadFileList: uploadFileList}); + } + + onFileError = (resumableFile, message) => { + let error = ''; + if (!message) { + error = gettext('Network error'); + } else { + // eg: '{"error": "Internal error" \n }' + let errorMessage = message.replace(/\n/g, ''); + errorMessage = JSON.parse(errorMessage); + error = errorMessage.error; + if (error === 'File locked by others.') { + error = gettext('File is locked by others.'); + } + if (error === 'Internal error.') { + error = gettext('Internal Server Error'); + } + } + + let uploadFileList = this.state.uploadFileList.map(item => { + if (item.uniqueIdentifier === resumableFile.uniqueIdentifier) { + this.state.retryFileList.push(item); + item.error = error; + } + return item; + }); + + this.loaded = 0; // reset loaded data; + this.setState({ + retryFileList: this.state.retryFileList, + uploadFileList: uploadFileList + }); + + } + + onComplete = () => { + this.notifiedFolders = []; + // reset upload link loaded + this.isUploadLinkLoaded = false; + this.setState({allFilesUploaded: true}); + } + + onPause = () => { + + } + + onError = (message) => { + // reset upload link loaded + this.isUploadLinkLoaded = false; + // After the error, the user can switch windows + Utils.registerGlobalVariable('uploader', 'totalProgress', 100); + } + + onFileRetry = () => { + // todo, cancel upload file, uploded again; + } + + onBeforeCancel = () => { + // todo, giving a pop message ? + } + + onCancel = () => { + + } + + setHeaders = (resumableFile, resumable) => { + let offset = resumable.offset; + let chunkSize = resumable.getOpt('chunkSize'); + let fileSize = resumableFile.size === 0 ? 1 : resumableFile.size; + let startByte = offset !== 0 ? offset * chunkSize : 0; + let endByte = Math.min(fileSize, (offset + 1) * chunkSize) - 1; + + if (fileSize - resumable.endByte < chunkSize && !resumable.getOpt('forceChunkSize')) { + endByte = fileSize; + } + + let headers = { + 'Accept': 'application/json; text/javascript, */*; q=0.01', + 'Content-Disposition': 'attachment; filename="' + encodeURI(resumableFile.fileName) + '"', + 'Content-Range': 'bytes ' + startByte + '-' + endByte + '/' + fileSize, + }; + + return headers; + } + + setQuery = (resumableFile) => { + let formData = resumableFile.formData; + return formData; + } + + generateUniqueIdentifier = (file) => { + let relativePath = file.webkitRelativePath||file.relativePath||file.fileName||file.name; + return MD5(relativePath + new Date()) + relativePath; + } + + onClick = (e) => { + e.nativeEvent.stopImmediatePropagation(); + e.stopPropagation(); + } + + onFileUpload = () => { + this.uploadInput.current.removeAttribute('webkitdirectory'); + + this.uploadInput.current.click(); + } + + onFolderUpload = () => { + this.uploadInput.current.setAttribute('webkitdirectory', 'webkitdirectory'); + this.uploadInput.current.click(); + } + + onDragStart = () => { + this.uploadInput.current.setAttribute('webkitdirectory', 'webkitdirectory'); + } + + onCloseUploadDialog = () => { + this.loaded = 0; + this.resumable.files = []; + // reset upload link loaded + this.isUploadLinkLoaded = false; + this.setState({isUploadProgressDialogShow: false, uploadFileList: [], forbidUploadFileList: []}); + Utils.registerGlobalVariable('uploader', 'isUploadProgressDialogShow', false); + } + + onUploadCancel = (uploadingItem) => { + + let uploadFileList = this.state.uploadFileList.filter(item => { + if (item.uniqueIdentifier === uploadingItem.uniqueIdentifier) { + item.cancel(); // execute cancel function will delete the file at the same time + return false; + } + return true; + }); + + if (!this.resumable.isUploading()) { + this.setState({ + totalProgress: '100', + allFilesUploaded: true, + }); + this.loaded = 0; + } + + this.setState({uploadFileList: uploadFileList}); + } + + onCancelAllUploading = () => { + let uploadFileList = this.state.uploadFileList.filter(item => { + if (Math.round(item.progress() !== 1)) { + item.cancel(); + return false; + } + return true; + }); + + this.loaded = 0; + + this.setState({ + allFilesUploaded: true, + totalProgress: '100', + uploadFileList: uploadFileList + }); + // reset upload link loaded + this.isUploadLinkLoaded = false; + } + + onUploadRetry = (resumableFile) => { + + let { token } = this.props; + seafileAPI.sharedLinkGetFileUploadUrl(token).then(res => { + this.resumable.opts.target = res.data.upload_link + '?ret-json=1'; + + let retryFileList = this.state.retryFileList.filter(item => { + return item.uniqueIdentifier !== resumableFile.uniqueIdentifier; + }); + let uploadFileList = this.state.uploadFileList.map(item => { + if (item.uniqueIdentifier === resumableFile.uniqueIdentifier) { + item.error = null; + this.retryUploadFile(item); + } + return item; + }); + + this.setState({ + retryFileList: retryFileList, + uploadFileList: uploadFileList + }); + }).catch(error => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + + onUploadRetryAll = () => { + + let { token } = this.props; + seafileAPI.sharedLinkGetFileUploadUrl(token).then(res => { + this.resumable.opts.target = res.data.upload_link + '?ret-json=1'; + this.state.retryFileList.forEach(item => { + item.error = false; + this.retryUploadFile(item); + }); + + let uploadFileList = this.state.uploadFileList.slice(0); + this.setState({ + retryFileList: [], + uploadFileList: uploadFileList + }); + + }).catch(error => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + + retryUploadFile = (resumableFile) => { + let { repoID, path } = this.props; + let fileName = resumableFile.fileName; + let isFile = resumableFile.fileName === resumableFile.relativePath; + if (!isFile) { + let relative_path = resumableFile.formData.relative_path; + let prefix = path === '/' ? (path + relative_path) : (path + '/' + relative_path); + fileName = prefix + fileName; + } + + resumableFile.bootstrap(); + var firedRetry = false; + resumableFile.resumableObj.on('chunkingComplete', () => { + if(!firedRetry) { + seafileAPI.getFileUploadedBytes(repoID, path, fileName).then(res => { + let uploadedBytes = res.data.uploadedBytes; + let blockSize = parseInt(resumableUploadFileBlockSize) * 1024 * 1024 || 1024 * 1024; + let offset = Math.floor(uploadedBytes / blockSize); + resumableFile.markChunksCompleted(offset); + + resumableFile.resumableObj.upload(); + + }).catch(error => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + firedRetry = true; + }); + } + + uploadFile = () => { + let resumableFile = this.resumable.files[this.resumable.files.length - 1]; + + let { token } = this.props; + seafileAPI.sharedLinkGetFileUploadUrl(token).then(res => { + this.resumable.opts.target = res.data.upload_link + '?ret-json=1'; + this.setState({ + isUploadRemindDialogShow: false, + isUploadProgressDialogShow: true, + uploadFileList: [...this.state.uploadFileList, resumableFile] + }, () => { + this.resumable.upload(); + }); + Utils.registerGlobalVariable('uploader', 'isUploadProgressDialogShow', true); + + }).catch(error => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + + cancelFileUpload = () => { + this.resumable.files.pop(); //delete latest fileļ¼› + this.setState({isUploadRemindDialogShow: false}); + } + + render() { + return ( + +
+
+ +
+
+ {this.state.isUploadProgressDialogShow && + + } +
+ ); + } +} + +FileUploader.propTypes = propTypes; + +export default FileUploader; diff --git a/frontend/src/components/shared-link-file-uploader/forbid-upload-list-item.js b/frontend/src/components/shared-link-file-uploader/forbid-upload-list-item.js new file mode 100644 index 0000000000..77de671bb6 --- /dev/null +++ b/frontend/src/components/shared-link-file-uploader/forbid-upload-list-item.js @@ -0,0 +1,28 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { gettext, maxUploadFileSize } from '../../utils/constants'; + +const propTypes = { + file: PropTypes.object, +}; + +class ForbidUploadListItem extends React.Component { + + render() { + let { file } = this.props; + let msg = gettext('Please upload files less than {placeholder}M').replace('{placeholder}', maxUploadFileSize); + return ( + + +
{file.name}
+ + + {msg} + + ); + } +} + +ForbidUploadListItem.propTypes = propTypes; + +export default ForbidUploadListItem; diff --git a/frontend/src/components/shared-link-file-uploader/upload-list-item.js b/frontend/src/components/shared-link-file-uploader/upload-list-item.js new file mode 100644 index 0000000000..df3a54ba97 --- /dev/null +++ b/frontend/src/components/shared-link-file-uploader/upload-list-item.js @@ -0,0 +1,144 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { gettext } from '../../utils/constants'; +import { Utils } from '../../utils/utils'; + +const propTypes = { + resumableFile: PropTypes.object.isRequired, + onUploadCancel: PropTypes.func.isRequired, + onUploadRetry: PropTypes.func.isRequired, +}; + +const UPLOAD_UPLOADING = 'uploading'; +const UPLOAD_ERROR = 'error'; +const UPLOAD_ISSAVING = 'isSaving'; +const UPLOAD_UPLOADED = 'uploaded'; + +class UploadListItem extends React.Component { + + constructor(props) { + super(props); + this.state = { + uploadState: UPLOAD_UPLOADING + }; + } + + componentWillReceiveProps(nextProps) { + let { resumableFile } = nextProps; + let uploadState = UPLOAD_UPLOADING; + + if (resumableFile.error) { + uploadState = UPLOAD_ERROR; + } else { + if (resumableFile.remainingTime === 0 && !resumableFile.isSaved) { + uploadState = UPLOAD_ISSAVING; + } + + if (resumableFile.isSaved) { + uploadState = UPLOAD_UPLOADED; + } + } + + this.setState({uploadState: uploadState}); + } + + onUploadCancel = (e) => { + e.preventDefault(); + this.props.onUploadCancel(this.props.resumableFile); + } + + onUploadRetry = (e) => { + e.preventDefault(); + this.props.onUploadRetry(this.props.resumableFile); + } + + formatFileSize = (size) => { + if (typeof size !== 'number') { + return ''; + } + if (size >= 1000 * 1000 * 1000) { + return (size / (1000 * 1000 * 1000)).toFixed(1) + ' G'; + } + if (size >= 1000 * 1000) { + return (size / (1000 * 1000)).toFixed(1) + ' M'; + } + if (size >= 1000) { + return (size / 1000).toFixed(1) + ' K'; + } + return size.toFixed(1) + ' B'; + } + + render() { + let { resumableFile } = this.props; + let progress = Math.round(resumableFile.progress() * 100); + let error = resumableFile.error; + + return ( + + +
{resumableFile.newFileName}
+ + + {this.formatFileSize(resumableFile.size)} + + + {(this.state.uploadState === UPLOAD_UPLOADING || this.state.uploadState === UPLOAD_ISSAVING) && + + {resumableFile.size >= (100 * 1000 * 1000) && + + {resumableFile.isUploading() && ( +
+
+
+
+ {(resumableFile.remainingTime === -1) &&
{gettext('Preparing to upload...')}
} + {(resumableFile.remainingTime > 0) &&
{gettext('Remaining')}{' '}{Utils.formatTime(resumableFile.remainingTime)}
} + {(resumableFile.remainingTime === 0) &&
{gettext('Indexing...')}
} +
+ )} + {!resumableFile.isUploading() && ( +
+
+
+
+
+ )} +
+ } + {(resumableFile.size < (100 * 1000 * 1000)) && +
+
+
+
+
+ } +
+ } + {this.state.uploadState === UPLOAD_ERROR && ( +
+ )} + + + + {this.state.uploadState === UPLOAD_UPLOADING && ( + {gettext('Cancel')} + )} + {this.state.uploadState === UPLOAD_ERROR && ( + {gettext('Retry')} + )} + {this.state.uploadState === UPLOAD_ISSAVING && ( + {gettext('Saving...')} + )} + {this.state.uploadState === UPLOAD_UPLOADED && ( + {gettext('Uploaded')} + )} + + + + ); + } +} + +UploadListItem.propTypes = propTypes; + +export default UploadListItem; diff --git a/frontend/src/components/shared-link-file-uploader/upload-progress-dialog.js b/frontend/src/components/shared-link-file-uploader/upload-progress-dialog.js new file mode 100644 index 0000000000..8d4aafdb81 --- /dev/null +++ b/frontend/src/components/shared-link-file-uploader/upload-progress-dialog.js @@ -0,0 +1,126 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { gettext } from '../../utils/constants'; +import { Utils } from '../../utils/utils'; +import UploadListItem from './upload-list-item'; +import ForbidUploadListItem from './forbid-upload-list-item'; + +const propTypes = { + uploadBitrate: PropTypes.number.isRequired, + totalProgress: PropTypes.number.isRequired, + retryFileList: PropTypes.array.isRequired, + uploadFileList: PropTypes.array.isRequired, + forbidUploadFileList: PropTypes.array.isRequired, + onCloseUploadDialog: PropTypes.func.isRequired, + onCancelAllUploading: PropTypes.func.isRequired, + onUploadCancel: PropTypes.func.isRequired, + onUploadRetry: PropTypes.func.isRequired, + onUploadRetryAll: PropTypes.func.isRequired, + allFilesUploaded: PropTypes.bool.isRequired, +}; + +class UploadProgressDialog extends React.Component { + + constructor(props) { + super(props); + this.state = { + isMinimized: false + }; + } + + onCancelAllUploading = () => { + this.props.onCancelAllUploading(); + } + + onMinimizeUpload = (e) => { + e.nativeEvent.stopImmediatePropagation(); + this.setState({isMinimized: !this.state.isMinimized}); + } + + onCloseUpload = (e) => { + e.nativeEvent.stopImmediatePropagation(); + this.props.onCloseUploadDialog(); + } + + render() { + + let uploadBitrate = Utils.formatBitRate(this.props.uploadBitrate); + let uploadedMessage = gettext('File Upload'); + let uploadingMessage = gettext('File Uploading...') + ' ' + this.props.totalProgress + '%' + ' (' + uploadBitrate + ')'; + + let uploadingOptions = (); + + let uploadedOptions = ( + + + + + ); + + let { totalProgress, allFilesUploaded, retryFileList } = this.props; + + return ( +
+
+
+ {totalProgress === 100 ? uploadedMessage : uploadingMessage} +
+
+ {totalProgress === 100 || allFilesUploaded ? uploadedOptions : uploadingOptions} +
+
+
+ + + + + + + + + + + + + + + { + this.props.forbidUploadFileList.map((file, index) => { + return (); + }) + } + { + this.props.uploadFileList.map((resumableFile, index) => { + return ( + + ); + }) + } + +
{gettext('name')}{gettext('size')}{gettext('progress')}{gettext('state')}
+ {retryFileList.length > 0 ? + {gettext('Retry All')} + : + {gettext('Retry All')} + } + + {!allFilesUploaded ? + {gettext('Cancel All')} + : + {gettext('Cancel All')} + } +
+
+
+ ); + } +} + +UploadProgressDialog.propTypes = propTypes; + +export default UploadProgressDialog; diff --git a/frontend/src/css/shared-dir-view.css b/frontend/src/css/shared-dir-view.css index 11f86494ae..7354ce72cf 100644 --- a/frontend/src/css/shared-dir-view.css +++ b/frontend/src/css/shared-dir-view.css @@ -11,10 +11,6 @@ body { height: 53px; flex-shrink: 0; } -.title { - font-size: 1.4rem; - margin-bottom: .5rem; -} .shared-dir-view-main { width: calc(100% - 40px); max-width: 950px; @@ -42,11 +38,14 @@ body { background-color: #ccc; color: #fff; } -.zip-btn { +.shared-dir-op-btn { height: 30px; line-height: 30px; padding: 0 10px; } +.shared-dir-upload-btn { + border: 1px solid #ccc; +} .grid-item .action-icon { position: absolute; top: 10px; diff --git a/frontend/src/shared-dir-view.js b/frontend/src/shared-dir-view.js index b937adf4f7..44ed759b4a 100644 --- a/frontend/src/shared-dir-view.js +++ b/frontend/src/shared-dir-view.js @@ -3,7 +3,7 @@ import ReactDOM from 'react-dom'; import { Button, Dropdown, DropdownToggle, DropdownItem } from 'reactstrap'; import moment from 'moment'; import Account from './components/common/account'; -import { gettext, siteRoot, mediaUrl, logoPath, logoWidth, logoHeight, siteTitle, thumbnailSizeForOriginal } from './utils/constants'; +import { isPro, gettext, siteRoot, mediaUrl, logoPath, logoWidth, logoHeight, siteTitle, thumbnailSizeForOriginal } from './utils/constants'; import { Utils } from './utils/utils'; import { seafileAPI } from './utils/seafile-api'; import Loading from './components/loading'; @@ -11,6 +11,7 @@ import toaster from './components/toast'; import ModalPortal from './components/modal-portal'; import ZipDownloadDialog from './components/dialog/zip-download-dialog'; import ImageDialog from './components/dialog/image-dialog'; +import FileUploader from './components/shared-link-file-uploader/file-uploader'; import './css/shared-dir-view.css'; import './css/grid-view.css'; @@ -18,7 +19,13 @@ import './css/grid-view.css'; moment.locale(window.app.config.lang); let loginUser = window.app.pageOptions.name; -const { token, trafficOverLimit, dirName, sharedBy, path, canDownload, mode, thumbnailSize, zipped } = window.shared.pageOptions; +const { + token, dirName, sharedBy, + repoID, path, + mode, thumbnailSize, zipped, + trafficOverLimit, canDownload, + noQuota, canUpload +} = window.shared.pageOptions; const showDownloadIcon = !trafficOverLimit && canDownload; @@ -235,6 +242,28 @@ class SharedDirView extends React.Component { }); } + onUploadFile = (e) => { + e.nativeEvent.stopImmediatePropagation(); + this.uploader.onFileUpload(); + } + + onFileUploadSuccess = (direntObject) => { + const { name, size } = direntObject; + const newItem = { + isSelected: false, + file_name: name, + file_path: Utils.joinPath(path, name), + is_dir: false, + last_modified: moment().format(), + size: size + }; + const folderItems = this.state.items.filter(item => { return item.is_dir; }); + // put the new file as the first file + let items = Array.from(this.state.items); + items.splice(folderItems.length, 0, newItem); + this.setState({items: items}); + } + render() { const isDesktop = Utils.isDesktop(); const modeBaseClass = 'btn btn-secondary btn-icon sf-view-mode-btn'; @@ -249,7 +278,7 @@ class SharedDirView extends React.Component {
-

{dirName}

+

{dirName}

{gettext('Shared by: ')}{sharedBy}

{gettext('Current path: ')}{this.renderPath()}

@@ -260,16 +289,31 @@ class SharedDirView extends React.Component {
} + {canUpload && ( + + )} {showDownloadIcon && {this.state.items.some(item => item.isSelected) ? - : - + : + } }
+ {!noQuota && canUpload && ( + this.uploader = uploader} + dragAndDrop={false} + token={token} + path={path} + repoID={repoID} + onFileUploadSuccess={this.onFileUploadSuccess} + /> + )} -1 || - navigator.userAgent.indexOf("Safari") > -1; + navigator.userAgent.indexOf('Safari') > -1; }, isIEBrower: function() { // is ie <= ie11 not include Edge @@ -615,7 +606,8 @@ export const Utils = { text: gettext('Preview and download'), permissionDetails: { 'can_edit': false, - "can_download": true + 'can_download': true, + 'can_upload': false } }; case 'preview_only': @@ -624,7 +616,18 @@ export const Utils = { text: gettext('Preview only'), permissionDetails: { 'can_edit': false, - "can_download": false + 'can_download': false, + 'can_upload': false + } + }; + case 'download_upload': + return { + value: permission, + text: gettext('Download and upload'), + permissionDetails: { + 'can_edit': false, + 'can_download': true, + 'can_upload': true } }; case 'edit_download': @@ -633,7 +636,8 @@ export const Utils = { text: gettext('Edit on cloud and download'), permissionDetails: { 'can_edit': true, - "can_download": true + 'can_download': true, + 'can_upload': false } }; case 'cloud_edit': @@ -642,7 +646,8 @@ export const Utils = { text: gettext('Edit on cloud only'), permissionDetails: { 'can_edit': true, - "can_download": false + 'can_download': false, + 'can_upload': false } }; } diff --git a/seahub/templates/view_shared_dir_react.html b/seahub/templates/view_shared_dir_react.html index c7c27fb2c1..1b9d7cc819 100644 --- a/seahub/templates/view_shared_dir_react.html +++ b/seahub/templates/view_shared_dir_react.html @@ -21,6 +21,7 @@ pageOptions: { dirName: '{{ dir_name|escapejs }}', sharedBy: '{{ username|email2nickname|escapejs }}', + repoID: '{{repo.id}}', path: '{{ path|escapejs }}', zipped: (function() { var list = []; @@ -36,7 +37,9 @@ mode: '{{ mode }}', thumbnailSize: {{ thumbnail_size }}, trafficOverLimit: {% if traffic_over_limit %}true{% else %}false{% endif %}, - canDownload: {% if permissions.can_download %}true{% else %}false{% endif %} + canDownload: {% if permissions.can_download %}true{% else %}false{% endif %}, + noQuota: {% if no_quota %}true{% else %}false{% endif %}, + canUpload: {% if permissions.can_upload %}true{% else %}false{% endif %} } }; diff --git a/seahub/views/repo.py b/seahub/views/repo.py index cb36a187a7..b1eb8bb9b2 100644 --- a/seahub/views/repo.py +++ b/seahub/views/repo.py @@ -328,7 +328,10 @@ def view_shared_dir(request, fileshare): req_image_path = posixpath.join(req_path, f.obj_name) src = get_share_link_thumbnail_src(token, thumbnail_size, req_image_path) f.encoded_thumbnail_src = urlquote(src) - + + # for 'upload file' + no_quota = True if seaserv.check_quota(repo_id) < 0 else False + #template = 'view_shared_dir.html' template = 'view_shared_dir_react.html' @@ -345,6 +348,7 @@ def view_shared_dir(request, fileshare): 'dir_list': dir_list, 'zipped': zipped, 'traffic_over_limit': traffic_over_limit, + 'no_quota': no_quota, 'permissions': permissions, 'ENABLE_THUMBNAIL': ENABLE_THUMBNAIL, 'mode': mode,