From fe735f9704c9d96398f3127a559e17848b08d140 Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Tue, 30 Jul 2024 14:26:56 +0800 Subject: [PATCH] feat: add more options to reverse shares (#495) * feat(reverse-share): optional simplified interface for reverse sharing. issue #155. * chore: Remove useless form validation. * feat: Share Ready modal adds a prompt that an email has been sent to the reverse share creator. * fix: Simplified reverse shared interface elements lack spacing when not logged in. * fix: Share Ready modal prompt contrast is too low in dark mode. * feat: add public access options to reverse share. * feat: remember reverse share simplified and publicAccess options in cookies. * style: npm run format. * chore(i18n): Improve translation. Co-authored-by: Elias Schneider Update frontend/src/i18n/translations/en-US.ts Co-authored-by: Elias Schneider Update frontend/src/i18n/translations/en-US.ts Co-authored-by: Elias Schneider chore(i18n): Improve translation. * chore: Improved variable naming. * chore(i18n): Improve translation. x2. * fix(backend/shares): Misjudged the permission of the share of the reverse share. --- .../migration.sql | 20 +++ .../migration.sql | 22 +++ backend/prisma/schema.prisma | 2 + backend/src/file/guard/fileSecurity.guard.ts | 4 +- .../dto/createReverseShare.dto.ts | 6 + .../src/reverseShare/dto/reverseShare.dto.ts | 3 + .../src/reverseShare/reverseShare.service.ts | 2 + backend/src/share/dto/shareComplete.dto.ts | 19 +++ .../src/share/guard/shareSecurity.guard.ts | 26 ++- backend/src/share/share.controller.ts | 3 +- backend/src/share/share.service.ts | 18 ++- .../modals/showCreateReverseShareModal.tsx | 32 +++- .../modals/showCompletedUploadModal.tsx | 19 ++- .../upload/modals/showCreateUploadModal.tsx | 149 ++++++++++++++++-- frontend/src/i18n/translations/en-US.ts | 11 ++ frontend/src/i18n/translations/zh-CN.ts | 9 ++ frontend/src/pages/share/[shareId]/edit.tsx | 6 + frontend/src/pages/share/[shareId]/index.tsx | 6 + .../src/pages/upload/[reverseShareToken].tsx | 10 +- frontend/src/pages/upload/index.tsx | 3 + frontend/src/services/share.service.ts | 4 + frontend/src/types/share.type.ts | 9 ++ 22 files changed, 355 insertions(+), 28 deletions(-) create mode 100644 backend/prisma/migrations/20240609145325_add_simplied_field_for_reverse_share/migration.sql create mode 100644 backend/prisma/migrations/20240725141038_add_public_access_field_for_reverse_share/migration.sql create mode 100644 backend/src/share/dto/shareComplete.dto.ts diff --git a/backend/prisma/migrations/20240609145325_add_simplied_field_for_reverse_share/migration.sql b/backend/prisma/migrations/20240609145325_add_simplied_field_for_reverse_share/migration.sql new file mode 100644 index 0000000..81a6b65 --- /dev/null +++ b/backend/prisma/migrations/20240609145325_add_simplied_field_for_reverse_share/migration.sql @@ -0,0 +1,20 @@ +-- RedefineTables +PRAGMA foreign_keys=OFF; +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, + "simplified" BOOLEAN NOT NULL DEFAULT false, + "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", "remainingUses", "sendEmailNotification", "shareExpiration", "token") SELECT "createdAt", "creatorId", "id", "maxShareSize", "remainingUses", "sendEmailNotification", "shareExpiration", "token" 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; diff --git a/backend/prisma/migrations/20240725141038_add_public_access_field_for_reverse_share/migration.sql b/backend/prisma/migrations/20240725141038_add_public_access_field_for_reverse_share/migration.sql new file mode 100644 index 0000000..e19c87c --- /dev/null +++ b/backend/prisma/migrations/20240725141038_add_public_access_field_for_reverse_share/migration.sql @@ -0,0 +1,22 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +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, + "simplified" BOOLEAN NOT NULL DEFAULT false, + "publicAccess" BOOLEAN NOT NULL DEFAULT true, + "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", "remainingUses", "sendEmailNotification", "shareExpiration", "simplified", "token") SELECT "createdAt", "creatorId", "id", "maxShareSize", "remainingUses", "sendEmailNotification", "shareExpiration", "simplified", "token" FROM "ReverseShare"; +DROP TABLE "ReverseShare"; +ALTER TABLE "new_ReverseShare" RENAME TO "ReverseShare"; +CREATE UNIQUE INDEX "ReverseShare_token_key" ON "ReverseShare"("token"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 8cd4a6c..ce87009 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -103,6 +103,8 @@ model ReverseShare { maxShareSize String sendEmailNotification Boolean remainingUses Int + simplified Boolean @default(false) + publicAccess Boolean @default(true) creatorId String creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade) diff --git a/backend/src/file/guard/fileSecurity.guard.ts b/backend/src/file/guard/fileSecurity.guard.ts index 16b1db2..e357e80 100644 --- a/backend/src/file/guard/fileSecurity.guard.ts +++ b/backend/src/file/guard/fileSecurity.guard.ts @@ -9,14 +9,16 @@ import * as moment from "moment"; import { PrismaService } from "src/prisma/prisma.service"; import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard"; import { ShareService } from "src/share/share.service"; +import { ConfigService } from "src/config/config.service"; @Injectable() export class FileSecurityGuard extends ShareSecurityGuard { constructor( private _shareService: ShareService, private _prisma: PrismaService, + _config: ConfigService, ) { - super(_shareService, _prisma); + super(_shareService, _prisma, _config); } async canActivate(context: ExecutionContext) { diff --git a/backend/src/reverseShare/dto/createReverseShare.dto.ts b/backend/src/reverseShare/dto/createReverseShare.dto.ts index c4fabe9..f73c8d2 100644 --- a/backend/src/reverseShare/dto/createReverseShare.dto.ts +++ b/backend/src/reverseShare/dto/createReverseShare.dto.ts @@ -13,4 +13,10 @@ export class CreateReverseShareDTO { @Min(1) @Max(1000) maxUseCount: number; + + @IsBoolean() + simplified: boolean; + + @IsBoolean() + publicAccess: boolean; } diff --git a/backend/src/reverseShare/dto/reverseShare.dto.ts b/backend/src/reverseShare/dto/reverseShare.dto.ts index b392b85..ce94933 100644 --- a/backend/src/reverseShare/dto/reverseShare.dto.ts +++ b/backend/src/reverseShare/dto/reverseShare.dto.ts @@ -13,6 +13,9 @@ export class ReverseShareDTO { @Expose() token: string; + @Expose() + simplified: boolean; + from(partial: Partial) { return plainToClass(ReverseShareDTO, partial, { excludeExtraneousValues: true, diff --git a/backend/src/reverseShare/reverseShare.service.ts b/backend/src/reverseShare/reverseShare.service.ts index 134b72d..1470035 100644 --- a/backend/src/reverseShare/reverseShare.service.ts +++ b/backend/src/reverseShare/reverseShare.service.ts @@ -49,6 +49,8 @@ export class ReverseShareService { remainingUses: data.maxUseCount, maxShareSize: data.maxShareSize, sendEmailNotification: data.sendEmailNotification, + simplified: data.simplified, + publicAccess: data.publicAccess, creatorId, }, }); diff --git a/backend/src/share/dto/shareComplete.dto.ts b/backend/src/share/dto/shareComplete.dto.ts new file mode 100644 index 0000000..67becf0 --- /dev/null +++ b/backend/src/share/dto/shareComplete.dto.ts @@ -0,0 +1,19 @@ +import { Expose, plainToClass } from "class-transformer"; +import { ShareDTO } from "./share.dto"; + +export class CompletedShareDTO extends ShareDTO { + @Expose() + notifyReverseShareCreator?: boolean; + + from(partial: Partial) { + return plainToClass(CompletedShareDTO, partial, { + excludeExtraneousValues: true, + }); + } + + fromList(partial: Partial[]) { + return partial.map((part) => + plainToClass(CompletedShareDTO, part, { excludeExtraneousValues: true }), + ); + } +} diff --git a/backend/src/share/guard/shareSecurity.guard.ts b/backend/src/share/guard/shareSecurity.guard.ts index 8b43c8c..24e9dd1 100644 --- a/backend/src/share/guard/shareSecurity.guard.ts +++ b/backend/src/share/guard/shareSecurity.guard.ts @@ -1,5 +1,4 @@ import { - CanActivate, ExecutionContext, ForbiddenException, Injectable, @@ -9,13 +8,19 @@ import { Request } from "express"; import * as moment from "moment"; import { PrismaService } from "src/prisma/prisma.service"; import { ShareService } from "src/share/share.service"; +import { ConfigService } from "src/config/config.service"; +import { JwtGuard } from "src/auth/guard/jwt.guard"; +import { User } from "@prisma/client"; @Injectable() -export class ShareSecurityGuard implements CanActivate { +export class ShareSecurityGuard extends JwtGuard { constructor( private shareService: ShareService, private prisma: PrismaService, - ) {} + configService: ConfigService, + ) { + super(configService); + } async canActivate(context: ExecutionContext) { const request: Request = context.switchToHttp().getRequest(); @@ -31,7 +36,7 @@ export class ShareSecurityGuard implements CanActivate { const share = await this.prisma.share.findUnique({ where: { id: shareId }, - include: { security: true }, + include: { security: true, reverseShare: true }, }); if ( @@ -53,6 +58,19 @@ export class ShareSecurityGuard implements CanActivate { "share_token_required", ); + // Run the JWTGuard to set the user + await super.canActivate(context); + const user = request.user as User; + + // Only the creator and reverse share creator can access the reverse share if it's not public + if (share.reverseShare && !share.reverseShare.publicAccess + && share.creatorId !== user?.id + && share.reverseShare.creatorId !== user?.id) + throw new ForbiddenException( + "Only reverse share creator can access this share", + "private_share", + ); + return true; } } diff --git a/backend/src/share/share.controller.ts b/backend/src/share/share.controller.ts index 2abb939..ff58309 100644 --- a/backend/src/share/share.controller.ts +++ b/backend/src/share/share.controller.ts @@ -29,6 +29,7 @@ import { ShareOwnerGuard } from "./guard/shareOwner.guard"; import { ShareSecurityGuard } from "./guard/shareSecurity.guard"; import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard"; import { ShareService } from "./share.service"; +import { CompletedShareDTO } from "./dto/shareComplete.dto"; @Controller("shares") export class ShareController { constructor( @@ -86,7 +87,7 @@ export class ShareController { @UseGuards(CreateShareGuard, ShareOwnerGuard) async complete(@Param("id") id: string, @Req() request: Request) { const { reverse_share_token } = request.cookies; - return new ShareDTO().from( + return new CompletedShareDTO().from( await this.shareService.complete(id, reverse_share_token), ); } diff --git a/backend/src/share/share.service.ts b/backend/src/share/share.service.ts index bc43e34..8c0e81b 100644 --- a/backend/src/share/share.service.ts +++ b/backend/src/share/share.service.ts @@ -159,11 +159,12 @@ export class ShareService { ); } - if ( - share.reverseShare && - this.config.get("smtp.enabled") && - share.reverseShare.sendEmailNotification - ) { + const notifyReverseShareCreator = share.reverseShare + ? this.config.get("smtp.enabled") && + share.reverseShare.sendEmailNotification + : undefined; + + if (notifyReverseShareCreator) { await this.emailService.sendMailToReverseShareCreator( share.reverseShare.creator.email, share.id, @@ -180,10 +181,15 @@ export class ShareService { }); } - return this.prisma.share.update({ + const updatedShare = await this.prisma.share.update({ where: { id }, data: { uploadLocked: true }, }); + + return { + ...updatedShare, + notifyReverseShareCreator, + }; } async revertComplete(id: string) { diff --git a/frontend/src/components/share/modals/showCreateReverseShareModal.tsx b/frontend/src/components/share/modals/showCreateReverseShareModal.tsx index 027848c..4cd40c7 100644 --- a/frontend/src/components/share/modals/showCreateReverseShareModal.tsx +++ b/frontend/src/components/share/modals/showCreateReverseShareModal.tsx @@ -22,6 +22,7 @@ import { getExpirationPreview } from "../../../utils/date.util"; import toast from "../../../utils/toast.util"; import FileSizeInput from "../FileSizeInput"; import showCompletedReverseShareModal from "./showCompletedReverseShareModal"; +import { getCookie, setCookie } from "cookies-next"; const showCreateReverseShareModal = ( modals: ModalsContextProps, @@ -61,10 +62,16 @@ const Body = ({ sendEmailNotification: false, expiration_num: 1, expiration_unit: "-days", + simplified: !!(getCookie("reverse-share.simplified") ?? false), + publicAccess: !!(getCookie("reverse-share.public-access") ?? true), }, }); const onSubmit = form.onSubmit(async (values) => { + // remember simplified and publicAccess in cookies + setCookie("reverse-share.simplified", values.simplified); + setCookie("reverse-share.public-access", values.publicAccess); + const expirationDate = moment().add( form.values.expiration_num, form.values.expiration_unit.replace( @@ -91,6 +98,8 @@ const Body = ({ values.maxShareSize, values.maxUseCount, values.sendEmailNotification, + values.simplified, + values.publicAccess, ) .then(({ link }) => { modals.closeAll(); @@ -210,7 +219,28 @@ const Body = ({ })} /> )} - + + diff --git a/frontend/src/components/upload/modals/showCompletedUploadModal.tsx b/frontend/src/components/upload/modals/showCompletedUploadModal.tsx index 6a11be8..b4648e0 100644 --- a/frontend/src/components/upload/modals/showCompletedUploadModal.tsx +++ b/frontend/src/components/upload/modals/showCompletedUploadModal.tsx @@ -7,12 +7,12 @@ import { FormattedMessage } from "react-intl"; import useTranslate, { translateOutsideContext, } from "../../../hooks/useTranslate.hook"; -import { Share } from "../../../types/share.type"; +import { CompletedShare } from "../../../types/share.type"; import CopyTextField from "../CopyTextField"; const showCompletedUploadModal = ( modals: ModalsContextProps, - share: Share, + share: CompletedShare, appUrl: string, ) => { const t = translateOutsideContext(); @@ -25,7 +25,7 @@ const showCompletedUploadModal = ( }); }; -const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => { +const Body = ({ share, appUrl }: { share: CompletedShare; appUrl: string }) => { const modals = useModals(); const router = useRouter(); const t = useTranslate(); @@ -35,6 +35,19 @@ const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => { return ( + {share.notifyReverseShareCreator === true && ( + ({ + color: + theme.colorScheme === "dark" + ? theme.colors.gray[3] + : theme.colors.dark[4], + })} + > + {t("upload.modal.completed.notified-reverse-share-creator")} + + )} ({ diff --git a/frontend/src/components/upload/modals/showCreateUploadModal.tsx b/frontend/src/components/upload/modals/showCreateUploadModal.tsx index 60d4f99..d47c54c 100644 --- a/frontend/src/components/upload/modals/showCreateUploadModal.tsx +++ b/frontend/src/components/upload/modals/showCreateUploadModal.tsx @@ -31,6 +31,7 @@ import { FileUpload } from "../../../types/File.type"; import { CreateShare } from "../../../types/share.type"; import { getExpirationPreview } from "../../../utils/date.util"; import React from "react"; +import toast from "../../../utils/toast.util"; const showCreateUploadModal = ( modals: ModalsContextProps, @@ -41,12 +42,26 @@ const showCreateUploadModal = ( allowUnauthenticatedShares: boolean; enableEmailRecepients: boolean; maxExpirationInHours: number; + simplified: boolean; }, files: FileUpload[], uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void, ) => { const t = translateOutsideContext(); + if (options.simplified) { + return modals.openModal({ + title: t("upload.modal.title"), + children: ( + + ), + }); + } + return modals.openModal({ title: t("upload.modal.title"), children: ( @@ -59,6 +74,23 @@ const showCreateUploadModal = ( }); }; +const generateLink = () => + Buffer.from(Math.random().toString(), "utf8") + .toString("base64") + .substring(10, 17); + +const generateAvailableLink = async (times = 10): Promise => { + if (times <= 0) { + throw new Error("Could not generate available link"); + } + const _link = generateLink(); + if (!(await shareService.isShareIdAvailable(_link))) { + return await generateAvailableLink(times - 1); + } else { + return _link; + } +}; + const CreateUploadModalBody = ({ uploadCallback, files, @@ -78,9 +110,7 @@ const CreateUploadModalBody = ({ const modals = useModals(); const t = useTranslate(); - const generatedLink = Buffer.from(Math.random().toString(), "utf8") - .toString("base64") - .substr(10, 7); + const generatedLink = generateLink(); const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(true); @@ -202,14 +232,7 @@ const CreateUploadModalBody = ({ @@ -429,4 +452,108 @@ const CreateUploadModalBody = ({ ); }; +const SimplifiedCreateUploadModalModal = ({ + uploadCallback, + files, + options, +}: { + files: FileUpload[]; + uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void; + options: { + isUserSignedIn: boolean; + isReverseShare: boolean; + appUrl: string; + allowUnauthenticatedShares: boolean; + enableEmailRecepients: boolean; + maxExpirationInHours: number; + }; +}) => { + const modals = useModals(); + const t = useTranslate(); + + const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(true); + + const validationSchema = yup.object().shape({ + name: yup + .string() + .transform((value) => value || undefined) + .min(3, t("common.error.too-short", { length: 3 })) + .max(30, t("common.error.too-long", { length: 30 })), + }); + + const form = useForm({ + initialValues: { + name: undefined, + description: undefined, + }, + validate: yupResolver(validationSchema), + }); + + const onSubmit = form.onSubmit(async (values) => { + const link = await generateAvailableLink().catch(() => { + toast.error(t("upload.modal.link.error.taken")); + return undefined; + }); + + if (!link) { + return; + } + + uploadCallback( + { + id: link, + name: values.name, + expiration: "never", + recipients: [], + description: values.description, + security: { + password: undefined, + maxViews: undefined, + }, + }, + files, + ); + modals.closeAll(); + }); + + return ( + + {showNotSignedInAlert && !options.isUserSignedIn && ( + setShowNotSignedInAlert(false)} + icon={} + title={t("upload.modal.not-signed-in")} + color="yellow" + > + + + )} +
+ + + +