From 25c590ffe63a64ef891b21ed991437c5296c9d6f Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Fri, 17 Apr 2026 08:15:57 +0200 Subject: [PATCH] Add background PDF generation for published document versions Move PDF generation from synchronous publish flow to a background polling job. Published versions with file_id IS NULL are picked up by the job, which generates the PDF, uploads to S3, and links the file. Export PDF now serves stored files for published versions (with optional signature page and watermark) and generates on the fly for drafts. Signed-off-by: Sacha Al Himdani --- e2e/console/document_version_test.go | 97 ++++++ pkg/coredata/document_version.go | 165 +++++++++-- pkg/coredata/errors.go | 7 +- pkg/coredata/migrations/20260416T160000Z.sql | 20 ++ pkg/docgen/generator.go | 19 ++ pkg/docgen/signature_page_template.html | 97 ++++++ .../watermarkpdf.go => pdfutils/pdfutils.go} | 17 +- pkg/probo/document_pdf_worker.go | 85 ++++++ pkg/probo/document_service.go | 277 ++++++++++++++++-- pkg/probod/probod.go | 15 + pkg/trust/document_service.go | 58 +++- pkg/trust/report_service.go | 4 +- pkg/trust/trust_center_file_service.go | 4 +- 13 files changed, 795 insertions(+), 70 deletions(-) create mode 100644 pkg/coredata/migrations/20260416T160000Z.sql create mode 100644 pkg/docgen/signature_page_template.html rename pkg/{watermarkpdf/watermarkpdf.go => pdfutils/pdfutils.go} (93%) create mode 100644 pkg/probo/document_pdf_worker.go diff --git a/e2e/console/document_version_test.go b/e2e/console/document_version_test.go index 534a07c50..afa3fed04 100644 --- a/e2e/console/document_version_test.go +++ b/e2e/console/document_version_test.go @@ -1483,3 +1483,100 @@ func TestDocumentVersion_DeleteDraft(t *testing.T) { }, ) } + +func TestDocumentVersion_ExportPDFSignatures(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + t.Run( + "can export draft with signatures", + func(t *testing.T) { + t.Parallel() + + _, docVersionID := createTestDocument(t, owner) + + var result struct { + ExportDocumentVersionPDF struct { + Data string `json:"data"` + } `json:"exportDocumentVersionPDF"` + } + + err := owner.Execute(` + mutation ExportPDF($input: ExportDocumentVersionPDFInput!) { + exportDocumentVersionPDF(input: $input) { + data + } + } + `, map[string]any{ + "input": map[string]any{ + "documentVersionId": docVersionID, + "withWatermark": false, + "withSignatures": true, + }, + }, &result) + require.NoError(t, err) + assert.NotEmpty(t, result.ExportDocumentVersionPDF.Data) + }, + ) + + t.Run( + "can export published with signatures", + func(t *testing.T) { + t.Parallel() + + docID, _ := createTestDocument(t, owner) + approveTestDocument(t, owner, docID) + + var versionResult struct { + Node struct { + Versions struct { + Edges []struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"edges"` + } `json:"versions"` + } `json:"node"` + } + + err := owner.Execute(` + query GetVersions($id: ID!) { + node(id: $id) { + ... on Document { + versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { + edges { node { id } } + } + } + } + } + `, map[string]any{"id": docID}, &versionResult) + require.NoError(t, err) + require.NotEmpty(t, versionResult.Node.Versions.Edges) + + publishedVersionID := versionResult.Node.Versions.Edges[0].Node.ID + + var result struct { + ExportDocumentVersionPDF struct { + Data string `json:"data"` + } `json:"exportDocumentVersionPDF"` + } + + err = owner.Execute(` + mutation ExportPDF($input: ExportDocumentVersionPDFInput!) { + exportDocumentVersionPDF(input: $input) { + data + } + } + `, map[string]any{ + "input": map[string]any{ + "documentVersionId": publishedVersionID, + "withWatermark": false, + "withSignatures": true, + }, + }, &result) + require.NoError(t, err) + assert.NotEmpty(t, result.ExportDocumentVersionPDF.Data) + }, + ) +} diff --git a/pkg/coredata/document_version.go b/pkg/coredata/document_version.go index 4e428e737..989ca3c3e 100644 --- a/pkg/coredata/document_version.go +++ b/pkg/coredata/document_version.go @@ -30,21 +30,23 @@ import ( type ( DocumentVersion struct { - ID gid.GID `db:"id"` - OrganizationID gid.GID `db:"organization_id"` - DocumentID gid.GID `db:"document_id"` - Title string `db:"title"` - Major int `db:"major"` - Minor int `db:"minor"` - Classification DocumentClassification `db:"classification"` - DocumentType DocumentType `db:"document_type"` - Content string `db:"content"` - Changelog string `db:"changelog"` - Status DocumentVersionStatus `db:"status"` - Orientation DocumentVersionOrientation `db:"orientation"` - PublishedAt *time.Time `db:"published_at"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + DocumentID gid.GID `db:"document_id"` + Title string `db:"title"` + Major int `db:"major"` + Minor int `db:"minor"` + Classification DocumentClassification `db:"classification"` + DocumentType DocumentType `db:"document_type"` + Content string `db:"content"` + Changelog string `db:"changelog"` + Status DocumentVersionStatus `db:"status"` + Orientation DocumentVersionOrientation `db:"orientation"` + FileID *gid.GID `db:"file_id"` + PdfAttemptCount int `db:"pdf_attempt_count"` + PublishedAt *time.Time `db:"published_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } DocumentVersions []*DocumentVersion @@ -95,6 +97,8 @@ SELECT changelog, status, orientation, + file_id, + pdf_attempt_count, published_at, created_at, updated_at @@ -159,6 +163,8 @@ SELECT changelog, status, orientation, + file_id, + pdf_attempt_count, published_at, created_at, updated_at @@ -215,6 +221,8 @@ INSERT INTO document_versions ( changelog, status, orientation, + file_id, + pdf_attempt_count, published_at, created_at, updated_at @@ -233,28 +241,32 @@ VALUES ( @changelog, @status, @orientation, + @file_id, + @pdf_attempt_count, @published_at, @created_at, @updated_at ) ` args := pgx.StrictNamedArgs{ - "tenant_id": scope.GetTenantID(), - "id": dv.ID, - "organization_id": dv.OrganizationID, - "document_id": dv.DocumentID, - "title": dv.Title, - "major": dv.Major, - "minor": dv.Minor, - "classification": dv.Classification, - "document_type": dv.DocumentType, - "content": dv.Content, - "changelog": dv.Changelog, - "status": dv.Status, - "orientation": dv.Orientation, - "published_at": dv.PublishedAt, - "created_at": dv.CreatedAt, - "updated_at": dv.UpdatedAt, + "tenant_id": scope.GetTenantID(), + "id": dv.ID, + "organization_id": dv.OrganizationID, + "document_id": dv.DocumentID, + "title": dv.Title, + "major": dv.Major, + "minor": dv.Minor, + "classification": dv.Classification, + "document_type": dv.DocumentType, + "content": dv.Content, + "changelog": dv.Changelog, + "status": dv.Status, + "orientation": dv.Orientation, + "file_id": dv.FileID, + "pdf_attempt_count": dv.PdfAttemptCount, + "published_at": dv.PublishedAt, + "created_at": dv.CreatedAt, + "updated_at": dv.UpdatedAt, } _, err := conn.Exec(ctx, q, args) @@ -295,6 +307,8 @@ SELECT changelog, status, orientation, + file_id, + pdf_attempt_count, published_at, created_at, updated_at @@ -355,6 +369,8 @@ SELECT changelog, status, orientation, + file_id, + pdf_attempt_count, published_at, created_at, updated_at @@ -411,6 +427,8 @@ SELECT changelog, status, orientation, + file_id, + pdf_attempt_count, published_at, created_at, updated_at @@ -466,6 +484,8 @@ UPDATE document_versions SET classification = @classification, document_type = @document_type, orientation = @orientation, + file_id = @file_id, + pdf_attempt_count = @pdf_attempt_count, updated_at = @updated_at WHERE %s AND id = @document_version_id @@ -485,6 +505,8 @@ WHERE %s "classification": dv.Classification, "document_type": dv.DocumentType, "orientation": dv.Orientation, + "file_id": dv.FileID, + "pdf_attempt_count": dv.PdfAttemptCount, "updated_at": dv.UpdatedAt, } maps.Copy(args, scope.SQLArguments()) @@ -523,6 +545,87 @@ WHERE %s return nil } +func (dv *DocumentVersion) ClaimNextPublishedWithoutFileForUpdate( + ctx context.Context, + conn pg.Tx, + maxAttempts int, +) error { + q := ` +SELECT + dv.id, + dv.organization_id, + dv.document_id, + dv.title, + dv.major, + dv.minor, + dv.classification, + dv.document_type, + dv.content, + dv.changelog, + dv.status, + dv.orientation, + dv.file_id, + dv.pdf_attempt_count, + dv.published_at, + dv.created_at, + dv.updated_at +FROM + document_versions dv +INNER JOIN + documents d ON d.id = dv.document_id AND d.tenant_id = dv.tenant_id +WHERE + dv.status = 'PUBLISHED' + AND dv.file_id IS NULL + AND dv.pdf_attempt_count < @max_pdf_attempts + AND d.deleted_at IS NULL +ORDER BY dv.created_at ASC +LIMIT 1 +FOR UPDATE OF dv SKIP LOCKED; +` + + rows, err := conn.Query(ctx, q, pgx.StrictNamedArgs{ + "max_pdf_attempts": maxAttempts, + }) + if err != nil { + return fmt.Errorf("cannot query document versions: %w", err) + } + + result, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNoDocumentPDFJobAvailable + } + return fmt.Errorf("cannot collect document version: %w", err) + } + + now := time.Now() + result.PdfAttemptCount++ + result.UpdatedAt = now + + uq := ` +UPDATE document_versions SET + pdf_attempt_count = @pdf_attempt_count, + updated_at = @updated_at +WHERE + tenant_id = @tenant_id + AND id = @id +` + uargs := pgx.StrictNamedArgs{ + "id": result.ID, + "tenant_id": result.ID.TenantID(), + "pdf_attempt_count": result.PdfAttemptCount, + "updated_at": result.UpdatedAt, + } + + if _, err := conn.Exec(ctx, uq, uargs); err != nil { + return fmt.Errorf("cannot mark document version as generating PDF: %w", err) + } + + *dv = result + + return nil +} + func (dv *DocumentVersions) CountByDocumentID( ctx context.Context, conn pg.Querier, diff --git a/pkg/coredata/errors.go b/pkg/coredata/errors.go index c1269d3a3..64d419065 100644 --- a/pkg/coredata/errors.go +++ b/pkg/coredata/errors.go @@ -19,7 +19,8 @@ import ( ) var ( - ErrResourceNotFound = errors.New("resource not found") - ErrResourceAlreadyExists = errors.New("resource already exists") - ErrResourceInUse = errors.New("resource is in use") + ErrResourceNotFound = errors.New("resource not found") + ErrResourceAlreadyExists = errors.New("resource already exists") + ErrResourceInUse = errors.New("resource is in use") + ErrNoDocumentPDFJobAvailable = errors.New("no document PDF job available") ) diff --git a/pkg/coredata/migrations/20260416T160000Z.sql b/pkg/coredata/migrations/20260416T160000Z.sql new file mode 100644 index 000000000..cd9f0249c --- /dev/null +++ b/pkg/coredata/migrations/20260416T160000Z.sql @@ -0,0 +1,20 @@ +-- Copyright (c) 2026 Probo Inc +-- +-- Permission to use, copy, modify, and 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. + +ALTER TABLE document_versions + ADD COLUMN file_id TEXT REFERENCES files(id), + ADD COLUMN pdf_attempt_count INT NOT NULL DEFAULT 0; + +ALTER TABLE document_versions + ALTER COLUMN pdf_attempt_count DROP DEFAULT; diff --git a/pkg/docgen/generator.go b/pkg/docgen/generator.go index 10d84d478..f567f9bd8 100644 --- a/pkg/docgen/generator.go +++ b/pkg/docgen/generator.go @@ -44,6 +44,9 @@ var ( //go:embed transfer_impact_assessments_template.html transferImpactAssessmentsTemplateContent string + //go:embed signature_page_template.html + signaturePageTemplateContent string + templateFuncs = template.FuncMap{ "now": func() time.Time { return time.Now() }, "eq": func(a, b any) bool { return a == b }, @@ -183,6 +186,8 @@ var ( dataProtectionImpactAssessmentsTemplate = template.Must(template.New("dataProtectionImpactAssessments").Funcs(templateFuncs).Parse(dataProtectionImpactAssessmentsTemplateContent)) transferImpactAssessmentsTemplate = template.Must(template.New("transferImpactAssessments").Funcs(templateFuncs).Parse(transferImpactAssessmentsTemplateContent)) + + signaturePageTemplate = template.Must(template.New("signaturePage").Funcs(templateFuncs).Parse(signaturePageTemplateContent)) ) type ( @@ -210,6 +215,11 @@ type ( RequestedAt time.Time } + SignaturePageData struct { + Signatures []SignatureData + Landscape bool + } + ProcessingActivityTableData struct { CompanyName string CompanyHorizontalLogoBase64 string @@ -399,6 +409,15 @@ func RenderHTML(data DocumentData) ([]byte, error) { return buf.Bytes(), nil } +func RenderSignaturePageHTML(data SignaturePageData) ([]byte, error) { + var buf bytes.Buffer + if err := signaturePageTemplate.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("cannot execute signature page template: %w", err) + } + + return buf.Bytes(), nil +} + func RenderProcessingActivitiesTableHTML(data ProcessingActivityTableData) ([]byte, error) { var buf bytes.Buffer if err := processingActivitiesTemplate.Execute(&buf, data); err != nil { diff --git a/pkg/docgen/signature_page_template.html b/pkg/docgen/signature_page_template.html new file mode 100644 index 000000000..fafeb3fe4 --- /dev/null +++ b/pkg/docgen/signature_page_template.html @@ -0,0 +1,97 @@ + + + + + + + + + + +

Document Signatures

+ + + + + + + + + + + {{- range .Signatures}} + + + + + + + {{- end}} + +
SignatoryStatusRequested DateSigned Date
{{.SignedBy}} + {{- if eq (string .State) "SIGNED"}} + {{string .State}} + {{- else}} + {{string .State}} + {{- end}} + {{.RequestedAt.Format "Jan 2, 2006"}} + {{- if .SignedAt}} + {{.SignedAt.Format "Jan 2, 2006"}} + {{- else}} + - + {{- end}} +
+ + + diff --git a/pkg/watermarkpdf/watermarkpdf.go b/pkg/pdfutils/pdfutils.go similarity index 93% rename from pkg/watermarkpdf/watermarkpdf.go rename to pkg/pdfutils/pdfutils.go index c7d81c66f..eb4590063 100644 --- a/pkg/watermarkpdf/watermarkpdf.go +++ b/pkg/pdfutils/pdfutils.go @@ -12,7 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -package watermarkpdf +package pdfutils import ( "bytes" @@ -20,6 +20,7 @@ import ( "image" "image/color" "image/png" + "io" "math" "time" @@ -47,6 +48,20 @@ var ( fontColor = color.RGBA{0, 0, 0, 255} ) +func MergePDFs(pdfs ...[]byte) ([]byte, error) { + readers := make([]io.ReadSeeker, len(pdfs)) + for i, pdf := range pdfs { + readers[i] = bytes.NewReader(pdf) + } + + var buf bytes.Buffer + if err := api.MergeRaw(readers, &buf, false, nil); err != nil { + return nil, fmt.Errorf("cannot merge PDFs: %w", err) + } + + return buf.Bytes(), nil +} + func AddConfidentialWithTimestamp(pdfData []byte, email mail.Addr) ([]byte, error) { reader := bytes.NewReader(pdfData) diff --git a/pkg/probo/document_pdf_worker.go b/pkg/probo/document_pdf_worker.go new file mode 100644 index 000000000..0cefdf046 --- /dev/null +++ b/pkg/probo/document_pdf_worker.go @@ -0,0 +1,85 @@ +// Copyright (c) 2026 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 probo + +import ( + "context" + "errors" + + "go.gearno.de/kit/log" + "go.gearno.de/kit/pg" + "go.gearno.de/kit/worker" + "go.probo.inc/probo/pkg/coredata" +) + +const maxPDFAttempts = 3 + +type documentPDFHandler struct { + service *Service + logger *log.Logger +} + +func NewDocumentPDFWorker( + service *Service, + logger *log.Logger, + opts ...worker.Option, +) *worker.Worker[coredata.DocumentVersion] { + h := &documentPDFHandler{ + service: service, + logger: logger, + } + + return worker.New( + "document-pdf-worker", + h, + logger, + opts..., + ) +} + +func (h *documentPDFHandler) Claim(ctx context.Context) (coredata.DocumentVersion, error) { + var version coredata.DocumentVersion + + if err := h.service.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + return version.ClaimNextPublishedWithoutFileForUpdate(ctx, tx, maxPDFAttempts) + }, + ); err != nil { + if errors.Is(err, coredata.ErrNoDocumentPDFJobAvailable) { + return coredata.DocumentVersion{}, worker.ErrNoTask + } + return coredata.DocumentVersion{}, err + } + + return version, nil +} + +func (h *documentPDFHandler) Process(ctx context.Context, version coredata.DocumentVersion) error { + tenantService := h.service.WithTenant(version.ID.TenantID()) + + if err := tenantService.Documents.generateAndUploadPublicationPDF(ctx, &version); err != nil { + h.logger.ErrorCtx( + ctx, + "document pdf worker failure", + log.Error(err), + log.String("document_version_id", version.ID.String()), + log.Int("attempt", version.PdfAttemptCount), + ) + return err + } + + return nil +} diff --git a/pkg/probo/document_service.go b/pkg/probo/document_service.go index 4da2c0168..06957dfa2 100644 --- a/pkg/probo/document_service.go +++ b/pkg/probo/document_service.go @@ -16,6 +16,7 @@ package probo import ( "archive/zip" + "bytes" "context" "encoding/json" "errors" @@ -40,10 +41,10 @@ import ( "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/mail" "go.probo.inc/probo/pkg/page" + "go.probo.inc/probo/pkg/pdfutils" "go.probo.inc/probo/pkg/prosemirror" "go.probo.inc/probo/pkg/statelesstoken" "go.probo.inc/probo/pkg/validator" - "go.probo.inc/probo/pkg/watermarkpdf" ) type ( @@ -2149,14 +2150,181 @@ func exportDocumentPDF( documentVersionID gid.GID, options ExportPDFOptions, ) ([]byte, error) { - document := &coredata.Document{} version := &coredata.DocumentVersion{} - organization := &coredata.Organization{} - if err := version.LoadByID(ctx, conn, scope, documentVersionID); err != nil { return nil, fmt.Errorf("cannot load document version: %w", err) } + // Published versions with a stored PDF: use the stored file, + // append signature page and watermark as needed. + if version.FileID != nil { + return exportStoredPDF(ctx, svc, html2pdfConverter, conn, scope, version, options) + } + + // No stored PDF: generate on the fly without watermark — watermark is + // applied after merging the signature page so all pages are watermarked. + generateOptions := options + generateOptions.WithWatermark = false + generateOptions.WatermarkEmail = nil + + pdfData, err := generateDocumentPDF(ctx, svc, html2pdfConverter, conn, scope, version, generateOptions) + if err != nil { + return nil, err + } + + if options.WithSignatures { + signaturePagePDF, err := generateSignaturePagePDF(ctx, svc, html2pdfConverter, conn, scope, version) + if err != nil { + return nil, fmt.Errorf("cannot generate signature page: %w", err) + } + + if signaturePagePDF != nil { + pdfData, err = pdfutils.MergePDFs(pdfData, signaturePagePDF) + if err != nil { + return nil, fmt.Errorf("cannot merge signature page: %w", err) + } + } + } + + if options.WithWatermark { + if options.WatermarkEmail == nil { + return nil, fmt.Errorf("watermark email is required with watermark enabled") + } + + pdfData, err = pdfutils.AddConfidentialWithTimestamp(pdfData, *options.WatermarkEmail) + if err != nil { + return nil, fmt.Errorf("cannot add watermark to PDF: %w", err) + } + } + + return pdfData, nil +} + +func exportStoredPDF( + ctx context.Context, + svc *TenantService, + html2pdfConverter *html2pdf.Converter, + conn pg.Querier, + scope coredata.Scoper, + version *coredata.DocumentVersion, + options ExportPDFOptions, +) ([]byte, error) { + fileRecord := &coredata.File{} + if err := fileRecord.LoadByID(ctx, conn, scope, *version.FileID); err != nil { + return nil, fmt.Errorf("cannot load document version file: %w", err) + } + + pdfData, err := svc.fileManager.GetFileBytes(ctx, fileRecord) + if err != nil { + return nil, fmt.Errorf("cannot download document version PDF: %w", err) + } + + if options.WithSignatures { + signaturePagePDF, err := generateSignaturePagePDF(ctx, svc, html2pdfConverter, conn, scope, version) + if err != nil { + return nil, fmt.Errorf("cannot generate signature page: %w", err) + } + + if signaturePagePDF != nil { + pdfData, err = pdfutils.MergePDFs(pdfData, signaturePagePDF) + if err != nil { + return nil, fmt.Errorf("cannot merge signature page: %w", err) + } + } + } + + if options.WithWatermark { + if options.WatermarkEmail == nil { + return nil, fmt.Errorf("watermark email is required with watermark enabled") + } + + pdfData, err = pdfutils.AddConfidentialWithTimestamp(pdfData, *options.WatermarkEmail) + if err != nil { + return nil, fmt.Errorf("cannot add watermark to PDF: %w", err) + } + } + + return pdfData, nil +} + +func generateSignaturePagePDF( + ctx context.Context, + svc *TenantService, + html2pdfConverter *html2pdf.Converter, + conn pg.Querier, + scope coredata.Scoper, + version *coredata.DocumentVersion, +) ([]byte, error) { + signaturesWithPeople := &coredata.DocumentVersionSignaturesWithPeople{} + if err := signaturesWithPeople.LoadByDocumentVersionIDWithPeople(ctx, conn, scope, version.ID, 1_000); err != nil { + return nil, fmt.Errorf("cannot load document version signatures: %w", err) + } + + if len(*signaturesWithPeople) == 0 { + return nil, nil + } + + signatureData := make([]docgen.SignatureData, len(*signaturesWithPeople)) + for i, sig := range *signaturesWithPeople { + signatureData[i] = docgen.SignatureData{ + SignedBy: sig.SignedByFullName, + SignedAt: sig.SignedAt, + State: sig.State, + RequestedAt: sig.RequestedAt, + } + } + + isLandscape := version.Orientation == coredata.DocumentVersionOrientationLandscape + + htmlContent, err := docgen.RenderSignaturePageHTML(docgen.SignaturePageData{ + Signatures: signatureData, + Landscape: isLandscape, + }) + if err != nil { + return nil, fmt.Errorf("cannot render signature page HTML: %w", err) + } + + orientation := html2pdf.OrientationPortrait + if isLandscape { + orientation = html2pdf.OrientationLandscape + } + + cfg := html2pdf.RenderConfig{ + PageFormat: html2pdf.PageFormatA4, + Orientation: orientation, + MarginTop: html2pdf.NewMarginInches(1.0), + MarginBottom: html2pdf.NewMarginInches(1.0), + MarginLeft: html2pdf.NewMarginInches(1.0), + MarginRight: html2pdf.NewMarginInches(1.0), + PrintBackground: true, + Scale: 1.0, + } + + pdfReader, err := html2pdfConverter.GeneratePDF(ctx, htmlContent, cfg) + if err != nil { + return nil, fmt.Errorf("cannot generate signature page PDF: %w", err) + } + + pdfData, err := io.ReadAll(pdfReader) + if err != nil { + return nil, fmt.Errorf("cannot read signature page PDF: %w", err) + } + + return pdfData, nil +} + +func generateDocumentPDF( + ctx context.Context, + svc *TenantService, + html2pdfConverter *html2pdf.Converter, + conn pg.Querier, + scope coredata.Scoper, + version *coredata.DocumentVersion, + options ExportPDFOptions, +) ([]byte, error) { + document := &coredata.Document{} + organization := &coredata.Organization{} + if err := document.LoadByID(ctx, conn, scope, version.DocumentID); err != nil { return nil, fmt.Errorf("cannot load document: %w", err) } @@ -2165,7 +2333,7 @@ func exportDocumentPDF( var approverNames []string lastQuorum := &coredata.DocumentVersionApprovalQuorum{} - if err := lastQuorum.LoadLastByDocumentVersionID(ctx, conn, scope, documentVersionID); err != nil { + if err := lastQuorum.LoadLastByDocumentVersionID(ctx, conn, scope, version.ID); err != nil { if !errors.Is(err, coredata.ErrResourceNotFound) { return nil, fmt.Errorf("cannot load last approval quorum: %w", err) } @@ -2215,24 +2383,6 @@ func exportDocumentPDF( return nil, fmt.Errorf("cannot load organization: %w", err) } - var signatureData []docgen.SignatureData - if options.WithSignatures { - signaturesWithPeople := &coredata.DocumentVersionSignaturesWithPeople{} - if err := signaturesWithPeople.LoadByDocumentVersionIDWithPeople(ctx, conn, scope, documentVersionID, 1_000); err != nil { - return nil, fmt.Errorf("cannot load document version signatures: %w", err) - } - - signatureData = make([]docgen.SignatureData, len(*signaturesWithPeople)) - for i, sig := range *signaturesWithPeople { - signatureData[i] = docgen.SignatureData{ - SignedBy: sig.SignedByFullName, - SignedAt: sig.SignedAt, - State: sig.State, - RequestedAt: sig.RequestedAt, - } - } - } - classification := docgen.ClassificationSecret switch version.Classification { case coredata.DocumentClassificationPublic: @@ -2265,7 +2415,6 @@ func exportDocumentPDF( Classification: classification, Approvers: approverNames, PublishedAt: version.PublishedAt, - Signatures: signatureData, CompanyHorizontalLogoBase64: horizontalLogoBase64, Landscape: isLandscape, } @@ -2307,7 +2456,7 @@ func exportDocumentPDF( return nil, fmt.Errorf("watermark email is required with watermark enabled") } - watermarkedPDF, err := watermarkpdf.AddConfidentialWithTimestamp(pdfData, *options.WatermarkEmail) + watermarkedPDF, err := pdfutils.AddConfidentialWithTimestamp(pdfData, *options.WatermarkEmail) if err != nil { return nil, fmt.Errorf("cannot add watermark to PDF: %w", err) } @@ -2599,3 +2748,81 @@ func (s *DocumentService) publishMinorVersionInTx( return document, documentVersion, nil } + +func (s *DocumentService) generateAndUploadPublicationPDF( + ctx context.Context, + documentVersion *coredata.DocumentVersion, +) error { + var pdfData []byte + + err := s.svc.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + var err error + pdfData, err = exportDocumentPDF( + ctx, + s.svc, + s.html2pdfConverter, + conn, + s.svc.scope, + documentVersion.ID, + ExportPDFOptions{}, + ) + return err + }, + ) + if err != nil { + return fmt.Errorf("cannot generate publication PDF: %w", err) + } + + now := time.Now() + + fileRecord := &coredata.File{ + ID: gid.New(s.svc.scope.GetTenantID(), coredata.FileEntityType), + OrganizationID: documentVersion.OrganizationID, + BucketName: s.svc.bucket, + MimeType: "application/pdf", + FileName: fmt.Sprintf("%s v%d.%d.pdf", documentVersion.Title, documentVersion.Major, documentVersion.Minor), + FileKey: uuid.MustNewV4().String(), + Visibility: coredata.FileVisibilityPrivate, + CreatedAt: now, + UpdatedAt: now, + } + + fileSize, err := s.svc.fileManager.PutFile( + ctx, + fileRecord, + bytes.NewReader(pdfData), + map[string]string{ + "type": "document-version-pdf", + "document-version-id": documentVersion.ID.String(), + }, + ) + if err != nil { + return fmt.Errorf("cannot upload publication PDF: %w", err) + } + + fileRecord.FileSize = fileSize + + err = s.svc.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := fileRecord.Insert(ctx, tx, s.svc.scope); err != nil { + return fmt.Errorf("cannot insert file record: %w", err) + } + + documentVersion.FileID = &fileRecord.ID + documentVersion.UpdatedAt = now + if err := documentVersion.Update(ctx, tx, s.svc.scope); err != nil { + return fmt.Errorf("cannot update document version with file ID: %w", err) + } + + return nil + }, + ) + if err != nil { + return fmt.Errorf("cannot save publication PDF file record: %w", err) + } + + return nil +} diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index b3b66d56f..3879ce762 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -681,6 +681,20 @@ func (impl *Implm) Run( }, ) + documentPDFWorker := probo.NewDocumentPDFWorker( + proboService, + l.Named("document-pdf-worker"), + worker.WithInterval(30*time.Second), + ) + documentPDFWorkerCtx, stopDocumentPDFWorker := context.WithCancel(context.Background()) + wg.Go( + func() { + if err := documentPDFWorker.Run(documentPDFWorkerCtx); err != nil { + cancel(fmt.Errorf("document pdf worker crashed: %w", err)) + } + }, + ) + accessReviewWorkerCtx, stopAccessReviewWorker := context.WithCancel(context.Background()) wg.Go( func() { @@ -774,6 +788,7 @@ func (impl *Implm) Run( stopESignService() stopMailingListWorker() stopEvidenceDescriptionWorker() + stopDocumentPDFWorker() stopExportJobExporter() stopAccessReviewWorker() stopIAMService() diff --git a/pkg/trust/document_service.go b/pkg/trust/document_service.go index c4e21160f..1f7728516 100644 --- a/pkg/trust/document_service.go +++ b/pkg/trust/document_service.go @@ -17,11 +17,10 @@ package trust import ( "context" "encoding/json" + "errors" "fmt" "io" - "errors" - "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/docgen" @@ -29,7 +28,7 @@ import ( "go.probo.inc/probo/pkg/html2pdf" "go.probo.inc/probo/pkg/mail" "go.probo.inc/probo/pkg/page" - "go.probo.inc/probo/pkg/watermarkpdf" + "go.probo.inc/probo/pkg/pdfutils" ) type ( @@ -82,7 +81,7 @@ func (s *DocumentService) ExportPDF( return nil, fmt.Errorf("cannot export document PDF: %w", err) } - watermarkedPDF, err := watermarkpdf.AddConfidentialWithTimestamp(pdfData, email) + watermarkedPDF, err := pdfutils.AddConfidentialWithTimestamp(pdfData, email) if err != nil { return nil, fmt.Errorf("cannot add watermark to PDF: %w", err) } @@ -141,8 +140,7 @@ func (s *DocumentService) exportPDFData( ) ([]byte, error) { document := &coredata.Document{} version := &coredata.DocumentVersion{} - organization := &coredata.Organization{} - var approverNames []string + fileRecord := &coredata.File{} err := s.svc.pg.WithConn( ctx, @@ -163,6 +161,54 @@ func (s *DocumentService) exportPDFData( return fmt.Errorf("cannot load latest published document version: %w", err) } + if version.FileID == nil { + return nil + } + + if err := fileRecord.LoadByID(ctx, conn, s.svc.scope, *version.FileID); err != nil { + return fmt.Errorf("cannot load document version file: %w", err) + } + + return nil + }, + ) + + if err != nil { + return nil, err + } + + if version.FileID != nil { + pdfData, err := s.svc.fileManager.GetFileBytes(ctx, fileRecord) + if err != nil { + return nil, fmt.Errorf("cannot fetch document PDF file: %w", err) + } + + return pdfData, nil + } + + // TODO: remove on-the-fly fallback once all published versions have a stored PDF. + pdfData, err := s.generatePDFOnTheFly(ctx, document, version) + if err != nil { + return nil, fmt.Errorf("cannot generate PDF on the fly: %w", err) + } + + return pdfData, nil +} + +// generatePDFOnTheFly generates a PDF from scratch for versions that don't have +// a stored file yet. Can be removed once all published versions have been +// processed by the document PDF worker. +func (s *DocumentService) generatePDFOnTheFly( + ctx context.Context, + document *coredata.Document, + version *coredata.DocumentVersion, +) ([]byte, error) { + organization := &coredata.Organization{} + var approverNames []string + + err := s.svc.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { lastQuorum := &coredata.DocumentVersionApprovalQuorum{} if err := lastQuorum.LoadLastByDocumentVersionID(ctx, conn, s.svc.scope, version.ID); err != nil { if !errors.Is(err, coredata.ErrResourceNotFound) { diff --git a/pkg/trust/report_service.go b/pkg/trust/report_service.go index 32ea9b297..b4af57c0c 100644 --- a/pkg/trust/report_service.go +++ b/pkg/trust/report_service.go @@ -25,7 +25,7 @@ import ( "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/mail" - "go.probo.inc/probo/pkg/watermarkpdf" + "go.probo.inc/probo/pkg/pdfutils" ) type ReportService struct { @@ -112,7 +112,7 @@ func (s ReportService) ExportPDF( return nil, fmt.Errorf("cannot export report PDF: %w", err) } - watermarkedPDF, err := watermarkpdf.AddConfidentialWithTimestamp(pdfData, email) + watermarkedPDF, err := pdfutils.AddConfidentialWithTimestamp(pdfData, email) if err != nil { return nil, fmt.Errorf("cannot add watermark to PDF: %w", err) } diff --git a/pkg/trust/trust_center_file_service.go b/pkg/trust/trust_center_file_service.go index 7c92a02c6..0f129fa93 100644 --- a/pkg/trust/trust_center_file_service.go +++ b/pkg/trust/trust_center_file_service.go @@ -25,7 +25,7 @@ import ( "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/mail" "go.probo.inc/probo/pkg/page" - "go.probo.inc/probo/pkg/watermarkpdf" + "go.probo.inc/probo/pkg/pdfutils" ) type TrustCenterFileService struct { @@ -104,7 +104,7 @@ func (s *TrustCenterFileService) ExportFile( } if mimeType == "application/pdf" { - watermarkedPDF, err := watermarkpdf.AddConfidentialWithTimestamp(fileData, email) + watermarkedPDF, err := pdfutils.AddConfidentialWithTimestamp(fileData, email) if err != nil { return nil, "", fmt.Errorf("cannot add watermark to PDF: %w", err) }