From 4a5fb549c6ac808261eb65d28db69510a82efd00 Mon Sep 17 00:00:00 2001 From: Elias Schneider <58886915+stonith404@users.noreply.github.com> Date: Thu, 26 Jan 2023 13:44:04 +0100 Subject: [PATCH] feat: reverse shares (#86) * add first concept * add reverse share funcionality to frontend * allow creator to limit share expiration * moved reverse share in seperate module * add table to manage reverse shares * delete complete share if reverse share was deleted * optimize function names * add db migration * enable reverse share email notifications * fix config variable descriptions * fix migration for new installations --- backend/package-lock.json | 30 +-- backend/package.json | 2 +- .../migration.sql | 91 ++++++++ backend/prisma/schema.prisma | 28 ++- backend/prisma/seed/config.seed.ts | 71 +++++-- backend/src/app.module.ts | 2 + backend/src/config/config.service.ts | 1 + backend/src/email/email.service.ts | 31 ++- backend/src/file/file.controller.ts | 3 +- backend/src/file/file.module.ts | 3 +- backend/src/file/file.service.ts | 9 +- .../dto/createReverseShare.dto.ts | 12 ++ .../src/reverseShare/dto/reverseShare.dto.ts | 18 ++ .../dto/reverseShareTokenWithShare.ts | 23 ++ .../guards/reverseShareOwner.guard.ts | 22 ++ .../reverseShare/reverseShare.controller.ts | 64 ++++++ .../src/reverseShare/reverseShare.module.ts | 12 ++ .../src/reverseShare/reverseShare.service.ts | 94 ++++++++ backend/src/share/guard/createShare.guard.ts | 29 +++ backend/src/share/share.controller.ts | 32 ++- backend/src/share/share.module.ts | 2 + backend/src/share/share.service.ts | 81 +++++-- .../admin/configuration/AdminConfigTable.tsx | 2 +- .../src/components/navBar/ActionAvatar.tsx | 11 +- frontend/src/components/navBar/NavBar.tsx | 6 + .../src/components/navBar/NavbarShareMenu.tsx | 29 +++ frontend/src/components/share/FileList.tsx | 4 +- .../src/components/share/FileSizeInput.tsx | 62 ++++++ .../modals/showCompletedReverseShareModal.tsx | 68 ++++++ .../modals/showCreateReverseShareModal.tsx | 156 ++++++++++++++ frontend/src/components/upload/Dropzone.tsx | 15 +- .../components/upload/ExpirationPreview.tsx | 19 -- frontend/src/components/upload/FileList.tsx | 4 +- .../upload/modals/showCreateUploadModal.tsx | 145 +++++++------ frontend/src/pages/account/reverseShares.tsx | 200 ++++++++++++++++++ frontend/src/pages/account/shares.tsx | 155 +++++++------- .../src/pages/upload/[reverseShareToken].tsx | 43 ++++ .../pages/{upload.tsx => upload/index.tsx} | 56 +++-- frontend/src/services/share.service.ts | 34 +++ frontend/src/types/share.type.ts | 7 + frontend/src/utils/date.util.ts | 26 +++ frontend/src/utils/fileSize.util.ts | 23 ++ .../math/byteStringToHumanSizeString.util.ts | 11 - 43 files changed, 1456 insertions(+), 280 deletions(-) create mode 100644 backend/prisma/migrations/20230126094911_reverse_share/migration.sql create mode 100644 backend/src/reverseShare/dto/createReverseShare.dto.ts create mode 100644 backend/src/reverseShare/dto/reverseShare.dto.ts create mode 100644 backend/src/reverseShare/dto/reverseShareTokenWithShare.ts create mode 100644 backend/src/reverseShare/guards/reverseShareOwner.guard.ts create mode 100644 backend/src/reverseShare/reverseShare.controller.ts create mode 100644 backend/src/reverseShare/reverseShare.module.ts create mode 100644 backend/src/reverseShare/reverseShare.service.ts create mode 100644 backend/src/share/guard/createShare.guard.ts create mode 100644 frontend/src/components/navBar/NavbarShareMenu.tsx create mode 100644 frontend/src/components/share/FileSizeInput.tsx create mode 100644 frontend/src/components/share/modals/showCompletedReverseShareModal.tsx create mode 100644 frontend/src/components/share/modals/showCreateReverseShareModal.tsx delete mode 100644 frontend/src/components/upload/ExpirationPreview.tsx create mode 100644 frontend/src/pages/account/reverseShares.tsx create mode 100644 frontend/src/pages/upload/[reverseShareToken].tsx rename frontend/src/pages/{upload.tsx => upload/index.tsx} (77%) create mode 100644 frontend/src/utils/date.util.ts create mode 100644 frontend/src/utils/fileSize.util.ts delete mode 100644 frontend/src/utils/math/byteStringToHumanSizeString.util.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 0580717..16e246d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -62,7 +62,7 @@ "eslint-plugin-prettier": "^4.2.1", "newman": "^5.3.2", "prettier": "^2.8.2", - "prisma": "^4.8.1", + "prisma": "^4.9.0", "source-map-support": "^0.5.21", "ts-loader": "^9.4.2", "tsconfig-paths": "4.1.2", @@ -1010,9 +1010,9 @@ } }, "node_modules/@prisma/engines": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.8.1.tgz", - "integrity": "sha512-93tctjNXcIS+i/e552IO6tqw17sX8liivv8WX9lDMCpEEe3ci+nT9F+1oHtAafqruXLepKF80i/D20Mm+ESlOw==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.9.0.tgz", + "integrity": "sha512-t1pt0Gsp+HcgPJrHFc+d/ZSAaKKWar2G/iakrE07yeKPNavDP3iVKPpfXP22OTCHZUWf7OelwKJxQgKAm5hkgw==", "devOptional": true, "hasInstallScript": true }, @@ -5871,13 +5871,13 @@ } }, "node_modules/prisma": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-4.8.1.tgz", - "integrity": "sha512-ZMLnSjwulIeYfaU1O6/LF6PEJzxN5par5weykxMykS9Z6ara/j76JH3Yo2AH3bgJbPN4Z6NeCK9s5fDkzf33cg==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-4.9.0.tgz", + "integrity": "sha512-bS96oZ5oDFXYgoF2l7PJ3Mp1wWWfLOo8B/jAfbA2Pn0Wm5Z/owBHzaMQKS3i1CzVBDWWPVnOohmbJmjvkcHS5w==", "devOptional": true, "hasInstallScript": true, "dependencies": { - "@prisma/engines": "4.8.1" + "@prisma/engines": "4.9.0" }, "bin": { "prisma": "build/index.js", @@ -8255,9 +8255,9 @@ } }, "@prisma/engines": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.8.1.tgz", - "integrity": "sha512-93tctjNXcIS+i/e552IO6tqw17sX8liivv8WX9lDMCpEEe3ci+nT9F+1oHtAafqruXLepKF80i/D20Mm+ESlOw==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.9.0.tgz", + "integrity": "sha512-t1pt0Gsp+HcgPJrHFc+d/ZSAaKKWar2G/iakrE07yeKPNavDP3iVKPpfXP22OTCHZUWf7OelwKJxQgKAm5hkgw==", "devOptional": true }, "@prisma/engines-version": { @@ -11995,12 +11995,12 @@ } }, "prisma": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-4.8.1.tgz", - "integrity": "sha512-ZMLnSjwulIeYfaU1O6/LF6PEJzxN5par5weykxMykS9Z6ara/j76JH3Yo2AH3bgJbPN4Z6NeCK9s5fDkzf33cg==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-4.9.0.tgz", + "integrity": "sha512-bS96oZ5oDFXYgoF2l7PJ3Mp1wWWfLOo8B/jAfbA2Pn0Wm5Z/owBHzaMQKS3i1CzVBDWWPVnOohmbJmjvkcHS5w==", "devOptional": true, "requires": { - "@prisma/engines": "4.8.1" + "@prisma/engines": "4.9.0" } }, "process-nextick-args": { diff --git a/backend/package.json b/backend/package.json index 301b664..b790f2f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -67,7 +67,7 @@ "eslint-plugin-prettier": "^4.2.1", "newman": "^5.3.2", "prettier": "^2.8.2", - "prisma": "^4.8.1", + "prisma": "^4.9.0", "source-map-support": "^0.5.21", "ts-loader": "^9.4.2", "tsconfig-paths": "4.1.2", diff --git a/backend/prisma/migrations/20230126094911_reverse_share/migration.sql b/backend/prisma/migrations/20230126094911_reverse_share/migration.sql new file mode 100644 index 0000000..95e445a --- /dev/null +++ b/backend/prisma/migrations/20230126094911_reverse_share/migration.sql @@ -0,0 +1,91 @@ +/* + Warnings: + + - The primary key for the `Config` table will be changed. If it partially fails, the table could be left without primary key constraint. + - Added the required column `id` to the `Config` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateTable +CREATE TABLE "ReverseShare" ( + "id" TEXT NOT NULL PRIMARY KEY, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "token" TEXT NOT NULL, + "shareExpiration" DATETIME NOT NULL, + "maxShareSize" TEXT NOT NULL, + "sendEmailNotification" BOOLEAN NOT NULL, + "used" BOOLEAN NOT NULL DEFAULT false, + "creatorId" TEXT NOT NULL, + "shareId" TEXT, + CONSTRAINT "ReverseShare_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ReverseShare_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "Share" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Config" ( + "id" INTEGER, + "updatedAt" DATETIME NOT NULL, + "key" TEXT NOT NULL, + "type" TEXT NOT NULL, + "value" TEXT NOT NULL, + "description" TEXT NOT NULL, + "category" TEXT NOT NULL, + "obscured" BOOLEAN NOT NULL DEFAULT false, + "secret" BOOLEAN NOT NULL DEFAULT true, + "locked" BOOLEAN NOT NULL DEFAULT false +); +INSERT INTO "new_Config" ("category", "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value") SELECT "category", "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value" FROM "Config"; +DROP TABLE "Config"; +ALTER TABLE "new_Config" RENAME TO "Config"; +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; + +-- CreateIndex +CREATE UNIQUE INDEX "ReverseShare_token_key" ON "ReverseShare"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "ReverseShare_shareId_key" ON "ReverseShare"("shareId"); + + +-- Add ids to existing settings + + +UPDATE Config SET id = 1 WHERE key = "SETUP_FINISHED"; +UPDATE Config SET id = 2 WHERE key = "APP_URL"; +UPDATE Config SET id = 3 WHERE key = "SHOW_HOME_PAGE"; +UPDATE Config SET id = 4 WHERE key = "ALLOW_REGISTRATION"; +UPDATE Config SET id = 5 WHERE key = "ALLOW_UNAUTHENTICATED_SHARES"; +UPDATE Config SET id = 6 WHERE key = "MAX_SHARE_SIZE"; +UPDATE Config SET id = 7 WHERE key = "JWT_SECRET"; +UPDATE Config SET id = 8 WHERE key = "TOTP_SECRET"; +UPDATE Config SET id = 9, key = "ENABLE_SHARE_EMAIL_RECIPIENTS" WHERE key = "ENABLE_EMAIL_RECIPIENTS"; +UPDATE Config SET id = 10, key = "SHARE_RECEPIENTS_EMAIL_MESSAGE" WHERE key = "EMAIL_MESSAGE"; +UPDATE Config SET id = 11, key = "SHARE_RECEPIENTS_EMAIL_SUBJECT" WHERE key = "EMAIL_SUBJECT"; +UPDATE Config SET id = 15 WHERE key = "SMTP_HOST"; +UPDATE Config SET id = 16 WHERE key = "SMTP_PORT"; +UPDATE Config SET id = 17 WHERE key = "SMTP_EMAIL"; +UPDATE Config SET id = 18 WHERE key = "SMTP_USERNAME"; +UPDATE Config SET id = 19 WHERE key = "SMTP_PASSWORD"; + +INSERT INTO Config (`id`, `key`, `description`, `type`, `value`, `category`, `secret`, `updatedAt`) VALUES (14, "SMTP_ENABLED", "Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.", "boolean", IFNULL((SELECT value FROM Config WHERE key="ENABLE_SHARE_EMAIL_RECIPIENTS"), "false"), "smtp", 0, strftime('%s', 'now')); + +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Config" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "updatedAt" DATETIME NOT NULL, + "key" TEXT NOT NULL, + "type" TEXT NOT NULL, + "value" TEXT NOT NULL, + "description" TEXT NOT NULL, + "category" TEXT NOT NULL, + "obscured" BOOLEAN NOT NULL DEFAULT false, + "secret" BOOLEAN NOT NULL DEFAULT true, + "locked" BOOLEAN NOT NULL DEFAULT false +); +INSERT INTO "new_Config" ("id", "category", "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value") SELECT "id", "category", "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value" FROM "Config"; +DROP TABLE "Config"; +ALTER TABLE "new_Config" RENAME TO "Config"; +DELETE from Config WHERE key="MAX_FILE_SIZE"; +CREATE UNIQUE INDEX "Config_key_key" ON "Config"("key"); +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d01b0de..7c85ae3 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -20,6 +20,7 @@ model User { shares Share[] refreshTokens RefreshToken[] loginTokens LoginToken[] + reverseShares ReverseShare[] totpEnabled Boolean @default(false) totpVerified Boolean @default(false) @@ -59,13 +60,33 @@ model Share { description String? removedReason String? - creatorId String? - creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade) + creatorId String? + creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade) + + reverseShare ReverseShare? + security ShareSecurity? recipients ShareRecipient[] files File[] } +model ReverseShare { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + + token String @unique @default(uuid()) + shareExpiration DateTime + maxShareSize String + sendEmailNotification Boolean + used Boolean @default(false) + + creatorId String + creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade) + + shareId String? @unique + share Share? @relation(fields: [shareId], references: [id], onDelete: Cascade) +} + model ShareRecipient { id String @id @default(uuid()) email String @@ -97,9 +118,10 @@ model ShareSecurity { } model Config { + id Int @id updatedAt DateTime @updatedAt - key String @id + key String @unique type String value String description String diff --git a/backend/prisma/seed/config.seed.ts b/backend/prisma/seed/config.seed.ts index 42501d0..c57cf70 100644 --- a/backend/prisma/seed/config.seed.ts +++ b/backend/prisma/seed/config.seed.ts @@ -3,6 +3,7 @@ import * as crypto from "crypto"; const configVariables: Prisma.ConfigCreateInput[] = [ { + id: 1, key: "SETUP_FINISHED", description: "Whether the setup has been finished", type: "boolean", @@ -12,6 +13,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [ locked: true, }, { + id: 2, key: "APP_URL", description: "On which URL Pingvin Share is available", type: "string", @@ -20,6 +22,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [ secret: false, }, { + id: 3, key: "SHOW_HOME_PAGE", description: "Whether to show the home page", type: "boolean", @@ -28,6 +31,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [ secret: false, }, { + id: 4, key: "ALLOW_REGISTRATION", description: "Whether registration is allowed", type: "boolean", @@ -36,6 +40,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [ secret: false, }, { + id: 5, key: "ALLOW_UNAUTHENTICATED_SHARES", description: "Whether unauthorized users can create shares", type: "boolean", @@ -44,6 +49,8 @@ const configVariables: Prisma.ConfigCreateInput[] = [ secret: false, }, { + id: 6, + key: "MAX_SHARE_SIZE", description: "Maximum share size in bytes", type: "number", @@ -52,6 +59,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [ secret: false, }, { + id: 7, key: "JWT_SECRET", description: "Long random string used to sign JWT tokens", type: "string", @@ -60,6 +68,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [ locked: true, }, { + id: 8, key: "TOTP_SECRET", description: "A 16 byte random string used to generate TOTP secrets", type: "string", @@ -68,65 +77,103 @@ const configVariables: Prisma.ConfigCreateInput[] = [ locked: true, }, { - key: "ENABLE_EMAIL_RECIPIENTS", + id: 9, + key: "ENABLE_SHARE_EMAIL_RECIPIENTS", description: - "Whether to send emails to recipients. Only set this to true if you entered the host, port, email, user and password of your SMTP server.", + "Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.", type: "boolean", value: "false", category: "email", secret: false, }, { - key: "EMAIL_MESSAGE", + id: 10, + key: "SHARE_RECEPIENTS_EMAIL_MESSAGE", description: - "Message which gets sent to the recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.", + "Message which gets sent to the share recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.", type: "text", value: "Hey!\n{creator} shared some files with you. View or download the files with this link: {shareUrl}\nShared securely with Pingvin Share 🐧", - category: "email", + category: "email", }, { - key: "EMAIL_SUBJECT", - description: "Subject of the email which gets sent to the recipients.", + id: 11, + key: "SHARE_RECEPIENTS_EMAIL_SUBJECT", + description: + "Subject of the email which gets sent to the share recipients.", type: "string", value: "Files shared with you", category: "email", }, { + id: 12, + key: "REVERSE_SHARE_EMAIL_MESSAGE", + description: + "Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.", + type: "text", + value: + "Hey!\nA share was just created with your reverse share link: {shareUrl}\nShared securely with Pingvin Share 🐧", + category: "email", + }, + { + id: 13, + key: "REVERSE_SHARE_EMAIL_SUBJECT", + description: + "Subject of the email which gets sent when someone created a share with your reverse share link.", + type: "string", + value: "Reverse share link used", + category: "email", + }, + { + id: 14, + key: "SMTP_ENABLED", + description: + "Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.", + type: "boolean", + value: "false", + category: "smtp", + secret: false, + }, + { + id: 15, key: "SMTP_HOST", description: "Host of the SMTP server", type: "string", value: "", - category: "email", + category: "smtp", }, { + id: 16, key: "SMTP_PORT", description: "Port of the SMTP server", type: "number", value: "0", - category: "email", + category: "smtp", }, { + id: 17, key: "SMTP_EMAIL", description: "Email address which the emails get sent from", type: "string", value: "", - category: "email", + category: "smtp", }, { + id: 18, key: "SMTP_USERNAME", description: "Username of the SMTP server", type: "string", value: "", - category: "email", + category: "smtp", }, { + id: 19, key: "SMTP_PASSWORD", description: "Password of the SMTP server", type: "string", value: "", obscured: true, - category: "email", + category: "smtp", }, ]; diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 60e8fd9..7f47e08 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -13,6 +13,7 @@ import { PrismaModule } from "./prisma/prisma.module"; import { ShareModule } from "./share/share.module"; import { UserModule } from "./user/user.module"; import { ClamScanModule } from "./clamscan/clamscan.module"; +import { ReverseShareModule } from "./reverseShare/reverseShare.module"; @Module({ imports: [ @@ -30,6 +31,7 @@ import { ClamScanModule } from "./clamscan/clamscan.module"; }), ScheduleModule.forRoot(), ClamScanModule, + ReverseShareModule, ], providers: [ { diff --git a/backend/src/config/config.service.ts b/backend/src/config/config.service.ts index 6c7516b..25f878b 100644 --- a/backend/src/config/config.service.ts +++ b/backend/src/config/config.service.ts @@ -29,6 +29,7 @@ export class ConfigService { async listForAdmin() { return await this.prisma.config.findMany({ + orderBy: { id: "asc" }, where: { locked: { equals: false } }, }); } diff --git a/backend/src/email/email.service.ts b/backend/src/email/email.service.ts index 389d002..b2c48c4 100644 --- a/backend/src/email/email.service.ts +++ b/backend/src/email/email.service.ts @@ -8,6 +8,9 @@ export class EmailService { constructor(private config: ConfigService) {} getTransporter() { + if (!this.config.get("SMTP_ENABLED")) + throw new InternalServerErrorException("SMTP is disabled"); + return nodemailer.createTransport({ host: this.config.get("SMTP_HOST"), port: parseInt(this.config.get("SMTP_PORT")), @@ -19,8 +22,12 @@ export class EmailService { }); } - async sendMail(recipientEmail: string, shareId: string, creator: User) { - if (!this.config.get("ENABLE_EMAIL_RECIPIENTS")) + async sendMailToShareRecepients( + recipientEmail: string, + shareId: string, + creator?: User + ) { + if (!this.config.get("ENABLE_SHARE_EMAIL_RECIPIENTS")) throw new InternalServerErrorException("Email service disabled"); const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`; @@ -28,11 +35,25 @@ export class EmailService { await this.getTransporter().sendMail({ from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, to: recipientEmail, - subject: this.config.get("EMAIL_SUBJECT"), + subject: this.config.get("SHARE_RECEPIENTS_EMAIL_SUBJECT"), text: this.config - .get("EMAIL_MESSAGE") + .get("SHARE_RECEPIENTS_EMAIL_MESSAGE") + .replaceAll("\\n", "\n") + .replaceAll("{creator}", creator?.username ?? "Someone") + .replaceAll("{shareUrl}", shareUrl), + }); + } + + async sendMailToReverseShareCreator(recipientEmail: string, shareId: string) { + const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`; + + await this.getTransporter().sendMail({ + from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, + to: recipientEmail, + subject: this.config.get("REVERSE_SHARE_EMAIL_SUBJECT"), + text: this.config + .get("REVERSE_SHARE_EMAIL_MESSAGE") .replaceAll("\\n", "\n") - .replaceAll("{creator}", creator.username) .replaceAll("{shareUrl}", shareUrl), }); } diff --git a/backend/src/file/file.controller.ts b/backend/src/file/file.controller.ts index b9b8c5a..ca10cc1 100644 --- a/backend/src/file/file.controller.ts +++ b/backend/src/file/file.controller.ts @@ -14,6 +14,7 @@ import * as contentDisposition from "content-disposition"; import { Response } from "express"; import { JwtGuard } from "src/auth/guard/jwt.guard"; import { FileDownloadGuard } from "src/file/guard/fileDownload.guard"; +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"; @@ -24,7 +25,7 @@ export class FileController { @Post() @SkipThrottle() - @UseGuards(JwtGuard, ShareOwnerGuard) + @UseGuards(CreateShareGuard, ShareOwnerGuard) async create( @Query() query: any, diff --git a/backend/src/file/file.module.ts b/backend/src/file/file.module.ts index f3be62b..9064317 100644 --- a/backend/src/file/file.module.ts +++ b/backend/src/file/file.module.ts @@ -1,11 +1,12 @@ import { Module } from "@nestjs/common"; import { JwtModule } from "@nestjs/jwt"; +import { ReverseShareModule } from "src/reverseShare/reverseShare.module"; import { ShareModule } from "src/share/share.module"; import { FileController } from "./file.controller"; import { FileService } from "./file.service"; @Module({ - imports: [JwtModule.register({}), ShareModule], + imports: [JwtModule.register({}), ReverseShareModule, ShareModule], controllers: [FileController], providers: [FileService], exports: [FileService], diff --git a/backend/src/file/file.service.ts b/backend/src/file/file.service.ts index 9d49857..bc16ce6 100644 --- a/backend/src/file/file.service.ts +++ b/backend/src/file/file.service.ts @@ -30,7 +30,7 @@ export class FileService { const share = await this.prisma.share.findUnique({ where: { id: shareId }, - include: { files: true }, + include: { files: true, reverseShare: true }, }); if (share.uploadLocked) @@ -64,9 +64,12 @@ export class FileService { 0 ); + const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength; + if ( - fileSizeSum + diskFileSize + buffer.byteLength > - this.config.get("MAX_SHARE_SIZE") + shareSizeSum > this.config.get("MAX_SHARE_SIZE") || + (share.reverseShare?.maxShareSize && + shareSizeSum > parseInt(share.reverseShare.maxShareSize)) ) { throw new HttpException( "Max share size exceeded", diff --git a/backend/src/reverseShare/dto/createReverseShare.dto.ts b/backend/src/reverseShare/dto/createReverseShare.dto.ts new file mode 100644 index 0000000..e4a6403 --- /dev/null +++ b/backend/src/reverseShare/dto/createReverseShare.dto.ts @@ -0,0 +1,12 @@ +import { IsBoolean, IsString } from "class-validator"; + +export class CreateReverseShareDTO { + @IsBoolean() + sendEmailNotification: boolean; + + @IsString() + maxShareSize: string; + + @IsString() + shareExpiration: string; +} diff --git a/backend/src/reverseShare/dto/reverseShare.dto.ts b/backend/src/reverseShare/dto/reverseShare.dto.ts new file mode 100644 index 0000000..8630303 --- /dev/null +++ b/backend/src/reverseShare/dto/reverseShare.dto.ts @@ -0,0 +1,18 @@ +import { Expose, plainToClass } from "class-transformer"; + +export class ReverseShareDTO { + @Expose() + id: string; + + @Expose() + maxShareSize: string; + + @Expose() + shareExpiration: Date; + + from(partial: Partial) { + return plainToClass(ReverseShareDTO, partial, { + excludeExtraneousValues: true, + }); + } +} diff --git a/backend/src/reverseShare/dto/reverseShareTokenWithShare.ts b/backend/src/reverseShare/dto/reverseShareTokenWithShare.ts new file mode 100644 index 0000000..ba5b36e --- /dev/null +++ b/backend/src/reverseShare/dto/reverseShareTokenWithShare.ts @@ -0,0 +1,23 @@ +import { OmitType } from "@nestjs/mapped-types"; +import { Expose, plainToClass, Type } from "class-transformer"; +import { MyShareDTO } from "src/share/dto/myShare.dto"; +import { ReverseShareDTO } from "./reverseShare.dto"; + +export class ReverseShareTokenWithShare extends OmitType(ReverseShareDTO, [ + "shareExpiration", +] as const) { + @Expose() + shareExpiration: Date; + + @Expose() + @Type(() => OmitType(MyShareDTO, ["recipients"] as const)) + share: Omit; + + fromList(partial: Partial[]) { + return partial.map((part) => + plainToClass(ReverseShareTokenWithShare, part, { + excludeExtraneousValues: true, + }) + ); + } +} diff --git a/backend/src/reverseShare/guards/reverseShareOwner.guard.ts b/backend/src/reverseShare/guards/reverseShareOwner.guard.ts new file mode 100644 index 0000000..662742e --- /dev/null +++ b/backend/src/reverseShare/guards/reverseShareOwner.guard.ts @@ -0,0 +1,22 @@ +import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; +import { User } from "@prisma/client"; +import { Request } from "express"; +import { PrismaService } from "src/prisma/prisma.service"; + +@Injectable() +export class ReverseShareOwnerGuard implements CanActivate { + constructor(private prisma: PrismaService) {} + + async canActivate(context: ExecutionContext) { + const request: Request = context.switchToHttp().getRequest(); + const { reverseShareId } = request.params; + + const reverseShare = await this.prisma.reverseShare.findUnique({ + where: { id: reverseShareId }, + }); + + if (!reverseShare) return false; + + return reverseShare.creatorId == (request.user as User).id; + } +} diff --git a/backend/src/reverseShare/reverseShare.controller.ts b/backend/src/reverseShare/reverseShare.controller.ts new file mode 100644 index 0000000..25de6be --- /dev/null +++ b/backend/src/reverseShare/reverseShare.controller.ts @@ -0,0 +1,64 @@ +import { + Body, + Controller, + Delete, + Get, + NotFoundException, + Param, + Post, + UseGuards, +} from "@nestjs/common"; +import { Throttle } from "@nestjs/throttler"; +import { User } from "@prisma/client"; +import { GetUser } from "src/auth/decorator/getUser.decorator"; +import { JwtGuard } from "src/auth/guard/jwt.guard"; +import { ConfigService } from "src/config/config.service"; +import { CreateReverseShareDTO } from "./dto/createReverseShare.dto"; +import { ReverseShareDTO } from "./dto/reverseShare.dto"; +import { ReverseShareTokenWithShare } from "./dto/reverseShareTokenWithShare"; +import { ReverseShareOwnerGuard } from "./guards/reverseShareOwner.guard"; +import { ReverseShareService } from "./reverseShare.service"; + +@Controller("reverseShares") +export class ReverseShareController { + constructor( + private reverseShareService: ReverseShareService, + private config: ConfigService + ) {} + + @Post() + @UseGuards(JwtGuard) + async create(@Body() body: CreateReverseShareDTO, @GetUser() user: User) { + const token = await this.reverseShareService.create(body, user.id); + + const link = `${this.config.get("APP_URL")}/upload/${token}`; + + return { token, link }; + } + + @Throttle(20, 60) + @Get(":reverseShareToken") + async getByToken(@Param("reverseShareToken") reverseShareToken: string) { + const isValid = await this.reverseShareService.isValid(reverseShareToken); + + if (!isValid) throw new NotFoundException("Reverse share token not found"); + + return new ReverseShareDTO().from( + await this.reverseShareService.getByToken(reverseShareToken) + ); + } + + @Get() + @UseGuards(JwtGuard) + async getAllByUser(@GetUser() user: User) { + return new ReverseShareTokenWithShare().fromList( + await this.reverseShareService.getAllByUser(user.id) + ); + } + + @Delete(":reverseShareId") + @UseGuards(JwtGuard, ReverseShareOwnerGuard) + async remove(@Param("reverseShareId") id: string) { + await this.reverseShareService.remove(id); + } +} diff --git a/backend/src/reverseShare/reverseShare.module.ts b/backend/src/reverseShare/reverseShare.module.ts new file mode 100644 index 0000000..75649a8 --- /dev/null +++ b/backend/src/reverseShare/reverseShare.module.ts @@ -0,0 +1,12 @@ +import { forwardRef, Module } from "@nestjs/common"; +import { FileModule } from "src/file/file.module"; +import { ReverseShareController } from "./reverseShare.controller"; +import { ReverseShareService } from "./reverseShare.service"; + +@Module({ + imports: [forwardRef(() => FileModule)], + controllers: [ReverseShareController], + providers: [ReverseShareService], + exports: [ReverseShareService], +}) +export class ReverseShareModule {} diff --git a/backend/src/reverseShare/reverseShare.service.ts b/backend/src/reverseShare/reverseShare.service.ts new file mode 100644 index 0000000..acd9c78 --- /dev/null +++ b/backend/src/reverseShare/reverseShare.service.ts @@ -0,0 +1,94 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; +import * as moment from "moment"; +import { ConfigService } from "src/config/config.service"; +import { FileService } from "src/file/file.service"; +import { PrismaService } from "src/prisma/prisma.service"; +import { CreateReverseShareDTO } from "./dto/createReverseShare.dto"; + +@Injectable() +export class ReverseShareService { + constructor( + private config: ConfigService, + private prisma: PrismaService, + private fileService: FileService + ) {} + + async create(data: CreateReverseShareDTO, creatorId: string) { + // Parse date string to date + const expirationDate = moment() + .add( + data.shareExpiration.split("-")[0], + data.shareExpiration.split( + "-" + )[1] as moment.unitOfTime.DurationConstructor + ) + .toDate(); + + const globalMaxShareSize = this.config.get("MAX_SHARE_SIZE"); + + if (globalMaxShareSize < data.maxShareSize) + throw new BadRequestException( + `Max share size can't be greater than ${globalMaxShareSize} bytes.` + ); + + const reverseShare = await this.prisma.reverseShare.create({ + data: { + shareExpiration: expirationDate, + maxShareSize: data.maxShareSize, + sendEmailNotification: data.sendEmailNotification, + creatorId, + }, + }); + + return reverseShare.token; + } + + async getByToken(reverseShareToken: string) { + const reverseShare = await this.prisma.reverseShare.findUnique({ + where: { token: reverseShareToken }, + }); + + return reverseShare; + } + + async getAllByUser(userId: string) { + const reverseShares = await this.prisma.reverseShare.findMany({ + where: { + creatorId: userId, + shareExpiration: { gt: new Date() }, + }, + orderBy: { + shareExpiration: "desc", + }, + include: { share: { include: { creator: true } } }, + }); + + return reverseShares; + } + + async isValid(reverseShareToken: string) { + const reverseShare = await this.prisma.reverseShare.findUnique({ + where: { token: reverseShareToken }, + }); + + if (!reverseShare) return false; + + const isExpired = new Date() > reverseShare.shareExpiration; + const isUsed = reverseShare.used; + + return !(isExpired || isUsed); + } + + async remove(id: string) { + const share = await this.prisma.share.findFirst({ + where: { reverseShare: { id } }, + }); + + if (share) { + await this.prisma.share.delete({ where: { id: share.id } }); + await this.fileService.deleteAllFiles(share.id); + } else { + await this.prisma.reverseShare.delete({ where: { id } }); + } + } +} diff --git a/backend/src/share/guard/createShare.guard.ts b/backend/src/share/guard/createShare.guard.ts new file mode 100644 index 0000000..2e2442a --- /dev/null +++ b/backend/src/share/guard/createShare.guard.ts @@ -0,0 +1,29 @@ +import { ExecutionContext, Injectable } from "@nestjs/common"; +import { JwtGuard } from "src/auth/guard/jwt.guard"; +import { ConfigService } from "src/config/config.service"; +import { ReverseShareService } from "src/reverseShare/reverseShare.service"; + +@Injectable() +export class CreateShareGuard extends JwtGuard { + constructor( + configService: ConfigService, + private reverseShareService: ReverseShareService + ) { + super(configService); + } + + async canActivate(context: ExecutionContext): Promise { + if (await super.canActivate(context)) return true; + + const reverseShareTokenId = context.switchToHttp().getRequest() + .cookies.reverse_share_token; + + if (!reverseShareTokenId) return false; + + const isReverseShareTokenValid = await this.reverseShareService.isValid( + reverseShareTokenId + ); + + return isReverseShareTokenValid; + } +} diff --git a/backend/src/share/share.controller.ts b/backend/src/share/share.controller.ts index dea6e1d..41735a8 100644 --- a/backend/src/share/share.controller.ts +++ b/backend/src/share/share.controller.ts @@ -6,24 +6,31 @@ import { HttpCode, Param, Post, + Req, UseGuards, } from "@nestjs/common"; import { Throttle } from "@nestjs/throttler"; import { User } from "@prisma/client"; +import { Request } from "express"; import { GetUser } from "src/auth/decorator/getUser.decorator"; import { JwtGuard } from "src/auth/guard/jwt.guard"; +import { ConfigService } from "src/config/config.service"; import { CreateShareDTO } from "./dto/createShare.dto"; import { MyShareDTO } from "./dto/myShare.dto"; import { ShareDTO } from "./dto/share.dto"; import { ShareMetaDataDTO } from "./dto/shareMetaData.dto"; import { SharePasswordDto } from "./dto/sharePassword.dto"; +import { CreateShareGuard } from "./guard/createShare.guard"; 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") export class ShareController { - constructor(private shareService: ShareService) {} + constructor( + private shareService: ShareService, + private config: ConfigService + ) {} @Get() @UseGuards(JwtGuard) @@ -46,9 +53,16 @@ export class ShareController { } @Post() - @UseGuards(JwtGuard) - async create(@Body() body: CreateShareDTO, @GetUser() user: User) { - return new ShareDTO().from(await this.shareService.create(body, user)); + @UseGuards(CreateShareGuard) + async create( + @Body() body: CreateShareDTO, + @Req() request: Request, + @GetUser() user: User + ) { + const { reverse_share_token } = request.cookies; + return new ShareDTO().from( + await this.shareService.create(body, user, reverse_share_token) + ); } @Delete(":id") @@ -59,11 +73,15 @@ export class ShareController { @Post(":id/complete") @HttpCode(202) - @UseGuards(JwtGuard, ShareOwnerGuard) - async complete(@Param("id") id: string) { - return new ShareDTO().from(await this.shareService.complete(id)); + @UseGuards(CreateShareGuard, ShareOwnerGuard) + async complete(@Param("id") id: string, @Req() request: Request) { + const { reverse_share_token } = request.cookies; + return new ShareDTO().from( + await this.shareService.complete(id, reverse_share_token) + ); } + @Throttle(10, 60) @Get("isShareIdAvailable/:id") async isShareIdAvailable(@Param("id") id: string) { return this.shareService.isShareIdAvailable(id); diff --git a/backend/src/share/share.module.ts b/backend/src/share/share.module.ts index 557011d..f4066cc 100644 --- a/backend/src/share/share.module.ts +++ b/backend/src/share/share.module.ts @@ -3,6 +3,7 @@ import { JwtModule } from "@nestjs/jwt"; import { ClamScanModule } from "src/clamscan/clamscan.module"; import { EmailModule } from "src/email/email.module"; import { FileModule } from "src/file/file.module"; +import { ReverseShareModule } from "src/reverseShare/reverseShare.module"; import { ShareController } from "./share.controller"; import { ShareService } from "./share.service"; @@ -11,6 +12,7 @@ import { ShareService } from "./share.service"; JwtModule.register({}), EmailModule, ClamScanModule, + ReverseShareModule, forwardRef(() => FileModule), ], controllers: [ShareController], diff --git a/backend/src/share/share.service.ts b/backend/src/share/share.service.ts index eae4556..a2f1891 100644 --- a/backend/src/share/share.service.ts +++ b/backend/src/share/share.service.ts @@ -15,6 +15,7 @@ import { ConfigService } from "src/config/config.service"; import { EmailService } from "src/email/email.service"; import { FileService } from "src/file/file.service"; import { PrismaService } from "src/prisma/prisma.service"; +import { ReverseShareService } from "src/reverseShare/reverseShare.service"; import { CreateShareDTO } from "./dto/createShare.dto"; @Injectable() @@ -25,10 +26,11 @@ export class ShareService { private emailService: EmailService, private config: ConfigService, private jwtService: JwtService, + private reverseShareService: ReverseShareService, private clamScanService: ClamScanService ) {} - async create(share: CreateShareDTO, user?: User) { + async create(share: CreateShareDTO, user?: User, reverseShareToken?: string) { if (!(await this.isShareIdAvailable(share.id)).isAvailable) throw new BadRequestException("Share id already in use"); @@ -39,30 +41,36 @@ export class ShareService { share.security.password = await argon.hash(share.security.password); } - // We have to add an exception for "never" (since moment won't like that) let expirationDate: Date; - 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"); + // If share is created by a reverse share token override the expiration date + if (reverseShareToken) { + const { shareExpiration } = await this.reverseShareService.getByToken( + reverseShareToken + ); + + expirationDate = shareExpiration; } else { - expirationDate = moment(0).toDate(); + // We have to add an exception for "never" (since moment won't like that) + if (share.expiration !== "never") { + expirationDate = moment() + .add( + share.expiration.split("-")[0], + share.expiration.split( + "-" + )[1] as moment.unitOfTime.DurationConstructor + ) + .toDate(); + } else { + expirationDate = moment(0).toDate(); + } } fs.mkdirSync(`./data/uploads/shares/${share.id}`, { recursive: true, }); - return await this.prisma.share.create({ + const shareTuple = await this.prisma.share.create({ data: { ...share, expiration: expirationDate, @@ -75,6 +83,18 @@ export class ShareService { }, }, }); + + if (reverseShareToken) { + // Assign share to reverse share token + await this.prisma.reverseShare.update({ + where: { token: reverseShareToken }, + data: { + shareId: share.id, + }, + }); + } + + return shareTuple; } async createZip(shareId: string) { @@ -96,10 +116,15 @@ export class ShareService { await archive.finalize(); } - async complete(id: string) { + async complete(id: string, reverseShareToken?: string) { const share = await this.prisma.share.findUnique({ where: { id }, - include: { files: true, recipients: true, creator: true }, + include: { + files: true, + recipients: true, + creator: true, + reverseShare: { include: { creator: true } }, + }, }); if (await this.isShareCompleted(id)) @@ -118,16 +143,34 @@ export class ShareService { // Send email for each recepient for (const recepient of share.recipients) { - await this.emailService.sendMail( + await this.emailService.sendMailToShareRecepients( recepient.email, share.id, share.creator ); } + if ( + share.reverseShare && + this.config.get("SMTP_ENABLED") && + share.reverseShare.sendEmailNotification + ) { + await this.emailService.sendMailToReverseShareCreator( + share.reverseShare.creator.email, + share.id + ); + } + // Check if any file is malicious with ClamAV this.clamScanService.checkAndRemove(share.id); + if (reverseShareToken) { + await this.prisma.reverseShare.update({ + where: { token: reverseShareToken }, + data: { used: true }, + }); + } + return await this.prisma.share.update({ where: { id }, data: { uploadLocked: true }, diff --git a/frontend/src/components/admin/configuration/AdminConfigTable.tsx b/frontend/src/components/admin/configuration/AdminConfigTable.tsx index dc31500..5a6e41a 100644 --- a/frontend/src/components/admin/configuration/AdminConfigTable.tsx +++ b/frontend/src/components/admin/configuration/AdminConfigTable.tsx @@ -100,7 +100,7 @@ const AdminConfigTable = () => { ))} - {category == "email" && ( + {category == "smtp" && ( diff --git a/frontend/src/components/navBar/ActionAvatar.tsx b/frontend/src/components/navBar/ActionAvatar.tsx index 5929112..4b0a5e0 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 Link from "next/link"; -import { TbDoorExit, TbLink, TbSettings, TbUser } from "react-icons/tb"; +import { TbDoorExit, TbSettings, TbUser } from "react-icons/tb"; import useUser from "../../hooks/user.hook"; import authService from "../../services/auth.service"; @@ -11,17 +11,10 @@ const ActionAvatar = () => { - + - } - > - My shares - }> My account diff --git a/frontend/src/components/navBar/NavBar.tsx b/frontend/src/components/navBar/NavBar.tsx index 4f172c0..d80dd54 100644 --- a/frontend/src/components/navBar/NavBar.tsx +++ b/frontend/src/components/navBar/NavBar.tsx @@ -1,4 +1,5 @@ import { + ActionIcon, Box, Burger, Container, @@ -13,10 +14,12 @@ import { import { useDisclosure } from "@mantine/hooks"; import Link from "next/link"; import { ReactNode, useEffect, useState } from "react"; +import { TbPlus } from "react-icons/tb"; import useConfig from "../../hooks/config.hook"; import useUser from "../../hooks/user.hook"; import Logo from "../Logo"; import ActionAvatar from "./ActionAvatar"; +import NavbarShareMenu from "./NavbarShareMenu"; const HEADER_HEIGHT = 60; @@ -117,6 +120,9 @@ const NavBar = () => { link: "/upload", label: "Upload", }, + { + component: , + }, { component: , }, diff --git a/frontend/src/components/navBar/NavbarShareMenu.tsx b/frontend/src/components/navBar/NavbarShareMenu.tsx new file mode 100644 index 0000000..3aa1bfe --- /dev/null +++ b/frontend/src/components/navBar/NavbarShareMenu.tsx @@ -0,0 +1,29 @@ +import { ActionIcon, Menu } from "@mantine/core"; +import Link from "next/link"; +import { TbArrowLoopLeft, TbLink } from "react-icons/tb"; + +const NavbarShareMneu = () => { + return ( + + + + + + + + }> + My shares + + } + > + Reverse shares + + + + ); +}; + +export default NavbarShareMneu; diff --git a/frontend/src/components/share/FileList.tsx b/frontend/src/components/share/FileList.tsx index af650ed..5bc3d7a 100644 --- a/frontend/src/components/share/FileList.tsx +++ b/frontend/src/components/share/FileList.tsx @@ -2,7 +2,7 @@ import { ActionIcon, Loader, Skeleton, Table } from "@mantine/core"; import { TbCircleCheck, TbDownload } from "react-icons/tb"; import shareService from "../../services/share.service"; -import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util"; +import { byteToHumanSizeString } from "../../utils/fileSize.util"; const FileList = ({ files, @@ -28,7 +28,7 @@ const FileList = ({ : files!.map((file) => ( {file.name} - {byteStringToHumanSizeString(file.size)} + {byteToHumanSizeString(file.size)} {file.uploadingState ? ( file.uploadingState != "finished" ? ( diff --git a/frontend/src/components/share/FileSizeInput.tsx b/frontend/src/components/share/FileSizeInput.tsx new file mode 100644 index 0000000..0e1920d --- /dev/null +++ b/frontend/src/components/share/FileSizeInput.tsx @@ -0,0 +1,62 @@ +import { Col, Grid, NumberInput, Select } from "@mantine/core"; +import { useEffect, useState } from "react"; +import { + byteToUnitAndSize, + unitAndSizeToByte, +} from "../../utils/fileSize.util"; + +const FileSizeInput = ({ + label, + value, + onChange, +}: { + label: string; + value: number; + onChange: (number: number) => void; +}) => { + const [unit, setUnit] = useState("MB"); + const [size, setSize] = useState(100); + + useEffect(() => { + const { unit, size } = byteToUnitAndSize(value); + setUnit(unit); + setSize(size); + }, [value]); + + return ( + + + { + setSize(value!); + onChange(unitAndSizeToByte(unit, value!)); + }} + /> + + + + + + ({ + color: theme.colors.gray[6], + })} + > + {getExpirationPreview("reverse share", form)} + + + form.setFieldValue("maxShareSize", number)} + /> + {showSendEmailNotificationOption && ( + + )} + + + + + + ); +}; + +export default showCreateReverseShareModal; diff --git a/frontend/src/components/upload/Dropzone.tsx b/frontend/src/components/upload/Dropzone.tsx index 9f9f914..50c953c 100644 --- a/frontend/src/components/upload/Dropzone.tsx +++ b/frontend/src/components/upload/Dropzone.tsx @@ -4,7 +4,7 @@ import { Dispatch, ForwardedRef, SetStateAction, useRef } from "react"; import { TbCloudUpload, TbUpload } from "react-icons/tb"; import useConfig from "../../hooks/config.hook"; import { FileUpload } from "../../types/File.type"; -import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util"; +import { byteToHumanSizeString } from "../../utils/fileSize.util"; import toast from "../../utils/toast.util"; const useStyles = createStyles((theme) => ({ @@ -33,10 +33,12 @@ const useStyles = createStyles((theme) => ({ const Dropzone = ({ isUploading, + maxShareSize, files, setFiles, }: { isUploading: boolean; + maxShareSize: number; files: FileUpload[]; setFiles: Dispatch>; }) => { @@ -58,10 +60,10 @@ const Dropzone = ({ 0 ); - if (fileSizeSum > config.get("MAX_SHARE_SIZE")) { + if (fileSizeSum > maxShareSize) { toast.error( - `Your files exceed the maximum share size of ${byteStringToHumanSizeString( - config.get("MAX_SHARE_SIZE") + `Your files exceed the maximum share size of ${byteToHumanSizeString( + maxShareSize )}.` ); } else { @@ -84,9 +86,8 @@ const Dropzone = ({ Drag'n'drop files here to start your share. We can accept - only files that are less than{" "} - {byteStringToHumanSizeString(config.get("MAX_SHARE_SIZE"))} in - total. + only files that are less than {byteToHumanSizeString(maxShareSize)}{" "} + in total. diff --git a/frontend/src/components/upload/ExpirationPreview.tsx b/frontend/src/components/upload/ExpirationPreview.tsx deleted file mode 100644 index 236429c..0000000 --- a/frontend/src/components/upload/ExpirationPreview.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import moment from "moment"; - -const ExpirationPreview = ({ 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(); - - return `This share will expire on ${moment(expirationDate).format("LLL")}`; -}; - -export default ExpirationPreview; diff --git a/frontend/src/components/upload/FileList.tsx b/frontend/src/components/upload/FileList.tsx index a1854e4..d41e9be 100644 --- a/frontend/src/components/upload/FileList.tsx +++ b/frontend/src/components/upload/FileList.tsx @@ -2,7 +2,7 @@ import { ActionIcon, Table } from "@mantine/core"; import { Dispatch, SetStateAction } from "react"; import { TbTrash } from "react-icons/tb"; import { FileUpload } from "../../types/File.type"; -import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util"; +import { byteToHumanSizeString } from "../../utils/fileSize.util"; import UploadProgressIndicator from "./UploadProgressIndicator"; const FileList = ({ @@ -19,7 +19,7 @@ const FileList = ({ const rows = files.map((file, i) => ( {file.name} - {byteStringToHumanSizeString(file.size.toString())} + {byteToHumanSizeString(file.size)} {file.uploadingProgress == 0 ? ( void; options: { isUserSignedIn: boolean; + isReverseShare: boolean; appUrl: string; allowUnauthenticatedShares: boolean; enableEmailRecepients: boolean; @@ -89,7 +90,7 @@ const CreateUploadModalBody = ({ validate: yupResolver(validationSchema), }); return ( - + <> {showNotSignedInAlert && !options.isUserSignedIn && ( - - - + + + + + + - - - - {/* Preview expiration date text */} - ({ - color: theme.colors.gray[6], - })} - > - {ExpirationPreview({ form })} - + ({ + color: theme.colors.gray[6], + })} + > + {getExpirationPreview("share", form)} + + + )} Description @@ -296,7 +303,7 @@ const CreateUploadModalBody = ({ - + ); }; diff --git a/frontend/src/pages/account/reverseShares.tsx b/frontend/src/pages/account/reverseShares.tsx new file mode 100644 index 0000000..2993dd7 --- /dev/null +++ b/frontend/src/pages/account/reverseShares.tsx @@ -0,0 +1,200 @@ +import { + ActionIcon, + Box, + Button, + Center, + Group, + LoadingOverlay, + Stack, + Table, + Text, + Title, + Tooltip, +} from "@mantine/core"; +import { useClipboard } from "@mantine/hooks"; +import { useModals } from "@mantine/modals"; +import moment from "moment"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; +import { TbInfoCircle, TbLink, TbPlus, TbTrash } from "react-icons/tb"; +import showShareLinkModal from "../../components/account/showShareLinkModal"; +import Meta from "../../components/Meta"; +import showCreateReverseShareModal from "../../components/share/modals/showCreateReverseShareModal"; +import useConfig from "../../hooks/config.hook"; +import useUser from "../../hooks/user.hook"; +import shareService from "../../services/share.service"; +import { MyReverseShare } from "../../types/share.type"; +import { byteToHumanSizeString } from "../../utils/fileSize.util"; +import toast from "../../utils/toast.util"; + +const MyShares = () => { + const modals = useModals(); + const clipboard = useClipboard(); + const router = useRouter(); + const config = useConfig(); + + const { user } = useUser(); + + const [reverseShares, setReverseShares] = useState(); + + const getReverseShares = () => { + shareService + .getMyReverseShares() + .then((shares) => setReverseShares(shares)); + }; + + useEffect(() => { + getReverseShares(); + }, []); + + if (!user) { + router.replace("/"); + } else { + if (!reverseShares) return ; + return ( + <> + + + + My reverse shares + + + + + + + + + {reverseShares.length == 0 ? ( +
+ + It's empty here 👀 + You don't have any reverse shares. + +
+ ) : ( + + + + + + + + + + + + + {reverseShares.map((reverseShare) => ( + + + + + + + + ))} + +
NameVisitorsMax share sizeExpires at
+ {reverseShare.share ? ( + reverseShare.share?.id + ) : ( + No share created yet + )} + {reverseShare.share?.views ?? "0"} + {byteToHumanSizeString( + parseInt(reverseShare.maxShareSize) + )} + + {moment(reverseShare.shareExpiration).unix() === 0 + ? "Never" + : moment(reverseShare.shareExpiration).format("LLL")} + + + {reverseShare.share && ( + { + if (window.isSecureContext) { + clipboard.copy( + `${config.get("APP_URL")}/share/${ + reverseShare.share!.id + }` + ); + toast.success( + "The share link was copied to the keyboard." + ); + } else { + showShareLinkModal( + modals, + reverseShare.share!.id, + config.get("APP_URL") + ); + } + }} + > + + + )} + { + modals.openConfirmModal({ + title: `Delete reverse share`, + children: ( + + Do you really want to delete this reverse + share? If you do, the share will be deleted as + well. + + ), + confirmProps: { + color: "red", + }, + labels: { confirm: "Confirm", cancel: "Cancel" }, + onConfirm: () => { + shareService.removeReverseShare( + reverseShare.id + ); + setReverseShares( + reverseShares.filter( + (item) => item.id !== reverseShare.id + ) + ); + }, + }); + }} + > + + + +
+
+ )} + + ); + } +}; + +export default MyShares; diff --git a/frontend/src/pages/account/shares.tsx b/frontend/src/pages/account/shares.tsx index 2e27ace..b761fba 100644 --- a/frontend/src/pages/account/shares.tsx +++ b/frontend/src/pages/account/shares.tsx @@ -1,5 +1,6 @@ import { ActionIcon, + Box, Button, Center, Group, @@ -61,83 +62,85 @@ const MyShares = () => { ) : ( - - - - - - - - - - - {shares.map((share) => ( - - - - - + +
NameVisitorsExpires at
{share.id}{share.views} - {moment(share.expiration).unix() === 0 - ? "Never" - : moment(share.expiration).format("LLL")} - - - { - if (window.isSecureContext) { - clipboard.copy( - `${config.get("APP_URL")}/share/${share.id}` - ); - toast.success( - "Your link was copied to the keyboard." - ); - } else { - showShareLinkModal( - modals, - share.id, - config.get("APP_URL") - ); - } - }} - > - - - { - 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).unix() === 0 + ? "Never" + : moment(share.expiration).format("LLL")} + + + + { + if (window.isSecureContext) { + clipboard.copy( + `${config.get("APP_URL")}/share/${share.id}` + ); + toast.success( + "Your link was copied to the keyboard." + ); + } else { + showShareLinkModal( + modals, + share.id, + config.get("APP_URL") + ); + } + }} + > + + + { + 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) + ); + }, + }); + }} + > + + + + + + ))} + + + )} ); diff --git a/frontend/src/pages/upload/[reverseShareToken].tsx b/frontend/src/pages/upload/[reverseShareToken].tsx new file mode 100644 index 0000000..890f9dc --- /dev/null +++ b/frontend/src/pages/upload/[reverseShareToken].tsx @@ -0,0 +1,43 @@ +import { LoadingOverlay } from "@mantine/core"; +import { useModals } from "@mantine/modals"; +import { GetServerSidePropsContext } from "next"; +import { useEffect, useState } from "react"; +import Upload from "."; +import showErrorModal from "../../components/share/showErrorModal"; +import shareService from "../../services/share.service"; + +export function getServerSideProps(context: GetServerSidePropsContext) { + return { + props: { reverseShareToken: context.params!.reverseShareToken }, + }; +} + +const Share = ({ reverseShareToken }: { reverseShareToken: string }) => { + const modals = useModals(); + const [isLoading, setIsLoading] = useState(true); + + const [maxShareSize, setMaxShareSize] = useState(0); + + useEffect(() => { + shareService + .setReverseShare(reverseShareToken) + .then((reverseShareTokenData) => { + setMaxShareSize(parseInt(reverseShareTokenData.maxShareSize)); + setIsLoading(false); + }) + .catch(() => { + showErrorModal( + modals, + "Invalid Link", + "This link is invalid. Please check your link." + ); + setIsLoading(false); + }); + }, []); + + if (isLoading) return ; + + return ; +}; + +export default Share; diff --git a/frontend/src/pages/upload.tsx b/frontend/src/pages/upload/index.tsx similarity index 77% rename from frontend/src/pages/upload.tsx rename to frontend/src/pages/upload/index.tsx index ee3ac30..af6a7bf 100644 --- a/frontend/src/pages/upload.tsx +++ b/frontend/src/pages/upload/index.tsx @@ -2,27 +2,34 @@ import { Button, Group } from "@mantine/core"; import { useModals } from "@mantine/modals"; import { cleanNotifications } from "@mantine/notifications"; import { AxiosError } from "axios"; +import { getCookie } from "cookies-next"; import { useRouter } from "next/router"; import pLimit from "p-limit"; import { useEffect, useState } from "react"; -import Meta from "../components/Meta"; -import Dropzone from "../components/upload/Dropzone"; -import FileList from "../components/upload/FileList"; -import showCompletedUploadModal from "../components/upload/modals/showCompletedUploadModal"; -import showCreateUploadModal from "../components/upload/modals/showCreateUploadModal"; -import useConfig from "../hooks/config.hook"; -import useUser from "../hooks/user.hook"; -import shareService from "../services/share.service"; -import { FileUpload } from "../types/File.type"; -import { CreateShare, Share } from "../types/share.type"; -import toast from "../utils/toast.util"; +import Meta from "../../components/Meta"; +import Dropzone from "../../components/upload/Dropzone"; +import FileList from "../../components/upload/FileList"; +import showCompletedUploadModal from "../../components/upload/modals/showCompletedUploadModal"; +import showCreateUploadModal from "../../components/upload/modals/showCreateUploadModal"; +import useConfig from "../../hooks/config.hook"; +import useUser from "../../hooks/user.hook"; +import shareService from "../../services/share.service"; +import { FileUpload } from "../../types/File.type"; +import { CreateShare, Share } from "../../types/share.type"; +import toast from "../../utils/toast.util"; const promiseLimit = pLimit(3); const chunkSize = 10 * 1024 * 1024; // 10MB let errorToastShown = false; let createdShare: Share; -const Upload = () => { +const Upload = ({ + maxShareSize, + isReverseShare = false, +}: { + maxShareSize?: number; + isReverseShare: boolean; +}) => { const router = useRouter(); const modals = useModals(); @@ -31,6 +38,8 @@ const Upload = () => { const [files, setFiles] = useState([]); const [isUploading, setisUploading] = useState(false); + maxShareSize ??= parseInt(config.get("MAX_SHARE_SIZE")); + const uploadFiles = async (share: CreateShare) => { setisUploading(true); createdShare = await shareService.create(share); @@ -138,9 +147,9 @@ const Upload = () => { ) { shareService .completeShare(createdShare.id) - .then(() => { + .then((share) => { setisUploading(false); - showCompletedUploadModal(modals, createdShare, config.get("APP_URL")); + showCompletedUploadModal(modals, share, config.get("APP_URL")); setFiles([]); }) .catch(() => @@ -149,8 +158,13 @@ const Upload = () => { } }, [files]); - if (!user && !config.get("ALLOW_UNAUTHENTICATED_SHARES")) { + if ( + !user && + !config.get("ALLOW_UNAUTHENTICATED_SHARES") && + !getCookie("reverse_share_token") + ) { router.replace("/"); + return null; } else { return ( <> @@ -164,11 +178,14 @@ const Upload = () => { modals, { isUserSignedIn: user ? true : false, + isReverseShare, appUrl: config.get("APP_URL"), allowUnauthenticatedShares: config.get( "ALLOW_UNAUTHENTICATED_SHARES" ), - enableEmailRecepients: config.get("ENABLE_EMAIL_RECIPIENTS"), + enableEmailRecepients: config.get( + "ENABLE_SHARE_EMAIL_RECIPIENTS" + ), }, uploadFiles ); @@ -177,7 +194,12 @@ const Upload = () => { Share - + {files.length > 0 && } ); diff --git a/frontend/src/services/share.service.ts b/frontend/src/services/share.service.ts index 6a8c6cb..c866030 100644 --- a/frontend/src/services/share.service.ts +++ b/frontend/src/services/share.service.ts @@ -1,6 +1,8 @@ +import { setCookie } from "cookies-next"; import { FileUploadResponse } from "../types/File.type"; import { CreateShare, + MyReverseShare, MyShare, Share, ShareMetaData, @@ -98,6 +100,34 @@ const uploadFile = async ( ).data; }; +const createReverseShare = async ( + shareExpiration: string, + maxShareSize: number, + sendEmailNotification: boolean +) => { + return ( + await api.post("reverseShares", { + shareExpiration, + maxShareSize: maxShareSize.toString(), + sendEmailNotification, + }) + ).data; +}; + +const getMyReverseShares = async (): Promise => { + return (await api.get("reverseShares")).data; +}; + +const setReverseShare = async (reverseShareToken: string) => { + const { data } = await api.get(`/reverseShares/${reverseShareToken}`); + setCookie("reverse_share_token", reverseShareToken); + return data; +}; + +const removeReverseShare = async (id: string) => { + await api.delete(`/reverseShares/${id}`); +}; + export default { create, completeShare, @@ -109,4 +139,8 @@ export default { isShareIdAvailable, downloadFile, uploadFile, + setReverseShare, + createReverseShare, + getMyReverseShares, + removeReverseShare, }; diff --git a/frontend/src/types/share.type.ts b/frontend/src/types/share.type.ts index f22467c..ebbfb3b 100644 --- a/frontend/src/types/share.type.ts +++ b/frontend/src/types/share.type.ts @@ -26,6 +26,13 @@ export type MyShare = Share & { cratedAt: Date; }; +export type MyReverseShare = { + id: string; + maxShareSize: string; + shareExpiration: Date; + share?: MyShare; +}; + export type ShareSecurity = { maxViews?: number; password?: string; diff --git a/frontend/src/utils/date.util.ts b/frontend/src/utils/date.util.ts new file mode 100644 index 0000000..bb86098 --- /dev/null +++ b/frontend/src/utils/date.util.ts @@ -0,0 +1,26 @@ +import moment from "moment"; + +export const getExpirationPreview = ( + name: string, + form: { + values: { + never_expires?: boolean; + expiration_num: number; + expiration_unit: string; + }; + } +) => { + const value = form.values.never_expires + ? "never" + : form.values.expiration_num + form.values.expiration_unit; + if (value === "never") return `This ${name} will never expire.`; + + const expirationDate = moment() + .add( + value.split("-")[0], + value.split("-")[1] as moment.unitOfTime.DurationConstructor + ) + .toDate(); + + return `This ${name} will expire on ${moment(expirationDate).format("LLL")}`; +}; diff --git a/frontend/src/utils/fileSize.util.ts b/frontend/src/utils/fileSize.util.ts new file mode 100644 index 0000000..235c663 --- /dev/null +++ b/frontend/src/utils/fileSize.util.ts @@ -0,0 +1,23 @@ +export function byteToHumanSizeString(bytes: number) { + const sizes = ["B", "KB", "MB", "GB", "TB"]; + if (bytes == 0) return "0 Byte"; + const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString()); + return (bytes / Math.pow(1024, i)).toFixed(1).toString() + " " + sizes[i]; +} + +export function byteToUnitAndSize(bytes: number) { + const units = ["B", "KB", "MB", "GB", "TB"]; + if (bytes == 0) return { unit: "B", size: 0 }; + const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString()); + + return { + size: parseFloat((bytes / Math.pow(1024, i)).toFixed(1)), + unit: units[i], + }; +} + +export function unitAndSizeToByte(unit: string, size: number) { + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = units.indexOf(unit); + return Math.pow(1024, i) * size; +} diff --git a/frontend/src/utils/math/byteStringToHumanSizeString.util.ts b/frontend/src/utils/math/byteStringToHumanSizeString.util.ts deleted file mode 100644 index 6a04c2f..0000000 --- a/frontend/src/utils/math/byteStringToHumanSizeString.util.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function byteStringToHumanSizeString(bytes: string) { - const bytesNumber = parseInt(bytes); - const sizes = ["B", "KB", "MB", "GB", "TB"]; - if (bytesNumber == 0) return "0 Byte"; - const i = parseInt( - Math.floor(Math.log(bytesNumber) / Math.log(1024)).toString() - ); - return ( - (bytesNumber / Math.pow(1024, i)).toFixed(1).toString() + " " + sizes[i] - ); -}