From 56349c6f4cc739d07bcf8ad862b0868e09342883 Mon Sep 17 00:00:00 2001 From: Steve Tautonico Date: Wed, 12 Oct 2022 16:59:04 -0400 Subject: [PATCH] feature: Added "never" expiration date --- backend/src/auth/jobs/jobs.service.ts | 57 +-- .../src/share/guard/shareSecurity.guard.ts | 2 +- backend/src/share/share.service.ts | 353 +++++++++--------- .../share/CreateUploadModalBody.tsx | 254 +++++++------ .../share/showEnterPasswordModal.tsx | 2 +- .../src/components/share/showErrorModal.tsx | 2 +- frontend/src/components/upload/Dropzone.tsx | 2 - .../upload/showCompletedUploadModal.tsx | 120 +++--- frontend/src/pages/account/shares.tsx | 4 +- 9 files changed, 406 insertions(+), 390 deletions(-) diff --git a/backend/src/auth/jobs/jobs.service.ts b/backend/src/auth/jobs/jobs.service.ts index a752afd..58334d4 100644 --- a/backend/src/auth/jobs/jobs.service.ts +++ b/backend/src/auth/jobs/jobs.service.ts @@ -2,36 +2,41 @@ import { Injectable } from "@nestjs/common"; import { Cron } from "@nestjs/schedule"; import { FileService } from "src/file/file.service"; import { PrismaService } from "src/prisma/prisma.service"; +import * as moment from "moment"; @Injectable() export class JobsService { - constructor( - private prisma: PrismaService, - private fileService: FileService - ) {} - - @Cron("0 * * * *") - async deleteExpiredShares() { - const expiredShares = await this.prisma.share.findMany({ - where: { expiration: { lt: new Date() } }, - }); - - for (const expiredShare of expiredShares) { - await this.prisma.share.delete({ - where: { id: expiredShare.id }, - }); - - await this.fileService.deleteAllFiles(expiredShare.id); + constructor( + private prisma: PrismaService, + private fileService: FileService + ) { } - console.log(`job: deleted ${expiredShares.length} expired shares`); - } + @Cron("0 * * * *") + async deleteExpiredShares() { + const expiredShares = await this.prisma.share.findMany({ + where: { + // We want to remove only shares that have an expiration date less than the current date, but not 0 + AND: [{expiration: {lt: new Date()}}, {expiration: {not: moment(0).toDate()}}] + }, + }); - @Cron("0 * * * *") - async deleteExpiredRefreshTokens() { - const expiredShares = await this.prisma.refreshToken.deleteMany({ - where: { expiresAt: { lt: new Date() } }, - }); - console.log(`job: deleted ${expiredShares.count} expired refresh tokens`); - } + for (const expiredShare of expiredShares) { + await this.prisma.share.delete({ + where: {id: expiredShare.id}, + }); + + await this.fileService.deleteAllFiles(expiredShare.id); + } + + console.log(`job: deleted ${expiredShares.length} expired shares`); + } + + @Cron("0 * * * *") + async deleteExpiredRefreshTokens() { + const expiredShares = await this.prisma.refreshToken.deleteMany({ + where: {expiresAt: {lt: new Date()}}, + }); + console.log(`job: deleted ${expiredShares.count} expired refresh tokens`); + } } diff --git a/backend/src/share/guard/shareSecurity.guard.ts b/backend/src/share/guard/shareSecurity.guard.ts index 5f9a729..57110ed 100644 --- a/backend/src/share/guard/shareSecurity.guard.ts +++ b/backend/src/share/guard/shareSecurity.guard.ts @@ -33,7 +33,7 @@ export class ShareSecurityGuard implements CanActivate { include: { security: true }, }); - if (!share || moment().isAfter(share.expiration)) + if (!share || (moment().isAfter(share.expiration) && moment(share.expiration).unix() !== 0)) throw new NotFoundException("Share not found"); if (!share.security) return true; diff --git a/backend/src/share/share.service.ts b/backend/src/share/share.service.ts index a8c4c4b..67d8c42 100644 --- a/backend/src/share/share.service.ts +++ b/backend/src/share/share.service.ts @@ -1,8 +1,8 @@ import { - BadRequestException, - ForbiddenException, - Injectable, - NotFoundException, + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { JwtService } from "@nestjs/jwt"; @@ -17,184 +17,195 @@ import { CreateShareDTO } from "./dto/createShare.dto"; @Injectable() export class ShareService { - constructor( - private prisma: PrismaService, - private fileService: FileService, - private config: ConfigService, - private jwtService: JwtService - ) {} - - async create(share: CreateShareDTO, user: User) { - if (!(await this.isShareIdAvailable(share.id)).isAvailable) - throw new BadRequestException("Share id already in use"); - - if (!share.security || Object.keys(share.security).length == 0) - share.security = undefined; - - if (share.security?.password) { - share.security.password = await argon.hash(share.security.password); + constructor( + private prisma: PrismaService, + private fileService: FileService, + private config: ConfigService, + private jwtService: JwtService + ) { } - const expirationDate = moment() - .add( - share.expiration.split("-")[0], - share.expiration.split("-")[1] as moment.unitOfTime.DurationConstructor - ) - .toDate(); + async create(share: CreateShareDTO, user: User) { + if (!(await this.isShareIdAvailable(share.id)).isAvailable) + throw new BadRequestException("Share id already in use"); - // Throw error if expiration date is now - if (expirationDate.setMilliseconds(0) == new Date().setMilliseconds(0)) - throw new BadRequestException("Invalid expiration date"); + if (!share.security || Object.keys(share.security).length == 0) + share.security = undefined; - return await this.prisma.share.create({ - data: { - ...share, - expiration: expirationDate, - creator: { connect: { id: user.id } }, - security: { create: share.security }, - }, - }); - } + if (share.security?.password) { + share.security.password = await argon.hash(share.security.password); + } - async createZip(shareId: string) { - const path = `./data/uploads/shares/${shareId}`; + // We have to add an exception for "never" (since moment won't like that) + let expirationDate; + if (share.expiration !== "never") { + expirationDate = moment() + .add( + share.expiration.split("-")[0], + share.expiration.split("-")[1] as moment.unitOfTime.DurationConstructor + ) + .toDate(); - const files = await this.prisma.file.findMany({ where: { shareId } }); - const archive = archiver("zip", { - zlib: { level: 9 }, - }); - const writeStream = fs.createWriteStream(`${path}/archive.zip`); + // Throw error if expiration date is now + if (expirationDate.setMilliseconds(0) == new Date().setMilliseconds(0)) + throw new BadRequestException("Invalid expiration date"); + } else { + expirationDate = moment(0).toDate(); + } - for (const file of files) { - archive.append(fs.createReadStream(`${path}/${file.id}`), { - name: file.name, - }); + return await this.prisma.share.create({ + data: { + ...share, + expiration: expirationDate, + creator: {connect: {id: user.id}}, + security: {create: share.security}, + }, + }); } - archive.pipe(writeStream); - await archive.finalize(); - } + async createZip(shareId: string) { + const path = `./data/uploads/shares/${shareId}`; - async complete(id: string) { - const moreThanOneFileInShare = - (await this.prisma.file.findMany({ where: { shareId: id } })).length != 0; + const files = await this.prisma.file.findMany({where: {shareId}}); + const archive = archiver("zip", { + zlib: {level: 9}, + }); + const writeStream = fs.createWriteStream(`${path}/archive.zip`); - if (!moreThanOneFileInShare) - throw new BadRequestException( - "You need at least on file in your share to complete it." - ); + for (const file of files) { + archive.append(fs.createReadStream(`${path}/${file.id}`), { + name: file.name, + }); + } - this.createZip(id).then(() => - this.prisma.share.update({ where: { id }, data: { isZipReady: true } }) - ); - - return await this.prisma.share.update({ - where: { id }, - data: { uploadLocked: true }, - }); - } - - async getSharesByUser(userId: string) { - return await this.prisma.share.findMany({ - where: { creator: { id: userId }, expiration: { gt: new Date() } }, - }); - } - - async get(id: string) { - let share: any = await this.prisma.share.findUnique({ - where: { id }, - include: { - files: true, - creator: true, - }, - }); - - if (!share || !share.uploadLocked) - throw new NotFoundException("Share not found"); - - share.files = share.files.map((file) => { - file["url"] = `http://localhost:8080/file/${file.id}`; - return file; - }); - - await this.increaseViewCount(share); - - return share; - } - - async getMetaData(id: string) { - const share = await this.prisma.share.findUnique({ - where: { id }, - }); - - if (!share || !share.uploadLocked) - throw new NotFoundException("Share not found"); - - return share; - } - - async remove(shareId: string) { - const share = await this.prisma.share.findUnique({ - where: { id: shareId }, - }); - - if (!share) throw new NotFoundException("Share not found"); - - await this.fileService.deleteAllFiles(shareId); - await this.prisma.share.delete({ where: { id: shareId } }); - } - - async isShareCompleted(id: string) { - return (await this.prisma.share.findUnique({ where: { id } })).uploadLocked; - } - - async isShareIdAvailable(id: string) { - const share = await this.prisma.share.findUnique({ where: { id } }); - return { isAvailable: !share }; - } - - async increaseViewCount(share: Share) { - await this.prisma.share.update({ - where: { id: share.id }, - data: { views: share.views + 1 }, - }); - } - - async exchangeSharePasswordWithToken(shareId: string, password: string) { - const sharePassword = ( - await this.prisma.shareSecurity.findFirst({ - where: { share: { id: shareId } }, - }) - ).password; - - if (!(await argon.verify(sharePassword, password))) - throw new ForbiddenException("Wrong password"); - - const token = this.generateShareToken(shareId); - return { token }; - } - - generateShareToken(shareId: string) { - return this.jwtService.sign( - { - shareId, - }, - { - expiresIn: "1h", - secret: this.config.get("JWT_SECRET"), - } - ); - } - - verifyShareToken(shareId: string, token: string) { - try { - const claims = this.jwtService.verify(token, { - secret: this.config.get("JWT_SECRET"), - }); - - return claims.shareId == shareId; - } catch { - return false; + archive.pipe(writeStream); + await archive.finalize(); + } + + async complete(id: string) { + const moreThanOneFileInShare = + (await this.prisma.file.findMany({where: {shareId: id}})).length != 0; + + if (!moreThanOneFileInShare) + throw new BadRequestException( + "You need at least on file in your share to complete it." + ); + + this.createZip(id).then(() => + this.prisma.share.update({where: {id}, data: {isZipReady: true}}) + ); + + return await this.prisma.share.update({ + where: {id}, + data: {uploadLocked: true}, + }); + } + + async getSharesByUser(userId: string) { + return await this.prisma.share.findMany({ + where: { + creator: {id: userId}, + // We want to grab any shares that are not expired or have their expiration date set to "never" (unix 0) + OR: [{expiration: {gt: new Date()}}, {expiration: {equals: moment(0).toDate()}}] + }, + }); + } + + async get(id: string) { + let share: any = await this.prisma.share.findUnique({ + where: {id}, + include: { + files: true, + creator: true, + }, + }); + + if (!share || !share.uploadLocked) + throw new NotFoundException("Share not found"); + + share.files = share.files.map((file) => { + file["url"] = `http://localhost:8080/file/${file.id}`; + return file; + }); + + await this.increaseViewCount(share); + + return share; + } + + async getMetaData(id: string) { + const share = await this.prisma.share.findUnique({ + where: {id}, + }); + + if (!share || !share.uploadLocked) + throw new NotFoundException("Share not found"); + + return share; + } + + async remove(shareId: string) { + const share = await this.prisma.share.findUnique({ + where: {id: shareId}, + }); + + if (!share) throw new NotFoundException("Share not found"); + + await this.fileService.deleteAllFiles(shareId); + await this.prisma.share.delete({where: {id: shareId}}); + } + + async isShareCompleted(id: string) { + return (await this.prisma.share.findUnique({where: {id}})).uploadLocked; + } + + async isShareIdAvailable(id: string) { + const share = await this.prisma.share.findUnique({where: {id}}); + return {isAvailable: !share}; + } + + async increaseViewCount(share: Share) { + await this.prisma.share.update({ + where: {id: share.id}, + data: {views: share.views + 1}, + }); + } + + async exchangeSharePasswordWithToken(shareId: string, password: string) { + const sharePassword = ( + await this.prisma.shareSecurity.findFirst({ + where: {share: {id: shareId}}, + }) + ).password; + + if (!(await argon.verify(sharePassword, password))) + throw new ForbiddenException("Wrong password"); + + const token = this.generateShareToken(shareId); + return {token}; + } + + generateShareToken(shareId: string) { + return this.jwtService.sign( + { + shareId, + }, + { + expiresIn: "1h", + secret: this.config.get("JWT_SECRET"), + } + ); + } + + verifyShareToken(shareId: string, token: string) { + try { + const claims = this.jwtService.verify(token, { + secret: this.config.get("JWT_SECRET"), + }); + + return claims.shareId == shareId; + } catch { + return false; + } } - } } diff --git a/frontend/src/components/share/CreateUploadModalBody.tsx b/frontend/src/components/share/CreateUploadModalBody.tsx index 659f5a8..14b28ea 100644 --- a/frontend/src/components/share/CreateUploadModalBody.tsx +++ b/frontend/src/components/share/CreateUploadModalBody.tsx @@ -1,14 +1,14 @@ import { - Accordion, - Button, - Col, - Grid, - NumberInput, - PasswordInput, - Select, - Stack, - Text, - TextInput, + Accordion, + Button, + Col, + Grid, + NumberInput, + PasswordInput, + Select, + Stack, + Text, + TextInput, } from "@mantine/core"; import { useForm, yupResolver } from "@mantine/form"; import { useModals } from "@mantine/modals"; @@ -17,129 +17,127 @@ import shareService from "../../services/share.service"; import { ShareSecurity } from "../../types/share.type"; const CreateUploadModalBody = ({ - uploadCallback, -}: { - uploadCallback: ( - id: string, - expiration: string, - security: ShareSecurity - ) => void; + uploadCallback, + }: { + uploadCallback: ( + id: string, + expiration: string, + security: ShareSecurity + ) => void; }) => { - const modals = useModals(); - const validationSchema = yup.object().shape({ - link: yup - .string() - .required() - .min(3) - .max(100) - .matches(new RegExp("^[a-zA-Z0-9_-]*$"), { - message: "Can only contain letters, numbers, underscores and hyphens", - }), - password: yup.string().min(3).max(30), - maxViews: yup.number().min(1), - }); - const form = useForm({ - initialValues: { - link: "", + const modals = useModals(); + const validationSchema = yup.object().shape({ + link: yup + .string() + .required() + .min(3) + .max(100) + .matches(new RegExp("^[a-zA-Z0-9_-]*$"), { + message: "Can only contain letters, numbers, underscores and hyphens", + }), + password: yup.string().min(3).max(30), + maxViews: yup.number().min(1), + }); + const form = useForm({ + initialValues: { + link: "", - password: undefined, - maxViews: undefined, - expiration: "1-day", - }, - validate: yupResolver(validationSchema), - }); + password: undefined, + maxViews: undefined, + expiration: "1-day", + }, + validate: yupResolver(validationSchema), + }); - return ( -
{ - if (!(await shareService.isShareIdAvailable(values.link))) { - form.setFieldError("link", "This link is already in use"); - } else { - uploadCallback(values.link, values.expiration, { - password: values.password, - maxViews: values.maxViews, - }); - modals.closeAll(); - } - })} - > - - - - - - - - - - - ({ - color: theme.colors.gray[6], - })} + return ( + { + if (!(await shareService.isShareIdAvailable(values.link))) { + form.setFieldError("link", "This link is already in use"); + } else { + uploadCallback(values.link, values.expiration, { + password: values.password, + maxViews: values.maxViews, + }); + modals.closeAll(); + } + })} > - {window.location.origin}/share/ - {form.values.link == "" ? "myAwesomeShare" : form.values.link} - - - - - - - - - -
- ); + + + Security options + + + + + + + + + + + + ); }; export default CreateUploadModalBody; diff --git a/frontend/src/components/share/showEnterPasswordModal.tsx b/frontend/src/components/share/showEnterPasswordModal.tsx index 7b910bc..e89bd89 100644 --- a/frontend/src/components/share/showEnterPasswordModal.tsx +++ b/frontend/src/components/share/showEnterPasswordModal.tsx @@ -1,4 +1,4 @@ -import { Button, Group, PasswordInput, Stack, Text, Title } from "@mantine/core"; +import { Button, PasswordInput, Stack, Text, Title } from "@mantine/core"; import { ModalsContextProps } from "@mantine/modals/lib/context"; import { useState } from "react"; diff --git a/frontend/src/components/share/showErrorModal.tsx b/frontend/src/components/share/showErrorModal.tsx index 0868ce3..c0c2783 100644 --- a/frontend/src/components/share/showErrorModal.tsx +++ b/frontend/src/components/share/showErrorModal.tsx @@ -1,4 +1,4 @@ -import { Button, Group, Stack, Text, Title } from "@mantine/core"; +import { Button, Stack, Text, Title } from "@mantine/core"; import { useModals } from "@mantine/modals"; import { ModalsContextProps } from "@mantine/modals/lib/context"; import { useRouter } from "next/router"; diff --git a/frontend/src/components/upload/Dropzone.tsx b/frontend/src/components/upload/Dropzone.tsx index e5a679c..2a7b1dd 100644 --- a/frontend/src/components/upload/Dropzone.tsx +++ b/frontend/src/components/upload/Dropzone.tsx @@ -4,7 +4,6 @@ import { createStyles, Group, Text, - useMantineTheme, } from "@mantine/core"; import { Dropzone as MantineDropzone } from "@mantine/dropzone"; import getConfig from "next/config"; @@ -46,7 +45,6 @@ const Dropzone = ({ isUploading: boolean; setFiles: Dispatch>; }) => { - const theme = useMantineTheme(); const { classes } = useStyles(); const openRef = useRef<() => void>(); return ( diff --git a/frontend/src/components/upload/showCompletedUploadModal.tsx b/frontend/src/components/upload/showCompletedUploadModal.tsx index 09c2b73..3d8374b 100644 --- a/frontend/src/components/upload/showCompletedUploadModal.tsx +++ b/frontend/src/components/upload/showCompletedUploadModal.tsx @@ -1,11 +1,10 @@ import { - ActionIcon, - Button, - Group, - Stack, - Text, - TextInput, - Title + ActionIcon, + Button, + Stack, + Text, + TextInput, + Title } from "@mantine/core"; import { useClipboard } from "@mantine/hooks"; import { useModals } from "@mantine/modals"; @@ -17,62 +16,65 @@ import { Share } from "../../types/share.type"; import toast from "../../utils/toast.util"; const showCompletedUploadModal = ( - modals: ModalsContextProps, - share: Share, + modals: ModalsContextProps, + share: Share, ) => { - return modals.openModal({ - closeOnClickOutside: false, - withCloseButton: false, - closeOnEscape: false, - title: ( - - Share ready - - ), - children: , - }); + return modals.openModal({ + closeOnClickOutside: false, + withCloseButton: false, + closeOnEscape: false, + title: ( + + Share ready + + ), + children: , + }); }; -const Body = ({ share }: { share: Share }) => { - const clipboard = useClipboard({ timeout: 500 }); - const modals = useModals(); - const router = useRouter(); - const link = `${window.location.origin}/share/${share.id}`; - return ( - - { - clipboard.copy(link); - toast.success("Your link was copied to the keyboard."); - }} - > - - - } - /> - ({ - color: theme.colors.gray[6], - })} - > - Your share expires at {moment(share.expiration).format("LLL")} - +const Body = ({share}: { share: Share }) => { + const clipboard = useClipboard({timeout: 500}); + const modals = useModals(); + const router = useRouter(); + const link = `${window.location.origin}/share/${share.id}`; + return ( + + { + clipboard.copy(link); + toast.success("Your link was copied to the keyboard."); + }} + > + + + } + /> + ({ + color: theme.colors.gray[6], + })} + > + {/* If our share.expiration is timestamp 0, show a different message */} + {moment(share.expiration).unix() === 0 + ? "This share will never expire." + : `This share will expire on ${moment(share.expiration).format("LLL")}`} + - - - ); + + + ); }; export default showCompletedUploadModal; diff --git a/frontend/src/pages/account/shares.tsx b/frontend/src/pages/account/shares.tsx index f3da20b..a9b4553 100644 --- a/frontend/src/pages/account/shares.tsx +++ b/frontend/src/pages/account/shares.tsx @@ -65,7 +65,9 @@ const MyShares = () => { {share.id} {share.views} - {moment(share.expiration).format("MMMM DD YYYY, HH:mm")} + {moment(share.expiration).unix() === 0 + ? "Never" + : moment(share.expiration).format("MMMM DD YYYY, HH:mm")}