Skip to content
This repository was archived by the owner on Jun 29, 2025. It is now read-only.

Commit 98380e2

Browse files
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>
1 parent e377ed1 commit 98380e2

File tree

15 files changed

+493
-36
lines changed

15 files changed

+493
-36
lines changed

backend/src/file/file.controller.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
Body,
33
Controller,
4+
Delete,
45
Get,
56
Param,
67
Post,
@@ -81,4 +82,14 @@ export class FileController {
8182

8283
return new StreamableFile(file.file);
8384
}
85+
86+
@Delete(":fileId")
87+
@SkipThrottle()
88+
@UseGuards(ShareOwnerGuard)
89+
async remove(
90+
@Param("fileId") fileId: string,
91+
@Param("shareId") shareId: string,
92+
) {
93+
await this.fileService.remove(shareId, fileId);
94+
}
8495
}

backend/src/file/file.service.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,18 @@ export class FileService {
124124
};
125125
}
126126

127+
async remove(shareId: string, fileId: string) {
128+
const fileMetaData = await this.prisma.file.findUnique({
129+
where: { id: fileId },
130+
});
131+
132+
if (!fileMetaData) throw new NotFoundException("File not found");
133+
134+
fs.unlinkSync(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
135+
136+
await this.prisma.file.delete({ where: { id: fileId } });
137+
}
138+
127139
async deleteAllFiles(shareId: string) {
128140
await fs.promises.rm(`${SHARE_DIRECTORY}/${shareId}`, {
129141
recursive: true,

backend/src/share/guard/shareOwner.guard.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
import {
2-
CanActivate,
32
ExecutionContext,
43
Injectable,
54
NotFoundException,
65
} from "@nestjs/common";
76
import { User } from "@prisma/client";
87
import { Request } from "express";
98
import { PrismaService } from "src/prisma/prisma.service";
9+
import { JwtGuard } from "../../auth/guard/jwt.guard";
10+
import { ConfigService } from "src/config/config.service";
1011

1112
@Injectable()
12-
export class ShareOwnerGuard implements CanActivate {
13-
constructor(private prisma: PrismaService) {}
13+
export class ShareOwnerGuard extends JwtGuard {
14+
constructor(
15+
configService: ConfigService,
16+
private prisma: PrismaService,
17+
) {
18+
super(configService);
19+
}
1420

1521
async canActivate(context: ExecutionContext) {
22+
if (!(await super.canActivate(context))) return false;
23+
1624
const request: Request = context.switchToHttp().getRequest();
1725
const shareId = Object.prototype.hasOwnProperty.call(
1826
request.params,

backend/src/share/share.controller.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ export class ShareController {
4343
return new ShareDTO().from(await this.shareService.get(id));
4444
}
4545

46+
@Get(":id/from-owner")
47+
@UseGuards(ShareOwnerGuard)
48+
async getFromOwner(@Param("id") id: string) {
49+
return new ShareDTO().from(await this.shareService.get(id));
50+
}
51+
4652
@Get(":id/metaData")
4753
@UseGuards(ShareSecurityGuard)
4854
async getMetaData(@Param("id") id: string) {
@@ -62,12 +68,6 @@ export class ShareController {
6268
);
6369
}
6470

65-
@Delete(":id")
66-
@UseGuards(JwtGuard, ShareOwnerGuard)
67-
async remove(@Param("id") id: string) {
68-
await this.shareService.remove(id);
69-
}
70-
7171
@Post(":id/complete")
7272
@HttpCode(202)
7373
@UseGuards(CreateShareGuard, ShareOwnerGuard)
@@ -78,6 +78,18 @@ export class ShareController {
7878
);
7979
}
8080

81+
@Delete(":id/complete")
82+
@UseGuards(ShareOwnerGuard)
83+
async revertComplete(@Param("id") id: string) {
84+
return new ShareDTO().from(await this.shareService.revertComplete(id));
85+
}
86+
87+
@Delete(":id")
88+
@UseGuards(ShareOwnerGuard)
89+
async remove(@Param("id") id: string) {
90+
await this.shareService.remove(id);
91+
}
92+
8193
@Throttle(10, 60)
8294
@Get("isShareIdAvailable/:id")
8395
async isShareIdAvailable(@Param("id") id: string) {

backend/src/share/share.service.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,13 @@ export class ShareService {
182182
});
183183
}
184184

185+
async revertComplete(id: string) {
186+
return this.prisma.share.update({
187+
where: { id },
188+
data: { uploadLocked: false, isZipReady: false },
189+
});
190+
}
191+
185192
async getSharesByUser(userId: string) {
186193
const shares = await this.prisma.share.findMany({
187194
where: {

frontend/src/components/upload/Dropzone.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,12 @@ const useStyles = createStyles((theme) => ({
3333
}));
3434

3535
const Dropzone = ({
36+
title,
3637
isUploading,
3738
maxShareSize,
3839
showCreateUploadModalCallback,
3940
}: {
41+
title?: string;
4042
isUploading: boolean;
4143
maxShareSize: number;
4244
showCreateUploadModalCallback: (files: FileUpload[]) => void;
@@ -78,7 +80,7 @@ const Dropzone = ({
7880
<TbCloudUpload size={50} />
7981
</Group>
8082
<Text align="center" weight={700} size="lg" mt="xl">
81-
<FormattedMessage id="upload.dropzone.title" />
83+
{title || <FormattedMessage id="upload.dropzone.title" />}
8284
</Text>
8385
<Text align="center" size="sm" mt="xs" color="dimmed">
8486
<FormattedMessage
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import { Button, Group } from "@mantine/core";
2+
import { useModals } from "@mantine/modals";
3+
import { cleanNotifications } from "@mantine/notifications";
4+
import { AxiosError } from "axios";
5+
import pLimit from "p-limit";
6+
import { useEffect, useMemo, useState } from "react";
7+
import { FormattedMessage } from "react-intl";
8+
import Dropzone from "../../components/upload/Dropzone";
9+
import FileList from "../../components/upload/FileList";
10+
import showCompletedUploadModal from "../../components/upload/modals/showCompletedUploadModal";
11+
import useConfig from "../../hooks/config.hook";
12+
import useTranslate from "../../hooks/useTranslate.hook";
13+
import shareService from "../../services/share.service";
14+
import { FileListItem, FileMetaData, FileUpload } from "../../types/File.type";
15+
import toast from "../../utils/toast.util";
16+
import { useRouter } from "next/router";
17+
18+
const promiseLimit = pLimit(3);
19+
const chunkSize = 10 * 1024 * 1024; // 10MB
20+
let errorToastShown = false;
21+
22+
const EditableUpload = ({
23+
maxShareSize,
24+
shareId,
25+
files: savedFiles = [],
26+
}: {
27+
maxShareSize?: number;
28+
isReverseShare?: boolean;
29+
shareId: string;
30+
files?: FileMetaData[];
31+
}) => {
32+
const t = useTranslate();
33+
const router = useRouter();
34+
const config = useConfig();
35+
36+
const [existingFiles, setExistingFiles] =
37+
useState<Array<FileMetaData & { deleted?: boolean }>>(savedFiles);
38+
const [uploadingFiles, setUploadingFiles] = useState<FileUpload[]>([]);
39+
const [isUploading, setIsUploading] = useState(false);
40+
41+
const existingAndUploadedFiles: FileListItem[] = useMemo(
42+
() => [...uploadingFiles, ...existingFiles],
43+
[existingFiles, uploadingFiles],
44+
);
45+
const dirty = useMemo(() => {
46+
return (
47+
existingFiles.some((file) => !!file.deleted) || !!uploadingFiles.length
48+
);
49+
}, [existingFiles, uploadingFiles]);
50+
51+
const setFiles = (files: FileListItem[]) => {
52+
const _uploadFiles = files.filter(
53+
(file) => "uploadingProgress" in file,
54+
) as FileUpload[];
55+
const _existingFiles = files.filter(
56+
(file) => !("uploadingProgress" in file),
57+
) as FileMetaData[];
58+
59+
setUploadingFiles(_uploadFiles);
60+
setExistingFiles(_existingFiles);
61+
};
62+
63+
maxShareSize ??= parseInt(config.get("share.maxSize"));
64+
65+
const uploadFiles = async (files: FileUpload[]) => {
66+
const fileUploadPromises = files.map(async (file, fileIndex) =>
67+
// Limit the number of concurrent uploads to 3
68+
promiseLimit(async () => {
69+
let fileId: string;
70+
71+
const setFileProgress = (progress: number) => {
72+
setUploadingFiles((files) =>
73+
files.map((file, callbackIndex) => {
74+
if (fileIndex == callbackIndex) {
75+
file.uploadingProgress = progress;
76+
}
77+
return file;
78+
}),
79+
);
80+
};
81+
82+
setFileProgress(1);
83+
84+
let chunks = Math.ceil(file.size / chunkSize);
85+
86+
// If the file is 0 bytes, we still need to upload 1 chunk
87+
if (chunks == 0) chunks++;
88+
89+
for (let chunkIndex = 0; chunkIndex < chunks; chunkIndex++) {
90+
const from = chunkIndex * chunkSize;
91+
const to = from + chunkSize;
92+
const blob = file.slice(from, to);
93+
try {
94+
await new Promise((resolve, reject) => {
95+
const reader = new FileReader();
96+
reader.onload = async (event) =>
97+
await shareService
98+
.uploadFile(
99+
shareId,
100+
event,
101+
{
102+
id: fileId,
103+
name: file.name,
104+
},
105+
chunkIndex,
106+
chunks,
107+
)
108+
.then((response) => {
109+
fileId = response.id;
110+
resolve(response);
111+
})
112+
.catch(reject);
113+
114+
reader.readAsDataURL(blob);
115+
});
116+
117+
setFileProgress(((chunkIndex + 1) / chunks) * 100);
118+
} catch (e) {
119+
if (
120+
e instanceof AxiosError &&
121+
e.response?.data.error == "unexpected_chunk_index"
122+
) {
123+
// Retry with the expected chunk index
124+
chunkIndex = e.response!.data!.expectedChunkIndex - 1;
125+
continue;
126+
} else {
127+
setFileProgress(-1);
128+
// Retry after 5 seconds
129+
await new Promise((resolve) => setTimeout(resolve, 5000));
130+
chunkIndex = -1;
131+
132+
continue;
133+
}
134+
}
135+
}
136+
}),
137+
);
138+
139+
await Promise.all(fileUploadPromises);
140+
};
141+
142+
const removeFiles = async () => {
143+
const removedFiles = existingFiles.filter((file) => !!file.deleted);
144+
145+
if (removedFiles.length > 0) {
146+
await Promise.all(
147+
removedFiles.map(async (file) => {
148+
await shareService.removeFile(shareId, file.id);
149+
}),
150+
);
151+
152+
setExistingFiles(existingFiles.filter((file) => !file.deleted));
153+
}
154+
};
155+
156+
const revertComplete = async () => {
157+
await shareService.revertComplete(shareId).then();
158+
};
159+
160+
const completeShare = async () => {
161+
return await shareService.completeShare(shareId);
162+
};
163+
164+
const save = async () => {
165+
setIsUploading(true);
166+
167+
try {
168+
await revertComplete();
169+
await uploadFiles(uploadingFiles);
170+
171+
const hasFailed = uploadingFiles.some(
172+
(file) => file.uploadingProgress == -1,
173+
);
174+
175+
if (!hasFailed) {
176+
await removeFiles();
177+
}
178+
179+
await completeShare();
180+
181+
if (!hasFailed) {
182+
toast.success(t("share.edit.notify.save-success"));
183+
router.back();
184+
}
185+
} catch {
186+
toast.error(t("share.edit.notify.generic-error"));
187+
} finally {
188+
setIsUploading(false);
189+
}
190+
};
191+
192+
const appendFiles = (appendingFiles: FileUpload[]) => {
193+
setUploadingFiles([...appendingFiles, ...uploadingFiles]);
194+
};
195+
196+
useEffect(() => {
197+
// Check if there are any files that failed to upload
198+
const fileErrorCount = uploadingFiles.filter(
199+
(file) => file.uploadingProgress == -1,
200+
).length;
201+
202+
if (fileErrorCount > 0) {
203+
if (!errorToastShown) {
204+
toast.error(
205+
t("upload.notify.count-failed", { count: fileErrorCount }),
206+
{
207+
withCloseButton: false,
208+
autoClose: false,
209+
},
210+
);
211+
}
212+
errorToastShown = true;
213+
} else {
214+
cleanNotifications();
215+
errorToastShown = false;
216+
}
217+
}, [uploadingFiles]);
218+
219+
return (
220+
<>
221+
<Group position="right" mb={20}>
222+
<Button loading={isUploading} disabled={!dirty} onClick={() => save()}>
223+
<FormattedMessage id="common.button.save" />
224+
</Button>
225+
</Group>
226+
<Dropzone
227+
title={t("share.edit.append-upload")}
228+
maxShareSize={maxShareSize}
229+
showCreateUploadModalCallback={appendFiles}
230+
isUploading={isUploading}
231+
/>
232+
{existingAndUploadedFiles.length > 0 && (
233+
<FileList files={existingAndUploadedFiles} setFiles={setFiles} />
234+
)}
235+
</>
236+
);
237+
};
238+
export default EditableUpload;

0 commit comments

Comments
 (0)