From c3ea7a3cd033b29529aa9c8a18a719013b3a1e65 Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Thu, 11 Sep 2025 17:37:56 +0200 Subject: [PATCH] Add watermark to public trust center documents Signed-off-by: Sacha Al Himdani --- .../src/hooks/useTrustCenterQueries.ts | 50 ++- .../components/PublicTrustCenterAudits.tsx | 47 +- go.mod | 6 + go.sum | 14 +- pkg/server/api/trust/v1/schema.graphql | 13 +- pkg/server/api/trust/v1/schema/schema.go | 407 ++++++++++++------ pkg/server/api/trust/v1/types/types.go | 13 +- pkg/server/api/trust/v1/v1_resolver.go | 90 ++-- pkg/trust/document_service.go | 10 +- pkg/trust/report_service.go | 34 ++ pkg/watermarkpdf/watermarkpdf.go | 58 +++ 11 files changed, 558 insertions(+), 184 deletions(-) create mode 100644 pkg/watermarkpdf/watermarkpdf.go diff --git a/apps/console/src/hooks/useTrustCenterQueries.ts b/apps/console/src/hooks/useTrustCenterQueries.ts index 22698340a..3b5c1a216 100644 --- a/apps/console/src/hooks/useTrustCenterQueries.ts +++ b/apps/console/src/hooks/useTrustCenterQueries.ts @@ -16,7 +16,6 @@ export interface TrustCenterAudit { report: { id: string; filename: string; - downloadUrl: string | null; } | null; } @@ -71,6 +70,12 @@ interface ExportDocumentPDFData { }; } +interface ExportReportPDFData { + exportReportPDF: { + data: string; + }; +} + interface CreateTrustCenterAccessData { createTrustCenterAccess: { trustCenterAccess: { @@ -111,7 +116,13 @@ interface AcceptNonDisclosureAgreementVariables { }; } -type GraphQLVariables = TrustCenterQueryVariables | ExportDocumentPDFVariables | CreateTrustCenterAccessVariables | AcceptNonDisclosureAgreementVariables | Record; +interface ExportReportPDFVariables { + input: { + reportId: string; + }; +} + +type GraphQLVariables = TrustCenterQueryVariables | ExportDocumentPDFVariables | ExportReportPDFVariables | CreateTrustCenterAccessVariables | AcceptNonDisclosureAgreementVariables | Record; async function trustCenterGraphQLRequest( operationName: string, @@ -189,7 +200,6 @@ const TRUST_CENTER_QUERY = ` report { id filename - downloadUrl } } } @@ -219,6 +229,16 @@ const EXPORT_DOCUMENT_PDF_MUTATION = ` } `; +const EXPORT_REPORT_PDF_MUTATION = ` + mutation PublicTrustCenterAuditsExportReportPDFMutation( + $input: ExportReportPDFInput! + ) { + exportReportPDF(input: $input) { + data + } + } +`; + const CREATE_TRUST_CENTER_ACCESS_MUTATION = ` mutation PublicTrustCenterAccessRequestDialogMutation( $input: CreateTrustCenterAccessInput! @@ -304,6 +324,30 @@ export function useExportDocumentPDF() { }); } +export function useExportReportPDF() { + return useMutation({ + mutationFn: async (reportId: string) => { + const result = await trustCenterGraphQLRequest( + "PublicTrustCenterAuditsExportReportPDFMutation", + EXPORT_REPORT_PDF_MUTATION, + { input: { reportId } } + ); + + if (result.errors && result.errors.length > 0) { + throw new Error( + `GraphQL error: ${result.errors.map((e) => e.message).join(", ")}` + ); + } + + if (!result.data) { + throw new Error("No data returned from mutation"); + } + + return result.data; + }, + }); +} + export function useCreateTrustCenterAccess() { return useMutation({ mutationFn: async (input: { trustCenterId: string; email: string; name: string }) => { diff --git a/apps/console/src/trust/components/PublicTrustCenterAudits.tsx b/apps/console/src/trust/components/PublicTrustCenterAudits.tsx index 00feb7d36..650611723 100644 --- a/apps/console/src/trust/components/PublicTrustCenterAudits.tsx +++ b/apps/console/src/trust/components/PublicTrustCenterAudits.tsx @@ -9,11 +9,13 @@ import { Button, IconArrowDown, IconLock, + useToast, } from "@probo/ui"; import { useTranslate } from "@probo/i18n"; import { sprintf } from "@probo/helpers"; import { FrameworkLogo } from "/components/FrameworkLogo"; import { PublicTrustCenterAccessRequestDialog } from "./PublicTrustCenterAccessRequestDialog"; +import { useExportReportPDF } from "../../hooks/useTrustCenterQueries"; import type { TrustCenterAudit } from "../pages/PublicTrustCenterPage"; type Props = { @@ -30,6 +32,31 @@ export function PublicTrustCenterAudits({ trustCenterId }: Props) { const { __ } = useTranslate(); + const { toast } = useToast(); + + const mutation = useExportReportPDF(); + + const handleDownload = (report: NonNullable) => { + mutation.mutate(report.id, { + onSuccess: (data) => { + if (data.exportReportPDF?.data) { + const link = window.document.createElement("a"); + link.href = data.exportReportPDF.data; + link.download = `${report.filename}`; + window.document.body.appendChild(link); + link.click(); + window.document.body.removeChild(link); + } + }, + onError: () => { + toast({ + title: __("Download Failed"), + description: __("Unable to download the report. Please try again."), + variant: "error", + }); + }, + }); + }; if (audits.length === 0) { return ( @@ -67,8 +94,6 @@ export function PublicTrustCenterAudits({ {audits.map((audit) => { const hasReport = audit.report !== null; - const downloadUrl = audit.report?.downloadUrl; - const reportName = audit.report?.filename || __("Compliance Report"); return ( @@ -102,25 +127,15 @@ export function PublicTrustCenterAudits({ trustCenterId={trustCenterId} organizationName={organizationName} /> - ) : downloadUrl ? ( + ) : ( - ) : ( - - {__("Not available")} - )} diff --git a/go.mod b/go.mod index 5f0a36f16..bca18acf8 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/jackc/pgx/v5 v5.7.5 github.com/jhillyerd/enmime v1.3.0 github.com/openai/openai-go v1.8.2 + github.com/pdfcpu/pdfcpu v0.11.0 github.com/prometheus/client_golang v1.22.0 github.com/stretchr/testify v1.10.0 github.com/vektah/gqlparser/v2 v2.5.30 @@ -61,6 +62,9 @@ require ( github.com/gorilla/websocket v1.5.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/hhrutter/lzw v1.0.0 // indirect + github.com/hhrutter/pkcs7 v0.2.0 // indirect + github.com/hhrutter/tiff v1.0.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -96,6 +100,7 @@ require ( go.opentelemetry.io/otel/sdk v1.37.0 // indirect go.opentelemetry.io/proto/otlp v1.7.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/image v0.27.0 // indirect golang.org/x/mod v0.25.0 // indirect golang.org/x/net v0.41.0 // indirect golang.org/x/sync v0.15.0 // indirect @@ -106,6 +111,7 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/grpc v1.73.0 // indirect google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect sigs.k8s.io/yaml v1.5.0 // indirect ) diff --git a/go.sum b/go.sum index 4050b28e3..b5514959e 100644 --- a/go.sum +++ b/go.sum @@ -92,6 +92,12 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+u github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= +github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= +github.com/hhrutter/pkcs7 v0.2.0 h1:i4HN2XMbGQpZRnKBLsUwO3dSckzgX142TNqY/KfXg+I= +github.com/hhrutter/pkcs7 v0.2.0/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE= +github.com/hhrutter/tiff v1.0.2 h1:7H3FQQpKu/i5WaSChoD1nnJbGx4MxU5TlNqqpxw55z8= +github.com/hhrutter/tiff v1.0.2/go.mod h1:pcOeuK5loFUE7Y/WnzGw20YxUdnqjY1P0Jlcieb/cCw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -125,6 +131,8 @@ github.com/openai/openai-go v1.8.2 h1:UqSkJ1vCOPUpz9Ka5tS0324EJFEuOvMc+lA/EarJWP github.com/openai/openai-go v1.8.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +github.com/pdfcpu/pdfcpu v0.11.0 h1:mL18Y3hSHzSezmnrzA21TqlayBOXuAx7BUzzZyroLGM= +github.com/pdfcpu/pdfcpu v0.11.0/go.mod h1:F1ca4GIVFdPtmgvIdvXAycAm88noyNxZwzr9CpTy+Mw= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -222,8 +230,8 @@ go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= -golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= -golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= +golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= +golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= @@ -248,6 +256,8 @@ google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/server/api/trust/v1/schema.graphql b/pkg/server/api/trust/v1/schema.graphql index 34a7cf75b..273569ea9 100644 --- a/pkg/server/api/trust/v1/schema.graphql +++ b/pkg/server/api/trust/v1/schema.graphql @@ -73,7 +73,6 @@ type Framework implements Node { type Report implements Node { id: ID! filename: String! - downloadUrl: String @goField(forceResolver: true) @mustBeAuthenticated(role: USER) } type Audit implements Node { @@ -249,6 +248,10 @@ input ExportDocumentPDFInput { documentId: ID! } +input ExportReportPDFInput { + reportId: ID! +} + input AcceptNonDisclosureAgreementInput { trustCenterId: ID! } @@ -257,6 +260,10 @@ type ExportDocumentPDFPayload { data: String! } +type ExportReportPDFPayload { + data: String! +} + type AcceptNonDisclosureAgreementPayload{ success: Boolean! } @@ -274,6 +281,10 @@ type Mutation { input: ExportDocumentPDFInput! ): ExportDocumentPDFPayload! @mustBeAuthenticated(role: USER) + exportReportPDF( + input: ExportReportPDFInput! + ): ExportReportPDFPayload! @mustBeAuthenticated(role: USER) + acceptNonDisclosureAgreement( input: AcceptNonDisclosureAgreementInput! ): AcceptNonDisclosureAgreementPayload! @mustBeAuthenticated(role: USER) diff --git a/pkg/server/api/trust/v1/schema/schema.go b/pkg/server/api/trust/v1/schema/schema.go index 66c982bcd..31f991c4b 100644 --- a/pkg/server/api/trust/v1/schema/schema.go +++ b/pkg/server/api/trust/v1/schema/schema.go @@ -48,7 +48,6 @@ type ResolverRoot interface { Mutation() MutationResolver Organization() OrganizationResolver Query() QueryResolver - Report() ReportResolver TrustCenter() TrustCenterResolver } @@ -101,6 +100,10 @@ type ComplexityRoot struct { Data func(childComplexity int) int } + ExportReportPDFPayload struct { + Data func(childComplexity int) int + } + Framework struct { ID func(childComplexity int) int Name func(childComplexity int) int @@ -110,6 +113,7 @@ type ComplexityRoot struct { AcceptNonDisclosureAgreement func(childComplexity int, input types.AcceptNonDisclosureAgreementInput) int CreateTrustCenterAccess func(childComplexity int, input types.CreateTrustCenterAccessInput) int ExportDocumentPDF func(childComplexity int, input types.ExportDocumentPDFInput) int + ExportReportPDF func(childComplexity int, input types.ExportReportPDFInput) int } Organization struct { @@ -130,9 +134,8 @@ type ComplexityRoot struct { } Report struct { - DownloadURL func(childComplexity int) int - Filename func(childComplexity int) int - ID func(childComplexity int) int + Filename func(childComplexity int) int + ID func(childComplexity int) int } TrustCenter struct { @@ -183,6 +186,7 @@ type AuditResolver interface { type MutationResolver interface { CreateTrustCenterAccess(ctx context.Context, input types.CreateTrustCenterAccessInput) (*types.CreateTrustCenterAccessPayload, error) ExportDocumentPDF(ctx context.Context, input types.ExportDocumentPDFInput) (*types.ExportDocumentPDFPayload, error) + ExportReportPDF(ctx context.Context, input types.ExportReportPDFInput) (*types.ExportReportPDFPayload, error) AcceptNonDisclosureAgreement(ctx context.Context, input types.AcceptNonDisclosureAgreementInput) (*types.AcceptNonDisclosureAgreementPayload, error) } type OrganizationResolver interface { @@ -191,9 +195,6 @@ type OrganizationResolver interface { type QueryResolver interface { TrustCenterBySlug(ctx context.Context, slug string) (*types.TrustCenter, error) } -type ReportResolver interface { - DownloadURL(ctx context.Context, obj *types.Report) (*string, error) -} type TrustCenterResolver interface { NdaFileURL(ctx context.Context, obj *types.TrustCenter) (*string, error) Organization(ctx context.Context, obj *types.TrustCenter) (*types.Organization, error) @@ -342,6 +343,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.ExportDocumentPDFPayload.Data(childComplexity), true + case "ExportReportPDFPayload.data": + if e.complexity.ExportReportPDFPayload.Data == nil { + break + } + + return e.complexity.ExportReportPDFPayload.Data(childComplexity), true + case "Framework.id": if e.complexity.Framework.ID == nil { break @@ -392,6 +400,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Mutation.ExportDocumentPDF(childComplexity, args["input"].(types.ExportDocumentPDFInput)), true + case "Mutation.exportReportPDF": + if e.complexity.Mutation.ExportReportPDF == nil { + break + } + + args, err := ec.field_Mutation_exportReportPDF_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.ExportReportPDF(childComplexity, args["input"].(types.ExportReportPDFInput)), true + case "Organization.id": if e.complexity.Organization.ID == nil { break @@ -453,13 +473,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Query.TrustCenterBySlug(childComplexity, args["slug"].(string)), true - case "Report.downloadUrl": - if e.complexity.Report.DownloadURL == nil { - break - } - - return e.complexity.Report.DownloadURL(childComplexity), true - case "Report.filename": if e.complexity.Report.Filename == nil { break @@ -675,6 +688,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputAcceptNonDisclosureAgreementInput, ec.unmarshalInputCreateTrustCenterAccessInput, ec.unmarshalInputExportDocumentPDFInput, + ec.unmarshalInputExportReportPDFInput, ) first := true @@ -847,7 +861,6 @@ type Framework implements Node { type Report implements Node { id: ID! filename: String! - downloadUrl: String @goField(forceResolver: true) @mustBeAuthenticated(role: USER) } type Audit implements Node { @@ -1023,6 +1036,10 @@ input ExportDocumentPDFInput { documentId: ID! } +input ExportReportPDFInput { + reportId: ID! +} + input AcceptNonDisclosureAgreementInput { trustCenterId: ID! } @@ -1031,6 +1048,10 @@ type ExportDocumentPDFPayload { data: String! } +type ExportReportPDFPayload { + data: String! +} + type AcceptNonDisclosureAgreementPayload{ success: Boolean! } @@ -1048,6 +1069,10 @@ type Mutation { input: ExportDocumentPDFInput! ): ExportDocumentPDFPayload! @mustBeAuthenticated(role: USER) + exportReportPDF( + input: ExportReportPDFInput! + ): ExportReportPDFPayload! @mustBeAuthenticated(role: USER) + acceptNonDisclosureAgreement( input: AcceptNonDisclosureAgreementInput! ): AcceptNonDisclosureAgreementPayload! @mustBeAuthenticated(role: USER) @@ -1157,6 +1182,29 @@ func (ec *executionContext) field_Mutation_exportDocumentPDF_argsInput( return zeroVal, nil } +func (ec *executionContext) field_Mutation_exportReportPDF_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Mutation_exportReportPDF_argsInput(ctx, rawArgs) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_exportReportPDF_argsInput( + ctx context.Context, + rawArgs map[string]any, +) (types.ExportReportPDFInput, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + if tmp, ok := rawArgs["input"]; ok { + return ec.unmarshalNExportReportPDFInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋtrustᚋv1ᚋtypesᚐExportReportPDFInput(ctx, tmp) + } + + var zeroVal types.ExportReportPDFInput + return zeroVal, nil +} + func (ec *executionContext) field_Query___type_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1712,8 +1760,6 @@ func (ec *executionContext) fieldContext_Audit_report(_ context.Context, field g return ec.fieldContext_Report_id(ctx, field) case "filename": return ec.fieldContext_Report_filename(ctx, field) - case "downloadUrl": - return ec.fieldContext_Report_downloadUrl(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Report", field.Name) }, @@ -2353,6 +2399,50 @@ func (ec *executionContext) fieldContext_ExportDocumentPDFPayload_data(_ context return fc, nil } +func (ec *executionContext) _ExportReportPDFPayload_data(ctx context.Context, field graphql.CollectedField, obj *types.ExportReportPDFPayload) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_ExportReportPDFPayload_data(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Data, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_ExportReportPDFPayload_data(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ExportReportPDFPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Framework_id(ctx context.Context, field graphql.CollectedField, obj *types.Framework) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Framework_id(ctx, field) if err != nil { @@ -2613,6 +2703,92 @@ func (ec *executionContext) fieldContext_Mutation_exportDocumentPDF(ctx context. return fc, nil } +func (ec *executionContext) _Mutation_exportReportPDF(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_exportReportPDF(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + directive0 := func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().ExportReportPDF(rctx, fc.Args["input"].(types.ExportReportPDFInput)) + } + + directive1 := func(ctx context.Context) (any, error) { + role, err := ec.unmarshalORole2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋtrustᚋv1ᚋtypesᚐRole(ctx, "USER") + if err != nil { + var zeroVal *types.ExportReportPDFPayload + return zeroVal, err + } + if ec.directives.MustBeAuthenticated == nil { + var zeroVal *types.ExportReportPDFPayload + return zeroVal, errors.New("directive mustBeAuthenticated is not implemented") + } + return ec.directives.MustBeAuthenticated(ctx, nil, directive0, role) + } + + tmp, err := directive1(rctx) + if err != nil { + return nil, graphql.ErrorOnPath(ctx, err) + } + if tmp == nil { + return nil, nil + } + if data, ok := tmp.(*types.ExportReportPDFPayload); ok { + return data, nil + } + return nil, fmt.Errorf(`unexpected type %T from directive, should be *github.com/getprobo/probo/pkg/server/api/trust/v1/types.ExportReportPDFPayload`, tmp) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*types.ExportReportPDFPayload) + fc.Result = res + return ec.marshalNExportReportPDFPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋtrustᚋv1ᚋtypesᚐExportReportPDFPayload(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_exportReportPDF(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "data": + return ec.fieldContext_ExportReportPDFPayload_data(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type ExportReportPDFPayload", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_exportReportPDF_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_acceptNonDisclosureAgreement(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation_acceptNonDisclosureAgreement(ctx, field) if err != nil { @@ -3320,74 +3496,6 @@ func (ec *executionContext) fieldContext_Report_filename(_ context.Context, fiel return fc, nil } -func (ec *executionContext) _Report_downloadUrl(ctx context.Context, field graphql.CollectedField, obj *types.Report) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Report_downloadUrl(ctx, field) - if err != nil { - return graphql.Null - } - ctx = graphql.WithFieldContext(ctx, fc) - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - ret = graphql.Null - } - }() - resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { - directive0 := func(rctx context.Context) (any, error) { - ctx = rctx // use context from middleware stack in children - return ec.resolvers.Report().DownloadURL(rctx, obj) - } - - directive1 := func(ctx context.Context) (any, error) { - role, err := ec.unmarshalORole2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋtrustᚋv1ᚋtypesᚐRole(ctx, "USER") - if err != nil { - var zeroVal *string - return zeroVal, err - } - if ec.directives.MustBeAuthenticated == nil { - var zeroVal *string - return zeroVal, errors.New("directive mustBeAuthenticated is not implemented") - } - return ec.directives.MustBeAuthenticated(ctx, obj, directive0, role) - } - - tmp, err := directive1(rctx) - if err != nil { - return nil, graphql.ErrorOnPath(ctx, err) - } - if tmp == nil { - return nil, nil - } - if data, ok := tmp.(*string); ok { - return data, nil - } - return nil, fmt.Errorf(`unexpected type %T from directive, should be *string`, tmp) - }) - if err != nil { - ec.Error(ctx, err) - return graphql.Null - } - if resTmp == nil { - return graphql.Null - } - res := resTmp.(*string) - fc.Result = res - return ec.marshalOString2ᚖstring(ctx, field.Selections, res) -} - -func (ec *executionContext) fieldContext_Report_downloadUrl(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "Report", - Field: field, - IsMethod: true, - IsResolver: true, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type String does not have child fields") - }, - } - return fc, nil -} - func (ec *executionContext) _TrustCenter_id(ctx context.Context, field graphql.CollectedField, obj *types.TrustCenter) (ret graphql.Marshaler) { fc, err := ec.fieldContext_TrustCenter_id(ctx, field) if err != nil { @@ -6609,6 +6717,33 @@ func (ec *executionContext) unmarshalInputExportDocumentPDFInput(ctx context.Con return it, nil } +func (ec *executionContext) unmarshalInputExportReportPDFInput(ctx context.Context, obj any) (types.ExportReportPDFInput, error) { + var it types.ExportReportPDFInput + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"reportId"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "reportId": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("reportId")) + data, err := ec.unmarshalNID2githubᚗcomᚋgetproboᚋproboᚋpkgᚋgidᚐGID(ctx, v) + if err != nil { + return it, err + } + it.ReportID = data + } + } + + return it, nil +} + // endregion **************************** input.gotpl ***************************** // region ************************** interface.gotpl *************************** @@ -7132,6 +7267,45 @@ func (ec *executionContext) _ExportDocumentPDFPayload(ctx context.Context, sel a return out } +var exportReportPDFPayloadImplementors = []string{"ExportReportPDFPayload"} + +func (ec *executionContext) _ExportReportPDFPayload(ctx context.Context, sel ast.SelectionSet, obj *types.ExportReportPDFPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, exportReportPDFPayloadImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ExportReportPDFPayload") + case "data": + out.Values[i] = ec._ExportReportPDFPayload_data(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var frameworkImplementors = []string{"Framework", "Node"} func (ec *executionContext) _Framework(ctx context.Context, sel ast.SelectionSet, obj *types.Framework) graphql.Marshaler { @@ -7209,6 +7383,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "exportReportPDF": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_exportReportPDF(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "acceptNonDisclosureAgreement": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_acceptNonDisclosureAgreement(ctx, field) @@ -7447,46 +7628,13 @@ func (ec *executionContext) _Report(ctx context.Context, sel ast.SelectionSet, o case "id": out.Values[i] = ec._Report_id(ctx, field, obj) if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) + out.Invalids++ } case "filename": out.Values[i] = ec._Report_filename(ctx, field, obj) if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) + out.Invalids++ } - case "downloadUrl": - field := field - - innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - } - }() - res = ec._Report_downloadUrl(ctx, field, obj) - return res - } - - if field.Deferrable != nil { - dfs, ok := deferred[field.Deferrable.Label] - di := 0 - if ok { - dfs.AddField(field) - di = len(dfs.Values) - 1 - } else { - dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) - deferred[field.Deferrable.Label] = dfs - } - dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { - return innerFunc(ctx, dfs) - }) - - // don't run the out.Concurrently() call below - out.Values[i] = graphql.Null - continue - } - - out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -8636,6 +8784,25 @@ func (ec *executionContext) marshalNExportDocumentPDFPayload2ᚖgithubᚗcomᚋg return ec._ExportDocumentPDFPayload(ctx, sel, v) } +func (ec *executionContext) unmarshalNExportReportPDFInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋtrustᚋv1ᚋtypesᚐExportReportPDFInput(ctx context.Context, v any) (types.ExportReportPDFInput, error) { + res, err := ec.unmarshalInputExportReportPDFInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNExportReportPDFPayload2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋtrustᚋv1ᚋtypesᚐExportReportPDFPayload(ctx context.Context, sel ast.SelectionSet, v types.ExportReportPDFPayload) graphql.Marshaler { + return ec._ExportReportPDFPayload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNExportReportPDFPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋtrustᚋv1ᚋtypesᚐExportReportPDFPayload(ctx context.Context, sel ast.SelectionSet, v *types.ExportReportPDFPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._ExportReportPDFPayload(ctx, sel, v) +} + func (ec *executionContext) marshalNFramework2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋtrustᚋv1ᚋtypesᚐFramework(ctx context.Context, sel ast.SelectionSet, v types.Framework) graphql.Marshaler { return ec._Framework(ctx, sel, &v) } diff --git a/pkg/server/api/trust/v1/types/types.go b/pkg/server/api/trust/v1/types/types.go index d2680de58..ec65bcfae 100644 --- a/pkg/server/api/trust/v1/types/types.go +++ b/pkg/server/api/trust/v1/types/types.go @@ -83,6 +83,14 @@ type ExportDocumentPDFPayload struct { Data string `json:"data"` } +type ExportReportPDFInput struct { + ReportID gid.GID `json:"reportId"` +} + +type ExportReportPDFPayload struct { + Data string `json:"data"` +} + type Framework struct { ID gid.GID `json:"id"` Name string `json:"name"` @@ -114,9 +122,8 @@ type Query struct { } type Report struct { - ID gid.GID `json:"id"` - Filename string `json:"filename"` - DownloadURL *string `json:"downloadUrl,omitempty"` + ID gid.GID `json:"id"` + Filename string `json:"filename"` } func (Report) IsNode() {} diff --git a/pkg/server/api/trust/v1/v1_resolver.go b/pkg/server/api/trust/v1/v1_resolver.go index b7749001b..24bce1a1a 100644 --- a/pkg/server/api/trust/v1/v1_resolver.go +++ b/pkg/server/api/trust/v1/v1_resolver.go @@ -105,7 +105,15 @@ func (r *mutationResolver) ExportDocumentPDF(ctx context.Context, input types.Ex return nil, fmt.Errorf("user has not accepted NDA") } - pdf, err := privateTrustService.Documents.ExportPDF(ctx, input.DocumentID) + userEmail := "" + if userData != nil { + userEmail = userData.EmailAddress + } + if tokenData != nil { + userEmail = tokenData.GetEmail() + } + + pdf, err := privateTrustService.Documents.ExportPDF(ctx, input.DocumentID, userEmail) if err != nil { panic(fmt.Errorf("cannot export document PDF: %w", err)) } @@ -115,6 +123,49 @@ func (r *mutationResolver) ExportDocumentPDF(ctx context.Context, input types.Ex }, nil } +// ExportReportPDF is the resolver for the exportReportPDF field. +func (r *mutationResolver) ExportReportPDF(ctx context.Context, input types.ExportReportPDFInput) (*types.ExportReportPDFPayload, error) { + privateTrustService, err := r.PrivateTrustService(ctx, input.ReportID.TenantID()) + if err != nil { + return nil, fmt.Errorf("cannot export report PDF: %w", err) + } + + hasAcceptedNDA := false + userData := r.UserFromContext(ctx) + if userData != nil { + hasAcceptedNDA = true + } + + tokenData := TokenAccessFromContext(ctx) + if tokenData != nil { + hasAcceptedNDA, err = privateTrustService.TrustCenterAccesses.HasAcceptedNonDisclosureAgreement(ctx, tokenData.TrustCenterID, tokenData.GetEmail()) + if err != nil { + panic(fmt.Errorf("cannot check if user has accepted NDA: %w", err)) + } + } + + if !hasAcceptedNDA { + return nil, fmt.Errorf("user has not accepted NDA") + } + + userEmail := "" + if userData != nil { + userEmail = userData.EmailAddress + } + if tokenData != nil { + userEmail = tokenData.GetEmail() + } + + pdf, err := privateTrustService.Reports.ExportPDF(ctx, input.ReportID, userEmail) + if err != nil { + panic(fmt.Errorf("cannot export report PDF: %w", err)) + } + + return &types.ExportReportPDFPayload{ + Data: fmt.Sprintf("data:application/pdf;base64,%s", base64.StdEncoding.EncodeToString(pdf)), + }, nil +} + // AcceptNonDisclosureAgreement is the resolver for the acceptNonDisclosureAgreement field. func (r *mutationResolver) AcceptNonDisclosureAgreement(ctx context.Context, input types.AcceptNonDisclosureAgreementInput) (*types.AcceptNonDisclosureAgreementPayload, error) { privateTrustService, err := r.PrivateTrustService(ctx, input.TrustCenterID.TenantID()) @@ -171,39 +222,6 @@ func (r *queryResolver) TrustCenterBySlug(ctx context.Context, slug string) (*ty return response, nil } -// DownloadURL is the resolver for the downloadUrl field. -func (r *reportResolver) DownloadURL(ctx context.Context, obj *types.Report) (*string, error) { - privateTrustService, err := r.PrivateTrustService(ctx, obj.ID.TenantID()) - if err != nil { - return nil, fmt.Errorf("cannot generate download URL: %w", err) - } - - hasAcceptedNDA := false - userData := UserFromContext(ctx) - if userData != nil { - hasAcceptedNDA = true - } - - tokenData := TokenAccessFromContext(ctx) - if tokenData != nil { - hasAcceptedNDA, err = privateTrustService.TrustCenterAccesses.HasAcceptedNonDisclosureAgreement(ctx, tokenData.TrustCenterID, tokenData.GetEmail()) - if err != nil { - panic(fmt.Errorf("cannot check if user has accepted NDA: %w", err)) - } - } - - if !hasAcceptedNDA { - return nil, fmt.Errorf("user has not accepted NDA") - } - - url, err := privateTrustService.Reports.GenerateDownloadURL(ctx, obj.ID, r.trustAuthCfg.ReportURLDuration) - if err != nil { - panic(fmt.Errorf("cannot generate download URL: %w", err)) - } - - return url, nil -} - // NdaFileURL is the resolver for the ndaFileUrl field. func (r *trustCenterResolver) NdaFileURL(ctx context.Context, obj *types.TrustCenter) (*string, error) { privateTrustService, err := r.PrivateTrustService(ctx, obj.ID.TenantID()) @@ -324,9 +342,6 @@ func (r *Resolver) Organization() schema.OrganizationResolver { return &organiza // Query returns schema.QueryResolver implementation. func (r *Resolver) Query() schema.QueryResolver { return &queryResolver{r} } -// Report returns schema.ReportResolver implementation. -func (r *Resolver) Report() schema.ReportResolver { return &reportResolver{r} } - // TrustCenter returns schema.TrustCenterResolver implementation. func (r *Resolver) TrustCenter() schema.TrustCenterResolver { return &trustCenterResolver{r} } @@ -334,5 +349,4 @@ type auditResolver struct{ *Resolver } type mutationResolver struct{ *Resolver } type organizationResolver struct{ *Resolver } type queryResolver struct{ *Resolver } -type reportResolver struct{ *Resolver } type trustCenterResolver struct{ *Resolver } diff --git a/pkg/trust/document_service.go b/pkg/trust/document_service.go index 725ecb707..0332a27df 100644 --- a/pkg/trust/document_service.go +++ b/pkg/trust/document_service.go @@ -24,6 +24,7 @@ import ( "github.com/getprobo/probo/pkg/gid" "github.com/getprobo/probo/pkg/html2pdf" "github.com/getprobo/probo/pkg/page" + "github.com/getprobo/probo/pkg/watermarkpdf" "go.gearno.de/kit/pg" ) @@ -86,6 +87,7 @@ func (s *DocumentService) ListForOrganizationId( func (s *DocumentService) ExportPDF( ctx context.Context, documentID gid.GID, + email string, ) ([]byte, error) { document := &coredata.Document{} version := &coredata.DocumentVersion{} @@ -169,5 +171,11 @@ func (s *DocumentService) ExportPDF( if err != nil { return nil, fmt.Errorf("cannot read PDF data: %w", err) } - return pdfData, nil + + watermarkedPDF, err := watermarkpdf.AddConfidentialWithTimestamp(pdfData, email) + if err != nil { + return nil, fmt.Errorf("cannot add watermark to PDF: %w", err) + } + + return watermarkedPDF, nil } diff --git a/pkg/trust/report_service.go b/pkg/trust/report_service.go index 95d4b2781..d948327ef 100644 --- a/pkg/trust/report_service.go +++ b/pkg/trust/report_service.go @@ -17,12 +17,14 @@ package trust import ( "context" "fmt" + "io" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/getprobo/probo/pkg/coredata" "github.com/getprobo/probo/pkg/gid" + "github.com/getprobo/probo/pkg/watermarkpdf" "go.gearno.de/kit/pg" ) @@ -82,3 +84,35 @@ func (s ReportService) GenerateDownloadURL( return &presignedReq.URL, nil } + +func (s ReportService) ExportPDF( + ctx context.Context, + reportID gid.GID, + email string, +) ([]byte, error) { + report, err := s.Get(ctx, reportID) + if err != nil { + return nil, fmt.Errorf("cannot get report: %w", err) + } + + result, err := s.svc.s3.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(s.svc.bucket), + Key: aws.String(report.ObjectKey), + }) + if err != nil { + return nil, fmt.Errorf("cannot download PDF from S3: %w", err) + } + defer result.Body.Close() + + pdfData, err := io.ReadAll(result.Body) + if err != nil { + return nil, fmt.Errorf("cannot read PDF data: %w", err) + } + + watermarkedPDF, err := watermarkpdf.AddConfidentialWithTimestamp(pdfData, email) + if err != nil { + return nil, fmt.Errorf("cannot add watermark to PDF: %w", err) + } + + return watermarkedPDF, nil +} diff --git a/pkg/watermarkpdf/watermarkpdf.go b/pkg/watermarkpdf/watermarkpdf.go new file mode 100644 index 000000000..8fa888525 --- /dev/null +++ b/pkg/watermarkpdf/watermarkpdf.go @@ -0,0 +1,58 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package watermarkpdf + +import ( + "bytes" + "fmt" + "strings" + "time" + + "github.com/pdfcpu/pdfcpu/pkg/api" + "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model" +) + +func AddConfidentialWithTimestamp(pdfData []byte, email string) ([]byte, error) { + reader := bytes.NewReader(pdfData) + var buf bytes.Buffer + + // Replace email with invisible characters to prevent auto-linking + formattedEmail := strings.ReplaceAll(email, "@", "\u200B@\u200B") + formattedEmail = strings.ReplaceAll(formattedEmail, ".", "\u200B.\u200B") + + watermarkText := strings.Join([]string{ + "Confidential", + formattedEmail, + time.Now().Format("02/01/2006"), + }, "\n") + + watermarkConf := model.DefaultWatermarkConfig() + watermarkConf.Mode = model.WMText + watermarkConf.TextString = watermarkText + watermarkConf.FontName = "Helvetica" + watermarkConf.FontSize = 120 + watermarkConf.Rotation = 55 + watermarkConf.Opacity = 0.20 + watermarkConf.OnTop = true + watermarkConf.ScaleAbs = true + watermarkConf.Update = false + + err := api.AddWatermarks(reader, &buf, nil, watermarkConf, nil) + if err != nil { + return nil, fmt.Errorf("failed to add watermark: %w", err) + } + + return buf.Bytes(), nil +}