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