diff --git a/.gitignore b/.gitignore index 21d982f..89d4739 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ yarn-error.log* # project specific /backend/data/ /data/ + +# Jetbrains specific (webstorm) +.idea/**/** \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 2721a0f..446770a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -29,7 +29,9 @@ "passport": "^0.6.0", "passport-jwt": "^4.0.0", "passport-local": "^1.0.0", - "rimraf": "^3.0.2" + "reflect-metadata": "^0.1.13", + "rimraf": "^3.0.2", + "rxjs": "^7.5.7" }, "devDependencies": { "@nestjs/cli": "^9.1.4", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index cb32321..4b5a7fe 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -2,7 +2,7 @@ import { Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; import { ScheduleModule } from "@nestjs/schedule"; import { AuthModule } from "./auth/auth.module"; -import { JobsService } from "./auth/jobs/jobs.service"; +import { JobsService } from "./jobs/jobs.service"; import { FileController } from "./file/file.controller"; import { FileModule } from "./file/file.module"; diff --git a/backend/src/auth/jobs/jobs.service.ts b/backend/src/jobs/jobs.service.ts similarity index 77% rename from backend/src/auth/jobs/jobs.service.ts rename to backend/src/jobs/jobs.service.ts index a752afd..688d2f3 100644 --- a/backend/src/auth/jobs/jobs.service.ts +++ b/backend/src/jobs/jobs.service.ts @@ -2,6 +2,7 @@ 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 { @@ -13,7 +14,13 @@ export class JobsService { @Cron("0 * * * *") async deleteExpiredShares() { const expiredShares = await this.prisma.share.findMany({ - where: { expiration: { lt: new Date() } }, + 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() } }, + ], + }, }); for (const expiredShare of expiredShares) { diff --git a/backend/src/share/guard/shareSecurity.guard.ts b/backend/src/share/guard/shareSecurity.guard.ts index 292ec5a..028689c 100644 --- a/backend/src/share/guard/shareSecurity.guard.ts +++ b/backend/src/share/guard/shareSecurity.guard.ts @@ -34,7 +34,11 @@ 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?.password && !shareToken) diff --git a/backend/src/share/guard/shareTokenSecurity.guard.ts b/backend/src/share/guard/shareTokenSecurity.guard.ts index 86f0dd6..f108842 100644 --- a/backend/src/share/guard/shareTokenSecurity.guard.ts +++ b/backend/src/share/guard/shareTokenSecurity.guard.ts @@ -5,19 +5,13 @@ import { 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 - ) {} + constructor(private prisma: PrismaService) {} async canActivate(context: ExecutionContext) { const request: Request = context.switchToHttp().getRequest(); @@ -33,7 +27,11 @@ export class ShareTokenSecurity implements CanActivate { include: { security: true }, }); - if (!share || moment().isAfter(share.expiration)) + if ( + !share || + (moment().isAfter(share.expiration) && + !moment(share.expiration).isSame(0)) + ) throw new NotFoundException("Share not found"); if (share.security?.maxViews && share.security.maxViews <= share.views) diff --git a/backend/src/share/share.service.ts b/backend/src/share/share.service.ts index 602e82e..8343233 100644 --- a/backend/src/share/share.service.ts +++ b/backend/src/share/share.service.ts @@ -35,16 +35,24 @@ export class ShareService { share.security.password = await argon.hash(share.security.password); } - const expirationDate = moment() - .add( - share.expiration.split("-")[0], - share.expiration.split("-")[1] as moment.unitOfTime.DurationConstructor - ) - .toDate(); + // 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(); - // Throw error if expiration date is now - if (expirationDate.setMilliseconds(0) == new Date().setMilliseconds(0)) - throw new BadRequestException("Invalid expiration date"); + // 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(); + } return await this.prisma.share.create({ data: { @@ -101,8 +109,12 @@ export class ShareService { return await this.prisma.share.findMany({ where: { creator: { id: userId }, - expiration: { gt: new Date() }, uploadLocked: true, + // 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() } }, + ], }, orderBy: { expiration: "desc", @@ -186,7 +198,6 @@ export class ShareService { const { expiration } = await this.prisma.share.findUnique({ where: { id: shareId }, }); - console.log(moment(expiration).diff(new Date(), "seconds")); return this.jwtService.sign( { shareId, @@ -198,10 +209,16 @@ export class ShareService { ); } - verifyShareToken(shareId: string, token: string) { + async verifyShareToken(shareId: string, token: string) { + const { expiration } = await this.prisma.share.findUnique({ + where: { id: shareId }, + }); + try { const claims = this.jwtService.verify(token, { secret: this.config.get("JWT_SECRET"), + // Ignore expiration if expiration is 0 + ignoreExpiration: moment(expiration).isSame(0), }); return claims.shareId == shareId; diff --git a/frontend/.env.example b/frontend/.env.example index dbda902..7e65299 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,3 +1,4 @@ SHOW_HOME_PAGE=true ALLOW_REGISTRATION=true MAX_FILE_SIZE=1000000000 +TWELVE_HOUR_TIME=false diff --git a/frontend/next.config.js b/frontend/next.config.js index b2b6bf5..dea14c0 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -5,7 +5,8 @@ const nextConfig = { ALLOW_REGISTRATION: process.env.ALLOW_REGISTRATION, SHOW_HOME_PAGE: process.env.SHOW_HOME_PAGE, MAX_FILE_SIZE: process.env.MAX_FILE_SIZE, - BACKEND_URL: process.env.BACKEND_URL + BACKEND_URL: process.env.BACKEND_URL, + TWELVE_HOUR_TIME: process.env.TWELVE_HOUR_TIME } } diff --git a/frontend/package.json b/frontend/package.json index 75cf76c..fe0793f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,9 +6,11 @@ "build": "next build", "start": "dotenv next start", "lint": "next lint", - "format": "prettier --write \"src/**/*.ts\"" + "format": "prettier --write \"src/**/*.ts*\"" }, "dependencies": { + "@emotion/react": "^11.10.4", + "@emotion/server": "^11.10.0", "@mantine/core": "^5.5.2", "@mantine/dropzone": "^5.5.2", "@mantine/form": "^5.5.2", diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index dc6eeb4..869e795 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -5,7 +5,10 @@ const Footer = () => {
- Made with 🖤 by Elias Schneider + Made with 🖤 by{" "} + + Elias Schneider +
diff --git a/frontend/src/components/navBar/ActionAvatar.tsx b/frontend/src/components/navBar/ActionAvatar.tsx index 867029c..2db5090 100644 --- a/frontend/src/components/navBar/ActionAvatar.tsx +++ b/frontend/src/components/navBar/ActionAvatar.tsx @@ -1,6 +1,6 @@ import { ActionIcon, Avatar, Menu } from "@mantine/core"; import { NextLink } from "@mantine/next"; -import { TbDoorExit, TbLink } from "react-icons/tb";; +import { TbDoorExit, TbLink } from "react-icons/tb"; import authService from "../../services/auth.service"; const ActionAvatar = () => { diff --git a/frontend/src/components/share/CreateUploadModalBody.tsx b/frontend/src/components/share/CreateUploadModalBody.tsx index 038994d..0450763 100644 --- a/frontend/src/components/share/CreateUploadModalBody.tsx +++ b/frontend/src/components/share/CreateUploadModalBody.tsx @@ -2,6 +2,7 @@ import { Accordion, Button, Col, + Checkbox, Grid, NumberInput, PasswordInput, @@ -15,6 +16,33 @@ import { useModals } from "@mantine/modals"; import * as yup from "yup"; import shareService from "../../services/share.service"; import { ShareSecurity } from "../../types/share.type"; +import moment from "moment"; +import getConfig from "next/config"; + +const { publicRuntimeConfig } = getConfig(); + +const PreviewExpiration = ({ form }: { form: any }) => { + const value = form.values.never_expires + ? "never" + : form.values.expiration_num + form.values.expiration_unit; + if (value === "never") return "This share will never expire."; + + const expirationDate = moment() + .add( + value.split("-")[0], + value.split("-")[1] as moment.unitOfTime.DurationConstructor + ) + .toDate(); + + if (publicRuntimeConfig.TWELVE_HOUR_TIME === "true") + return `This share will expire on ${moment(expirationDate).format( + "MMMM Do YYYY, h:mm a" + )}`; + else + return `This share will expire on ${moment(expirationDate).format( + "MMMM DD YYYY, HH:mm" + )}`; +}; const CreateUploadModalBody = ({ uploadCallback, @@ -44,7 +72,9 @@ const CreateUploadModalBody = ({ password: undefined, maxViews: undefined, - expiration: "1-day", + expiration_num: 1, + expiration_unit: "-days", + never_expires: false, }, validate: yupResolver(validationSchema), }); @@ -55,7 +85,10 @@ const CreateUploadModalBody = ({ if (!(await shareService.isShareIdAvailable(values.link))) { form.setFieldError("link", "This link is already in use"); } else { - uploadCallback(values.link, values.expiration, { + const expiration = form.values.never_expires + ? "never" + : form.values.expiration_num + form.values.expiration_unit; + uploadCallback(values.link, expiration, { password: values.password, maxViews: values.maxViews, }); @@ -91,6 +124,7 @@ const CreateUploadModalBody = ({ ({ color: theme.colors.gray[6], @@ -99,20 +133,70 @@ const CreateUploadModalBody = ({ {window.location.origin}/share/ {form.values.link == "" ? "myAwesomeShare" : form.values.link} - + + + + + {/* Preview expiration date text */} + ({ + color: theme.colors.gray[6], + })} + > + {PreviewExpiration({ form })} + + Security options diff --git a/frontend/src/components/share/FileList.tsx b/frontend/src/components/share/FileList.tsx index 9cce244..8ffeef0 100644 --- a/frontend/src/components/share/FileList.tsx +++ b/frontend/src/components/share/FileList.tsx @@ -1,5 +1,5 @@ import { ActionIcon, Loader, Skeleton, Table } from "@mantine/core"; -import { TbCircleCheck, TbDownload } from "react-icons/tb";; +import { TbCircleCheck, TbDownload } from "react-icons/tb"; import shareService from "../../services/share.service"; import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util"; 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 9ad55bf..4556798 100644 --- a/frontend/src/components/upload/Dropzone.tsx +++ b/frontend/src/components/upload/Dropzone.tsx @@ -2,7 +2,7 @@ import { Button, Center, createStyles, Group, Text } from "@mantine/core"; import { Dropzone as MantineDropzone } from "@mantine/dropzone"; import getConfig from "next/config"; import { Dispatch, ForwardedRef, SetStateAction, useRef } from "react"; -import { TbCloudUpload, TbUpload } from "react-icons/tb";;; +import { TbCloudUpload, TbUpload } from "react-icons/tb"; import { FileUpload } from "../../types/File.type"; import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util"; import toast from "../../utils/toast.util"; diff --git a/frontend/src/components/upload/FileList.tsx b/frontend/src/components/upload/FileList.tsx index fb4c2a7..a1854e4 100644 --- a/frontend/src/components/upload/FileList.tsx +++ b/frontend/src/components/upload/FileList.tsx @@ -1,6 +1,6 @@ import { ActionIcon, Table } from "@mantine/core"; import { Dispatch, SetStateAction } from "react"; -import { TbTrash } from "react-icons/tb";; +import { TbTrash } from "react-icons/tb"; import { FileUpload } from "../../types/File.type"; import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util"; import UploadProgressIndicator from "./UploadProgressIndicator"; diff --git a/frontend/src/components/upload/showCompletedUploadModal.tsx b/frontend/src/components/upload/showCompletedUploadModal.tsx index 372b9a3..68c32ae 100644 --- a/frontend/src/components/upload/showCompletedUploadModal.tsx +++ b/frontend/src/components/upload/showCompletedUploadModal.tsx @@ -10,10 +10,14 @@ import { useClipboard } from "@mantine/hooks"; import { useModals } from "@mantine/modals"; import { ModalsContextProps } from "@mantine/modals/lib/context"; import moment from "moment"; +import getConfig from "next/config"; import { useRouter } from "next/router"; import { TbCopy } from "react-icons/tb"; import { Share } from "../../types/share.type"; import toast from "../../utils/toast.util"; + +const { publicRuntimeConfig } = getConfig(); + const showCompletedUploadModal = (modals: ModalsContextProps, share: Share) => { return modals.openModal({ closeOnClickOutside: false, @@ -55,7 +59,14 @@ const Body = ({ share }: { share: Share }) => { color: theme.colors.gray[6], })} > - Your share expires at {moment(share.expiration).format("LLL")} + {/* 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 ${ + publicRuntimeConfig.TWELVE_HOUR_TIME === "true" + ? moment(share.expiration).format("MMMM Do YYYY, h:mm a") + : moment(share.expiration).format("MMMM DD YYYY, HH:mm") + }`}