diff --git a/pkg/gotenberg/mocks.go b/pkg/gotenberg/mocks.go index 458216f9..faa85743 100644 --- a/pkg/gotenberg/mocks.go +++ b/pkg/gotenberg/mocks.go @@ -52,6 +52,7 @@ type PdfEngineMock struct { ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error ReadMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error + EncryptMock func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error } func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { @@ -78,6 +79,10 @@ func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *zap.Logg return engine.WriteMetadataMock(ctx, logger, metadata, inputPath) } +func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword) +} + // PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface. type PdfEngineProviderMock struct { PdfEngineMock func() (PdfEngine, error) diff --git a/pkg/gotenberg/pdfengine.go b/pkg/gotenberg/pdfengine.go index bb9d43ab..a27910ec 100644 --- a/pkg/gotenberg/pdfengine.go +++ b/pkg/gotenberg/pdfengine.go @@ -23,6 +23,10 @@ var ( // ErrPdfEngineMetadataValueNotSupported is returned when a metadata value // is not supported. ErrPdfEngineMetadataValueNotSupported = errors.New("metadata value not supported") + + // ErrPdfEncryptionNotSupported is returned when encryption + // is not supported by the PDF engine. + ErrPdfEncryptionNotSupported = errors.New("encryption not supported") ) const ( @@ -113,6 +117,12 @@ type PdfEngine interface { // WriteMetadata writes the metadata into a given PDF file. WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) 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 *zap.Logger, inputPath, userPassword, ownerPassword string) error } // PdfEngineProvider offers an interface to instantiate a [PdfEngine]. diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go index 080837d1..56d67296 100644 --- a/pkg/modules/chromium/routes.go +++ b/pkg/modules/chromium/routes.go @@ -332,6 +332,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) var url string err := form. @@ -341,7 +342,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate form data: %w", err) } - err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata) + err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword) if err != nil { return fmt.Errorf("convert URL to PDF: %w", err) } @@ -393,6 +394,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) var inputPath string err := form. @@ -403,7 +405,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { } url := fmt.Sprintf("file://%s", inputPath) - err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata) + err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword) if err != nil { return fmt.Errorf("convert HTML to PDF: %w", err) } @@ -456,6 +458,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) var ( inputPath string @@ -475,7 +478,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("transform markdown file(s) to HTML: %w", err) } - err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata) + err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword) if err != nil { return fmt.Errorf("convert markdown to PDF: %w", err) } @@ -599,7 +602,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]interface{}) error { +func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]interface{}, userPassword, ownerPassword string) error { outputPath := ctx.GeneratePath(".pdf") // See https://github.com/gotenberg/gotenberg/issues/1130. filename := ctx.OutputFilename(outputPath) @@ -656,6 +659,11 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url return fmt.Errorf("write metadata: %w", err) } + err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths) + if err != nil { + return fmt.Errorf("encrypt PDFs: %w", err) + } + zeroValuedSplitMode := gotenberg.SplitMode{} zeroValuedPdfFormats := gotenberg.PdfFormats{} if mode != zeroValuedSplitMode && pdfFormats != zeroValuedPdfFormats { diff --git a/pkg/modules/exiftool/exiftool.go b/pkg/modules/exiftool/exiftool.go index 6127035f..ae33e884 100644 --- a/pkg/modules/exiftool/exiftool.go +++ b/pkg/modules/exiftool/exiftool.go @@ -176,6 +176,11 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m return nil } +// Encrypt is not available in this implementation. +func (engine *ExifTool) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + return fmt.Errorf("encrypt PDF using ExifTool: %w", gotenberg.ErrPdfEncryptionNotSupported) +} + // Interface guards. var ( _ gotenberg.Module = (*ExifTool)(nil) diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine.go b/pkg/modules/libreoffice/pdfengine/pdfengine.go index 94dfc4f1..e47d9b8d 100644 --- a/pkg/modules/libreoffice/pdfengine/pdfengine.go +++ b/pkg/modules/libreoffice/pdfengine/pdfengine.go @@ -91,6 +91,11 @@ func (engine *LibreOfficePdfEngine) WriteMetadata(ctx context.Context, logger *z return fmt.Errorf("write PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// Encrypt is not available in this implementation. +func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + return fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // Interface guards. var ( _ gotenberg.Module = (*LibreOfficePdfEngine)(nil) diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go index ceaf282e..748f6a72 100644 --- a/pkg/modules/libreoffice/routes.go +++ b/pkg/modules/libreoffice/routes.go @@ -30,6 +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) zeroValuedSplitMode := gotenberg.SplitMode{} @@ -277,6 +278,11 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap } } + err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths) + if err != nil { + return fmt.Errorf("encrypt PDFs: %w", err) + } + err = ctx.AddOutputPaths(outputPaths...) if err != nil { return fmt.Errorf("add output paths: %w", err) diff --git a/pkg/modules/pdfcpu/pdfcpu.go b/pkg/modules/pdfcpu/pdfcpu.go index 50b7acbe..d681f952 100644 --- a/pkg/modules/pdfcpu/pdfcpu.go +++ b/pkg/modules/pdfcpu/pdfcpu.go @@ -171,6 +171,38 @@ func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *zap.Logger, met return fmt.Errorf("write PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// Encrypt adds password protection to a PDF file using pdfcpu. +func (engine *PdfCpu) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + if userPassword == "" { + return errors.New("user password cannot be empty") + } + + // If owner password is not provided, use the user password as owner password + if ownerPassword == "" { + ownerPassword = userPassword + } + + var args []string + args = append(args, "encrypt") + args = append(args, "-mode", "aes") // Use AES encryption + args = append(args, "-upw", userPassword) + args = append(args, "-opw", ownerPassword) + args = append(args, "-perm", "all") // Grant all permissions with owner password + args = append(args, inputPath, inputPath) + + cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) + if err != nil { + return fmt.Errorf("create command: %w", err) + } + + _, err = cmd.Exec() + if err != nil { + return fmt.Errorf("encrypt PDF with pdfcpu: %w", err) + } + + return nil +} + // Interface guards. var ( _ gotenberg.Module = (*PdfCpu)(nil) diff --git a/pkg/modules/pdfengines/multi.go b/pkg/modules/pdfengines/multi.go index 4347a0db..640c776c 100644 --- a/pkg/modules/pdfengines/multi.go +++ b/pkg/modules/pdfengines/multi.go @@ -18,6 +18,7 @@ type multiPdfEngines struct { convertEngines []gotenberg.PdfEngine readMetadataEngines []gotenberg.PdfEngine writeMetadataEngines []gotenberg.PdfEngine + passwordEngines []gotenberg.PdfEngine } func newMultiPdfEngines( @@ -26,7 +27,8 @@ func newMultiPdfEngines( flattenEngines, convertEngines, readMetadataEngines, - writeMetadataEngines []gotenberg.PdfEngine, + writeMetadataEngines, + passwordEngines []gotenberg.PdfEngine, ) *multiPdfEngines { return &multiPdfEngines{ mergeEngines: mergeEngines, @@ -35,6 +37,7 @@ func newMultiPdfEngines( convertEngines: convertEngines, readMetadataEngines: readMetadataEngines, writeMetadataEngines: writeMetadataEngines, + passwordEngines: passwordEngines, } } @@ -206,6 +209,31 @@ func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Log return fmt.Errorf("write PDF metadata with multi PDF engines: %w", err) } +// 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 *zap.Logger, inputPath, userPassword, ownerPassword string) error { + var err error + errChan := make(chan error, 1) + + for _, engine := range multi.passwordEngines { + go func(engine gotenberg.PdfEngine) { + errChan <- engine.Encrypt(ctx, logger, inputPath, userPassword, ownerPassword) + }(engine) + + select { + case protectErr := <-errChan: + errored := multierr.AppendInto(&err, protectErr) + if !errored { + return nil + } + case <-ctx.Done(): + return ctx.Err() + } + } + + return fmt.Errorf("encrypt PDF using multi PDF engines: %w", err) +} + // Interface guards. var ( _ gotenberg.PdfEngine = (*multiPdfEngines)(nil) diff --git a/pkg/modules/pdfengines/multi_test.go b/pkg/modules/pdfengines/multi_test.go index f5d5b211..496a4403 100644 --- a/pkg/modules/pdfengines/multi_test.go +++ b/pkg/modules/pdfengines/multi_test.go @@ -103,6 +103,97 @@ func TestMultiPdfEngines_Merge(t *testing.T) { } } +func TestMultiPdfEngines_Encrypt(t *testing.T) { + for _, tc := range []struct { + scenario string + engine *multiPdfEngines + ctx context.Context + expectError bool + }{ + { + scenario: "nominal behavior", + engine: &multiPdfEngines{ + passwordEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + return nil + }, + }, + }, + }, + ctx: context.Background(), + }, + { + scenario: "at least one engine does not return an error", + engine: &multiPdfEngines{ + passwordEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + return errors.New("foo") + }, + }, + &gotenberg.PdfEngineMock{ + EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + return nil + }, + }, + }, + }, + ctx: context.Background(), + }, + { + scenario: "all engines return an error", + engine: &multiPdfEngines{ + passwordEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + return errors.New("foo") + }, + }, + &gotenberg.PdfEngineMock{ + EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + return errors.New("foo") + }, + }, + }, + }, + ctx: context.Background(), + expectError: true, + }, + { + scenario: "context expired", + engine: &multiPdfEngines{ + passwordEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + return nil + }, + }, + }, + }, + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + return ctx + }(), + expectError: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + err := tc.engine.Encrypt(tc.ctx, zap.NewNop(), "", "", "") + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + if tc.expectError && err == nil { + t.Fatal("expected error but got none") + } + }) + } +} + func TestMultiPdfEngines_Split(t *testing.T) { for _, tc := range []struct { scenario string diff --git a/pkg/modules/pdfengines/pdfengines.go b/pkg/modules/pdfengines/pdfengines.go index c73ea85d..48f1050a 100644 --- a/pkg/modules/pdfengines/pdfengines.go +++ b/pkg/modules/pdfengines/pdfengines.go @@ -33,6 +33,7 @@ type PdfEngines struct { convertNames []string readMetadataNames []string writeMetadataNames []string + encryptNames []string engines []gotenberg.PdfEngine disableRoutes bool } @@ -49,6 +50,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor { fs.StringSlice("pdfengines-convert-engines", []string{"libreoffice-pdfengine"}, "Set the PDF engines and their order for the convert feature - empty means all") fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all") fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all") + fs.StringSlice("pdfengines-encrypt-engines", []string{"qpdf", "pdftk", "pdfcpu"}, "Set the PDF engines and their order for the password protection feature - empty means all") fs.Bool("pdfengines-disable-routes", false, "Disable the routes") // Deprecated flags. @@ -74,6 +76,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { convertNames := flags.MustStringSlice("pdfengines-convert-engines") readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines") writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines") + encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines") mod.disableRoutes = flags.MustBool("pdfengines-disable-routes") engines, err := ctx.Modules(new(gotenberg.PdfEngine)) @@ -130,6 +133,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { mod.writeMetadataNames = writeMetadataNames } + mod.encryptNames = defaultNames + if len(encryptNames) > 0 { + mod.encryptNames = encryptNames + } + return nil } @@ -183,6 +191,7 @@ func (mod *PdfEngines) Validate() error { findNonExistingEngines(mod.convertNames) findNonExistingEngines(mod.readMetadataNames) findNonExistingEngines(mod.writeMetadataNames) + findNonExistingEngines(mod.encryptNames) if len(nonExistingEngines) == 0 { return nil @@ -201,6 +210,7 @@ func (mod *PdfEngines) SystemMessages() []string { fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")), fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")), fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames[:], " ")), + fmt.Sprintf("password protection engines - %s", strings.Join(mod.encryptNames[:], " ")), } } @@ -227,6 +237,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) { engines(mod.convertNames), engines(mod.readMetadataNames), engines(mod.writeMetadataNames), + engines(mod.encryptNames), ), nil } @@ -250,6 +261,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) { convertRoute(engine), readMetadataRoute(engine), writeMetadataRoute(engine), + encryptRoute(engine), }, nil } diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go index a3161424..262a18af 100644 --- a/pkg/modules/pdfengines/routes.go +++ b/pkg/modules/pdfengines/routes.go @@ -254,6 +254,29 @@ func WriteMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata ma return nil } +// EncryptPdfStub adds password protection to PDF files. +// 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 +} + +func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, userPassword, ownerPassword string, inputPaths []string) error { + if userPassword == "" { + return nil + } + + for _, inputPath := range inputPaths { + err := engine.Encrypt(ctx, ctx.Log(), inputPath, userPassword, ownerPassword) + if err != nil { + return fmt.Errorf("encrypt PDF '%s': %w", inputPath, err) + } + } + + return nil +} + // mergeRoute returns an [api.Route] which can merge PDFs. func mergeRoute(engine gotenberg.PdfEngine) api.Route { return api.Route{ @@ -266,6 +289,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { form := ctx.FormData() pdfFormats := FormDataPdfFormats(form) metadata := FormDataPdfMetadata(form, false) + userPassword, ownerPassword := FormDataPdfEncrypt(form) var inputPaths []string var flatten bool @@ -300,6 +324,11 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { } } + err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths) + if err != nil { + return fmt.Errorf("encrypt PDFs: %w", err) + } + err = ctx.AddOutputPaths(outputPaths...) if err != nil { return fmt.Errorf("add output paths: %w", err) @@ -323,6 +352,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route { mode := FormDataPdfSplitMode(form, true) pdfFormats := FormDataPdfFormats(form) metadata := FormDataPdfMetadata(form, false) + userPassword, ownerPassword := FormDataPdfEncrypt(form) var inputPaths []string var flatten bool @@ -356,6 +386,11 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route { } } + err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths) + if err != nil { + return fmt.Errorf("encrypt PDFs: %w", err) + } + zeroValuedSplitMode := gotenberg.SplitMode{} zeroValuedPdfFormats := gotenberg.PdfFormats{} if mode != zeroValuedSplitMode && pdfFormats != zeroValuedPdfFormats { @@ -424,6 +459,7 @@ func convertRoute(engine gotenberg.PdfEngine) api.Route { form := ctx.FormData() pdfFormats := FormDataPdfFormats(form) + userPassword, ownerPassword := FormDataPdfEncrypt(form) var inputPaths []string err := form. @@ -456,11 +492,15 @@ func convertRoute(engine gotenberg.PdfEngine) api.Route { if err != nil { return fmt.Errorf("rename output path: %w", err) } - outputPaths[i] = inputPath } } + err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths) + if err != nil { + return fmt.Errorf("encrypt PDFs: %w", err) + } + err = ctx.AddOutputPaths(outputPaths...) if err != nil { return fmt.Errorf("add output paths: %w", err) @@ -548,3 +588,41 @@ func writeMetadataRoute(engine gotenberg.PdfEngine) api.Route { }, } } + +// encryptRoute returns an [api.Route] which can add password protection to PDFs. +func encryptRoute(engine gotenberg.PdfEngine) api.Route { + return api.Route{ + Method: http.MethodPost, + Path: "/forms/pdfengines/encrypt", + IsMultipart: true, + Handler: func(c echo.Context) error { + ctx := c.Get("context").(*api.Context) + + form := ctx.FormData() + + 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) + if err != nil { + return fmt.Errorf("encrypt PDFs: %w", err) + } + + err = ctx.AddOutputPaths(inputPaths...) + if err != nil { + return fmt.Errorf("add output paths: %w", err) + } + + return nil + }, + } +} diff --git a/pkg/modules/pdftk/pdftk.go b/pkg/modules/pdftk/pdftk.go index b2831dcc..755d172b 100644 --- a/pkg/modules/pdftk/pdftk.go +++ b/pkg/modules/pdftk/pdftk.go @@ -145,6 +145,43 @@ func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *zap.Logger, meta return fmt.Errorf("write PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// Encrypt adds password protection to a PDF file using PDFtk. +func (engine *PdfTk) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + if userPassword == "" { + return errors.New("user password cannot be empty") + } + + // If owner password is not provided, use the user password as owner password + if ownerPassword == "" { + ownerPassword = userPassword + } + + var args []string + args = append(args, inputPath) + args = append(args, "output", inputPath) + args = append(args, "encrypt_128bit") + + if userPassword != "" { + args = append(args, "user_pw", userPassword) + } + + if ownerPassword != "" { + args = append(args, "owner_pw", ownerPassword) + } + + cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) + if err != nil { + return fmt.Errorf("create command: %w", err) + } + + _, err = cmd.Exec() + if err != nil { + return fmt.Errorf("encrypt PDF with PDFtk: %w", err) + } + + return nil +} + // Interface guards. var ( _ gotenberg.Module = (*PdfTk)(nil) diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go index 7a65991b..8d2cd1f8 100644 --- a/pkg/modules/qpdf/qpdf.go +++ b/pkg/modules/qpdf/qpdf.go @@ -172,6 +172,37 @@ func (engine *QPdf) WriteMetadata(ctx context.Context, logger *zap.Logger, metad return fmt.Errorf("write PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// Encrypt adds password protection to a PDF file using QPDF. +func (engine *QPdf) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { + if userPassword == "" { + return errors.New("user password cannot be empty") + } + + // If owner password is not provided, use the user password as owner password + if ownerPassword == "" { + ownerPassword = userPassword + } + + // QPDF command to encrypt a PDF + var args []string + args = append(args, inputPath) + args = append(args, engine.globalArgs...) + args = append(args, "--encrypt", userPassword, ownerPassword, "256", "--use-aes=y", "--") + args = append(args, inputPath) + + cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) + if err != nil { + return fmt.Errorf("create command: %w", err) + } + + _, err = cmd.Exec() + if err != nil { + return fmt.Errorf("encrypt PDF with QPDF: %w", err) + } + + return nil +} + var ( _ gotenberg.Module = (*QPdf)(nil) _ gotenberg.Provisioner = (*QPdf)(nil) diff --git a/test/integration/features/chromium_convert_html.feature b/test/integration/features/chromium_convert_html.feature index ab77d1bc..c34db024 100644 --- a/test/integration/features/chromium_convert_html.feature +++ b/test/integration/features/chromium_convert_html.feature @@ -920,3 +920,75 @@ Feature: /forms/chromium/convert/html | files | testdata/page-1-html/index.html | file | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" + + Scenario: POST /forms/chromium/convert/html with encryption (user password only) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | userPassword | test123 | field | + | Gotenberg-Output-Filename | encrypted | header | + 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 there should be the following file(s) in the response: + | encrypted.pdf | + Then the "encrypted.pdf" PDF should be encrypted + Then the "encrypted.pdf" PDF should have 1 page(s) + + Scenario: POST /forms/chromium/convert/html with encryption (user and owner passwords) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | userPassword | user123 | field | + | ownerPassword | owner456 | field | + | Gotenberg-Output-Filename | encrypted | header | + 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 there should be the following file(s) in the response: + | encrypted.pdf | + Then the "encrypted.pdf" PDF should be encrypted + Then the "encrypted.pdf" PDF should have 1 page(s) + + Scenario: POST /forms/chromium/convert/html with encryption and PDF/A conversion + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | userPassword | test123 | field | + | pdfa | PDF/A-1a | field | + | Gotenberg-Output-Filename | encrypted | header | + 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 there should be the following file(s) in the response: + | encrypted.pdf | + Then the "encrypted.pdf" PDF should be encrypted + Then the "encrypted.pdf" PDF should have 1 page(s) + + Scenario: POST /forms/chromium/convert/html with encryption and page splitting + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/pages-12-html/index.html | file | + | userPassword | test123 | field | + | splitMode | intervals | field | + | splitSpan | 5 | field | + | Gotenberg-Output-Filename | encrypted | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be the following file(s) in the response: + | encrypted.zip | + Then the "encrypted.zip" archive should contain encrypted PDF file(s) + + Scenario: POST /forms/chromium/convert/html without encryption (empty password) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | userPassword | | field | + | Gotenberg-Output-Filename | unencrypted | header | + 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 there should be the following file(s) in the response: + | unencrypted.pdf | + Then the "unencrypted.pdf" PDF should NOT be encrypted + Then the "unencrypted.pdf" PDF should have 1 page(s) diff --git a/test/integration/features/chromium_convert_markdown.feature b/test/integration/features/chromium_convert_markdown.feature index aa50c65c..f080b1b4 100644 --- a/test/integration/features/chromium_convert_markdown.feature +++ b/test/integration/features/chromium_convert_markdown.feature @@ -1051,3 +1051,81 @@ Feature: /forms/chromium/convert/markdown | files | testdata/page-1-markdown/page_1.md | file | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" + + Scenario: POST /forms/chromium/convert/markdown with encryption (user password only) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | userPassword | test123 | field | + | Gotenberg-Output-Filename | encrypted | header | + 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 there should be the following file(s) in the response: + | encrypted.pdf | + Then the "encrypted.pdf" PDF should be encrypted + Then the "encrypted.pdf" PDF should have 1 page(s) + + Scenario: POST /forms/chromium/convert/markdown with encryption (user and owner passwords) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | userPassword | user123 | field | + | ownerPassword | owner456 | field | + | Gotenberg-Output-Filename | encrypted | header | + 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 there should be the following file(s) in the response: + | encrypted.pdf | + Then the "encrypted.pdf" PDF should be encrypted + Then the "encrypted.pdf" PDF should have 1 page(s) + + Scenario: POST /forms/chromium/convert/markdown with encryption and PDF/A conversion + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | userPassword | test123 | field | + | pdfa | PDF/A-1a | field | + | Gotenberg-Output-Filename | encrypted | header | + 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 there should be the following file(s) in the response: + | encrypted.pdf | + Then the "encrypted.pdf" PDF should be encrypted + Then the "encrypted.pdf" PDF should have 1 page(s) + + Scenario: POST /forms/chromium/convert/markdown with encryption and page splitting + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/pages-12-markdown/index.html | file | + | files | testdata/pages-12-markdown/page_1.md | file | + | files | testdata/pages-12-markdown/page_2.md | file | + | userPassword | test123 | field | + | splitMode | intervals | field | + | splitSpan | 5 | field | + | Gotenberg-Output-Filename | encrypted | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be the following file(s) in the response: + | encrypted.zip | + Then the "encrypted.zip" archive should contain encrypted PDF file(s) + + Scenario: POST /forms/chromium/convert/markdown without encryption (empty password) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | userPassword | | field | + | Gotenberg-Output-Filename | unencrypted | header | + 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 there should be the following file(s) in the response: + | unencrypted.pdf | + Then the "unencrypted.pdf" PDF should NOT be encrypted + Then the "unencrypted.pdf" PDF should have 1 page(s) diff --git a/test/integration/features/chromium_convert_url.feature b/test/integration/features/chromium_convert_url.feature index 6bcae01a..db8f4773 100644 --- a/test/integration/features/chromium_convert_url.feature +++ b/test/integration/features/chromium_convert_url.feature @@ -999,3 +999,80 @@ Feature: /forms/chromium/convert/url | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" + + Scenario: POST /forms/chromium/convert/url with encryption (user password only) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | userPassword | test123 | field | + | Gotenberg-Output-Filename | encrypted | header | + 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 there should be the following file(s) in the response: + | encrypted.pdf | + Then the "encrypted.pdf" PDF should be encrypted + Then the "encrypted.pdf" PDF should have 1 page(s) + + Scenario: POST /forms/chromium/convert/url with encryption (user and owner passwords) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | userPassword | user123 | field | + | ownerPassword | owner456 | field | + | Gotenberg-Output-Filename | encrypted | header | + 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 there should be the following file(s) in the response: + | encrypted.pdf | + Then the "encrypted.pdf" PDF should be encrypted + Then the "encrypted.pdf" PDF should have 1 page(s) + + Scenario: POST /forms/chromium/convert/url with encryption and PDF/A conversion + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | userPassword | test123 | field | + | pdfa | PDF/A-1a | field | + | Gotenberg-Output-Filename | encrypted | header | + 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 there should be the following file(s) in the response: + | encrypted.pdf | + Then the "encrypted.pdf" PDF should be encrypted + Then the "encrypted.pdf" PDF should have 1 page(s) + + Scenario: POST /forms/chromium/convert/url with encryption and page splitting + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/pages-12-html/index.html | field | + | userPassword | test123 | field | + | splitMode | intervals | field | + | splitSpan | 5 | field | + | Gotenberg-Output-Filename | encrypted | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be the following file(s) in the response: + | encrypted.zip | + Then the "encrypted.zip" archive should contain encrypted PDF file(s) + + Scenario: POST /forms/chromium/convert/url without encryption (empty password) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | userPassword | | field | + | Gotenberg-Output-Filename | unencrypted | header | + 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 there should be the following file(s) in the response: + | unencrypted.pdf | + Then the "unencrypted.pdf" PDF should NOT be encrypted + Then the "unencrypted.pdf" PDF should have 1 page(s) diff --git a/test/integration/features/libreoffice_convert.feature b/test/integration/features/libreoffice_convert.feature index 8a96a936..15ebb29f 100644 --- a/test/integration/features/libreoffice_convert.feature +++ b/test/integration/features/libreoffice_convert.feature @@ -631,3 +631,90 @@ Feature: /forms/libreoffice/convert | files | testdata/page_1.docx | file | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" + + Scenario: POST /forms/libreoffice/convert with encryption (user password only) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | userPassword | test123 | field | + | Gotenberg-Output-Filename | encrypted | header | + 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 there should be the following file(s) in the response: + | encrypted.pdf | + Then the "encrypted.pdf" PDF should be encrypted + Then the "encrypted.pdf" PDF should have 1 page(s) + + Scenario: POST /forms/libreoffice/convert with encryption (user and owner passwords) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | userPassword | user123 | field | + | ownerPassword | owner456 | field | + | Gotenberg-Output-Filename | encrypted | header | + 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 there should be the following file(s) in the response: + | encrypted.pdf | + Then the "encrypted.pdf" PDF should be encrypted + Then the "encrypted.pdf" PDF should have 1 page(s) + + Scenario: POST /forms/libreoffice/convert with encryption and PDF/A conversion + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | userPassword | test123 | field | + | pdfa | PDF/A-1a | field | + | Gotenberg-Output-Filename | encrypted | header | + 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 there should be the following file(s) in the response: + | encrypted.pdf | + Then the "encrypted.pdf" PDF should be encrypted + Then the "encrypted.pdf" PDF should have 1 page(s) + + Scenario: POST /forms/libreoffice/convert with encryption (multiple files) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | files | testdata/page_2.docx | file | + | userPassword | test123 | field | + | Gotenberg-Output-Filename | encrypted | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be the following file(s) in the response: + | encrypted.zip | + Then the response PDF(s) should be encrypted + Then the "encrypted.zip" archive should contain encrypted PDF file(s) + + Scenario: POST /forms/libreoffice/convert with encryption and flattening + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | userPassword | test123 | field | + | flatten | true | field | + | Gotenberg-Output-Filename | encrypted | header | + 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 there should be the following file(s) in the response: + | encrypted.pdf | + Then the "encrypted.pdf" PDF should be encrypted + Then the "encrypted.pdf" PDF should have 1 page(s) + + Scenario: POST /forms/libreoffice/convert without encryption (empty password) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | userPassword | | field | + | Gotenberg-Output-Filename | unencrypted | header | + 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 there should be the following file(s) in the response: + | unencrypted.pdf | + Then the "unencrypted.pdf" PDF should NOT be encrypted + Then the "unencrypted.pdf" PDF should have 1 page(s) diff --git a/test/integration/features/pdfengines_convert.feature b/test/integration/features/pdfengines_convert.feature index 867dfcf0..b0a77dfb 100644 --- a/test/integration/features/pdfengines_convert.feature +++ b/test/integration/features/pdfengines_convert.feature @@ -183,3 +183,78 @@ Feature: /forms/pdfengines/convert | pdfa | PDF/A-1b | field | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" + + Scenario: POST /forms/pdfengines/convert with encryption (user password only) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | pdfa | PDF/A-1b | field | + | userPassword | test123 | field | + | Gotenberg-Output-Filename | encrypted | header | + 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 there should be the following file(s) in the response: + | encrypted.pdf | + Then the "encrypted.pdf" PDF should be encrypted + Then the "encrypted.pdf" PDF should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s) + + Scenario: POST /forms/pdfengines/convert with encryption (user and owner passwords) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | pdfa | PDF/A-1b | field | + | userPassword | user123 | field | + | ownerPassword | owner456 | field | + | Gotenberg-Output-Filename | encrypted | header | + 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 there should be the following file(s) in the response: + | encrypted.pdf | + Then the "encrypted.pdf" PDF should be encrypted + Then the "encrypted.pdf" PDF should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s) + + Scenario: POST /forms/pdfengines/convert with encryption (multiple files) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | pdfa | PDF/A-1b | field | + | userPassword | test123 | field | + | Gotenberg-Output-Filename | encrypted | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be the following file(s) in the response: + | encrypted.zip | + Then the response PDF(s) should be encrypted + Then the "encrypted.zip" archive should contain encrypted PDF file(s) + + Scenario: POST /forms/pdfengines/convert with encryption and PDF/UA + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | pdfua | true | field | + | userPassword | test123 | field | + | Gotenberg-Output-Filename | encrypted | header | + 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 there should be the following file(s) in the response: + | encrypted.pdf | + Then the "encrypted.pdf" PDF should be encrypted + + Scenario: POST /forms/pdfengines/convert without encryption (empty password) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | pdfa | PDF/A-1b | field | + | userPassword | | field | + | Gotenberg-Output-Filename | unencrypted | header | + 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 there should be the following file(s) in the response: + | unencrypted.pdf | + Then the "unencrypted.pdf" PDF should NOT be encrypted + Then the "unencrypted.pdf" PDF should be valid "PDF/A-1b" with a tolerance of 1 failed rule(s) diff --git a/test/integration/features/pdfengines_encrypt.feature b/test/integration/features/pdfengines_encrypt.feature new file mode 100644 index 00000000..28901690 --- /dev/null +++ b/test/integration/features/pdfengines_encrypt.feature @@ -0,0 +1,129 @@ +Feature: /forms/pdfengines/encrypt + + Scenario: POST /forms/pdfengines/encrypt (default - QPDF) + 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 | + | userPassword | test123 | field | + | Gotenberg-Output-Filename | protected | header | + 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 there should be the following file(s) in the response: + | protected.pdf | + Then the "protected.pdf" PDF should be encrypted + + Scenario: POST /forms/pdfengines/encrypt with user and owner passwords (QPDF) + 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 | + | userPassword | user123 | field | + | ownerPassword | owner456 | field | + | Gotenberg-Output-Filename | protected | header | + 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 there should be the following file(s) in the response: + | protected.pdf | + Then the "protected.pdf" PDF should be encrypted + + Scenario: POST /forms/pdfengines/encrypt (PDFtk) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_PASSWORD_ENGINES | pdftk | + 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 | + | userPassword | test123 | field | + | Gotenberg-Output-Filename | protected | header | + 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 there should be the following file(s) in the response: + | protected.pdf | + Then the "protected.pdf" PDF should be encrypted + + Scenario: POST /forms/pdfengines/encrypt (pdfcpu) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_PASSWORD_ENGINES | pdfcpu | + 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 | + | userPassword | test123 | field | + | Gotenberg-Output-Filename | protected | header | + 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 there should be the following file(s) in the response: + | protected.pdf | + Then the "protected.pdf" PDF should be encrypted + + Scenario: POST /forms/pdfengines/encrypt with multiple files + 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 | + | files | testdata/page_2.pdf | file | + | userPassword | test123 | field | + | Gotenberg-Output-Filename | protected | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be the following file(s) in the response: + | protected.zip | + Then the "protected.zip" archive should contain 2 file(s) + Then the "protected.zip" archive should contain encrypted PDF file(s) + + Scenario: POST /forms/pdfengines/encrypt without required userPassword field + 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 | + | Gotenberg-Output-Filename | protected | header | + Then the response status code should be 400 + Then the response body should contain "userPassword" + + Scenario: POST /forms/pdfengines/encrypt with password engines that don't support password protection + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_PASSWORD_ENGINES | exiftool | + 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 | + | userPassword | test123 | field | + | Gotenberg-Output-Filename | protected | header | + Then the response status code should be 500 + Then the response body should contain "password protection not supported" + + Scenario: POST /forms/pdfengines/encrypt (Download From) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s): + | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_1.pdf","extraHttpHeaders":{"X-Foo":"bar"}}] | field | + | userPassword | test123 | field | + | Gotenberg-Output-Filename | protected | header | + 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 there should be the following file(s) in the response: + | protected.pdf | + Then the "protected.pdf" PDF should be encrypted + + Scenario: POST /forms/pdfengines/encrypt (Webhook) + Given I have a default Gotenberg container + Given I have a webhook server + 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 | + | Gotenberg-Output-Filename | foo | header | + | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header | + | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header | + Then the response status code should be 204 + When I wait for the asynchronous request to the webhook + Then the webhook request header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the webhook request + Then there should be the following file(s) in the webhook request: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Page 1 + """ + + Scenario: POST /forms/pdfengines/encrypt (Routes Disabled) + Given I have a Gotenberg container with the following environment variable(s): + | PDFENGINES_DISABLE_ROUTES | true | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + Then the response status code should be 404 diff --git a/test/integration/features/pdfengines_merge.feature b/test/integration/features/pdfengines_merge.feature index 248a81ad..b6fdae99 100644 --- a/test/integration/features/pdfengines_merge.feature +++ b/test/integration/features/pdfengines_merge.feature @@ -322,3 +322,81 @@ Feature: /forms/pdfengines/merge | files | testdata/page_2.pdf | file | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" + + Scenario: POST /forms/pdfengines/merge with encryption (user password only) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | userPassword | test123 | field | + | Gotenberg-Output-Filename | encrypted | header | + 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 there should be the following file(s) in the response: + | encrypted.pdf | + Then the "encrypted.pdf" PDF should be encrypted + Then the "encrypted.pdf" PDF should have 2 page(s) + + Scenario: POST /forms/pdfengines/merge with encryption (user and owner passwords) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | userPassword | user123 | field | + | ownerPassword | owner456 | field | + | Gotenberg-Output-Filename | encrypted | header | + 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 there should be the following file(s) in the response: + | encrypted.pdf | + Then the "encrypted.pdf" PDF should be encrypted + Then the "encrypted.pdf" PDF should have 2 page(s) + + Scenario: POST /forms/pdfengines/merge with encryption and PDF/A conversion + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | userPassword | test123 | field | + | pdfa | PDF/A-1a | field | + | Gotenberg-Output-Filename | encrypted | header | + 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 there should be the following file(s) in the response: + | encrypted.pdf | + Then the "encrypted.pdf" PDF should be encrypted + Then the "encrypted.pdf" PDF should have 2 page(s) + + Scenario: POST /forms/pdfengines/merge with encryption and flattening + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | userPassword | test123 | field | + | flatten | true | field | + | Gotenberg-Output-Filename | encrypted | header | + 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 there should be the following file(s) in the response: + | encrypted.pdf | + Then the "encrypted.pdf" PDF should be encrypted + Then the "encrypted.pdf" PDF should have 2 page(s) + + Scenario: POST /forms/pdfengines/merge without encryption (empty password) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | userPassword | | field | + | Gotenberg-Output-Filename | unencrypted | header | + 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 there should be the following file(s) in the response: + | unencrypted.pdf | + Then the "unencrypted.pdf" PDF should NOT be encrypted + Then the "unencrypted.pdf" PDF should have 2 page(s) diff --git a/test/integration/features/pdfengines_metadata.feature b/test/integration/features/pdfengines_metadata.feature index 9c75588d..3cd0b291 100644 --- a/test/integration/features/pdfengines_metadata.feature +++ b/test/integration/features/pdfengines_metadata.feature @@ -284,3 +284,61 @@ Feature: /forms/pdfengines/{write|read} | files | teststore/foo.pdf | file | Then the response status code should be 200 Then the response header "Content-Type" should be "application/json" + + Scenario: POST /forms/pdfengines/metadata/write with encryption (user password only) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | metadata | {"Title":"Encrypted Sample","Author":"Test Author","Subject":"Test Subject"} | field | + | userPassword | test123 | field | + | Gotenberg-Output-Filename | encrypted | header | + 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 there should be the following file(s) in the response: + | encrypted.pdf | + Then the "encrypted.pdf" PDF should be encrypted + + Scenario: POST /forms/pdfengines/metadata/write with encryption (user and owner passwords) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | metadata | {"Title":"Encrypted Sample","Author":"Test Author","Subject":"Test Subject"} | field | + | userPassword | user123 | field | + | ownerPassword | owner456 | field | + | Gotenberg-Output-Filename | encrypted | header | + 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 there should be the following file(s) in the response: + | encrypted.pdf | + Then the "encrypted.pdf" PDF should be encrypted + + Scenario: POST /forms/pdfengines/metadata/write with encryption (multiple files) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | metadata | {"Title":"Encrypted Sample","Author":"Test Author","Subject":"Test Subject"} | field | + | userPassword | test123 | field | + | Gotenberg-Output-Filename | encrypted | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be the following file(s) in the response: + | encrypted.zip | + Then the response PDF(s) should be encrypted + Then the "encrypted.zip" archive should contain encrypted PDF file(s) + + Scenario: POST /forms/pdfengines/metadata/write without encryption (empty password) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | metadata | {"Title":"Unencrypted Sample","Author":"Test Author","Subject":"Test Subject"} | field | + | userPassword | | field | + | Gotenberg-Output-Filename | unencrypted | header | + 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 there should be the following file(s) in the response: + | unencrypted.pdf | + Then the "unencrypted.pdf" PDF should NOT be encrypted diff --git a/test/integration/features/pdfengines_split.feature b/test/integration/features/pdfengines_split.feature index d9813bfb..d336239b 100644 --- a/test/integration/features/pdfengines_split.feature +++ b/test/integration/features/pdfengines_split.feature @@ -549,3 +549,69 @@ Feature: /forms/pdfengines/split | splitSpan | 2 | field | Then the response status code should be 200 Then the response header "Content-Type" should be "application/zip" + + Scenario: POST /forms/pdfengines/split with encryption (intervals) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | userPassword | test123 | field | + | Gotenberg-Output-Filename | encrypted | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | encrypted.zip | + Then the response PDF(s) should be encrypted + Then the "encrypted.zip" archive should contain encrypted PDF file(s) + + Scenario: POST /forms/pdfengines/split with encryption (pages) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | pages | field | + | splitSpan | 2- | field | + | userPassword | user123 | field | + | ownerPassword | owner456 | field | + | Gotenberg-Output-Filename | encrypted | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | encrypted.zip | + Then the response PDF(s) should be encrypted + Then the "encrypted.zip" archive should contain encrypted PDF file(s) + + Scenario: POST /forms/pdfengines/split with encryption and PDF/A conversion + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | userPassword | test123 | field | + | pdfa | PDF/A-1a | field | + | Gotenberg-Output-Filename | encrypted | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | encrypted.zip | + Then the response PDF(s) should be encrypted + Then the "encrypted.zip" archive should contain encrypted PDF file(s) + + Scenario: POST /forms/pdfengines/split without encryption (empty password) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | userPassword | | field | + | Gotenberg-Output-Filename | unencrypted | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/zip" + Then there should be 2 PDF(s) in the response + Then there should be the following file(s) in the response: + | unencrypted.zip | + Then the "unencrypted.zip" archive should contain 2 file(s) + Then the "unencrypted.zip" archive should NOT contain encrypted PDF file(s) diff --git a/test/integration/scenario/scenario.go b/test/integration/scenario/scenario.go index a8f31a93..8a666675 100644 --- a/test/integration/scenario/scenario.go +++ b/test/integration/scenario/scenario.go @@ -839,6 +839,98 @@ func (s *scenario) thePdfsShouldBeFlatten(ctx context.Context, kind, should stri return nil } +// thePdfShouldBeEncrypted checks if a PDF file is encrypted or not encrypted based on condition. +func (s *scenario) thePdfShouldBeEncrypted(ctx context.Context, filename, should string) error { + filePath := fmt.Sprintf("%s/%s", s.workdir, filename) + + cmd := []string{ + "qpdf", + "--check", + filepath.Base(filePath), + } + + output, err := execCommandInIntegrationToolsContainer(ctx, cmd, filePath) + + invert := should == "should NOT" + isEncrypted := err != nil && (strings.Contains(output, "password") || strings.Contains(output, "encrypted")) + + if invert && isEncrypted { + return fmt.Errorf("expected PDF %s to not be encrypted, but it is encrypted", filename) + } + + if !invert && !isEncrypted { + return fmt.Errorf("expected PDF %s to be encrypted, but it is not", filename) + } + + return nil +} + +// theArchiveShouldContainEncryptedPdfFiles checks if a zip archive contains encrypted PDF files based on condition. +func (s *scenario) theArchiveShouldContainEncryptedPdfFiles(ctx context.Context, archiveFilename, should string) error { + archivePath := fmt.Sprintf("%s/%s", s.workdir, archiveFilename) + + // First, extract the archive inside the container + extractCmd := []string{ + "sh", + "-c", + fmt.Sprintf("mkdir -p /tmp/extract && unzip -o %s -d /tmp/extract", filepath.Base(archivePath)), + } + + _, err := execCommandInIntegrationToolsContainer(ctx, extractCmd, archivePath) + if err != nil { + return fmt.Errorf("extract archive in container: %w", err) + } + + // List PDF files in the extracted directory + listCmd := []string{ + "sh", + "-c", + "find /tmp/extract -name '*.pdf' -type f", + } + + output, err := execCommandInIntegrationToolsContainer(ctx, listCmd, archivePath) + if err != nil { + return fmt.Errorf("list PDFs in container: %w", err) + } + + // No PDFs found + if output == "" { + return fmt.Errorf("no PDF files found in archive %s", archiveFilename) + } + + // Check each PDF for password protection + pdfPaths := strings.Split(strings.TrimSpace(output), "\n") + foundProtectedPdf := false + + for _, pdfPath := range pdfPaths { + checkCmd := []string{ + "qpdf", + "--check", + pdfPath, + } + + checkOutput, err := execCommandInIntegrationToolsContainer(ctx, checkCmd, archivePath) + + // If we get a password error, we found a protected PDF + if err != nil && (strings.Contains(checkOutput, "password") || strings.Contains(checkOutput, "encrypted")) { + foundProtectedPdf = true + break + } + } + + invert := should == "should NOT" + + if invert && foundProtectedPdf { + return fmt.Errorf("found encrypted PDF files in archive %s, but expected none", archiveFilename) + } + + if !invert && !foundProtectedPdf { + return fmt.Errorf("no encrypted PDF files found in archive %s", archiveFilename) + } + + return nil +} + func InitializeScenario(ctx *godog.ScenarioContext) { s := &scenario{} ctx.Before(func(ctx context.Context, sc *godog.Scenario) (context.Context, error) { @@ -874,6 +966,8 @@ func InitializeScenario(ctx *godog.ScenarioContext) { ctx.Then(`^the "([^"]*)" PDF should have (\d+) page\(s\)$`, s.thePdfShouldHavePages) ctx.Then(`^the "([^"]*)" PDF (should|should NOT) be set to landscape orientation$`, s.thePdfShouldBeSetToLandscapeOrientation) ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage) + ctx.Then(`^the "([^"]*)" PDF (should|should NOT) be encrypted$`, s.thePdfShouldBeEncrypted) + ctx.Then(`^the "([^"]*)" archive (should|should NOT) contain encrypted PDF file\(s\)$`, s.theArchiveShouldContainEncryptedPdfFiles) ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) { if s.gotenbergContainer != nil { errTerminate := s.gotenbergContainer.Terminate(ctx, testcontainers.StopTimeout(0))