Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: change file for exists share #306

Merged
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