From 13cd7c555cdd6c6058b8a3692281191a09e61e46 Mon Sep 17 00:00:00 2001 From: Marc Seitz <4049052+mfts@users.noreply.github.com> Date: Sat, 31 Aug 2024 18:45:02 +0200 Subject: [PATCH] feat: add watermarking (#576) * feat: add watermarking * fix: proper export * fix * fix: imports * fix: duplicate * fix: asset prefix * feat: limit to trial and datarooms plan * feat: improve watermarking * feat: add ip address and cleanup types --- components/links/link-sheet/index.tsx | 6 +- components/links/link-sheet/link-options.tsx | 6 + .../link-sheet/watermark-panel/index.tsx | 309 ++++++++++++++++++ .../links/link-sheet/watermark-section.tsx | 168 ++++++++++ components/links/links-table.tsx | 4 +- components/ui/select.tsx | 4 +- components/view/PagesViewerNew.tsx | 28 ++ components/view/dataroom/dataroom-view.tsx | 13 +- components/view/document-view.tsx | 24 +- components/view/nav.tsx | 31 +- components/view/view-data.tsx | 7 +- components/view/watermark-svg.tsx | 161 +++++++++ lib/types.ts | 31 ++ lib/utils.ts | 9 + lib/utils/ip.ts | 8 + next.config.mjs | 3 +- package-lock.json | 85 +++++ package.json | 3 + pages/api/links/[id]/duplicate.ts | 2 + pages/api/links/[id]/index.ts | 4 + pages/api/links/domains/[...domainSlug].ts | 2 + pages/api/links/download/index.ts | 46 +++ pages/api/links/index.ts | 4 + pages/api/mupdf/annotate-document.ts | 209 ++++++++++++ pages/api/views-dataroom.ts | 15 +- pages/api/views.ts | 14 +- .../migration.sql | 4 + prisma/schema.prisma | 2 + 28 files changed, 1182 insertions(+), 20 deletions(-) create mode 100644 components/links/link-sheet/watermark-panel/index.tsx create mode 100644 components/links/link-sheet/watermark-section.tsx create mode 100644 components/view/watermark-svg.tsx create mode 100644 lib/utils/ip.ts create mode 100644 pages/api/mupdf/annotate-document.ts create mode 100644 prisma/migrations/20240830000000_add_watermarks/migration.sql diff --git a/components/links/link-sheet/index.tsx b/components/links/link-sheet/index.tsx index d27a9fd68..8ffaa2e17 100644 --- a/components/links/link-sheet/index.tsx +++ b/components/links/link-sheet/index.tsx @@ -23,7 +23,7 @@ import { import { useAnalytics } from "@/lib/analytics"; import { usePlan } from "@/lib/swr/use-billing"; import { useDomains } from "@/lib/swr/use-domains"; -import { LinkWithViews } from "@/lib/types"; +import { LinkWithViews, WatermarkConfig } from "@/lib/types"; import { convertDataUrlToFile, uploadImage } from "@/lib/utils"; import DomainSection from "./domain-section"; @@ -54,6 +54,8 @@ export const DEFAULT_LINK_PROPS = (linkType: LinkType) => ({ enableAgreement: false, agreementId: null, showBanner: linkType === LinkType.DOCUMENT_LINK ? true : false, + enableWatermark: false, + watermarkConfig: null, }); export type DEFAULT_LINK_TYPE = { @@ -81,6 +83,8 @@ export type DEFAULT_LINK_TYPE = { enableAgreement: boolean; // agreement agreementId: string | null; showBanner: boolean; + enableWatermark: boolean; + watermarkConfig: WatermarkConfig | null; }; export default function LinkSheet({ diff --git a/components/links/link-sheet/link-options.tsx b/components/links/link-sheet/link-options.tsx index 534846e68..ce7395a94 100644 --- a/components/links/link-sheet/link-options.tsx +++ b/components/links/link-sheet/link-options.tsx @@ -22,6 +22,7 @@ import useLimits from "@/lib/swr/use-limits"; import AgreementSection from "./agreement-section"; import QuestionSection from "./question-section"; import ScreenshotProtectionSection from "./screenshot-protection-section"; +import WatermarkSection from "./watermark-section"; export type LinkUpgradeOptions = { state: boolean; @@ -131,6 +132,11 @@ export const LinkOptions = ({ } handleUpgradeStateChange={handleUpgradeStateChange} /> + void; + initialConfig: Partial; + onSave: (config: WatermarkConfig) => void; +} + +export default function WatermarkConfigSheet({ + isOpen, + onOpenChange, + initialConfig, + onSave, +}: WatermarkConfigSheetProps) { + const [formValues, setFormValues] = + useState>(initialConfig); + const [errors, setErrors] = useState>({}); + + useEffect(() => { + setFormValues(initialConfig); + }, [initialConfig]); + + const handleInputChange = ( + e: React.ChangeEvent, + ) => { + const { name, value } = e.target; + setFormValues((prevValues) => ({ + ...prevValues, + [name]: value, + })); + }; + + const validateAndSave = () => { + try { + const validatedData = WatermarkConfigSchema.parse(formValues); + onSave(validatedData); + setErrors({}); + onOpenChange(false); + } catch (error) { + if (error instanceof z.ZodError) { + const fieldErrors: Record = {}; + error.errors.forEach((err) => { + if (err.path[0]) { + fieldErrors[err.path[0] as string] = err.message; + } + }); + setErrors(fieldErrors); + } + } + }; + + return ( + + + + Watermark Configuration + + Configure the watermark settings for your document. + + + + +
+
+ + +
+ {["email", "date", "time", "link", "ipAddress"].map((item) => ( + + ))} +
+ {errors.text &&

{errors.text}

} +
+ +
+
+ { + setFormValues((prevValues) => ({ + ...prevValues, + isTiled: checked === true, + })); + }} + className="mt-0.5 border border-gray-400 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-gray-300 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-white data-[state=checked]:bg-black data-[state=checked]:text-white" + /> + +
+ {errors.isTiled && ( +

{errors.isTiled}

+ )} +
+ +
+ + + {errors.position && ( +

{errors.position}

+ )} +
+ +
+ + + {errors.rotation && ( +

{errors.rotation}

+ )} +
+ +
+ +
+ + +
+ + + { + setFormValues({ + ...formValues, + color: value as WatermarkConfig["color"], + }); + }} + /> + + + { + setFormValues({ + ...formValues, + color: value as WatermarkConfig["color"], + }); + }} + prefixed + /> +
+ {errors.color &&

{errors.color}

} +
+ +
+
+ + { + setFormValues({ + ...formValues, + fontSize: parseInt( + e.target.value, + ) as WatermarkConfig["fontSize"], + }); + }} + className="focus:ring-inset" + /> + {errors.fontSize && ( +

{errors.fontSize}

+ )} +
+
+ + + {errors.opacity && ( +

{errors.opacity}

+ )} +
+
+
+ + + + + + + + ); +} diff --git a/components/links/link-sheet/watermark-section.tsx b/components/links/link-sheet/watermark-section.tsx new file mode 100644 index 000000000..3305efea8 --- /dev/null +++ b/components/links/link-sheet/watermark-section.tsx @@ -0,0 +1,168 @@ +import { useEffect, useState } from "react"; + +import { motion } from "framer-motion"; +import { SettingsIcon, StampIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +import { FADE_IN_ANIMATION_SETTINGS } from "@/lib/constants"; +import { WatermarkConfig } from "@/lib/types"; + +import { DEFAULT_LINK_TYPE } from "."; +import LinkItem from "./link-item"; +import { LinkUpgradeOptions } from "./link-options"; +import WatermarkConfigSheet from "./watermark-panel"; + +export default function WatermarkSection({ + data, + setData, + isAllowed, + handleUpgradeStateChange, +}: { + data: DEFAULT_LINK_TYPE; + setData: React.Dispatch>; + isAllowed: boolean; + handleUpgradeStateChange: ({ + state, + trigger, + plan, + }: LinkUpgradeOptions) => void; +}) { + const { enableWatermark, watermarkConfig } = data; + const [enabled, setEnabled] = useState(false); + const [isConfigOpen, setIsConfigOpen] = useState(false); + + useEffect(() => { + setEnabled(enableWatermark); + }, [enableWatermark]); + + const handleWatermarkToggle = () => { + const updatedWatermark = !enabled; + + setData({ + ...data, + enableWatermark: updatedWatermark, + watermarkConfig: watermarkConfig || null, + }); + setEnabled(updatedWatermark); + }; + + const initialconfig: WatermarkConfig = { + text: watermarkConfig?.text ?? "", + isTiled: watermarkConfig?.isTiled ?? false, + opacity: watermarkConfig?.opacity ?? 0.5, + color: watermarkConfig?.color ?? "#000000", + fontSize: watermarkConfig?.fontSize ?? 24, + rotation: watermarkConfig?.rotation ?? 45, + position: watermarkConfig?.position ?? "middle-center", + }; + + const handleConfigSave = (config: WatermarkConfig) => { + setData({ + ...data, + watermarkConfig: config, + }); + }; + + return ( +
+ + handleUpgradeStateChange({ + state: true, + trigger: "link_sheet_watermark_section", + plan: "Data Rooms", + }) + } + /> + + {enabled && ( + +
+
+ + { + setData((prevData) => ({ + ...prevData, + watermarkConfig: { + ...(prevData.watermarkConfig || initialconfig), + text: e.target.value, + }, + })); + }} + className="focus:ring-inset" + /> +
+ {["email", "date", "time", "link", "ipAddress"].map((item) => ( + + ))} +
+
+
+ +
+

+ {initialconfig.isTiled ? `tiled` : initialconfig.position},{" "} + {initialconfig.rotation}º, {initialconfig.fontSize}px,{" "} + {initialconfig.color.toUpperCase()},{" "} + {(1 - initialconfig.opacity) * 100}% transparent +

+ +
+
+ )} + + +
+ ); +} diff --git a/components/links/links-table.tsx b/components/links/links-table.tsx index 6d7545996..4bd2258d6 100644 --- a/components/links/links-table.tsx +++ b/components/links/links-table.tsx @@ -33,7 +33,7 @@ import { } from "@/components/ui/table"; import { usePlan } from "@/lib/swr/use-billing"; -import { LinkWithViews } from "@/lib/types"; +import { LinkWithViews, WatermarkConfig } from "@/lib/types"; import { cn, copyToClipboard, nFormatter, timeAgo } from "@/lib/utils"; import ProcessStatusBar from "../documents/process-status-bar"; @@ -102,6 +102,8 @@ export default function LinksTable({ enableAgreement: link.enableAgreement ? link.enableAgreement : false, agreementId: link.agreementId, showBanner: link.showBanner ?? false, + enableWatermark: link.enableWatermark ?? false, + watermarkConfig: link.watermarkConfig as WatermarkConfig | null, }); //wait for dropdown to close before opening the link sheet setTimeout(() => { diff --git a/components/ui/select.tsx b/components/ui/select.tsx index d34ce855b..dbb9b87f5 100644 --- a/components/ui/select.tsx +++ b/components/ui/select.tsx @@ -20,7 +20,7 @@ const SelectTrigger = React.forwardRef<
+ + {/* Add Watermark Component */} + {watermarkConfig ? ( + + ) : null} + {page.pageLinks ? ( {page.pageLinks diff --git a/components/view/dataroom/dataroom-view.tsx b/components/view/dataroom/dataroom-view.tsx index 4e516defb..85b7dcd8e 100644 --- a/components/view/dataroom/dataroom-view.tsx +++ b/components/view/dataroom/dataroom-view.tsx @@ -16,7 +16,7 @@ import AccessForm, { } from "@/components/view/access-form"; import { useAnalytics } from "@/lib/analytics"; -import { LinkWithDataroom } from "@/lib/types"; +import { LinkWithDataroom, WatermarkConfig } from "@/lib/types"; import DataroomViewer from "../DataroomViewer"; import PagesViewerNew from "../PagesViewerNew"; @@ -62,6 +62,7 @@ export type DEFAULT_DOCUMENT_VIEW_TYPE = { sheetData?: SheetData[] | null; notionData?: { recordMap: ExtendedRecordMap | null }; fileType?: string; + ipAddress?: string; }; export default function DataroomView({ @@ -160,6 +161,7 @@ export default function DataroomView({ sheetData, fileType, isPreview, + ipAddress, } = fetchData as DEFAULT_DOCUMENT_VIEW_TYPE; plausible("dataroomViewed"); // track the event analytics.identify( @@ -183,6 +185,7 @@ export default function DataroomView({ sheetData, fileType, isPreview, + ipAddress, })); setSubmitted(true); setVerificationRequested(false); @@ -243,6 +246,7 @@ export default function DataroomView({ file: undefined, viewId: "", notionData: undefined, + ipAddress: undefined, })); // This effect is specifically for handling changes to `documentData` post-mount }, [documentData]); @@ -350,6 +354,13 @@ export default function DataroomView({ dataroomId={dataroom.id} setDocumentData={setDocumentData} isVertical={documentData.isVertical} + watermarkConfig={ + link.enableWatermark + ? (link.watermarkConfig as WatermarkConfig) + : null + } + ipAddress={viewData.ipAddress} + linkName={link.name ?? `Link #${link.id.slice(-5)}`} />
) : null; diff --git a/components/view/document-view.tsx b/components/view/document-view.tsx index 059bcb761..c2462ed80 100644 --- a/components/view/document-view.tsx +++ b/components/view/document-view.tsx @@ -14,7 +14,7 @@ import AccessForm, { } from "@/components/view/access-form"; import { useAnalytics } from "@/lib/analytics"; -import { LinkWithDocument } from "@/lib/types"; +import { LinkWithDocument, WatermarkConfig } from "@/lib/types"; import EmailVerificationMessage from "./email-verification-form"; import ViewData from "./view-data"; @@ -41,6 +41,7 @@ export type DEFAULT_DOCUMENT_VIEW_TYPE = { sheetData?: SheetData[] | null; fileType?: string; isPreview?: boolean; + ipAddress?: string; }; export default function DocumentView({ @@ -129,8 +130,15 @@ export default function DocumentView({ setVerificationRequested(true); setIsLoading(false); } else { - const { viewId, file, pages, sheetData, fileType, isPreview } = - fetchData as DEFAULT_DOCUMENT_VIEW_TYPE; + const { + viewId, + file, + pages, + sheetData, + fileType, + isPreview, + ipAddress, + } = fetchData as DEFAULT_DOCUMENT_VIEW_TYPE; plausible("documentViewed"); // track the event analytics.identify( userEmail ?? verifiedEmail ?? data.email ?? undefined, @@ -142,7 +150,15 @@ export default function DocumentView({ viewerId: viewId, viewerEmail: data.email ?? verifiedEmail ?? userEmail, }); - setViewData({ viewId, file, pages, sheetData, fileType, isPreview }); + setViewData({ + viewId, + file, + pages, + sheetData, + fileType, + isPreview, + ipAddress, + }); setSubmitted(true); setVerificationRequested(false); setIsLoading(false); diff --git a/components/view/nav.tsx b/components/view/nav.tsx index 679d2fc29..aca03bdff 100644 --- a/components/view/nav.tsx +++ b/components/view/nav.tsx @@ -57,6 +57,7 @@ export default function Nav({ isVertical, isMobile, isPreview, + hasWatermark, }: { pageNumber?: number; numPages?: number; @@ -74,6 +75,7 @@ export default function Nav({ isVertical?: boolean; isMobile?: boolean; isPreview?: boolean; + hasWatermark?: boolean; }) { const downloadFile = async () => { if (!allowDownload || type === "notion" || isPreview) return; @@ -86,13 +88,30 @@ export default function Nav({ body: JSON.stringify({ linkId, viewId }), }); - if (!response.ok) { - toast.error("Error downloading file"); - return; - } + if (hasWatermark) { + const pdfBlob = await response.blob(); + const blobUrl = URL.createObjectURL(pdfBlob); + + console.log("Blob URL:", blobUrl); + + const a = document.createElement("a"); + a.href = blobUrl; + a.download = "watermarked_document.pdf"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); - const { downloadUrl } = await response.json(); - window.open(downloadUrl, "_blank"); + // Clean up the Blob URL + URL.revokeObjectURL(blobUrl); + } else { + if (!response.ok) { + toast.error("Error downloading file"); + return; + } + const { downloadUrl } = await response.json(); + + window.open(downloadUrl, "_blank"); + } } catch (error) { console.error("Error downloading file:", error); } diff --git a/components/view/view-data.tsx b/components/view/view-data.tsx index 09764849b..26a52bdb8 100644 --- a/components/view/view-data.tsx +++ b/components/view/view-data.tsx @@ -8,7 +8,7 @@ import PDFViewer from "@/components/view/PDFViewer"; import PagesViewerNew from "@/components/view/PagesViewerNew"; import { DEFAULT_DOCUMENT_VIEW_TYPE } from "@/components/view/document-view"; -import { LinkWithDocument } from "@/lib/types"; +import { LinkWithDocument, WatermarkConfig } from "@/lib/types"; import AdvancedExcelViewer from "./viewer/advanced-excel-viewer"; @@ -95,6 +95,11 @@ export default function ViewData({ feedback={link.feedback} isVertical={document.versions[0].isVertical} viewerEmail={viewerEmail} + watermarkConfig={ + link.enableWatermark ? (link.watermarkConfig as WatermarkConfig) : null + } + ipAddress={viewData.ipAddress} + linkName={link.name ?? `Link #${link.id.slice(-5)}`} /> ) : ( { + const watermarkText = useMemo(() => { + const template = Handlebars.compile(config.text); + return template(viewerData); + }, [config.text, viewerData]); + + const { width, height } = documentDimensions; + + // Calculate a responsive font size + const calculateFontSize = () => { + const baseFontSize = Math.min(width, height) * (config.fontSize / 1000); + return Math.max(8, Math.min(baseFontSize, config.fontSize)); // Clamp between 8px and config.fontSize + }; + + const fontSize = calculateFontSize(); + + const createPattern = () => { + // Estimate text width (this is an approximation) + const textWidth = watermarkText.length * fontSize * 0.6; + + // Make pattern size larger than text to avoid cut-off + const patternWidth = textWidth; + const patternHeight = fontSize * 10; + + return ( + + + {watermarkText} + + + ); + }; + + const createSingleWatermark = () => { + let x, y; + switch (config.position) { + case "top-left": + x = fontSize / 2; + y = fontSize; + break; + case "top-center": + x = width / 2; + y = fontSize; + break; + case "top-right": + x = width - fontSize / 2; + y = fontSize; + break; + case "middle-left": + x = fontSize / 2; + y = height / 2; + break; + case "middle-center": + x = width / 2; + y = height / 2; + break; + case "middle-right": + x = width - fontSize / 2; + y = height / 2; + break; + case "bottom-left": + x = fontSize / 2; + y = height - fontSize; + break; + case "bottom-center": + x = width / 2; + y = height - fontSize; + break; + case "bottom-right": + x = width - fontSize / 2; + y = height - fontSize; + break; + default: + x = width / 2; + y = height / 2; + } + + return ( + + {watermarkText} + + ); + }; + + return ( + + {config.isTiled ? ( + <> + {createPattern()} + + + ) : ( + createSingleWatermark() + )} + + ); +}; diff --git a/lib/types.ts b/lib/types.ts index e6e7970c2..c75779c61 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -11,6 +11,7 @@ import { View, } from "@prisma/client"; import { User as NextAuthUser } from "next-auth"; +import { z } from "zod"; export type CustomUser = NextAuthUser & PrismaUser; @@ -241,3 +242,33 @@ export interface TeamDetail { userId: string; }[]; } + +export const WatermarkConfigSchema = z.object({ + text: z.string().min(1, "Text is required."), + isTiled: z.boolean(), + position: z.enum([ + "top-left", + "top-center", + "top-right", + "middle-left", + "middle-center", + "middle-right", + "bottom-left", + "bottom-center", + "bottom-right", + ]), + rotation: z.union([ + z.literal(0), + z.literal(30), + z.literal(45), + z.literal(90), + z.literal(180), + ]), + color: z.string().refine((val) => /^#([0-9A-F]{3}){1,2}$/i.test(val), { + message: "Invalid color format. Use HEX format like #RRGGBB.", + }), + fontSize: z.number().min(1, "Font size must be greater than 0."), + opacity: z.number().min(0).max(1, "Opacity must be between 0 and 1."), +}); + +export type WatermarkConfig = z.infer; diff --git a/lib/utils.ts b/lib/utils.ts index 670831264..d799a6afb 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -6,6 +6,7 @@ import crypto from "crypto"; import ms from "ms"; import { customAlphabet } from "nanoid"; import { ThreadMessage } from "openai/resources/beta/threads/messages/messages"; +import { rgb } from "pdf-lib"; import { toast } from "sonner"; import { twMerge } from "tailwind-merge"; @@ -491,3 +492,11 @@ export const sanitizeAllowDenyList = (list: string): string[] => { .filter((item) => item !== "") // Remove empty items .filter((item) => emailRegex.test(item) || domainRegex.test(item)); // Remove items that don't match email or domain regex }; + +export function hexToRgb(hex: string) { + let bigint = parseInt(hex.slice(1), 16); + let r = ((bigint >> 16) & 255) / 255; // Convert to 0-1 range + let g = ((bigint >> 8) & 255) / 255; // Convert to 0-1 range + let b = (bigint & 255) / 255; // Convert to 0-1 range + return rgb(r, g, g); +} diff --git a/lib/utils/ip.ts b/lib/utils/ip.ts new file mode 100644 index 000000000..cb8ed1e53 --- /dev/null +++ b/lib/utils/ip.ts @@ -0,0 +1,8 @@ +export function getIpAddress(headers: { + [key: string]: string | string[] | undefined; +}): string { + if (typeof headers["x-forwarded-for"] === "string") { + return (headers["x-forwarded-for"] ?? "127.0.0.1").split(",")[0]; + } + return "127.0.0.1"; +} diff --git a/next.config.mjs b/next.config.mjs index 5a346970e..3db77dca9 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -9,7 +9,8 @@ const nextConfig = { transpilePackages: ["@trigger.dev/react"], skipTrailingSlashRedirect: true, assetPrefix: - process.env.NODE_ENV === "production" + process.env.NODE_ENV === "production" && + process.env.VERCEL_ENV === "production" ? process.env.NEXT_PUBLIC_BASE_URL : undefined, async redirects() { diff --git a/package-lock.json b/package-lock.json index 1d40fb0bf..35d87e5f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@github/webauthn-json": "^2.1.1", "@jitsu/js": "^1.9.8", "@next-auth/prisma-adapter": "^1.0.7", + "@pdf-lib/fontkit": "^1.1.1", "@prisma/client": "^5.18.0", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-avatar": "^1.1.0", @@ -66,6 +67,7 @@ "eslint": "8.57.0", "eslint-config-next": "^14.2.5", "framer-motion": "^11.3.29", + "handlebars": "^4.7.8", "js-cookie": "^3.0.5", "lucide-react": "^0.429.0", "mime-types": "^2.1.35", @@ -80,6 +82,7 @@ "notion-client": "^6.16.0", "notion-utils": "^6.16.0", "openai": "4.20.1", + "pdf-lib": "^1.17.1", "postcss": "^8.4.41", "posthog-js": "^1.157.2", "react": "^18.3.1", @@ -3326,6 +3329,30 @@ "node": ">=0.10" } }, + "node_modules/@pdf-lib/fontkit": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/fontkit/-/fontkit-1.1.1.tgz", + "integrity": "sha512-KjMd7grNapIWS/Dm0gvfHEilSyAmeLvrEGVcqLGi0VYebuqqzTbgF29efCx7tvx+IEbG3zQciRSWl3GkUSvjZg==", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -12054,6 +12081,26 @@ "unenv": "^1.9.0" } }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-bigints": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", @@ -15640,6 +15687,11 @@ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -15749,6 +15801,22 @@ "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==" }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, "node_modules/pdfjs-dist": { "version": "3.11.174", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz", @@ -19701,6 +19769,18 @@ "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz", "integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==" }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/ulid": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ulid/-/ulid-2.3.0.tgz", @@ -21581,6 +21661,11 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==" + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", diff --git a/package.json b/package.json index d81cafdba..967a89bb6 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@github/webauthn-json": "^2.1.1", "@jitsu/js": "^1.9.8", "@next-auth/prisma-adapter": "^1.0.7", + "@pdf-lib/fontkit": "^1.1.1", "@prisma/client": "^5.18.0", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-avatar": "^1.1.0", @@ -75,6 +76,7 @@ "eslint": "8.57.0", "eslint-config-next": "^14.2.5", "framer-motion": "^11.3.29", + "handlebars": "^4.7.8", "js-cookie": "^3.0.5", "lucide-react": "^0.429.0", "mime-types": "^2.1.35", @@ -89,6 +91,7 @@ "notion-client": "^6.16.0", "notion-utils": "^6.16.0", "openai": "4.20.1", + "pdf-lib": "^1.17.1", "postcss": "^8.4.41", "posthog-js": "^1.157.2", "react": "^18.3.1", diff --git a/pages/api/links/[id]/duplicate.ts b/pages/api/links/[id]/duplicate.ts index d6415c2e5..f536e9b36 100644 --- a/pages/api/links/[id]/duplicate.ts +++ b/pages/api/links/[id]/duplicate.ts @@ -1,5 +1,6 @@ import { NextApiRequest, NextApiResponse } from "next"; +import { Prisma } from "@prisma/client"; import { getServerSession } from "next-auth/next"; import { errorhandler } from "@/lib/errorHandler"; @@ -67,6 +68,7 @@ export default async function handle( id: undefined, slug: linkData.slug ? linkData.slug + "-copy" : null, name: newLinkName, + watermarkConfig: linkData.watermarkConfig || Prisma.JsonNull, createdAt: undefined, updatedAt: undefined, }, diff --git a/pages/api/links/[id]/index.ts b/pages/api/links/[id]/index.ts index 243ba7a87..398b1bcd3 100644 --- a/pages/api/links/[id]/index.ts +++ b/pages/api/links/[id]/index.ts @@ -52,6 +52,8 @@ export default async function handle( enableAgreement: true, agreement: true, showBanner: true, + enableWatermark: true, + watermarkConfig: true, }, }); @@ -211,6 +213,8 @@ export default async function handle( enableAgreement: linkData.enableAgreement, agreementId: linkData.agreementId || null, showBanner: linkData.showBanner, + enableWatermark: linkData.enableWatermark || false, + watermarkConfig: linkData.watermarkConfig || null, }, include: { views: { diff --git a/pages/api/links/domains/[...domainSlug].ts b/pages/api/links/domains/[...domainSlug].ts index 64fcf75c5..b73b43643 100644 --- a/pages/api/links/domains/[...domainSlug].ts +++ b/pages/api/links/domains/[...domainSlug].ts @@ -62,6 +62,8 @@ export default async function handle( enableAgreement: true, agreement: true, showBanner: true, + enableWatermark: true, + watermarkConfig: true, document: { select: { team: { diff --git a/pages/api/links/download/index.ts b/pages/api/links/download/index.ts index 2c6374f5a..8c71b098c 100644 --- a/pages/api/links/download/index.ts +++ b/pages/api/links/download/index.ts @@ -2,6 +2,7 @@ import { NextApiRequest, NextApiResponse } from "next"; import { getFile } from "@/lib/files/get-file"; import prisma from "@/lib/prisma"; +import { getIpAddress } from "@/lib/utils/ip"; export default async function handle( req: NextApiRequest, @@ -20,11 +21,15 @@ export default async function handle( select: { id: true, viewedAt: true, + viewerEmail: true, link: { select: { allowDownload: true, expiresAt: true, isArchived: true, + enableWatermark: true, + watermarkConfig: true, + name: true, }, }, document: { @@ -36,6 +41,7 @@ export default async function handle( type: true, file: true, storageType: true, + numPages: true, }, take: 1, }, @@ -98,6 +104,46 @@ export default async function handle( isDownload: true, }); + if (view.link.enableWatermark) { + const response = await fetch( + `${process.env.NEXTAUTH_URL}/api/mupdf/annotate-document`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + url: downloadUrl, + numPages: view.document!.versions[0].numPages, + watermarkConfig: view.link.watermarkConfig, + viewerData: { + email: view.viewerEmail, + date: new Date(view.viewedAt).toLocaleDateString(), + ipAddress: getIpAddress(req.headers), + link: view.link.name, + time: new Date(view.viewedAt).toLocaleTimeString(), + }, + }), + }, + ); + + if (!response.ok) { + return res.status(500).json({ error: "Error downloading" }); + } + + const pdfBuffer = await response.arrayBuffer(); + + // Set appropriate headers + res.setHeader("Content-Type", "application/pdf"); + res.setHeader( + "Content-Disposition", + 'attachment; filename="watermarked.pdf"', + ); + + // Send the buffer directly + return res.send(Buffer.from(pdfBuffer)); + } + return res.status(200).json({ downloadUrl }); } catch (error) { return res.status(500).json({ diff --git a/pages/api/links/index.ts b/pages/api/links/index.ts index 1d86c9d66..dfd40c4be 100644 --- a/pages/api/links/index.ts +++ b/pages/api/links/index.ts @@ -160,6 +160,10 @@ export default async function handler( enableAgreement: linkData.enableAgreement, agreementId: linkData.agreementId, }), + ...(linkData.enableWatermark && { + enableWatermark: linkData.enableWatermark, + watermarkConfig: linkData.watermarkConfig, + }), showBanner: linkData.showBanner, }, }); diff --git a/pages/api/mupdf/annotate-document.ts b/pages/api/mupdf/annotate-document.ts new file mode 100644 index 000000000..c8da649fc --- /dev/null +++ b/pages/api/mupdf/annotate-document.ts @@ -0,0 +1,209 @@ +import { NextApiRequest, NextApiResponse } from "next"; + +import fontkit from "@pdf-lib/fontkit"; +import Handlebars from "handlebars"; +import { PDFDocument, StandardFonts, degrees, rgb } from "pdf-lib"; + +import { hexToRgb, log } from "@/lib/utils"; + +// This function can run for a maximum of 120 seconds +export const config = { + maxDuration: 180, +}; + +interface WatermarkConfig { + text: string; + isTiled: boolean; + position: + | "top-left" + | "top-center" + | "top-right" + | "middle-left" + | "middle-center" + | "middle-right" + | "bottom-left" + | "bottom-center" + | "bottom-right"; + rotation: 0 | 30 | 45 | 90 | 180; + color: string; + fontSize: number; + opacity: number; // 0 to 0.8 +} + +interface ViewerData { + email: string; + date: string; + ipAddress: string; + link: string; + time: string; +} + +function getPositionCoordinates( + position: WatermarkConfig["position"], + width: number, + height: number, + textWidth: number, + textHeight: number, +): number[] { + const positions = { + "top-left": [10, height - textHeight], + "top-center": [(width - textWidth) / 2, height - textHeight], + "top-right": [width - textWidth - 10, height - textHeight], + "middle-left": [10, (height - textHeight) / 2], + "middle-center": [(width - textWidth) / 2, (height - textHeight) / 2], + "middle-right": [width - textWidth - 10, (height - textHeight) / 2], + "bottom-left": [10, 20], + "bottom-center": [(width - textWidth) / 2, 20], + "bottom-right": [width - textWidth - 10, 20], + }; + return positions[position]; +} + +async function insertWatermark( + pdfDoc: PDFDocument, + config: WatermarkConfig, + viewerData: ViewerData, + pageIndex: number, +): Promise { + const pages = pdfDoc.getPages(); + const page = pages[pageIndex]; + const { width, height } = page.getSize(); + + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + + // Compile the Handlebars template + const template = Handlebars.compile(config.text); + const watermarkText = template(viewerData); + + // Calculate a responsive font size + const calculateFontSize = () => { + const baseFontSize = Math.min(width, height) * (config.fontSize / 1000); + return Math.max(8, Math.min(baseFontSize, config.fontSize)); + }; + const fontSize = calculateFontSize(); + + const textWidth = font.widthOfTextAtSize(watermarkText, fontSize); + const textHeight = font.heightAtSize(fontSize); + + if (config.isTiled) { + const patternWidth = textWidth / 1.1; + const patternHeight = textHeight * 15; + + // Calculate the offset to center the pattern + const offsetX = -patternWidth / 4; + const offsetY = -patternHeight / 4; + + const maxTilesPerRow = Math.ceil(width / patternWidth) + 1; + const maxTilesPerColumn = Math.ceil(height / patternHeight) + 1; + + for (let i = 0; i < maxTilesPerRow; i++) { + for (let j = 0; j < maxTilesPerColumn; j++) { + const x = i * patternWidth + offsetX; + const y = j * patternHeight + offsetY; + + page.drawText(watermarkText, { + x, + y, + size: fontSize, + font, + color: hexToRgb(config.color) ?? rgb(0, 0, 0), + opacity: config.opacity, + rotate: degrees(config.rotation), + }); + } + } + } else { + const [x, y] = getPositionCoordinates( + config.position, + width, + height, + textWidth, + textHeight, + ); + + page.drawText(watermarkText, { + x, + y, + size: fontSize, + font, + color: hexToRgb(config.color) ?? rgb(0, 0, 0), + opacity: config.opacity, + rotate: degrees(config.rotation), + }); + } +} + +export default async (req: NextApiRequest, res: NextApiResponse) => { + // check if post method + if (req.method !== "POST") { + res.status(405).json({ error: "Method Not Allowed" }); + return; + } + + // // Extract the API Key from the Authorization header + // const authHeader = req.headers.authorization; + // const token = authHeader?.split(" ")[1]; // Assuming the format is "Bearer [token]" + + // // Check if the API Key matches + // if (token !== process.env.INTERNAL_API_KEY) { + // res.status(401).json({ message: "Unauthorized" }); + // return; + // } + + const { url, watermarkConfig, viewerData, numPages } = req.body as { + url: string; + watermarkConfig: WatermarkConfig; + viewerData: ViewerData; + numPages: number; + }; + + try { + // Fetch the PDF data + let response: Response; + try { + response = await fetch(url); + } catch (error) { + log({ + message: `Failed to fetch PDF in conversion process with error: \n\n Error: ${error}`, + type: "error", + mention: true, + }); + throw new Error(`Failed to fetch pdf`); + } + + // Convert the response to a buffer + const pdfBuffer = await response.arrayBuffer(); + + // Load the PDF document + const pdfDoc = await PDFDocument.load(pdfBuffer); + + // Register fontkit + pdfDoc.registerFontkit(fontkit); + + // Add watermark to each page + for (let i = 0; i < numPages; i++) { + await insertWatermark(pdfDoc, watermarkConfig, viewerData, i); + } + + // Save the modified PDF + const pdfBytes = await pdfDoc.save(); + + // Set appropriate headers + res.setHeader("Content-Type", "application/pdf"); + res.setHeader( + "Content-Disposition", + 'attachment; filename="watermarked.pdf"', + ); + + res.status(200).send(Buffer.from(pdfBytes)); + + return; + } catch (error) { + log({ + message: `Failed to convert page with error: \n\n Error: ${error}`, + type: "error", + mention: true, + }); + throw error; + } +}; diff --git a/pages/api/views-dataroom.ts b/pages/api/views-dataroom.ts index 015e8e1c7..f23ada27d 100644 --- a/pages/api/views-dataroom.ts +++ b/pages/api/views-dataroom.ts @@ -3,7 +3,6 @@ import { NextApiRequest, NextApiResponse } from "next"; import { waitUntil } from "@vercel/functions"; import { getServerSession } from "next-auth/next"; import { parsePageId } from "notion-utils"; -import { record } from "zod"; import sendNotification from "@/lib/api/notification-helper"; import { sendVerificationEmail } from "@/lib/emails/send-email-verification"; @@ -12,8 +11,9 @@ import { newId } from "@/lib/id-helper"; import notion from "@/lib/notion"; import prisma from "@/lib/prisma"; import { parseSheet } from "@/lib/sheet"; -import { CustomUser } from "@/lib/types"; +import { CustomUser, WatermarkConfigSchema } from "@/lib/types"; import { checkPassword, decryptEncrpytedPassword, log } from "@/lib/utils"; +import { getIpAddress } from "@/lib/utils/ip"; import { authOptions } from "./auth/[...nextauth]"; @@ -99,6 +99,8 @@ export default async function handle( denyList: true, enableAgreement: true, agreementId: true, + enableWatermark: true, + watermarkConfig: true, }, }); @@ -518,6 +520,15 @@ export default async function handle( : recordMap ? "notion" : undefined, + watermarkConfig: link.enableWatermark ? link.watermarkConfig : undefined, + ipAddress: + link.enableWatermark && + link.watermarkConfig && + WatermarkConfigSchema.parse(link.watermarkConfig).text.includes( + "{{ipAddress}}", + ) + ? getIpAddress(req.headers) + : undefined, }; return res.status(200).json(returnObject); diff --git a/pages/api/views.ts b/pages/api/views.ts index 43a90a07e..f29d15a85 100644 --- a/pages/api/views.ts +++ b/pages/api/views.ts @@ -9,8 +9,9 @@ import { getFile } from "@/lib/files/get-file"; import { newId } from "@/lib/id-helper"; import prisma from "@/lib/prisma"; import { parseSheet } from "@/lib/sheet"; -import { CustomUser } from "@/lib/types"; +import { CustomUser, WatermarkConfigSchema } from "@/lib/types"; import { checkPassword, decryptEncrpytedPassword, log } from "@/lib/utils"; +import { getIpAddress } from "@/lib/utils/ip"; import { authOptions } from "./auth/[...nextauth]"; @@ -86,6 +87,8 @@ export default async function handle( denyList: true, enableAgreement: true, agreementId: true, + enableWatermark: true, + watermarkConfig: true, }, }); @@ -400,6 +403,15 @@ export default async function handle( : documentPages ? "pdf" : undefined, + watermarkConfig: link.enableWatermark ? link.watermarkConfig : undefined, + ipAddress: + link.enableWatermark && + link.watermarkConfig && + WatermarkConfigSchema.parse(link.watermarkConfig).text.includes( + "{{ipAddress}}", + ) + ? getIpAddress(req.headers) + : undefined, }; return res.status(200).json(returnObject); diff --git a/prisma/migrations/20240830000000_add_watermarks/migration.sql b/prisma/migrations/20240830000000_add_watermarks/migration.sql new file mode 100644 index 000000000..a421fb72b --- /dev/null +++ b/prisma/migrations/20240830000000_add_watermarks/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Link" ADD COLUMN "enableWatermark" BOOLEAN DEFAULT false, +ADD COLUMN "watermarkConfig" JSONB; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f40fda756..e125b1bc5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -231,6 +231,8 @@ model Link { agreement Agreement? @relation(fields: [agreementId], references: [id], onDelete: SetNull) agreementId String? // This can be nullable, representing links without agreements showBanner Boolean? @default(false) // Optional give user a option to show the banner and end of document signup form + enableWatermark Boolean? @default(false) // Optional give user a option to enable the watermark + watermarkConfig Json? // This will store the watermark configuration: {text: "Confidential", isTiled: false, color: "#000000", fontSize: 12, opacity: 0.5, rotation: 30, position: "top-right"} // custom metatags metaTitle String? // This will be the meta title of the link