diff --git a/backend/src/file/file.controller.ts b/backend/src/file/file.controller.ts index 8f7997f6..5ca7651f 100644 --- a/backend/src/file/file.controller.ts +++ b/backend/src/file/file.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Delete, Get, Param, Post, @@ -81,4 +82,14 @@ export class FileController { return new StreamableFile(file.file); } + + @Delete(":fileId") + @SkipThrottle() + @UseGuards(ShareOwnerGuard) + async remove( + @Param("fileId") fileId: string, + @Param("shareId") shareId: string, + ) { + await this.fileService.remove(shareId, fileId); + } } diff --git a/backend/src/file/file.service.ts b/backend/src/file/file.service.ts index 8c6d77d2..b4275848 100644 --- a/backend/src/file/file.service.ts +++ b/backend/src/file/file.service.ts @@ -124,6 +124,18 @@ export class FileService { }; } + async remove(shareId: string, fileId: string) { + const fileMetaData = await this.prisma.file.findUnique({ + where: { id: fileId }, + }); + + if (!fileMetaData) throw new NotFoundException("File not found"); + + fs.unlinkSync(`${SHARE_DIRECTORY}/${shareId}/${fileId}`); + + await this.prisma.file.delete({ where: { id: fileId } }); + } + async deleteAllFiles(shareId: string) { await fs.promises.rm(`${SHARE_DIRECTORY}/${shareId}`, { recursive: true, diff --git a/backend/src/share/guard/shareOwner.guard.ts b/backend/src/share/guard/shareOwner.guard.ts index 7be0be9a..ae3e6451 100644 --- a/backend/src/share/guard/shareOwner.guard.ts +++ b/backend/src/share/guard/shareOwner.guard.ts @@ -1,5 +1,4 @@ import { - CanActivate, ExecutionContext, Injectable, NotFoundException, @@ -7,12 +6,21 @@ import { import { User } from "@prisma/client"; import { Request } from "express"; import { PrismaService } from "src/prisma/prisma.service"; +import { JwtGuard } from "../../auth/guard/jwt.guard"; +import { ConfigService } from "src/config/config.service"; @Injectable() -export class ShareOwnerGuard implements CanActivate { - constructor(private prisma: PrismaService) {} +export class ShareOwnerGuard extends JwtGuard { + constructor( + configService: ConfigService, + private prisma: PrismaService, + ) { + super(configService); + } async canActivate(context: ExecutionContext) { + if (!(await super.canActivate(context))) return false; + const request: Request = context.switchToHttp().getRequest(); const shareId = Object.prototype.hasOwnProperty.call( request.params, diff --git a/backend/src/share/share.controller.ts b/backend/src/share/share.controller.ts index 7bd46ad1..5cdc7d71 100644 --- a/backend/src/share/share.controller.ts +++ b/backend/src/share/share.controller.ts @@ -43,6 +43,12 @@ export class ShareController { return new ShareDTO().from(await this.shareService.get(id)); } + @Get(":id/from-owner") + @UseGuards(ShareOwnerGuard) + async getFromOwner(@Param("id") id: string) { + return new ShareDTO().from(await this.shareService.get(id)); + } + @Get(":id/metaData") @UseGuards(ShareSecurityGuard) async getMetaData(@Param("id") id: string) { @@ -62,12 +68,6 @@ export class ShareController { ); } - @Delete(":id") - @UseGuards(JwtGuard, ShareOwnerGuard) - async remove(@Param("id") id: string) { - await this.shareService.remove(id); - } - @Post(":id/complete") @HttpCode(202) @UseGuards(CreateShareGuard, ShareOwnerGuard) @@ -78,6 +78,18 @@ export class ShareController { ); } + @Delete(":id/complete") + @UseGuards(ShareOwnerGuard) + async revertComplete(@Param("id") id: string) { + return new ShareDTO().from(await this.shareService.revertComplete(id)); + } + + @Delete(":id") + @UseGuards(ShareOwnerGuard) + async remove(@Param("id") id: string) { + await this.shareService.remove(id); + } + @Throttle(10, 60) @Get("isShareIdAvailable/:id") async isShareIdAvailable(@Param("id") id: string) { diff --git a/backend/src/share/share.service.ts b/backend/src/share/share.service.ts index b3c745fd..ad44dd65 100644 --- a/backend/src/share/share.service.ts +++ b/backend/src/share/share.service.ts @@ -182,6 +182,13 @@ export class ShareService { }); } + async revertComplete(id: string) { + return this.prisma.share.update({ + where: { id }, + data: { uploadLocked: false, isZipReady: false }, + }); + } + async getSharesByUser(userId: string) { const shares = await this.prisma.share.findMany({ where: { diff --git a/frontend/src/components/upload/Dropzone.tsx b/frontend/src/components/upload/Dropzone.tsx index 1c834fd2..51031082 100644 --- a/frontend/src/components/upload/Dropzone.tsx +++ b/frontend/src/components/upload/Dropzone.tsx @@ -33,10 +33,12 @@ const useStyles = createStyles((theme) => ({ })); const Dropzone = ({ + title, isUploading, maxShareSize, showCreateUploadModalCallback, }: { + title?: string; isUploading: boolean; maxShareSize: number; showCreateUploadModalCallback: (files: FileUpload[]) => void; @@ -78,7 +80,7 @@ const Dropzone = ({ - + {title || } { + const t = useTranslate(); + const router = useRouter(); + const config = useConfig(); + + const [existingFiles, setExistingFiles] = + useState>(savedFiles); + const [uploadingFiles, setUploadingFiles] = useState([]); + const [isUploading, setIsUploading] = useState(false); + + const existingAndUploadedFiles: FileListItem[] = useMemo( + () => [...uploadingFiles, ...existingFiles], + [existingFiles, uploadingFiles], + ); + const dirty = useMemo(() => { + return ( + existingFiles.some((file) => !!file.deleted) || !!uploadingFiles.length + ); + }, [existingFiles, uploadingFiles]); + + const setFiles = (files: FileListItem[]) => { + const _uploadFiles = files.filter( + (file) => "uploadingProgress" in file, + ) as FileUpload[]; + const _existingFiles = files.filter( + (file) => !("uploadingProgress" in file), + ) as FileMetaData[]; + + setUploadingFiles(_uploadFiles); + setExistingFiles(_existingFiles); + }; + + maxShareSize ??= parseInt(config.get("share.maxSize")); + + const uploadFiles = async (files: FileUpload[]) => { + const fileUploadPromises = files.map(async (file, fileIndex) => + // Limit the number of concurrent uploads to 3 + promiseLimit(async () => { + let fileId: string; + + const setFileProgress = (progress: number) => { + setUploadingFiles((files) => + files.map((file, callbackIndex) => { + if (fileIndex == callbackIndex) { + file.uploadingProgress = progress; + } + return file; + }), + ); + }; + + setFileProgress(1); + + let chunks = Math.ceil(file.size / chunkSize); + + // If the file is 0 bytes, we still need to upload 1 chunk + if (chunks == 0) chunks++; + + for (let chunkIndex = 0; chunkIndex < chunks; chunkIndex++) { + const from = chunkIndex * chunkSize; + const to = from + chunkSize; + const blob = file.slice(from, to); + try { + await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = async (event) => + await shareService + .uploadFile( + shareId, + event, + { + id: fileId, + name: file.name, + }, + chunkIndex, + chunks, + ) + .then((response) => { + fileId = response.id; + resolve(response); + }) + .catch(reject); + + reader.readAsDataURL(blob); + }); + + setFileProgress(((chunkIndex + 1) / chunks) * 100); + } catch (e) { + if ( + e instanceof AxiosError && + e.response?.data.error == "unexpected_chunk_index" + ) { + // Retry with the expected chunk index + chunkIndex = e.response!.data!.expectedChunkIndex - 1; + continue; + } else { + setFileProgress(-1); + // Retry after 5 seconds + await new Promise((resolve) => setTimeout(resolve, 5000)); + chunkIndex = -1; + + continue; + } + } + } + }), + ); + + await Promise.all(fileUploadPromises); + }; + + const removeFiles = async () => { + const removedFiles = existingFiles.filter((file) => !!file.deleted); + + if (removedFiles.length > 0) { + await Promise.all( + removedFiles.map(async (file) => { + await shareService.removeFile(shareId, file.id); + }), + ); + + setExistingFiles(existingFiles.filter((file) => !file.deleted)); + } + }; + + const revertComplete = async () => { + await shareService.revertComplete(shareId).then(); + }; + + const completeShare = async () => { + return await shareService.completeShare(shareId); + }; + + const save = async () => { + setIsUploading(true); + + try { + await revertComplete(); + await uploadFiles(uploadingFiles); + + const hasFailed = uploadingFiles.some( + (file) => file.uploadingProgress == -1, + ); + + if (!hasFailed) { + await removeFiles(); + } + + await completeShare(); + + if (!hasFailed) { + toast.success(t("share.edit.notify.save-success")); + router.back(); + } + } catch { + toast.error(t("share.edit.notify.generic-error")); + } finally { + setIsUploading(false); + } + }; + + const appendFiles = (appendingFiles: FileUpload[]) => { + setUploadingFiles([...appendingFiles, ...uploadingFiles]); + }; + + useEffect(() => { + // Check if there are any files that failed to upload + const fileErrorCount = uploadingFiles.filter( + (file) => file.uploadingProgress == -1, + ).length; + + if (fileErrorCount > 0) { + if (!errorToastShown) { + toast.error( + t("upload.notify.count-failed", { count: fileErrorCount }), + { + withCloseButton: false, + autoClose: false, + }, + ); + } + errorToastShown = true; + } else { + cleanNotifications(); + errorToastShown = false; + } + }, [uploadingFiles]); + + return ( + <> + + + + + {existingAndUploadedFiles.length > 0 && ( + + )} + + ); +}; +export default EditableUpload; diff --git a/frontend/src/components/upload/FileList.tsx b/frontend/src/components/upload/FileList.tsx index b6186413..98468e8a 100644 --- a/frontend/src/components/upload/FileList.tsx +++ b/frontend/src/components/upload/FileList.tsx @@ -1,41 +1,106 @@ import { ActionIcon, Table } from "@mantine/core"; -import { Dispatch, SetStateAction } from "react"; import { TbTrash } from "react-icons/tb"; -import { FileUpload } from "../../types/File.type"; +import { GrUndo } from "react-icons/gr"; +import { FileListItem } from "../../types/File.type"; import { byteToHumanSizeString } from "../../utils/fileSize.util"; import UploadProgressIndicator from "./UploadProgressIndicator"; import { FormattedMessage } from "react-intl"; -const FileList = ({ +const FileListRow = ({ + file, + onRemove, + onRestore, +}: { + file: FileListItem; + onRemove?: () => void; + onRestore?: () => void; +}) => { + { + const uploadable = "uploadingProgress" in file; + const uploading = uploadable && file.uploadingProgress !== 0; + const removable = uploadable + ? file.uploadingProgress === 0 + : onRemove && !file.deleted; + const restorable = onRestore && !uploadable && !!file.deleted; // maybe undefined, force boolean + const deleted = !uploadable && !!file.deleted; + + return ( + + {file.name} + {byteToHumanSizeString(+file.size)} + + {removable && ( + + + + )} + {uploading && ( + + )} + {restorable && ( + + + + )} + + + ); + } +}; + +const FileList = ({ files, setFiles, }: { - files: FileUpload[]; - setFiles: Dispatch>; + files: T[]; + setFiles: (files: T[]) => void; }) => { const remove = (index: number) => { - files.splice(index, 1); + const file = files[index]; + + if ("uploadingProgress" in file) { + files.splice(index, 1); + } else { + files[index] = { ...file, deleted: true }; + } + setFiles([...files]); }; + + const restore = (index: number) => { + const file = files[index]; + + if ("uploadingProgress" in file) { + return; + } else { + files[index] = { ...file, deleted: false }; + } + + setFiles([...files]); + }; + const rows = files.map((file, i) => ( - - {file.name} - {byteToHumanSizeString(file.size)} - - {file.uploadingProgress == 0 ? ( - remove(i)} - > - - - ) : ( - - )} - - + remove(i)} + onRestore={() => restore(i)} + /> )); return ( diff --git a/frontend/src/i18n/translations/en-US.ts b/frontend/src/i18n/translations/en-US.ts index df90a399..36eefcc4 100644 --- a/frontend/src/i18n/translations/en-US.ts +++ b/frontend/src/i18n/translations/en-US.ts @@ -358,6 +358,13 @@ export default { // END /share/[id] + // /share/[id]/edit + "share.edit.title": "Edit {shareId}", + "share.edit.append-upload": "Append file", + "share.edit.notify.generic-error": "An error occurred while finishing your share.", + "share.edit.notify.save-success": "Share updated successfully", + // END /share/[id]/edit + // /admin/config "admin.config.title": "Configuration", "admin.config.category.general": "General", diff --git a/frontend/src/i18n/translations/zh-CN.ts b/frontend/src/i18n/translations/zh-CN.ts index 421fbc01..9762829a 100644 --- a/frontend/src/i18n/translations/zh-CN.ts +++ b/frontend/src/i18n/translations/zh-CN.ts @@ -264,6 +264,12 @@ export default { "share.modal.file-preview.error.not-supported.title": "该文件类型不支持预览", "share.modal.file-preview.error.not-supported.description": "该文件类型不支持预览,请下载后打开查看", // END /share/[id] + // /share/[id]/edit + "share.edit.title": "编辑 {shareId}", + "share.edit.append-upload": "追加文件", + "share.edit.notify.generic-error": "保存共享的过程中发生了错误", + "share.edit.notify.save-success": "共享已更新成功", + // END /share/[id]/edit // /admin/config "admin.config.title": "配置管理", "admin.config.category.general": "通用", diff --git a/frontend/src/pages/account/shares.tsx b/frontend/src/pages/account/shares.tsx index 230498d5..9edb2286 100644 --- a/frontend/src/pages/account/shares.tsx +++ b/frontend/src/pages/account/shares.tsx @@ -16,7 +16,7 @@ import { useModals } from "@mantine/modals"; import moment from "moment"; import Link from "next/link"; import { useEffect, useState } from "react"; -import { TbInfoCircle, TbLink, TbTrash } from "react-icons/tb"; +import { TbEdit, TbInfoCircle, TbLink, TbTrash } from "react-icons/tb"; import { FormattedMessage } from "react-intl"; import Meta from "../../components/Meta"; import showShareInformationsModal from "../../components/account/showShareInformationsModal"; @@ -110,6 +110,11 @@ const MyShares = () => { + + + + + { + const t = useTranslate(); + const modals = useModals(); + const [isLoading, setIsLoading] = useState(true); + const [share, setShare] = useState(); + + useEffect(() => { + shareService + .getFromOwner(shareId) + .then((share) => { + setShare(share); + }) + .catch((e) => { + const { error } = e.response.data; + if (e.response.status == 404) { + if (error == "share_removed") { + showErrorModal( + modals, + t("share.error.removed.title"), + e.response.data.message, + ); + } else { + showErrorModal( + modals, + t("share.error.not-found.title"), + t("share.error.not-found.description"), + ); + } + } else { + showErrorModal(modals, t("common.error"), t("common.error.unknown")); + } + }) + .finally(() => { + setIsLoading(false); + }); + }, []); + + if (isLoading) return ; + + return ( + <> + + + + ); +}; + +export default Share; diff --git a/frontend/src/pages/upload/index.tsx b/frontend/src/pages/upload/index.tsx index 1b688528..4ab894d1 100644 --- a/frontend/src/pages/upload/index.tsx +++ b/frontend/src/pages/upload/index.tsx @@ -202,7 +202,9 @@ const Upload = ({ showCreateUploadModalCallback={showCreateUploadModalCallback} isUploading={isUploading} /> - {files.length > 0 && } + {files.length > 0 && ( + files={files} setFiles={setFiles} /> + )} ); }; diff --git a/frontend/src/services/share.service.ts b/frontend/src/services/share.service.ts index a256b808..e7ea73a5 100644 --- a/frontend/src/services/share.service.ts +++ b/frontend/src/services/share.service.ts @@ -19,10 +19,18 @@ const completeShare = async (id: string) => { return (await api.post(`shares/${id}/complete`)).data; }; +const revertComplete = async (id: string) => { + return (await api.delete(`shares/${id}/complete`)).data; +}; + const get = async (id: string): Promise => { return (await api.get(`shares/${id}`)).data; }; +const getFromOwner = async (id: string): Promise => { + return (await api.get(`shares/${id}/from-owner`)).data; +}; + const getMetaData = async (id: string): Promise => { return (await api.get(`shares/${id}/metaData`)).data; }; @@ -63,6 +71,10 @@ const downloadFile = async (shareId: string, fileId: string) => { window.location.href = `${window.location.origin}/api/shares/${shareId}/files/${fileId}`; }; +const removeFile = async (shareId: string, fileId: string) => { + await api.delete(`shares/${shareId}/files/${fileId}`); +}; + const uploadFile = async ( shareId: string, readerEvent: ProgressEvent, @@ -121,14 +133,17 @@ const removeReverseShare = async (id: string) => { export default { create, completeShare, + revertComplete, getShareToken, get, + getFromOwner, remove, getMetaData, doesFileSupportPreview, getMyShares, isShareIdAvailable, downloadFile, + removeFile, uploadFile, setReverseShare, createReverseShare, diff --git a/frontend/src/types/File.type.ts b/frontend/src/types/File.type.ts index 7c0927af..f50cd9db 100644 --- a/frontend/src/types/File.type.ts +++ b/frontend/src/types/File.type.ts @@ -7,3 +7,5 @@ export type FileMetaData = { name: string; size: string; }; + +export type FileListItem = FileUpload | (FileMetaData & { deleted?: boolean });