diff --git a/backend/prisma/migrations/20230209101345_reset_password/migration.sql b/backend/prisma/migrations/20230209101345_reset_password/migration.sql deleted file mode 100644 index 1508d82..0000000 --- a/backend/prisma/migrations/20230209101345_reset_password/migration.sql +++ /dev/null @@ -1,14 +0,0 @@ --- CreateTable -CREATE TABLE "ResetPasswordToken" ( - "token" TEXT NOT NULL PRIMARY KEY, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "expiresAt" DATETIME NOT NULL, - "userId" TEXT NOT NULL, - CONSTRAINT "ResetPasswordToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE -); - --- Disable TOTP as secret isn't encrypted anymore -UPDATE User SET totpEnabled=false, totpSecret=null, totpVerified=false WHERE totpSecret IS NOT NULL; - --- CreateIndex -CREATE UNIQUE INDEX "ResetPasswordToken_userId_key" ON "ResetPasswordToken"("userId"); diff --git a/backend/prisma/migrations/20230209205230_v0_10_0/migration.sql b/backend/prisma/migrations/20230209205230_v0_10_0/migration.sql new file mode 100644 index 0000000..1ac3a69 --- /dev/null +++ b/backend/prisma/migrations/20230209205230_v0_10_0/migration.sql @@ -0,0 +1,64 @@ +/* + Warnings: + + - You are about to drop the column `shareId` on the `ReverseShare` table. All the data in the column will be lost. + - You are about to drop the column `used` on the `ReverseShare` table. All the data in the column will be lost. + - Added the required column `remainingUses` to the `ReverseShare` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateTable +PRAGMA foreign_keys=OFF; +CREATE TABLE "ResetPasswordToken" ( + "token" TEXT NOT NULL PRIMARY KEY, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" DATETIME NOT NULL, + "userId" TEXT NOT NULL, + CONSTRAINT "ResetPasswordToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- Disable TOTP as secret isn't encrypted anymore +UPDATE User SET totpEnabled=false, totpSecret=null, totpVerified=false WHERE totpSecret IS NOT NULL; + +-- RedefineTables +CREATE TABLE "new_Share" ( + "id" TEXT NOT NULL PRIMARY KEY, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "uploadLocked" BOOLEAN NOT NULL DEFAULT false, + "isZipReady" BOOLEAN NOT NULL DEFAULT false, + "views" INTEGER NOT NULL DEFAULT 0, + "expiration" DATETIME NOT NULL, + "description" TEXT, + "removedReason" TEXT, + "creatorId" TEXT, + "reverseShareId" TEXT, + CONSTRAINT "Share_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "Share_reverseShareId_fkey" FOREIGN KEY ("reverseShareId") REFERENCES "ReverseShare" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +INSERT INTO "new_Share" ("createdAt", "creatorId", "description", "expiration", "id", "isZipReady", "removedReason", "uploadLocked", "views", "reverseShareId") +SELECT "createdAt", "creatorId", "description", "expiration", "id", "isZipReady", "removedReason", "uploadLocked", "views", (SELECT id FROM ReverseShare WHERE shareId=Share.id) +FROM "Share"; + + +DROP TABLE "Share"; +ALTER TABLE "new_Share" RENAME TO "Share"; +CREATE TABLE "new_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, + "remainingUses" INTEGER NOT NULL, + "creatorId" TEXT NOT NULL, + CONSTRAINT "ReverseShare_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_ReverseShare" ("createdAt", "creatorId", "id", "maxShareSize", "sendEmailNotification", "shareExpiration", "token", "remainingUses") SELECT "createdAt", "creatorId", "id", "maxShareSize", "sendEmailNotification", "shareExpiration", "token", iif("ReverseShare".used, 0, 1) FROM "ReverseShare"; +DROP TABLE "ReverseShare"; +ALTER TABLE "new_ReverseShare" RENAME TO "ReverseShare"; +CREATE UNIQUE INDEX "ReverseShare_token_key" ON "ReverseShare"("token"); +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; + +-- CreateIndex +CREATE UNIQUE INDEX "ResetPasswordToken_userId_key" ON "ResetPasswordToken"("userId"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 303cd29..00089a0 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -22,8 +22,8 @@ model User { loginTokens LoginToken[] reverseShares ReverseShare[] - totpEnabled Boolean @default(false) - totpVerified Boolean @default(false) + totpEnabled Boolean @default(false) + totpVerified Boolean @default(false) totpSecret String? resetPasswordToken ResetPasswordToken? } @@ -57,7 +57,7 @@ model ResetPasswordToken { expiresAt DateTime userId String @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model Share { @@ -74,7 +74,8 @@ model Share { creatorId String? creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade) - reverseShare ReverseShare? + reverseShareId String? + reverseShare ReverseShare? @relation(fields: [reverseShareId], references: [id], onDelete: Cascade) security ShareSecurity? recipients ShareRecipient[] @@ -89,13 +90,12 @@ model ReverseShare { shareExpiration DateTime maxShareSize String sendEmailNotification Boolean - used Boolean @default(false) + remainingUses Int creatorId String creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade) - shareId String? @unique - share Share? @relation(fields: [shareId], references: [id], onDelete: Cascade) + shares Share[] } model ShareRecipient { diff --git a/backend/src/reverseShare/dto/createReverseShare.dto.ts b/backend/src/reverseShare/dto/createReverseShare.dto.ts index e4a6403..c4fabe9 100644 --- a/backend/src/reverseShare/dto/createReverseShare.dto.ts +++ b/backend/src/reverseShare/dto/createReverseShare.dto.ts @@ -1,4 +1,4 @@ -import { IsBoolean, IsString } from "class-validator"; +import { IsBoolean, IsString, Max, Min } from "class-validator"; export class CreateReverseShareDTO { @IsBoolean() @@ -9,4 +9,8 @@ export class CreateReverseShareDTO { @IsString() shareExpiration: string; + + @Min(1) + @Max(1000) + maxUseCount: number; } diff --git a/backend/src/reverseShare/dto/reverseShareTokenWithShare.ts b/backend/src/reverseShare/dto/reverseShareTokenWithShares.ts similarity index 68% rename from backend/src/reverseShare/dto/reverseShareTokenWithShare.ts rename to backend/src/reverseShare/dto/reverseShareTokenWithShares.ts index 55dac0b..1e47501 100644 --- a/backend/src/reverseShare/dto/reverseShareTokenWithShare.ts +++ b/backend/src/reverseShare/dto/reverseShareTokenWithShares.ts @@ -3,7 +3,7 @@ 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, [ +export class ReverseShareTokenWithShares extends OmitType(ReverseShareDTO, [ "shareExpiration", ] as const) { @Expose() @@ -11,14 +11,17 @@ export class ReverseShareTokenWithShare extends OmitType(ReverseShareDTO, [ @Expose() @Type(() => OmitType(MyShareDTO, ["recipients", "hasPassword"] as const)) - share: Omit< + shares: Omit< MyShareDTO, "recipients" | "files" | "from" | "fromList" | "hasPassword" - >; + >[]; - fromList(partial: Partial[]) { + @Expose() + remainingUses: number; + + fromList(partial: Partial[]) { return partial.map((part) => - plainToClass(ReverseShareTokenWithShare, part, { + plainToClass(ReverseShareTokenWithShares, part, { excludeExtraneousValues: true, }) ); diff --git a/backend/src/reverseShare/reverseShare.controller.ts b/backend/src/reverseShare/reverseShare.controller.ts index 25de6be..9bc67f3 100644 --- a/backend/src/reverseShare/reverseShare.controller.ts +++ b/backend/src/reverseShare/reverseShare.controller.ts @@ -15,7 +15,7 @@ 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 { ReverseShareTokenWithShares } from "./dto/reverseShareTokenWithShares"; import { ReverseShareOwnerGuard } from "./guards/reverseShareOwner.guard"; import { ReverseShareService } from "./reverseShare.service"; @@ -51,7 +51,7 @@ export class ReverseShareController { @Get() @UseGuards(JwtGuard) async getAllByUser(@GetUser() user: User) { - return new ReverseShareTokenWithShare().fromList( + return new ReverseShareTokenWithShares().fromList( await this.reverseShareService.getAllByUser(user.id) ); } diff --git a/backend/src/reverseShare/reverseShare.service.ts b/backend/src/reverseShare/reverseShare.service.ts index acd9c78..454e991 100644 --- a/backend/src/reverseShare/reverseShare.service.ts +++ b/backend/src/reverseShare/reverseShare.service.ts @@ -34,6 +34,7 @@ export class ReverseShareService { const reverseShare = await this.prisma.reverseShare.create({ data: { shareExpiration: expirationDate, + remainingUses: data.maxUseCount, maxShareSize: data.maxShareSize, sendEmailNotification: data.sendEmailNotification, creatorId, @@ -60,7 +61,7 @@ export class ReverseShareService { orderBy: { shareExpiration: "desc", }, - include: { share: { include: { creator: true } } }, + include: { shares: { include: { creator: true } } }, }); return reverseShares; @@ -74,9 +75,9 @@ export class ReverseShareService { if (!reverseShare) return false; const isExpired = new Date() > reverseShare.shareExpiration; - const isUsed = reverseShare.used; + const remainingUsesExceeded = reverseShare.remainingUses <= 0; - return !(isExpired || isUsed); + return !(isExpired || remainingUsesExceeded); } async remove(id: string) { diff --git a/frontend/src/components/share/modals/showCreateReverseShareModal.tsx b/frontend/src/components/share/modals/showCreateReverseShareModal.tsx index e8504c7..0c00b86 100644 --- a/frontend/src/components/share/modals/showCreateReverseShareModal.tsx +++ b/frontend/src/components/share/modals/showCreateReverseShareModal.tsx @@ -47,6 +47,7 @@ const Body = ({ const form = useForm({ initialValues: { maxShareSize: 104857600, + maxUseCount: 1, sendEmailNotification: false, expiration_num: 1, expiration_unit: "-days", @@ -60,6 +61,7 @@ const Body = ({ .createReverseShare( values.expiration_num + values.expiration_unit, values.maxShareSize, + values.maxUseCount, values.sendEmailNotification ) .then(({ link }) => { @@ -132,6 +134,15 @@ const Body = ({ value={form.values.maxShareSize} onChange={(number) => form.setFieldValue("maxShareSize", number)} /> + {showSendEmailNotificationOption && ( { position="bottom" multiline width={220} - label="A reverse share allows you to generate a unique URL for a single-use share for an external user." + label="A reverse share allows you to generate a unique URL that allows external users to create a share." events={{ hover: true, focus: false, touch: true }} > @@ -87,8 +88,8 @@ const MyShares = () => { - - + + @@ -97,14 +98,63 @@ const MyShares = () => { {reverseShares.map((reverseShare) => ( - - + @@ -115,33 +165,6 @@ const MyShares = () => {
NameVisitorsSharesRemaining uses Max share size Expires at
- {reverseShare.share ? ( - reverseShare.share?.id + + {reverseShare.shares.length == 0 ? ( + + No shares created yet + ) : ( - No share created yet + + + + + {`${reverseShare.shares.length} share${ + reverseShare.shares.length > 1 ? "s" : "" + }`} + + + + {reverseShare.shares.map((share) => ( + + + {share.id} + + { + if (window.isSecureContext) { + clipboard.copy( + `${config.get("APP_URL")}/share/${ + share.id + }` + ); + toast.success( + "The share link was copied to the keyboard." + ); + } else { + showShareLinkModal( + modals, + share.id, + config.get("APP_URL") + ); + } + }} + > + + + + ))} + + + )} {reverseShare.share?.views ?? "0"}{reverseShare.remainingUses} {byteToHumanSizeString(parseInt(reverseShare.maxShareSize))} - {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") - ); - } - }} - > - - - )} { children: ( Do you really want to delete this reverse share? - If you do, the share will be deleted as well. + If you do, the associated shares will be deleted + as well. ), confirmProps: { color: "red", }, - labels: { confirm: "Confirm", cancel: "Cancel" }, + labels: { confirm: "Delete", cancel: "Cancel" }, onConfirm: () => { shareService.removeReverseShare(reverseShare.id); setReverseShares( diff --git a/frontend/src/services/share.service.ts b/frontend/src/services/share.service.ts index a7e1dd5..a745431 100644 --- a/frontend/src/services/share.service.ts +++ b/frontend/src/services/share.service.ts @@ -99,12 +99,14 @@ const uploadFile = async ( const createReverseShare = async ( shareExpiration: string, maxShareSize: number, + maxUseCount: number, sendEmailNotification: boolean ) => { return ( await api.post("reverseShares", { shareExpiration, maxShareSize: maxShareSize.toString(), + maxUseCount, sendEmailNotification, }) ).data; diff --git a/frontend/src/types/share.type.ts b/frontend/src/types/share.type.ts index 80618de..4c8986f 100644 --- a/frontend/src/types/share.type.ts +++ b/frontend/src/types/share.type.ts @@ -31,7 +31,8 @@ export type MyReverseShare = { id: string; maxShareSize: string; shareExpiration: Date; - share?: MyShare; + remainingUses: number; + shares: MyShare[]; }; export type ShareSecurity = {