diff --git a/.bruno/Chromium/Convert/HTML to PDF.bru b/.bruno/Chromium/Convert/HTML to PDF.bru
index d340368..c0cb4a0 100644
--- a/.bruno/Chromium/Convert/HTML to PDF.bru
+++ b/.bruno/Chromium/Convert/HTML to PDF.bru
@@ -50,6 +50,12 @@ body:multipart-form {
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~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_2.xml)
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
diff --git a/.bruno/Chromium/Convert/Markdown to PDF.bru b/.bruno/Chromium/Convert/Markdown to PDF.bru
index ca5e90a..65e0ff6 100644
--- a/.bruno/Chromium/Convert/Markdown to PDF.bru
+++ b/.bruno/Chromium/Convert/Markdown to PDF.bru
@@ -51,6 +51,12 @@ body:multipart-form {
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~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_2.xml)
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
diff --git a/.bruno/Chromium/Convert/URL to PDF.bru b/.bruno/Chromium/Convert/URL to PDF.bru
index 7dd5d5a..8283c24 100644
--- a/.bruno/Chromium/Convert/URL to PDF.bru
+++ b/.bruno/Chromium/Convert/URL to PDF.bru
@@ -50,6 +50,12 @@ body:multipart-form {
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~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_2.xml)
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
diff --git a/.bruno/LibreOffice/Convert to PDF.bru b/.bruno/LibreOffice/Convert to PDF.bru
index 5816480..1e82364 100644
--- a/.bruno/LibreOffice/Convert to PDF.bru
+++ b/.bruno/LibreOffice/Convert to PDF.bru
@@ -67,6 +67,12 @@ body:multipart-form {
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~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_2.xml)
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
diff --git a/.bruno/PDF Engines/Encrypt/Encrypt PDF.bru b/.bruno/PDF Engines/Encrypt/Encrypt PDF.bru
index 7ede2a7..3e36835 100644
--- a/.bruno/PDF Engines/Encrypt/Encrypt PDF.bru
+++ b/.bruno/PDF Engines/Encrypt/Encrypt PDF.bru
@@ -14,6 +14,12 @@ body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
userPassword: secret123
~ownerPassword: owner456
+ ~allowPrinting: false
+ ~allowCopying: false
+ ~allowModifying: false
+ ~allowAnnotating: false
+ ~allowFillingForms: false
+ ~allowAssembling: false
}
headers {
diff --git a/.bruno/PDF Engines/Merge/Merge PDFs.bru b/.bruno/PDF Engines/Merge/Merge PDFs.bru
index f01d18a..3923e15 100644
--- a/.bruno/PDF Engines/Merge/Merge PDFs.bru
+++ b/.bruno/PDF Engines/Merge/Merge PDFs.bru
@@ -21,6 +21,12 @@ body:multipart-form {
~bookmarks: [{"title":"Page 1","page":1},{"title":"Page 2","page":2}]
~userPassword:
~ownerPassword:
+ ~allowPrinting: false
+ ~allowCopying: false
+ ~allowModifying: false
+ ~allowAnnotating: false
+ ~allowFillingForms: false
+ ~allowAssembling: false
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:
diff --git a/.bruno/PDF Engines/Split/Split PDF.bru b/.bruno/PDF Engines/Split/Split PDF.bru
index d83a239..6806b45 100644
--- a/.bruno/PDF Engines/Split/Split PDF.bru
+++ b/.bruno/PDF Engines/Split/Split PDF.bru
@@ -21,6 +21,12 @@ body:multipart-form {
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
+ ~allowPrinting: false
+ ~allowCopying: false
+ ~allowModifying: false
+ ~allowAnnotating: false
+ ~allowFillingForms: false
+ ~allowAssembling: false
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:
diff --git a/pkg/gotenberg/mocks.go b/pkg/gotenberg/mocks.go
index 024f504..c84780f 100644
--- a/pkg/gotenberg/mocks.go
+++ b/pkg/gotenberg/mocks.go
@@ -53,7 +53,7 @@ type PdfEngineMock struct {
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
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
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
@@ -96,8 +96,8 @@ func (engine *PdfEngineMock) ReadBookmarks(ctx context.Context, logger *slog.Log
return engine.ReadBookmarksMock(ctx, logger, inputPath)
}
-func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
- return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword)
+func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts EncryptOptions) error {
+ return engine.EncryptMock(ctx, logger, inputPath, opts)
}
func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
diff --git a/pkg/gotenberg/pdfengine.go b/pkg/gotenberg/pdfengine.go
index 23cf436..ef9a142 100644
--- a/pkg/gotenberg/pdfengine.go
+++ b/pkg/gotenberg/pdfengine.go
@@ -147,6 +147,56 @@ type PdfFormats struct {
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
// (table of contents).
type Bookmark struct {
@@ -247,11 +297,11 @@ type PdfEngine interface {
// The bookmarks parameter represents the hierarchical tree of the outline.
WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
- // Encrypt adds password protection to a PDF file.
- // The userPassword is required to open the document.
- // The ownerPassword provides full access to the document.
- // If the ownerPassword is empty, it defaults to the userPassword.
- Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
+ // Encrypt adds password protection and permission restrictions to a PDF
+ // file, as described by [EncryptOptions]. An empty user password with a set
+ // owner password yields an owner-only document (opens without a password,
+ // permissions enforced).
+ 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
// without modifying the main PDF content.
diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go
index 1198e43..01ca707 100644
--- a/pkg/modules/chromium/routes.go
+++ b/pkg/modules/chromium/routes.go
@@ -446,7 +446,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
mode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
- userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
+ encrypt := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
@@ -478,7 +478,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
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 {
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)
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
- userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
+ encrypt := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
@@ -564,7 +564,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
url := fmt.Sprintf("file://%s", inputPath)
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 {
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)
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
- userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
+ encrypt := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
@@ -656,7 +656,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
}
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 {
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
}
-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")
// See https://github.com/gotenberg/gotenberg/issues/1130.
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)
}
- 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 {
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)
}
- err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
+ err = pdfengines.EncryptPdfStub(ctx, engine, encrypt, convertOutputPaths)
if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err)
}
diff --git a/pkg/modules/exiftool/exiftool.go b/pkg/modules/exiftool/exiftool.go
index a5bab08..e639291 100644
--- a/pkg/modules/exiftool/exiftool.go
+++ b/pkg/modules/exiftool/exiftool.go
@@ -449,7 +449,7 @@ func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *slog.Logger,
}
// 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",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine.go b/pkg/modules/libreoffice/pdfengine/pdfengine.go
index 5d26aea..eeffb63 100644
--- a/pkg/modules/libreoffice/pdfengine/pdfengine.go
+++ b/pkg/modules/libreoffice/pdfengine/pdfengine.go
@@ -106,7 +106,7 @@ func (engine *LibreOfficePdfEngine) ReadBookmarks(ctx context.Context, logger *s
}
// 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)
}
diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go
index 4edd12f..f706a22 100644
--- a/pkg/modules/libreoffice/routes.go
+++ b/pkg/modules/libreoffice/routes.go
@@ -30,7 +30,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
splitMode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
- userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
+ encrypt := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
@@ -314,7 +314,12 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
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 {
return err
}
@@ -518,7 +523,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
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 {
return fmt.Errorf("encrypt PDFs: %w", err)
}
diff --git a/pkg/modules/pdfcpu/pdfcpu.go b/pkg/modules/pdfcpu/pdfcpu.go
index 50d696a..68e27ae 100644
--- a/pkg/modules/pdfcpu/pdfcpu.go
+++ b/pkg/modules/pdfcpu/pdfcpu.go
@@ -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.
-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",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
- if userPassword == "" {
- err := errors.New("user password cannot be empty")
+ ownerPassword := opts.OwnerPassword
+ 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.SetStatus(codes.Error, err.Error())
return err
}
- if ownerPassword == "" {
- ownerPassword = userPassword
+ // pdfcpu only supports coarse permissions: all actions or none.
+ perm := "all"
+ if opts.Permissions.Restricted() {
+ perm = "none"
}
args := make([]string, 0, 11)
args = append(args, "encrypt")
args = append(args, "--mode", "aes")
- args = append(args, "--upw", userPassword)
+ args = append(args, "--upw", opts.UserPassword)
args = append(args, "--opw", ownerPassword)
- args = append(args, "--perm", "all")
+ args = append(args, "--perm", perm)
args = append(args, inputPath, inputPath)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
diff --git a/pkg/modules/pdfengines/multi.go b/pkg/modules/pdfengines/multi.go
index d0e34a1..7962619 100644
--- a/pkg/modules/pdfengines/multi.go
+++ b/pkg/modules/pdfengines/multi.go
@@ -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
// 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,
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) },
)
diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go
index 14f5f4b..165d0b0 100644
--- a/pkg/modules/pdfengines/routes.go
+++ b/pkg/modules/pdfengines/routes.go
@@ -667,21 +667,44 @@ func InjectFacturXXMPStub(ctx *api.Context, engine gotenberg.PdfEngine, facturX
return nil
}
-// FormDataPdfEncrypt extracts encryption parameters from form data.
-func FormDataPdfEncrypt(form *api.FormData) (userPassword, ownerPassword string) {
- form.String("userPassword", &userPassword, "")
- form.String("ownerPassword", &ownerPassword, "")
- return userPassword, ownerPassword
+// FormDataPdfEncrypt extracts the encryption parameters and permissions from
+// form data. Permissions default to allowed.
+func FormDataPdfEncrypt(form *api.FormData) gotenberg.EncryptOptions {
+ var opts gotenberg.EncryptOptions
+ 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.
-func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, userPassword, ownerPassword string, inputPaths []string) error {
- if userPassword == "" {
+// ValidatePdfEncryptCompat returns a 400 error when permission restrictions are
+// requested without a password to anchor them.
+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
}
for _, inputPath := range inputPaths {
- err := engine.Encrypt(ctx, ctx.Log(), inputPath, userPassword, ownerPassword)
+ err := engine.Encrypt(ctx, ctx.Log(), inputPath, opts)
if err != nil {
return fmt.Errorf("encrypt PDF '%s': %w", inputPath, err)
}
@@ -899,7 +922,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
pdfFormats := FormDataPdfFormats(form)
metadata := FormDataPdfMetadata(form, false)
bookmarks := FormDataPdfBookmarks(form, false)
- userPassword, ownerPassword := FormDataPdfEncrypt(form)
+ encrypt := FormDataPdfEncrypt(form)
embedPaths := FormDataPdfEmbeds(form)
watermark := FormDataPdfWatermark(form, false)
watermarkFile := FormDataPdfWatermarkFile(form)
@@ -930,7 +953,12 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
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 {
return err
}
@@ -1043,7 +1071,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("apply Factur-X: %w", err)
}
- err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
+ err = EncryptPdfStub(ctx, engine, encrypt, outputPaths)
if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err)
}
@@ -1071,7 +1099,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
mode := FormDataPdfSplitMode(form, true)
pdfFormats := FormDataPdfFormats(form)
metadata := FormDataPdfMetadata(form, false)
- userPassword, ownerPassword := FormDataPdfEncrypt(form)
+ encrypt := FormDataPdfEncrypt(form)
embedPaths := FormDataPdfEmbeds(form)
watermark := FormDataPdfWatermark(form, false)
watermarkFile := FormDataPdfWatermarkFile(form)
@@ -1100,7 +1128,12 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
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 {
return err
}
@@ -1166,7 +1199,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("apply Factur-X: %w", err)
}
- err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
+ err = EncryptPdfStub(ctx, engine, encrypt, convertOutputPaths)
if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err)
}
@@ -1450,20 +1483,26 @@ func encryptRoute(engine gotenberg.PdfEngine) api.Route {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
+ encrypt := FormDataPdfEncrypt(form)
var inputPaths []string
- var userPassword string
- var ownerPassword string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
- MandatoryString("userPassword", &userPassword).
- String("ownerPassword", &ownerPassword, "").
Validate()
if err != nil {
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 {
return fmt.Errorf("encrypt PDFs: %w", err)
}
diff --git a/pkg/modules/pdftk/pdftk.go b/pkg/modules/pdftk/pdftk.go
index dcc5767..244351d 100644
--- a/pkg/modules/pdftk/pdftk.go
+++ b/pkg/modules/pdftk/pdftk.go
@@ -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.
-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",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
- if userPassword == "" {
- err := errors.New("user password cannot be empty")
+ if opts.UserPassword == "" || opts.Permissions.Restricted() {
+ 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.SetStatus(codes.Error, err.Error())
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")
span.RecordError(err)
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, "output", tmpPath)
args = append(args, "encrypt_128bit")
- args = append(args, "user_pw", userPassword)
- args = append(args, "owner_pw", ownerPassword)
+ args = append(args, "user_pw", opts.UserPassword)
+ args = append(args, "owner_pw", opts.OwnerPassword)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
diff --git a/pkg/modules/qpdf/encrypt_test.go b/pkg/modules/qpdf/encrypt_test.go
new file mode 100644
index 0000000..42bf0e9
--- /dev/null
+++ b/pkg/modules/qpdf/encrypt_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go
index 753dac3..88e7168 100644
--- a/pkg/modules/qpdf/qpdf.go
+++ b/pkg/modules/qpdf/qpdf.go
@@ -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.
-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",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
- if userPassword == "" {
- err := errors.New("user password cannot be empty")
+ ownerPassword := opts.OwnerPassword
+ 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.SetStatus(codes.Error, err.Error())
return err
}
- if ownerPassword == "" {
- ownerPassword = userPassword
- }
-
- args := make([]string, 0, 7+len(engine.globalArgs))
+ args := make([]string, 0, 14+len(engine.globalArgs))
args = append(args, inputPath)
args = append(args, engine.globalArgs...)
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...)
if err != nil {
diff --git a/test/integration/README.md b/test/integration/README.md
index 0132c2f..29b1161 100644
--- a/test/integration/README.md
+++ b/test/integration/README.md
@@ -72,6 +72,7 @@ Available tags:
- `the (response|webhook request) PDF(s) should be valid "" with a tolerance of 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 encrypted`
+- `the (response|webhook request) PDF(s) (should|should NOT) allow ""` (actions: `printing`, `copying`, `modifying`, `annotating`)
- `the (response|webhook request) PDF(s) (should|should NOT) have the "" file embedded`
- `the "" PDF should have image(s)`
- `the Gotenberg container (should|should NOT) log the following entries:` (table of log substrings)
diff --git a/test/integration/features/pdfengines_encrypt.feature b/test/integration/features/pdfengines_encrypt.feature
index 62bc6a3..17b8636 100644
--- a/test/integration/features/pdfengines_encrypt.feature
+++ b/test/integration/features/pdfengines_encrypt.feature
@@ -113,9 +113,31 @@ Feature: /forms/pdfengines/encrypt
Then the response status code should be 400
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)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_DISABLE_ROUTES | true |
diff --git a/test/integration/scenario/scenario.go b/test/integration/scenario/scenario.go
index 967e4e4..1683a27 100644
--- a/test/integration/scenario/scenario.go
+++ b/test/integration/scenario/scenario.go
@@ -1217,6 +1217,67 @@ func (s *scenario) thePdfsShouldBeFlatten(ctx context.Context, kind, should stri
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 {
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|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) 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 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)