Add watermark to public trust center documents

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-09-11 17:37:56 +02:00
parent b06bd113f3
commit c3ea7a3cd0
11 changed files with 558 additions and 184 deletions

View File

@@ -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<string, never>;
interface ExportReportPDFVariables {
input: {
reportId: string;
};
}
type GraphQLVariables = TrustCenterQueryVariables | ExportDocumentPDFVariables | ExportReportPDFVariables | CreateTrustCenterAccessVariables | AcceptNonDisclosureAgreementVariables | Record<string, never>;
async function trustCenterGraphQLRequest<T = unknown>(
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<ExportReportPDFData, Error, string>({
mutationFn: async (reportId: string) => {
const result = await trustCenterGraphQLRequest<ExportReportPDFData>(
"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<CreateTrustCenterAccessData, Error, { trustCenterId: string; email: string; name: string }>({
mutationFn: async (input: { trustCenterId: string; email: string; name: string }) => {

View File

@@ -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<TrustCenterAudit["report"]>) => {
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({
<Tbody>
{audits.map((audit) => {
const hasReport = audit.report !== null;
const downloadUrl = audit.report?.downloadUrl;
const reportName = audit.report?.filename || __("Compliance Report");
return (
<Tr key={audit.id}>
@@ -102,25 +127,15 @@ export function PublicTrustCenterAudits({
trustCenterId={trustCenterId}
organizationName={organizationName}
/>
) : downloadUrl ? (
) : (
<Button
variant="secondary"
icon={IconArrowDown}
onClick={() => {
const link = document.createElement('a');
link.href = downloadUrl;
link.download = reportName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}
onClick={() => handleDownload(audit.report!)}
disabled={mutation.isPending}
>
{__("Download")}
{mutation.isPending ? __("Downloading...") : __("Download")}
</Button>
) : (
<span className="text-txt-tertiary text-sm">
{__("Not available")}
</span>
)}
</Td>
</Tr>

6
go.mod
View File

@@ -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
)

14
go.sum
View File

@@ -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=

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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() {}

View File

@@ -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 }

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// 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
}