0
Fork 0
mirror of https://github.com/stonith404/pingvin-share.git synced 2025-02-19 01:55:48 -05:00

feat: ability to add and delete files of existing share (#306)

* feat(share): delete file api, revert complete share api.

* feat(share): share edit page.

* feat(share): Modify the DropZone title of the edit sharing UI.

* feat(share): i18n for edit share. (en, zh)

* feat(share): allow creator get share by id.

* feat(share): add edit button in account/shares.

* style(share): lint.

* chore: some minor adjustments.

* refactor: run formatter

* refactor: remove unused return

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Ivan Li 2023-11-05 03:39:58 +08:00 committed by GitHub
parent e377ed10e1
commit 98380e2d48
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 493 additions and 36 deletions

View file

@ -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);
}
}

View file

@ -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,

View file

@ -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,

View file

@ -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) {

View file

@ -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: {

View file

@ -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 = ({
<TbCloudUpload size={50} />
</Group>
<Text align="center" weight={700} size="lg" mt="xl">
<FormattedMessage id="upload.dropzone.title" />
{title || <FormattedMessage id="upload.dropzone.title" />}
</Text>
<Text align="center" size="sm" mt="xs" color="dimmed">
<FormattedMessage

View file

@ -0,0 +1,238 @@
import { Button, Group } from "@mantine/core";
import { useModals } from "@mantine/modals";
import { cleanNotifications } from "@mantine/notifications";
import { AxiosError } from "axios";
import pLimit from "p-limit";
import { useEffect, useMemo, useState } from "react";
import { FormattedMessage } from "react-intl";
import Dropzone from "../../components/upload/Dropzone";
import FileList from "../../components/upload/FileList";
import showCompletedUploadModal from "../../components/upload/modals/showCompletedUploadModal";
import useConfig from "../../hooks/config.hook";
import useTranslate from "../../hooks/useTranslate.hook";
import shareService from "../../services/share.service";
import { FileListItem, FileMetaData, FileUpload } from "../../types/File.type";
import toast from "../../utils/toast.util";
import { useRouter } from "next/router";
const promiseLimit = pLimit(3);
const chunkSize = 10 * 1024 * 1024; // 10MB
let errorToastShown = false;
const EditableUpload = ({
maxShareSize,
shareId,
files: savedFiles = [],
}: {
maxShareSize?: number;
isReverseShare?: boolean;
shareId: string;
files?: FileMetaData[];
}) => {
const t = useTranslate();
const router = useRouter();
const config = useConfig();
const [existingFiles, setExistingFiles] =
useState<Array<FileMetaData & { deleted?: boolean }>>(savedFiles);
const [uploadingFiles, setUploadingFiles] = useState<FileUpload[]>([]);
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 (
<>
<Group position="right" mb={20}>
<Button loading={isUploading} disabled={!dirty} onClick={() => save()}>
<FormattedMessage id="common.button.save" />
</Button>
</Group>
<Dropzone
title={t("share.edit.append-upload")}
maxShareSize={maxShareSize}
showCreateUploadModalCallback={appendFiles}
isUploading={isUploading}
/>
{existingAndUploadedFiles.length > 0 && (
<FileList files={existingAndUploadedFiles} setFiles={setFiles} />
)}
</>
);
};
export default EditableUpload;

View file

@ -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 (
<tr
style={{
color: deleted ? "rgba(120, 120, 120, 0.5)" : "inherit",
textDecoration: deleted ? "line-through" : "none",
}}
>
<td>{file.name}</td>
<td>{byteToHumanSizeString(+file.size)}</td>
<td>
{removable && (
<ActionIcon
color="red"
variant="light"
size={25}
onClick={onRemove}
>
<TbTrash />
</ActionIcon>
)}
{uploading && (
<UploadProgressIndicator progress={file.uploadingProgress} />
)}
{restorable && (
<ActionIcon
color="primary"
variant="light"
size={25}
onClick={onRestore}
>
<GrUndo />
</ActionIcon>
)}
</td>
</tr>
);
}
};
const FileList = <T extends FileListItem = FileListItem>({
files,
setFiles,
}: {
files: FileUpload[];
setFiles: Dispatch<SetStateAction<FileUpload[]>>;
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) => (
<tr key={i}>
<td>{file.name}</td>
<td>{byteToHumanSizeString(file.size)}</td>
<td>
{file.uploadingProgress == 0 ? (
<ActionIcon
color="red"
variant="light"
size={25}
onClick={() => remove(i)}
>
<TbTrash />
</ActionIcon>
) : (
<UploadProgressIndicator progress={file.uploadingProgress} />
)}
</td>
</tr>
<FileListRow
key={i}
file={file}
onRemove={() => remove(i)}
onRestore={() => restore(i)}
/>
));
return (

View file

@ -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",

View file

@ -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": "通用",

View file

@ -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 = () => {
</td>
<td>
<Group position="right">
<Link href={`/share/${share.id}/edit`}>
<ActionIcon color="orange" variant="light" size={25}>
<TbEdit />
</ActionIcon>
</Link>
<ActionIcon
color="blue"
variant="light"

View file

@ -0,0 +1,65 @@
import { LoadingOverlay } from "@mantine/core";
import { useModals } from "@mantine/modals";
import { GetServerSidePropsContext } from "next";
import { useEffect, useState } from "react";
import showErrorModal from "../../../components/share/showErrorModal";
import shareService from "../../../services/share.service";
import { Share as ShareType } from "../../../types/share.type";
import useTranslate from "../../../hooks/useTranslate.hook";
import EditableUpload from "../../../components/upload/EditableUpload";
import Meta from "../../../components/Meta";
export function getServerSideProps(context: GetServerSidePropsContext) {
return {
props: { shareId: context.params!.shareId },
};
}
const Share = ({ shareId }: { shareId: string }) => {
const t = useTranslate();
const modals = useModals();
const [isLoading, setIsLoading] = useState(true);
const [share, setShare] = useState<ShareType>();
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 <LoadingOverlay visible />;
return (
<>
<Meta title={t("share.edit.title", { shareId })} />
<EditableUpload shareId={shareId} files={share?.files || []} />
</>
);
};
export default Share;

View file

@ -202,7 +202,9 @@ const Upload = ({
showCreateUploadModalCallback={showCreateUploadModalCallback}
isUploading={isUploading}
/>
{files.length > 0 && <FileList files={files} setFiles={setFiles} />}
{files.length > 0 && (
<FileList<FileUpload> files={files} setFiles={setFiles} />
)}
</>
);
};

View file

@ -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<Share> => {
return (await api.get(`shares/${id}`)).data;
};
const getFromOwner = async (id: string): Promise<Share> => {
return (await api.get(`shares/${id}/from-owner`)).data;
};
const getMetaData = async (id: string): Promise<ShareMetaData> => {
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<FileReader>,
@ -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,

View file

@ -7,3 +7,5 @@ export type FileMetaData = {
name: string;
size: string;
};
export type FileListItem = FileUpload | (FileMetaData & { deleted?: boolean });