feat(pdfengines): support owner-only encryption and document permissions

This commit is contained in:
Julien Neuhart
2026-06-06 14:03:58 +02:00
parent 287ee5be72
commit 3b1e4cbac4
22 changed files with 411 additions and 67 deletions
+6
View File
@@ -50,6 +50,12 @@ body:multipart-form {
~metadata: {"Author":"Bruno","Title":"Test"} ~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword: ~userPassword:
~ownerPassword: ~ownerPassword:
~allowPrinting: false
~allowCopying: false
~allowModifying: false
~allowAnnotating: false
~allowFillingForms: false
~allowAssembling: false
~embeds: @file(../test/integration/testdata/embed_1.xml) ~embeds: @file(../test/integration/testdata/embed_1.xml)
~embeds: @file(../test/integration/testdata/embed_2.xml) ~embeds: @file(../test/integration/testdata/embed_2.xml)
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}} ~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
@@ -51,6 +51,12 @@ body:multipart-form {
~metadata: {"Author":"Bruno","Title":"Test"} ~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword: ~userPassword:
~ownerPassword: ~ownerPassword:
~allowPrinting: false
~allowCopying: false
~allowModifying: false
~allowAnnotating: false
~allowFillingForms: false
~allowAssembling: false
~embeds: @file(../test/integration/testdata/embed_1.xml) ~embeds: @file(../test/integration/testdata/embed_1.xml)
~embeds: @file(../test/integration/testdata/embed_2.xml) ~embeds: @file(../test/integration/testdata/embed_2.xml)
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}} ~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
+6
View File
@@ -50,6 +50,12 @@ body:multipart-form {
~metadata: {"Author":"Bruno","Title":"Test"} ~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword: ~userPassword:
~ownerPassword: ~ownerPassword:
~allowPrinting: false
~allowCopying: false
~allowModifying: false
~allowAnnotating: false
~allowFillingForms: false
~allowAssembling: false
~embeds: @file(../test/integration/testdata/embed_1.xml) ~embeds: @file(../test/integration/testdata/embed_1.xml)
~embeds: @file(../test/integration/testdata/embed_2.xml) ~embeds: @file(../test/integration/testdata/embed_2.xml)
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}} ~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
+6
View File
@@ -67,6 +67,12 @@ body:multipart-form {
~metadata: {"Author":"Bruno","Title":"Test"} ~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword: ~userPassword:
~ownerPassword: ~ownerPassword:
~allowPrinting: false
~allowCopying: false
~allowModifying: false
~allowAnnotating: false
~allowFillingForms: false
~allowAssembling: false
~embeds: @file(../test/integration/testdata/embed_1.xml) ~embeds: @file(../test/integration/testdata/embed_1.xml)
~embeds: @file(../test/integration/testdata/embed_2.xml) ~embeds: @file(../test/integration/testdata/embed_2.xml)
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}} ~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
@@ -14,6 +14,12 @@ body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf) files: @file(../../test/integration/testdata/page_1.pdf)
userPassword: secret123 userPassword: secret123
~ownerPassword: owner456 ~ownerPassword: owner456
~allowPrinting: false
~allowCopying: false
~allowModifying: false
~allowAnnotating: false
~allowFillingForms: false
~allowAssembling: false
} }
headers { headers {
+6
View File
@@ -21,6 +21,12 @@ body:multipart-form {
~bookmarks: [{"title":"Page 1","page":1},{"title":"Page 2","page":2}] ~bookmarks: [{"title":"Page 1","page":1},{"title":"Page 2","page":2}]
~userPassword: ~userPassword:
~ownerPassword: ~ownerPassword:
~allowPrinting: false
~allowCopying: false
~allowModifying: false
~allowAnnotating: false
~allowFillingForms: false
~allowAssembling: false
~watermarkSource: text ~watermarkSource: text
~watermarkExpression: CONFIDENTIAL ~watermarkExpression: CONFIDENTIAL
~watermarkPages: ~watermarkPages:
+6
View File
@@ -21,6 +21,12 @@ body:multipart-form {
~metadata: {"Author":"Bruno","Title":"Test"} ~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword: ~userPassword:
~ownerPassword: ~ownerPassword:
~allowPrinting: false
~allowCopying: false
~allowModifying: false
~allowAnnotating: false
~allowFillingForms: false
~allowAssembling: false
~watermarkSource: text ~watermarkSource: text
~watermarkExpression: CONFIDENTIAL ~watermarkExpression: CONFIDENTIAL
~watermarkPages: ~watermarkPages:
+3 -3
View File
@@ -53,7 +53,7 @@ type PdfEngineMock struct {
PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
ReadBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error) ReadBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath string, opts EncryptOptions) error
EmbedFilesMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error EmbedFilesMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
EmbedFilesMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error EmbedFilesMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error
WriteBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error WriteBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
@@ -96,8 +96,8 @@ func (engine *PdfEngineMock) ReadBookmarks(ctx context.Context, logger *slog.Log
return engine.ReadBookmarksMock(ctx, logger, inputPath) return engine.ReadBookmarksMock(ctx, logger, inputPath)
} }
func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts EncryptOptions) error {
return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword) return engine.EncryptMock(ctx, logger, inputPath, opts)
} }
func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error { func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
+55 -5
View File
@@ -147,6 +147,56 @@ type PdfFormats struct {
PdfUa bool PdfUa bool
} }
// PdfPermissions gathers the document permissions enforced when a PDF is
// encrypted. Each field defaults to true (the action is allowed); set a field
// to false to restrict it. Restrictions are advisory: viewers honor them, but
// they are not cryptographically enforced once the document opens.
type PdfPermissions struct {
// AllowPrinting permits printing the document.
AllowPrinting bool
// AllowCopying permits extracting text and graphics.
AllowCopying bool
// AllowModifying permits changing the document content.
AllowModifying bool
// AllowAnnotating permits adding or modifying annotations.
AllowAnnotating bool
// AllowFillingForms permits filling in form fields.
AllowFillingForms bool
// AllowAssembling permits inserting, deleting, and rotating pages.
AllowAssembling bool
}
// Restricted reports whether at least one permission is denied.
func (p PdfPermissions) Restricted() bool {
return !p.AllowPrinting ||
!p.AllowCopying ||
!p.AllowModifying ||
!p.AllowAnnotating ||
!p.AllowFillingForms ||
!p.AllowAssembling
}
// EncryptOptions gathers the parameters for encrypting a PDF. An empty
// UserPassword with a set OwnerPassword produces an owner-only document: it
// opens without a password but enforces the [PdfPermissions].
type EncryptOptions struct {
// UserPassword is required to open the document. Empty means no open
// password.
UserPassword string
// OwnerPassword grants full access (lifts the permission restrictions).
// When empty, it defaults to UserPassword.
OwnerPassword string
// Permissions are the actions allowed when opened with the user password.
Permissions PdfPermissions
}
// Bookmark represents a node in the PDF document's outline // Bookmark represents a node in the PDF document's outline
// (table of contents). // (table of contents).
type Bookmark struct { type Bookmark struct {
@@ -247,11 +297,11 @@ type PdfEngine interface {
// The bookmarks parameter represents the hierarchical tree of the outline. // The bookmarks parameter represents the hierarchical tree of the outline.
WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
// Encrypt adds password protection to a PDF file. // Encrypt adds password protection and permission restrictions to a PDF
// The userPassword is required to open the document. // file, as described by [EncryptOptions]. An empty user password with a set
// The ownerPassword provides full access to the document. // owner password yields an owner-only document (opens without a password,
// If the ownerPassword is empty, it defaults to the userPassword. // permissions enforced).
Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts EncryptOptions) error
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments // EmbedFiles embeds files into a PDF. All files are embedded as file attachments
// without modifying the main PDF content. // without modifying the main PDF content.
+14 -9
View File
@@ -446,7 +446,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
mode := pdfengines.FormDataPdfSplitMode(form, false) mode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form) pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false) metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) encrypt := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form) embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false) watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form) watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
@@ -478,7 +478,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate stamp: %w", err) return fmt.Errorf("validate stamp: %w", err)
} }
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages) err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
if err != nil { if err != nil {
return fmt.Errorf("convert URL to PDF: %w", err) return fmt.Errorf("convert URL to PDF: %w", err)
} }
@@ -535,7 +535,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
mode := pdfengines.FormDataPdfSplitMode(form, false) mode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form) pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false) metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) encrypt := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form) embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false) watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form) watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
@@ -564,7 +564,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
url := fmt.Sprintf("file://%s", inputPath) url := fmt.Sprintf("file://%s", inputPath)
options.AllowedFilePrefixes = []string{ctx.DirPath()} options.AllowedFilePrefixes = []string{ctx.DirPath()}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages) err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
if err != nil { if err != nil {
return fmt.Errorf("convert HTML to PDF: %w", err) return fmt.Errorf("convert HTML to PDF: %w", err)
} }
@@ -618,7 +618,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
mode := pdfengines.FormDataPdfSplitMode(form, false) mode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form) pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false) metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) encrypt := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form) embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false) watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form) watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
@@ -656,7 +656,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
} }
options.AllowedFilePrefixes = []string{ctx.DirPath()} options.AllowedFilePrefixes = []string{ctx.DirPath()}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages) err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
if err != nil { if err != nil {
return fmt.Errorf("convert markdown to PDF: %w", err) return fmt.Errorf("convert markdown to PDF: %w", err)
} }
@@ -781,7 +781,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
return fmt.Sprintf("file://%s", inputPath), nil return fmt.Sprintf("file://%s", inputPath), nil
} }
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, embedPaths []string, embedsMetadata map[string]map[string]string, facturX gotenberg.FacturX, facturxXmlPath string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error { func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, encrypt gotenberg.EncryptOptions, embedPaths []string, embedsMetadata map[string]map[string]string, facturX gotenberg.FacturX, facturxXmlPath string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
outputPath := ctx.GeneratePath(".pdf") outputPath := ctx.GeneratePath(".pdf")
// See https://github.com/gotenberg/gotenberg/issues/1130. // See https://github.com/gotenberg/gotenberg/issues/1130.
filename := ctx.OutputFilename(outputPath) filename := ctx.OutputFilename(outputPath)
@@ -843,7 +843,12 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
return fmt.Errorf("convert to PDF: %w", err) return fmt.Errorf("convert to PDF: %w", err)
} }
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths) err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
if err != nil {
return err
}
err = pdfengines.ValidatePdfEncryptCompat(encrypt)
if err != nil { if err != nil {
return err return err
} }
@@ -902,7 +907,7 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
return fmt.Errorf("apply Factur-X: %w", err) return fmt.Errorf("apply Factur-X: %w", err)
} }
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths) err = pdfengines.EncryptPdfStub(ctx, engine, encrypt, convertOutputPaths)
if err != nil { if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err) return fmt.Errorf("encrypt PDFs: %w", err)
} }
+1 -1
View File
@@ -449,7 +449,7 @@ func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *slog.Logger,
} }
// Encrypt is not available in this implementation. // Encrypt is not available in this implementation.
func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Encrypt", _, span := gotenberg.Tracer().Start(ctx, "exiftool.Encrypt",
trace.WithSpanKind(trace.SpanKindClient), trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)), trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
@@ -106,7 +106,7 @@ func (engine *LibreOfficePdfEngine) ReadBookmarks(ctx context.Context, logger *s
} }
// Encrypt is not available in this implementation. // Encrypt is not available in this implementation.
func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
return fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) return fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
+8 -3
View File
@@ -30,7 +30,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
splitMode := pdfengines.FormDataPdfSplitMode(form, false) splitMode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form) pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false) metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) encrypt := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form) embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false) watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form) watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
@@ -314,7 +314,12 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
return fmt.Errorf("validate stamp: %w", err) return fmt.Errorf("validate stamp: %w", err)
} }
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths) err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
if err != nil {
return err
}
err = pdfengines.ValidatePdfEncryptCompat(encrypt)
if err != nil { if err != nil {
return err return err
} }
@@ -518,7 +523,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
return fmt.Errorf("apply Factur-X: %w", err) return fmt.Errorf("apply Factur-X: %w", err)
} }
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths) err = pdfengines.EncryptPdfStub(ctx, engine, encrypt, outputPaths)
if err != nil { if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err) return fmt.Errorf("encrypt PDFs: %w", err)
} }
+15 -7
View File
@@ -503,30 +503,38 @@ func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *slog.Logger, fileP
} }
// Encrypt adds password protection to a PDF file using pdfcpu. // Encrypt adds password protection to a PDF file using pdfcpu.
func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Encrypt", ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Encrypt",
trace.WithSpanKind(trace.SpanKindClient), trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)), trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
) )
defer span.End() defer span.End()
if userPassword == "" { ownerPassword := opts.OwnerPassword
err := errors.New("user password cannot be empty") if ownerPassword == "" {
ownerPassword = opts.UserPassword
}
// An empty user password is allowed: it produces an owner-only document.
if opts.UserPassword == "" && ownerPassword == "" {
err := errors.New("at least a user or owner password is required")
span.RecordError(err) span.RecordError(err)
span.SetStatus(codes.Error, err.Error()) span.SetStatus(codes.Error, err.Error())
return err return err
} }
if ownerPassword == "" { // pdfcpu only supports coarse permissions: all actions or none.
ownerPassword = userPassword perm := "all"
if opts.Permissions.Restricted() {
perm = "none"
} }
args := make([]string, 0, 11) args := make([]string, 0, 11)
args = append(args, "encrypt") args = append(args, "encrypt")
args = append(args, "--mode", "aes") args = append(args, "--mode", "aes")
args = append(args, "--upw", userPassword) args = append(args, "--upw", opts.UserPassword)
args = append(args, "--opw", ownerPassword) args = append(args, "--opw", ownerPassword)
args = append(args, "--perm", "all") args = append(args, "--perm", perm)
args = append(args, inputPath, inputPath) args = append(args, inputPath, inputPath)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
+2 -2
View File
@@ -259,10 +259,10 @@ func (multi *multiPdfEngines) WriteBookmarks(ctx context.Context, logger *slog.L
// Encrypt adds password protection to a PDF file using the first available // Encrypt adds password protection to a PDF file using the first available
// engine that supports password protection. // engine that supports password protection.
func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
return runWithFallbackVoid(ctx, "pdfengines.Encrypt", multi.passwordEngines, return runWithFallbackVoid(ctx, "pdfengines.Encrypt", multi.passwordEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error { func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.Encrypt(ctx, logger, inputPath, userPassword, ownerPassword) return engine.Encrypt(ctx, logger, inputPath, opts)
}, },
func(err error) error { return fmt.Errorf("encrypt PDF using multi PDF engines: %w", err) }, func(err error) error { return fmt.Errorf("encrypt PDF using multi PDF engines: %w", err) },
) )
+59 -20
View File
@@ -667,21 +667,44 @@ func InjectFacturXXMPStub(ctx *api.Context, engine gotenberg.PdfEngine, facturX
return nil return nil
} }
// FormDataPdfEncrypt extracts encryption parameters from form data. // FormDataPdfEncrypt extracts the encryption parameters and permissions from
func FormDataPdfEncrypt(form *api.FormData) (userPassword, ownerPassword string) { // form data. Permissions default to allowed.
form.String("userPassword", &userPassword, "") func FormDataPdfEncrypt(form *api.FormData) gotenberg.EncryptOptions {
form.String("ownerPassword", &ownerPassword, "") var opts gotenberg.EncryptOptions
return userPassword, ownerPassword form.
String("userPassword", &opts.UserPassword, "").
String("ownerPassword", &opts.OwnerPassword, "").
Bool("allowPrinting", &opts.Permissions.AllowPrinting, true).
Bool("allowCopying", &opts.Permissions.AllowCopying, true).
Bool("allowModifying", &opts.Permissions.AllowModifying, true).
Bool("allowAnnotating", &opts.Permissions.AllowAnnotating, true).
Bool("allowFillingForms", &opts.Permissions.AllowFillingForms, true).
Bool("allowAssembling", &opts.Permissions.AllowAssembling, true)
return opts
} }
// EncryptPdfStub adds password protection to PDF files. // ValidatePdfEncryptCompat returns a 400 error when permission restrictions are
func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, userPassword, ownerPassword string, inputPaths []string) error { // requested without a password to anchor them.
if userPassword == "" { func ValidatePdfEncryptCompat(opts gotenberg.EncryptOptions) error {
if opts.Permissions.Restricted() && opts.UserPassword == "" && opts.OwnerPassword == "" {
return api.WrapError(
errors.New("permission restrictions require a password"),
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: permission restrictions require a 'userPassword' or 'ownerPassword'"),
)
}
return nil
}
// EncryptPdfStub adds password protection and permission restrictions to PDF
// files. It does nothing when no password is provided.
func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, opts gotenberg.EncryptOptions, inputPaths []string) error {
if opts.UserPassword == "" && opts.OwnerPassword == "" {
return nil return nil
} }
for _, inputPath := range inputPaths { for _, inputPath := range inputPaths {
err := engine.Encrypt(ctx, ctx.Log(), inputPath, userPassword, ownerPassword) err := engine.Encrypt(ctx, ctx.Log(), inputPath, opts)
if err != nil { if err != nil {
return fmt.Errorf("encrypt PDF '%s': %w", inputPath, err) return fmt.Errorf("encrypt PDF '%s': %w", inputPath, err)
} }
@@ -899,7 +922,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
pdfFormats := FormDataPdfFormats(form) pdfFormats := FormDataPdfFormats(form)
metadata := FormDataPdfMetadata(form, false) metadata := FormDataPdfMetadata(form, false)
bookmarks := FormDataPdfBookmarks(form, false) bookmarks := FormDataPdfBookmarks(form, false)
userPassword, ownerPassword := FormDataPdfEncrypt(form) encrypt := FormDataPdfEncrypt(form)
embedPaths := FormDataPdfEmbeds(form) embedPaths := FormDataPdfEmbeds(form)
watermark := FormDataPdfWatermark(form, false) watermark := FormDataPdfWatermark(form, false)
watermarkFile := FormDataPdfWatermarkFile(form) watermarkFile := FormDataPdfWatermarkFile(form)
@@ -930,7 +953,12 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate stamp: %w", err) return fmt.Errorf("validate stamp: %w", err)
} }
err = ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths) err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
if err != nil {
return err
}
err = ValidatePdfEncryptCompat(encrypt)
if err != nil { if err != nil {
return err return err
} }
@@ -1043,7 +1071,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("apply Factur-X: %w", err) return fmt.Errorf("apply Factur-X: %w", err)
} }
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths) err = EncryptPdfStub(ctx, engine, encrypt, outputPaths)
if err != nil { if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err) return fmt.Errorf("encrypt PDFs: %w", err)
} }
@@ -1071,7 +1099,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
mode := FormDataPdfSplitMode(form, true) mode := FormDataPdfSplitMode(form, true)
pdfFormats := FormDataPdfFormats(form) pdfFormats := FormDataPdfFormats(form)
metadata := FormDataPdfMetadata(form, false) metadata := FormDataPdfMetadata(form, false)
userPassword, ownerPassword := FormDataPdfEncrypt(form) encrypt := FormDataPdfEncrypt(form)
embedPaths := FormDataPdfEmbeds(form) embedPaths := FormDataPdfEmbeds(form)
watermark := FormDataPdfWatermark(form, false) watermark := FormDataPdfWatermark(form, false)
watermarkFile := FormDataPdfWatermarkFile(form) watermarkFile := FormDataPdfWatermarkFile(form)
@@ -1100,7 +1128,12 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate stamp: %w", err) return fmt.Errorf("validate stamp: %w", err)
} }
err = ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths) err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
if err != nil {
return err
}
err = ValidatePdfEncryptCompat(encrypt)
if err != nil { if err != nil {
return err return err
} }
@@ -1166,7 +1199,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("apply Factur-X: %w", err) return fmt.Errorf("apply Factur-X: %w", err)
} }
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths) err = EncryptPdfStub(ctx, engine, encrypt, convertOutputPaths)
if err != nil { if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err) return fmt.Errorf("encrypt PDFs: %w", err)
} }
@@ -1450,20 +1483,26 @@ func encryptRoute(engine gotenberg.PdfEngine) api.Route {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form := ctx.FormData() form := ctx.FormData()
encrypt := FormDataPdfEncrypt(form)
var inputPaths []string var inputPaths []string
var userPassword string
var ownerPassword string
err := form. err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths). MandatoryPaths([]string{".pdf"}, &inputPaths).
MandatoryString("userPassword", &userPassword).
String("ownerPassword", &ownerPassword, "").
Validate() Validate()
if err != nil { if err != nil {
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
} }
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, inputPaths) // At least one password is required; an empty user password with an
// owner password yields an owner-only document.
if encrypt.UserPassword == "" && encrypt.OwnerPassword == "" {
return api.WrapError(
errors.New("no password provided"),
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: a 'userPassword' or 'ownerPassword' is required"),
)
}
err = EncryptPdfStub(ctx, engine, encrypt, inputPaths)
if err != nil { if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err) return fmt.Errorf("encrypt PDFs: %w", err)
} }
+6 -6
View File
@@ -259,21 +259,21 @@ func (engine *PdfTk) ReadBookmarks(ctx context.Context, logger *slog.Logger, inp
} }
// Encrypt adds password protection to a PDF file using PDFtk. // Encrypt adds password protection to a PDF file using PDFtk.
func (engine *PdfTk) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { func (engine *PdfTk) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Encrypt", ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Encrypt",
trace.WithSpanKind(trace.SpanKindClient), trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)), trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
) )
defer span.End() defer span.End()
if userPassword == "" { if opts.UserPassword == "" || opts.Permissions.Restricted() {
err := errors.New("user password cannot be empty") err := gotenberg.NewPdfEngineInvalidArgs("pdftk", "owner-only encryption and permission restrictions are not supported; consider switching to another PDF engine (e.g. qpdf)")
span.RecordError(err) span.RecordError(err)
span.SetStatus(codes.Error, err.Error()) span.SetStatus(codes.Error, err.Error())
return err return err
} }
if ownerPassword == userPassword || ownerPassword == "" { if opts.OwnerPassword == opts.UserPassword || opts.OwnerPassword == "" {
err := gotenberg.NewPdfEngineInvalidArgs("pdftk", "both 'userPassword' and 'ownerPassword' must be provided and different. Consider switching to another PDF engine if this behavior does not work with your workflow") err := gotenberg.NewPdfEngineInvalidArgs("pdftk", "both 'userPassword' and 'ownerPassword' must be provided and different. Consider switching to another PDF engine if this behavior does not work with your workflow")
span.RecordError(err) span.RecordError(err)
span.SetStatus(codes.Error, err.Error()) span.SetStatus(codes.Error, err.Error())
@@ -287,8 +287,8 @@ func (engine *PdfTk) Encrypt(ctx context.Context, logger *slog.Logger, inputPath
args = append(args, inputPath) args = append(args, inputPath)
args = append(args, "output", tmpPath) args = append(args, "output", tmpPath)
args = append(args, "encrypt_128bit") args = append(args, "encrypt_128bit")
args = append(args, "user_pw", userPassword) args = append(args, "user_pw", opts.UserPassword)
args = append(args, "owner_pw", ownerPassword) args = append(args, "owner_pw", opts.OwnerPassword)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil { if err != nil {
+76
View File
@@ -0,0 +1,76 @@
package qpdf
import (
"reflect"
"testing"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestQpdfPermissionArgs(t *testing.T) {
allAllowed := gotenberg.PdfPermissions{
AllowPrinting: true,
AllowCopying: true,
AllowModifying: true,
AllowAnnotating: true,
AllowFillingForms: true,
AllowAssembling: true,
}
for _, tc := range []struct {
scenario string
perms gotenberg.PdfPermissions
expect []string
}{
{
scenario: "all allowed yields no flags",
perms: allAllowed,
expect: nil,
},
{
scenario: "printing denied",
perms: gotenberg.PdfPermissions{
AllowPrinting: false,
AllowCopying: true,
AllowModifying: true,
AllowAnnotating: true,
AllowFillingForms: true,
AllowAssembling: true,
},
expect: []string{
"--print=none",
"--extract=y",
"--modify-other=y",
"--annotate=y",
"--form=y",
"--assemble=y",
},
},
{
scenario: "copying denied",
perms: gotenberg.PdfPermissions{
AllowPrinting: true,
AllowCopying: false,
AllowModifying: true,
AllowAnnotating: true,
AllowFillingForms: true,
AllowAssembling: true,
},
expect: []string{
"--print=full",
"--extract=n",
"--modify-other=y",
"--annotate=y",
"--form=y",
"--assemble=y",
},
},
} {
t.Run(tc.scenario, func(t *testing.T) {
got := qpdfPermissionArgs(tc.perms)
if !reflect.DeepEqual(got, tc.expect) {
t.Errorf("expected %v but got %v", tc.expect, got)
}
})
}
}
+43 -9
View File
@@ -295,29 +295,63 @@ func (engine *QPdf) ReadBookmarks(ctx context.Context, logger *slog.Logger, inpu
} }
// Encrypt adds password protection to a PDF file using QPDF. // Encrypt adds password protection to a PDF file using QPDF.
func (engine *QPdf) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { // qpdfPermissionArgs maps PDF permissions to QPDF --encrypt restriction flags.
// It returns nil when every permission is allowed, matching QPDF's default
// (all actions permitted).
func qpdfPermissionArgs(p gotenberg.PdfPermissions) []string {
if !p.Restricted() {
return nil
}
yn := func(allowed bool) string {
if allowed {
return "y"
}
return "n"
}
print := "full"
if !p.AllowPrinting {
print = "none"
}
return []string{
"--print=" + print,
"--extract=" + yn(p.AllowCopying),
"--modify-other=" + yn(p.AllowModifying),
"--annotate=" + yn(p.AllowAnnotating),
"--form=" + yn(p.AllowFillingForms),
"--assemble=" + yn(p.AllowAssembling),
}
}
func (engine *QPdf) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Encrypt", ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Encrypt",
trace.WithSpanKind(trace.SpanKindClient), trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)), trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
) )
defer span.End() defer span.End()
if userPassword == "" { ownerPassword := opts.OwnerPassword
err := errors.New("user password cannot be empty") if ownerPassword == "" {
ownerPassword = opts.UserPassword
}
// An empty user password is allowed: it produces an owner-only document.
if opts.UserPassword == "" && ownerPassword == "" {
err := errors.New("at least a user or owner password is required")
span.RecordError(err) span.RecordError(err)
span.SetStatus(codes.Error, err.Error()) span.SetStatus(codes.Error, err.Error())
return err return err
} }
if ownerPassword == "" { args := make([]string, 0, 14+len(engine.globalArgs))
ownerPassword = userPassword
}
args := make([]string, 0, 7+len(engine.globalArgs))
args = append(args, inputPath) args = append(args, inputPath)
args = append(args, engine.globalArgs...) args = append(args, engine.globalArgs...)
args = append(args, "--replace-input") args = append(args, "--replace-input")
args = append(args, "--encrypt", userPassword, ownerPassword, "256", "--") args = append(args, "--encrypt", opts.UserPassword, ownerPassword, "256")
args = append(args, qpdfPermissionArgs(opts.Permissions)...)
args = append(args, "--")
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil { if err != nil {
+1
View File
@@ -72,6 +72,7 @@ Available tags:
- `the (response|webhook request) PDF(s) should be valid "<standard>" with a tolerance of <N> failed rule(s)` (standards: `PDF/A-1b`, `PDF/A-2b`, `PDF/A-3b`, `PDF/UA-1`, `PDF/UA-2`) - `the (response|webhook request) PDF(s) should be valid "<standard>" with a tolerance of <N> failed rule(s)` (standards: `PDF/A-1b`, `PDF/A-2b`, `PDF/A-3b`, `PDF/UA-1`, `PDF/UA-2`)
- `the (response|webhook request) PDF(s) (should|should NOT) be flatten` - `the (response|webhook request) PDF(s) (should|should NOT) be flatten`
- `the (response|webhook request) PDF(s) (should|should NOT) be encrypted` - `the (response|webhook request) PDF(s) (should|should NOT) be encrypted`
- `the (response|webhook request) PDF(s) (should|should NOT) allow "<action>"` (actions: `printing`, `copying`, `modifying`, `annotating`)
- `the (response|webhook request) PDF(s) (should|should NOT) have the "<filename>" file embedded` - `the (response|webhook request) PDF(s) (should|should NOT) have the "<filename>" file embedded`
- `the "<name>" PDF should have <N> image(s)` - `the "<name>" PDF should have <N> image(s)`
- `the Gotenberg container (should|should NOT) log the following entries:` (table of log substrings) - `the Gotenberg container (should|should NOT) log the following entries:` (table of log substrings)
@@ -113,9 +113,31 @@ Feature: /forms/pdfengines/encrypt
Then the response status code should be 400 Then the response status code should be 400
Then the response body should match string: Then the response body should match string:
""" """
Invalid form data: form field 'userPassword' is required Invalid form data: a 'userPassword' or 'ownerPassword' is required
""" """
# https://github.com/gotenberg/gotenberg/discussions/1571
# Owner-only: opens without a password but restricts printing.
Scenario: POST /forms/pdfengines/encrypt (Owner-only, restrict printing)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| ownerPassword | owner-secret | field |
| allowPrinting | false | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the response PDF(s) should NOT be encrypted
Then the response PDF(s) should NOT allow "printing"
# Permission restrictions need a password to anchor them.
Scenario: POST /forms/pdfengines/encrypt (Permissions without password)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| allowPrinting | false | field |
Then the response status code should be 400
Scenario: POST /forms/pdfengines/encrypt (Routes Disabled) Scenario: POST /forms/pdfengines/encrypt (Routes Disabled)
Given I have a Gotenberg container with the following environment variable(s): Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_DISABLE_ROUTES | true | | PDFENGINES_DISABLE_ROUTES | true |
+62
View File
@@ -1217,6 +1217,67 @@ func (s *scenario) thePdfsShouldBeFlatten(ctx context.Context, kind, should stri
return nil return nil
} }
// permissionFlags maps a human action to the permission key reported in a PDF's
// encryption dictionary.
var permissionFlags = map[string]string{
"printing": "print",
"copying": "copy",
"modifying": "change",
"annotating": "addNotes",
}
// thePdfsShouldAllowAction asserts whether every response PDF permits a given
// action (printing, copying, modifying, annotating). It reads the document's
// permission flags; an unencrypted document has no restrictions.
func (s *scenario) thePdfsShouldAllowAction(ctx context.Context, kind, should, action string) error {
flag, ok := permissionFlags[action]
if !ok {
return fmt.Errorf("unsupported permission action %q", action)
}
dirPath := s.teststoreDir
_, err := os.Stat(dirPath)
if os.IsNotExist(err) {
return fmt.Errorf("directory %q does not exist", dirPath)
}
var paths []string
err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
if pathErr != nil {
return pathErr
}
if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
paths = append(paths, path)
}
return nil
})
if err != nil {
return fmt.Errorf("walk %q: %w", dirPath, err)
}
invert := should == "should NOT"
for _, path := range paths {
output, err := execCommandInIntegrationToolsContainer(ctx, []string{"pdfinfo", filepath.Base(path)}, path)
if err != nil {
return fmt.Errorf("read permissions of %q: %w", path, err)
}
stripped := strings.ReplaceAll(strings.ReplaceAll(output, " ", ""), "\n", "")
denied := strings.Contains(stripped, flag+":no")
allowed := strings.Contains(stripped, flag+":yes")
if invert && !denied {
return fmt.Errorf("expected PDF %q to deny %q, got: %q", path, action, output)
}
if !invert && !allowed {
return fmt.Errorf("expected PDF %q to allow %q, got: %q", path, action, output)
}
}
return nil
}
func (s *scenario) thePdfsShouldBeEncrypted(ctx context.Context, kind string, should string) error { func (s *scenario) thePdfsShouldBeEncrypted(ctx context.Context, kind string, should string) error {
dirPath := s.teststoreDir dirPath := s.teststoreDir
@@ -1474,6 +1535,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
ctx.Then(`^the (response|webhook request) PDF\(s\) should be valid "([^"]*)" with a tolerance of (\d+) failed rule\(s\)$`, s.thePdfsShouldBeValidWithAToleranceOf) ctx.Then(`^the (response|webhook request) PDF\(s\) should be valid "([^"]*)" with a tolerance of (\d+) failed rule\(s\)$`, s.thePdfsShouldBeValidWithAToleranceOf)
ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be flatten$`, s.thePdfsShouldBeFlatten) ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be flatten$`, s.thePdfsShouldBeFlatten)
ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be encrypted`, s.thePdfsShouldBeEncrypted) ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be encrypted`, s.thePdfsShouldBeEncrypted)
ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) allow "([^"]*)"$`, s.thePdfsShouldAllowAction)
ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) have the "([^"]*)" file embedded$`, s.thePdfsShouldHaveEmbeddedFile) ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) have the "([^"]*)" file embedded$`, s.thePdfsShouldHaveEmbeddedFile)
ctx.Then(`^the (response|webhook request) PDF\(s\) should have the "([^"]*)" file embedded with relationship "([^"]*)"$`, s.thePdfsShouldHaveEmbeddedFileWithRelationship) ctx.Then(`^the (response|webhook request) PDF\(s\) should have the "([^"]*)" file embedded with relationship "([^"]*)"$`, s.thePdfsShouldHaveEmbeddedFileWithRelationship)
ctx.Then(`^the (response|webhook request) PDF\(s\) should declare Factur-X XMP with conformance level "([^"]*)"$`, s.thePdfsShouldDeclareFacturXConformanceLevel) ctx.Then(`^the (response|webhook request) PDF\(s\) should declare Factur-X XMP with conformance level "([^"]*)"$`, s.thePdfsShouldDeclareFacturXConformanceLevel)