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"}
|
||||
~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"}}
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
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 {
|
||||
|
||||
@@ -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|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 "<action>"` (actions: `printing`, `copying`, `modifying`, `annotating`)
|
||||
- `the (response|webhook request) PDF(s) (should|should NOT) have the "<filename>" file embedded`
|
||||
- `the "<name>" PDF should have <N> image(s)`
|
||||
- `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 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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user