Skip to content

Commit

Permalink
feat: ability to add and delete files of existing share (#306)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
IvanLi-CN and stonith404 authored Nov 4, 2023
1 parent e377ed1 commit 98380e2
Show file tree
Hide file tree
Showing 15 changed files with 493 additions and 36 deletions.
11 changes: 11 additions & 0 deletions backend/src/file/file.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Expand Down Expand Up @@ -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);
}
}
12 changes: 12 additions & 0 deletions backend/src/file/file.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 11 additions & 3 deletions backend/src/share/guard/shareOwner.guard.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
import {
CanActivate,
ExecutionContext,
Injectable,
NotFoundException,
} from "@nestjs/common";
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,
Expand Down
24 changes: 18 additions & 6 deletions backend/src/share/share.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Expand All @@ -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) {
Expand Down
7 changes: 7 additions & 0 deletions backend/src/share/share.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/upload/Dropzone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ const useStyles = createStyles((theme) => ({
}));

const Dropzone = ({
title,
isUploading,
maxShareSize,
showCreateUploadModalCallback,
}: {
title?: string;
isUploading: boolean;
maxShareSize: number;
showCreateUploadModalCallback: (files: FileUpload[]) => void;
Expand Down Expand Up @@ -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
Expand Down
238 changes: 238 additions & 0 deletions frontend/src/components/upload/EditableUpload.tsx
Original file line number Diff line number Diff line change
@@ -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;
Loading

0 comments on commit 98380e2

Please sign in to comment.