mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-07-02 00:17:40 +08:00
feat(pdfengines): support owner-only encryption and document permissions
This commit is contained in:
@@ -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"}}
|
||||||
|
|||||||
@@ -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"}}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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...)
|
||||||
|
|||||||
@@ -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) },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user