diff --git a/backend/src/file/file.controller.ts b/backend/src/file/file.controller.ts index d861bb7..9120f19 100644 --- a/backend/src/file/file.controller.ts +++ b/backend/src/file/file.controller.ts @@ -14,8 +14,8 @@ import * as contentDisposition from "content-disposition"; import { Response } from "express"; import { CreateShareGuard } from "src/share/guard/createShare.guard"; import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard"; -import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard"; import { FileService } from "./file.service"; +import { FileSecurityGuard } from "./guard/fileSecurity.guard"; @Controller("shares/:shareId/files") export class FileController { @@ -43,7 +43,7 @@ export class FileController { } @Get("zip") - @UseGuards(ShareSecurityGuard) + @UseGuards(FileSecurityGuard) async getZip( @Res({ passthrough: true }) res: Response, @Param("shareId") shareId: string @@ -58,7 +58,7 @@ export class FileController { } @Get(":fileId") - @UseGuards(ShareSecurityGuard) + @UseGuards(FileSecurityGuard) async getFile( @Res({ passthrough: true }) res: Response, @Param("shareId") shareId: string, diff --git a/backend/src/file/file.service.ts b/backend/src/file/file.service.ts index cf499e8..0d77881 100644 --- a/backend/src/file/file.service.ts +++ b/backend/src/file/file.service.ts @@ -135,6 +135,4 @@ export class FileService { getZip(shareId: string) { return fs.createReadStream(`./data/uploads/shares/${shareId}/archive.zip`); } - - } diff --git a/backend/src/file/guard/fileSecurity.guard.ts b/backend/src/file/guard/fileSecurity.guard.ts new file mode 100644 index 0000000..02a5165 --- /dev/null +++ b/backend/src/file/guard/fileSecurity.guard.ts @@ -0,0 +1,65 @@ +import { + ExecutionContext, + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { Request } from "express"; +import * as moment from "moment"; +import { PrismaService } from "src/prisma/prisma.service"; +import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard"; +import { ShareService } from "src/share/share.service"; + +@Injectable() +export class FileSecurityGuard extends ShareSecurityGuard { + constructor( + private _shareService: ShareService, + private _prisma: PrismaService + ) { + super(_shareService, _prisma); + } + + async canActivate(context: ExecutionContext) { + const request: Request = context.switchToHttp().getRequest(); + + const shareId = Object.prototype.hasOwnProperty.call( + request.params, + "shareId" + ) + ? request.params.shareId + : request.params.id; + + const shareToken = request.cookies[`share_${shareId}_token`]; + + const share = await this._prisma.share.findUnique({ + where: { id: shareId }, + include: { security: true }, + }); + + // If there is no share token the user requests a file directly + if (!shareToken) { + if ( + !share || + (moment().isAfter(share.expiration) && + !moment(share.expiration).isSame(0)) + ) { + throw new NotFoundException("File not found"); + } + + if (share.security?.password) + throw new ForbiddenException("This share is password protected"); + + if (share.security?.maxViews && share.security.maxViews <= share.views) { + throw new ForbiddenException( + "Maximum views exceeded", + "share_max_views_exceeded" + ); + } + + await this._shareService.increaseViewCount(share); + return true; + } else { + return super.canActivate(context); + } + } +} diff --git a/backend/src/reverseShare/dto/reverseShareTokenWithShare.ts b/backend/src/reverseShare/dto/reverseShareTokenWithShare.ts index ba5b36e..537707b 100644 --- a/backend/src/reverseShare/dto/reverseShareTokenWithShare.ts +++ b/backend/src/reverseShare/dto/reverseShareTokenWithShare.ts @@ -10,8 +10,11 @@ export class ReverseShareTokenWithShare extends OmitType(ReverseShareDTO, [ shareExpiration: Date; @Expose() - @Type(() => OmitType(MyShareDTO, ["recipients"] as const)) - share: Omit; + @Type(() => OmitType(MyShareDTO, ["recipients", "hasPassword"] as const)) + share: Omit< + MyShareDTO, + "recipients" | "files" | "from" | "fromList" | "hasPassword" + >; fromList(partial: Partial[]) { return partial.map((part) => diff --git a/backend/src/share/dto/share.dto.ts b/backend/src/share/dto/share.dto.ts index 19324f5..03d4821 100644 --- a/backend/src/share/dto/share.dto.ts +++ b/backend/src/share/dto/share.dto.ts @@ -20,6 +20,9 @@ export class ShareDTO { @Expose() description: string; + @Expose() + hasPassword: boolean; + from(partial: Partial) { return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true }); } diff --git a/backend/src/share/guard/shareSecurity.guard.ts b/backend/src/share/guard/shareSecurity.guard.ts index 9511af0..8bbab54 100644 --- a/backend/src/share/guard/shareSecurity.guard.ts +++ b/backend/src/share/guard/shareSecurity.guard.ts @@ -34,10 +34,12 @@ export class ShareSecurityGuard implements CanActivate { include: { security: true }, }); - const isExpired = - moment().isAfter(share.expiration) && !moment(share.expiration).isSame(0); - - if (!share || isExpired) throw new NotFoundException("Share not found"); + if ( + !share || + (moment().isAfter(share.expiration) && + !moment(share.expiration).isSame(0)) + ) + throw new NotFoundException("Share not found"); if (share.security?.password && !shareToken) throw new ForbiddenException( diff --git a/backend/src/share/guard/shareTokenSecurity.guard.ts b/backend/src/share/guard/shareTokenSecurity.guard.ts index 4363fac..0b33241 100644 --- a/backend/src/share/guard/shareTokenSecurity.guard.ts +++ b/backend/src/share/guard/shareTokenSecurity.guard.ts @@ -26,10 +26,12 @@ export class ShareTokenSecurity implements CanActivate { include: { security: true }, }); - const isExpired = - moment().isAfter(share.expiration) && !moment(share.expiration).isSame(0); - - if (!share || isExpired) throw new NotFoundException("Share not found"); + if ( + !share || + (moment().isAfter(share.expiration) && + !moment(share.expiration).isSame(0)) + ) + throw new NotFoundException("Share not found"); return true; } diff --git a/backend/src/share/share.service.ts b/backend/src/share/share.service.ts index 25c0d60..6d2c5a4 100644 --- a/backend/src/share/share.service.ts +++ b/backend/src/share/share.service.ts @@ -204,12 +204,13 @@ export class ShareService { return sharesWithEmailRecipients; } - async get(id: string) { + async get(id: string): Promise { const share = await this.prisma.share.findUnique({ where: { id }, include: { files: true, creator: true, + security: true, }, }); @@ -218,8 +219,10 @@ export class ShareService { if (!share || !share.uploadLocked) throw new NotFoundException("Share not found"); - - return share as any; + return { + ...share, + hasPassword: share.security?.password ? true : false, + }; } async getMetaData(id: string) { diff --git a/frontend/src/components/share/FileList.tsx b/frontend/src/components/share/FileList.tsx index 1cadbfc..b6014ae 100644 --- a/frontend/src/components/share/FileList.tsx +++ b/frontend/src/components/share/FileList.tsx @@ -1,20 +1,57 @@ -import { ActionIcon, Group, Skeleton, Table } from "@mantine/core"; +import { + ActionIcon, + Group, + Skeleton, + Stack, + Table, + TextInput, +} from "@mantine/core"; +import { useClipboard } from "@mantine/hooks"; +import { useModals } from "@mantine/modals"; import mime from "mime-types"; + import Link from "next/link"; -import { TbDownload, TbEye } from "react-icons/tb"; +import { TbDownload, TbEye, TbLink } from "react-icons/tb"; +import useConfig from "../../hooks/config.hook"; import shareService from "../../services/share.service"; import { FileMetaData } from "../../types/File.type"; +import { Share } from "../../types/share.type"; import { byteToHumanSizeString } from "../../utils/fileSize.util"; +import toast from "../../utils/toast.util"; const FileList = ({ files, - shareId, + share, isLoading, }: { files?: FileMetaData[]; - shareId: string; + share: Share; isLoading: boolean; }) => { + const clipboard = useClipboard(); + const config = useConfig(); + const modals = useModals(); + + const copyFileLink = (file: FileMetaData) => { + const link = `${config.get("APP_URL")}/api/shares/${share.id}/files/${ + file.id + }`; + + if (window.isSecureContext) { + clipboard.copy(link); + toast.success("Your file link was copied to the keyboard."); + } else { + modals.openModal({ + title: "File link", + children: ( + + + + ), + }); + } + }; + return ( @@ -36,7 +73,7 @@ const FileList = ({ {shareService.doesFileSupportPreview(file.name) && ( )} + {!share.hasPassword && ( + copyFileLink(file)}> + + + )} { - await shareService.downloadFile(shareId, file.id); + await shareService.downloadFile(share.id, file.id); }} > diff --git a/frontend/src/pages/share/[shareId]/index.tsx b/frontend/src/pages/share/[shareId]/index.tsx index e1c293f..8c33331 100644 --- a/frontend/src/pages/share/[shareId]/index.tsx +++ b/frontend/src/pages/share/[shareId]/index.tsx @@ -85,7 +85,7 @@ const Share = ({ shareId }: { shareId: string }) => { {share?.files.length > 1 && } - + ); }; diff --git a/frontend/src/types/share.type.ts b/frontend/src/types/share.type.ts index ebbfb3b..80618de 100644 --- a/frontend/src/types/share.type.ts +++ b/frontend/src/types/share.type.ts @@ -6,6 +6,7 @@ export type Share = { creator: User; description?: string; expiration: Date; + hasPassword: boolean; }; export type CreateShare = {