diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index e6d24b1..973e07d 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -27,6 +27,7 @@ export class AuthController { } @Post("signIn") + @HttpCode(200) signIn(@Body() dto: AuthSignInDTO) { return this.authService.signIn(dto); } diff --git a/backend/src/file/file.service.ts b/backend/src/file/file.service.ts index 6388f6c..2437687 100644 --- a/backend/src/file/file.service.ts +++ b/backend/src/file/file.service.ts @@ -100,12 +100,12 @@ export class FileService { ); } - verifyFileDownloadToken(shareId: string, fileId: string, token: string) { + verifyFileDownloadToken(shareId: string, token: string) { try { const claims = this.jwtService.verify(token, { secret: this.config.get("JWT_SECRET"), }); - return claims.shareId == shareId && claims.fileId == fileId; + return claims.shareId == shareId; } catch { return false; } diff --git a/backend/src/file/guard/fileDownload.guard.ts b/backend/src/file/guard/fileDownload.guard.ts index ee99601..e972b4e 100644 --- a/backend/src/file/guard/fileDownload.guard.ts +++ b/backend/src/file/guard/fileDownload.guard.ts @@ -1,23 +1,17 @@ import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; -import { Reflector } from "@nestjs/core"; import { Request } from "express"; import { FileService } from "src/file/file.service"; -import { PrismaService } from "src/prisma/prisma.service"; @Injectable() export class FileDownloadGuard implements CanActivate { - constructor( - private reflector: Reflector, - private fileService: FileService, - private prisma: PrismaService - ) {} + constructor(private fileService: FileService) {} async canActivate(context: ExecutionContext) { const request: Request = context.switchToHttp().getRequest(); const token = request.query.token as string; - const { shareId, fileId } = request.params; + const { shareId } = request.params; - return this.fileService.verifyFileDownloadToken(shareId, fileId, token); + return this.fileService.verifyFileDownloadToken(shareId, token); } } diff --git a/backend/src/share/dto/createShare.dto.ts b/backend/src/share/dto/createShare.dto.ts index 3d05140..a6ad3fd 100644 --- a/backend/src/share/dto/createShare.dto.ts +++ b/backend/src/share/dto/createShare.dto.ts @@ -1,5 +1,5 @@ import { Type } from "class-transformer"; -import { IsString, Matches, ValidateNested } from "class-validator"; +import { IsString, Length, Matches, ValidateNested } from "class-validator"; import { ShareSecurityDTO } from "./shareSecurity.dto"; export class CreateShareDTO { @@ -7,6 +7,7 @@ export class CreateShareDTO { @Matches("^[a-zA-Z0-9_-]*$", undefined, { message: "ID only can contain letters, numbers, underscores and hyphens", }) + @Length(3, 50) id: string; @IsString() diff --git a/backend/src/share/dto/sharePassword.dto.ts b/backend/src/share/dto/sharePassword.dto.ts index c43dd81..69379e6 100644 --- a/backend/src/share/dto/sharePassword.dto.ts +++ b/backend/src/share/dto/sharePassword.dto.ts @@ -1,6 +1,5 @@ import { IsNotEmpty } from "class-validator"; export class SharePasswordDto { - @IsNotEmpty() password: string; } diff --git a/backend/src/share/guard/shareOwner.guard.ts b/backend/src/share/guard/shareOwner.guard.ts index ee7b65e..7b99736 100644 --- a/backend/src/share/guard/shareOwner.guard.ts +++ b/backend/src/share/guard/shareOwner.guard.ts @@ -1,16 +1,16 @@ -import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; -import { Reflector } from "@nestjs/core"; +import { + CanActivate, + ExecutionContext, + Injectable, + NotFoundException, +} from "@nestjs/common"; import { User } from "@prisma/client"; import { Request } from "express"; -import { ExtractJwt } from "passport-jwt"; import { PrismaService } from "src/prisma/prisma.service"; -import { ShareService } from "src/share/share.service"; @Injectable() export class ShareOwnerGuard implements CanActivate { - constructor( - private prisma: PrismaService - ) {} + constructor(private prisma: PrismaService) {} async canActivate(context: ExecutionContext) { const request: Request = context.switchToHttp().getRequest(); @@ -26,7 +26,7 @@ export class ShareOwnerGuard implements CanActivate { include: { security: true }, }); - + if (!share) throw new NotFoundException("Share not found"); return share.creatorId == (request.user as User).id; } diff --git a/backend/src/share/guard/shareSecurity.guard.ts b/backend/src/share/guard/shareSecurity.guard.ts index 5f9a729..292ec5a 100644 --- a/backend/src/share/guard/shareSecurity.guard.ts +++ b/backend/src/share/guard/shareSecurity.guard.ts @@ -21,6 +21,7 @@ export class ShareSecurityGuard implements CanActivate { async canActivate(context: ExecutionContext) { const request: Request = context.switchToHttp().getRequest(); + const shareToken = request.get("X-Share-Token"); const shareId = Object.prototype.hasOwnProperty.call( request.params, "shareId" @@ -36,19 +37,15 @@ export class ShareSecurityGuard implements CanActivate { if (!share || moment().isAfter(share.expiration)) throw new NotFoundException("Share not found"); - if (!share.security) return true; - - if (share.security.maxViews && share.security.maxViews <= share.views) - throw new ForbiddenException( - "Maximum views exceeded", - "share_max_views_exceeded" - ); - - if ( - !this.shareService.verifyShareToken(shareId, request.get("X-Share-Token")) - ) + if (share.security?.password && !shareToken) throw new ForbiddenException( "This share is password protected", + "share_password_required" + ); + + if (!this.shareService.verifyShareToken(shareId, shareToken)) + throw new ForbiddenException( + "Share token required", "share_token_required" ); diff --git a/backend/src/share/guard/shareTokenSecurity.guard.ts b/backend/src/share/guard/shareTokenSecurity.guard.ts new file mode 100644 index 0000000..86f0dd6 --- /dev/null +++ b/backend/src/share/guard/shareTokenSecurity.guard.ts @@ -0,0 +1,47 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { Request } from "express"; +import * as moment from "moment"; +import { PrismaService } from "src/prisma/prisma.service"; +import { ShareService } from "src/share/share.service"; + +@Injectable() +export class ShareTokenSecurity implements CanActivate { + constructor( + private reflector: Reflector, + private shareService: ShareService, + private prisma: PrismaService + ) {} + + 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 share = await this.prisma.share.findUnique({ + where: { id: shareId }, + include: { security: true }, + }); + + if (!share || moment().isAfter(share.expiration)) + throw new NotFoundException("Share not found"); + + if (share.security?.maxViews && share.security.maxViews <= share.views) + throw new ForbiddenException( + "Maximum views exceeded", + "share_max_views_exceeded" + ); + + return true; + } +} diff --git a/backend/src/share/share.controller.ts b/backend/src/share/share.controller.ts index 0556b2f..17d2a9b 100644 --- a/backend/src/share/share.controller.ts +++ b/backend/src/share/share.controller.ts @@ -18,6 +18,7 @@ import { ShareMetaDataDTO } from "./dto/shareMetaData.dto"; import { SharePasswordDto } from "./dto/sharePassword.dto"; import { ShareOwnerGuard } from "./guard/shareOwner.guard"; import { ShareSecurityGuard } from "./guard/shareSecurity.guard"; +import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard"; import { ShareService } from "./share.service"; @Controller("shares") @@ -68,11 +69,10 @@ export class ShareController { return this.shareService.isShareIdAvailable(id); } - @Post(":id/password") - async exchangeSharePasswordWithToken( - @Param("id") id: string, - @Body() body: SharePasswordDto - ) { - return this.shareService.exchangeSharePasswordWithToken(id, body.password); + @HttpCode(200) + @UseGuards(ShareTokenSecurity) + @Post(":id/token") + async getShareToken(@Param("id") id: string, @Body() body: SharePasswordDto) { + return this.shareService.getShareToken(id, body.password); } } diff --git a/backend/src/share/share.service.ts b/backend/src/share/share.service.ts index a8c4c4b..270511f 100644 --- a/backend/src/share/share.service.ts +++ b/backend/src/share/share.service.ts @@ -76,6 +76,9 @@ export class ShareService { } async complete(id: string) { + if (await this.isShareCompleted(id)) + throw new BadRequestException("Share already completed"); + const moreThanOneFileInShare = (await this.prisma.file.findMany({ where: { shareId: id } })).length != 0; @@ -117,8 +120,6 @@ export class ShareService { return file; }); - await this.increaseViewCount(share); - return share; } @@ -160,27 +161,36 @@ export class ShareService { }); } - async exchangeSharePasswordWithToken(shareId: string, password: string) { - const sharePassword = ( - await this.prisma.shareSecurity.findFirst({ - where: { share: { id: shareId } }, - }) - ).password; + async getShareToken(shareId: string, password: string) { + const share = await this.prisma.share.findFirst({ + where: { id: shareId }, + include: { + security: true, + }, + }); - if (!(await argon.verify(sharePassword, password))) + if ( + share?.security?.password && + !(await argon.verify(share.security.password, password)) + ) throw new ForbiddenException("Wrong password"); - const token = this.generateShareToken(shareId); + const token = await this.generateShareToken(shareId); + await this.increaseViewCount(share); return { token }; } - generateShareToken(shareId: string) { + async generateShareToken(shareId: string) { + const { expiration } = await this.prisma.share.findUnique({ + where: { id: shareId }, + }); + console.log(moment(expiration).diff(new Date(), "seconds")); return this.jwtService.sign( { shareId, }, { - expiresIn: "1h", + expiresIn: moment(expiration).diff(new Date(), "seconds") + "s", secret: this.config.get("JWT_SECRET"), } ); diff --git a/frontend/src/components/share/CreateUploadModalBody.tsx b/frontend/src/components/share/CreateUploadModalBody.tsx index 659f5a8..038994d 100644 --- a/frontend/src/components/share/CreateUploadModalBody.tsx +++ b/frontend/src/components/share/CreateUploadModalBody.tsx @@ -31,7 +31,7 @@ const CreateUploadModalBody = ({ .string() .required() .min(3) - .max(100) + .max(50) .matches(new RegExp("^[a-zA-Z0-9_-]*$"), { message: "Can only contain letters, numbers, underscores and hyphens", }), diff --git a/frontend/src/components/share/DownloadAllButton.tsx b/frontend/src/components/share/DownloadAllButton.tsx index 092b031..4449797 100644 --- a/frontend/src/components/share/DownloadAllButton.tsx +++ b/frontend/src/components/share/DownloadAllButton.tsx @@ -25,7 +25,7 @@ const DownloadAllButton = ({ shareId }: { shareId: string }) => { setIsZipReady(share.isZipReady); if (share.isZipReady) clearInterval(timer); }) - .catch(() => {}); + .catch(() => clearInterval(timer)); }, 5000); return () => { clearInterval(timer); diff --git a/frontend/src/pages/account/shares.tsx b/frontend/src/pages/account/shares.tsx index f3da20b..f633f11 100644 --- a/frontend/src/pages/account/shares.tsx +++ b/frontend/src/pages/account/shares.tsx @@ -14,9 +14,11 @@ import { useClipboard } from "@mantine/hooks"; import { useModals } from "@mantine/modals"; import { NextLink } from "@mantine/next"; import moment from "moment"; -import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import { useState } from "react"; import { Link, Trash } from "tabler-icons-react"; import Meta from "../../components/Meta"; +import useUser from "../../hooks/user.hook"; import shareService from "../../services/share.service"; import { MyShare } from "../../types/share.type"; import toast from "../../utils/toast.util"; @@ -24,100 +26,108 @@ import toast from "../../utils/toast.util"; const MyShares = () => { const modals = useModals(); const clipboard = useClipboard(); + const router = useRouter(); + const user = useUser(); const [shares, setShares] = useState(); - useEffect(() => { - shareService.getMyShares().then((shares) => setShares(shares)); - }, []); + // useEffect(() => { + // shareService.getMyShares().then((shares) => setShares(shares)); + // }, []); - if (!shares) return ; - return ( - <> - - - My shares - - {shares.length == 0 ? ( -
- - It's empty here 👀 - You don't have any shares. - - - -
- ) : ( - - - - - - - - - - - {shares.map((share) => ( - - - - - + if (!user) { + router.replace("/"); + } else { + if (!shares) return ; + return ( + <> + + + My shares + + {shares.length == 0 ? ( +
+ + It's empty here 👀 + You don't have any shares. + + + +
+ ) : ( +
NameVisitorsExpires at
{share.id}{share.views} - {moment(share.expiration).format("MMMM DD YYYY, HH:mm")} - - - { - clipboard.copy( - `${window.location.origin}/share/${share.id}` - ); - toast.success("Your link was copied to the keyboard."); - }} - > - - - { - modals.openConfirmModal({ - title: `Delete share ${share.id}`, - children: ( - - Do you really want to delete this share? - - ), - confirmProps: { - color: "red", - }, - labels: { confirm: "Confirm", cancel: "Cancel" }, - onConfirm: () => { - shareService.remove(share.id); - setShares( - shares.filter((item) => item.id !== share.id) - ); - }, - }); - }} - > - - - -
+ + + + + + - ))} - -
NameVisitorsExpires at
- )} - - ); + + + {shares.map((share) => ( + + {share.id} + {share.views} + + {moment(share.expiration).format("MMMM DD YYYY, HH:mm")} + + + + { + clipboard.copy( + `${window.location.origin}/share/${share.id}` + ); + toast.success( + "Your link was copied to the keyboard." + ); + }} + > + + + { + modals.openConfirmModal({ + title: `Delete share ${share.id}`, + children: ( + + Do you really want to delete this share? + + ), + confirmProps: { + color: "red", + }, + labels: { confirm: "Confirm", cancel: "Cancel" }, + onConfirm: () => { + shareService.remove(share.id); + setShares( + shares.filter((item) => item.id !== share.id) + ); + }, + }); + }} + > + + + + + + ))} + + + )} + + ); + } }; export default MyShares; diff --git a/frontend/src/pages/share/[shareId].tsx b/frontend/src/pages/share/[shareId].tsx index 5ecc7ef..21b23fd 100644 --- a/frontend/src/pages/share/[shareId].tsx +++ b/frontend/src/pages/share/[shareId].tsx @@ -15,12 +15,21 @@ const Share = () => { const shareId = router.query.shareId as string; const [fileList, setFileList] = useState([]); - const submitPassword = async (password: string) => { + const getShareToken = async (password?: string) => { await shareService - .exchangeSharePasswordWithToken(shareId, password) + .getShareToken(shareId, password) .then(() => { modals.closeAll(); getFiles(); + }) + .catch((e) => { + if (e.response.data.error == "share_max_views_exceeded") { + showErrorModal( + modals, + "Visitor limit exceeded", + "The visitor limit from this share has been exceeded." + ); + } }); }; @@ -38,14 +47,10 @@ const Share = () => { "Not found", "This share can't be found. Please check your link." ); + } else if (error == "share_password_required") { + showEnterPasswordModal(modals, getShareToken); } else if (error == "share_token_required") { - showEnterPasswordModal(modals, submitPassword); - } else if (error == "share_max_views_exceeded") { - showErrorModal( - modals, - "Visitor limit exceeded", - "The visitor limit from this share has been exceeded." - ); + getShareToken(); } else if (error == "forbidden") { showErrorModal( modals, @@ -69,9 +74,7 @@ const Share = () => { description="Look what I've shared with you." /> - + => { return (await api.get("shares")).data; }; -const exchangeSharePasswordWithToken = async (id: string, password: string) => { - const { token } = (await api.post(`/shares/${id}/password`, { password })) - .data; +const getShareToken = async (id: string, password?: string) => { + const { token } = (await api.post(`/shares/${id}/token`, { password })).data; localStorage.setItem(`share_${id}_token`, token); }; @@ -87,7 +86,7 @@ const uploadFile = async ( export default { create, completeShare, - exchangeSharePasswordWithToken, + getShareToken, get, remove, getMetaData,