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 <sacha@getprobo.com>
This commit is contained in:
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
20
pkg/coredata/migrations/20260416T160000Z.sql
Normal file
20
pkg/coredata/migrations/20260416T160000Z.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Copyright (c) 2026 Probo Inc <hello@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;
|
||||
@@ -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 {
|
||||
|
||||
97
pkg/docgen/signature_page_template.html
Normal file
97
pkg/docgen/signature_page_template.html
Normal file
@@ -0,0 +1,97 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
@page {
|
||||
size: {{- if .Landscape}} A4 landscape {{- else}} A4 {{- end}};
|
||||
margin: 1in;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
font-size: 10pt;
|
||||
line-height: 1.5;
|
||||
color: #1a1a1a;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.signatures-title {
|
||||
font-size: 13pt;
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.signatures-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 9pt;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.signatures-table th,
|
||||
.signatures-table td {
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.signatures-table th {
|
||||
background: #f5f5f5;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.signature-signed {
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.signature-requested {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2 class="signatures-title">Document Signatures</h2>
|
||||
<table class="signatures-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Signatory</th>
|
||||
<th>Status</th>
|
||||
<th>Requested Date</th>
|
||||
<th>Signed Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{- range .Signatures}}
|
||||
<tr>
|
||||
<td>{{.SignedBy}}</td>
|
||||
<td>
|
||||
{{- if eq (string .State) "SIGNED"}}
|
||||
<span class="signature-signed">{{string .State}}</span>
|
||||
{{- else}}
|
||||
<span class="signature-requested">{{string .State}}</span>
|
||||
{{- end}}
|
||||
</td>
|
||||
<td>{{.RequestedAt.Format "Jan 2, 2006"}}</td>
|
||||
<td>
|
||||
{{- if .SignedAt}}
|
||||
{{.SignedAt.Format "Jan 2, 2006"}}
|
||||
{{- else}}
|
||||
-
|
||||
{{- end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{- end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -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)
|
||||
|
||||
85
pkg/probo/document_pdf_worker.go
Normal file
85
pkg/probo/document_pdf_worker.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 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 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user