feat: add PDF encryption feature (#1217)

* Adding PDF encryption option

* LibreOffice
* QPDF
* PDFtk
* pdfcpu

* Update pkg/modules/pdfengines/pdfengines.go

Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>

* npx prettier

* go fmt

* PR comments

* Update test/integration/scenario/scenario.go

Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>

* renamed ProtectWithPassword to encrypt

* more clean up

* Use the same input path as requested

* This commit completes the encryption implementation

* Clean up. Added webhook, disable route and download from tests

---------

Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>
This commit is contained in:
Stevenson Michel
2025-05-27 08:49:24 -04:00
committed by Julien Neuhart
parent 638c4e6b23
commit 99307befdd
23 changed files with 1168 additions and 6 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
},
}
}