From b06d7445d96eff88f414ccab02db2574ab49a1f7 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Fri, 20 Sep 2024 20:36:45 +0200 Subject: [PATCH] feat(libreoffice): add password form field for converting protected document --- pkg/modules/libreoffice/api/api.go | 13 +++- pkg/modules/libreoffice/api/libreoffice.go | 20 ++++-- .../libreoffice/api/libreoffice_test.go | 62 +++++++++++++++++- pkg/modules/libreoffice/routes.go | 14 +++- pkg/modules/libreoffice/routes_test.go | 33 +++++++++- test/testdata/libreoffice/protected.docx | Bin 0 -> 12288 bytes 6 files changed, 126 insertions(+), 16 deletions(-) create mode 100644 test/testdata/libreoffice/protected.docx diff --git a/pkg/modules/libreoffice/api/api.go b/pkg/modules/libreoffice/api/api.go index 7de4801f..e32712cf 100644 --- a/pkg/modules/libreoffice/api/api.go +++ b/pkg/modules/libreoffice/api/api.go @@ -25,9 +25,11 @@ var ( // by LibreOffice. ErrInvalidPdfFormats = errors.New("invalid PDF formats") - // ErrMalformedPageRanges happens if the page ranges option cannot be - // interpreted by LibreOffice. - ErrMalformedPageRanges = errors.New("page ranges are malformed") + // ErrUnoException happens when unoconverter returns an exit code 5. + ErrUnoException = errors.New("uno exception") + + // ErrRuntimeException happens when unoconverter returns an exit code 6. + ErrRuntimeException = errors.New("uno exception") // ErrCoreDumped happens randomly; sometime a conversion will work as // expected, and some other time the same conversion will fail. @@ -48,6 +50,9 @@ type Api struct { // Options gathers available options when converting a document to PDF. // See: https://help.libreoffice.org/latest/en-US/text/shared/guide/pdf_params.html. type Options struct { + // Password specifies the password for opening the source file. + Password string + // Landscape allows to change the orientation of the resulting PDF. Landscape bool @@ -141,6 +146,7 @@ type Options struct { // DefaultOptions returns the default values for Options. func DefaultOptions() Options { return Options{ + Password: "", Landscape: false, PageRanges: "", ExportFormFields: true, @@ -380,6 +386,7 @@ func (a *Api) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath // See https://github.com/gotenberg/gotenberg/issues/639. if errors.Is(err, ErrCoreDumped) { + logger.Debug(fmt.Sprintf("got a '%s' error, retry conversion", err)) return a.Pdf(ctx, logger, inputPath, outputPath, options) } diff --git a/pkg/modules/libreoffice/api/libreoffice.go b/pkg/modules/libreoffice/api/libreoffice.go index afc2b705..3eb7796f 100644 --- a/pkg/modules/libreoffice/api/libreoffice.go +++ b/pkg/modules/libreoffice/api/libreoffice.go @@ -266,6 +266,10 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP args = append(args, "-vvv") } + if options.Password != "" { + args = append(args, "--password", options.Password) + } + if options.Landscape { args = append(args, "--printer", "PaperOrientation=landscape") } @@ -343,11 +347,8 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP } // LibreOffice's errors are not explicit. - // That's why we have to make an educated guess according to the exit code - // and given inputs. - if exitCode == 5 && options.PageRanges != "" { - return ErrMalformedPageRanges - } + // For instance, an exit code 5 may be explained by a malformed page + // ranges, but also by a not required password. // We may want to retry in case of a core dumped event. // See https://github.com/gotenberg/gotenberg/issues/639. @@ -355,6 +356,15 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP return ErrCoreDumped } + if exitCode == 5 { + // Potentially malformed page ranges or password not required. + return ErrUnoException + } + if exitCode == 6 { + // Password potentially required or invalid. + return ErrRuntimeException + } + // Possible errors: // 1. LibreOffice failed for some reason. // 2. Context done. diff --git a/pkg/modules/libreoffice/api/libreoffice_test.go b/pkg/modules/libreoffice/api/libreoffice_test.go index a8de5048..953cb908 100644 --- a/pkg/modules/libreoffice/api/libreoffice_test.go +++ b/pkg/modules/libreoffice/api/libreoffice_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "os" "testing" "time" @@ -250,7 +251,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) { expectedError: ErrInvalidPdfFormats, }, { - scenario: "ErrMalformedPageRanges", + scenario: "ErrUnoException", libreOffice: newLibreOfficeProcess( libreOfficeArguments{ binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), @@ -267,7 +268,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) { t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) } - err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("ErrMalformedPageRanges"), 0o755) + err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Context done"), 0o755) if err != nil { t.Fatalf("expected no error but got: %v", err) } @@ -277,7 +278,61 @@ func TestLibreOfficeProcess_pdf(t *testing.T) { cancelledCtx: false, start: true, expectError: true, - expectedError: ErrMalformedPageRanges, + expectedError: ErrUnoException, + }, + { + scenario: "ErrRuntimeException", + libreOffice: newLibreOfficeProcess( + libreOfficeArguments{ + binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"), + startTimeout: 5 * time.Second, + }, + ), + options: Options{Password: "foo"}, + fs: func() *gotenberg.FileSystem { + fs := gotenberg.NewFileSystem() + + err := os.MkdirAll(fs.WorkingDirPath(), 0o755) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + in, err := os.Open("/tests/test/testdata/libreoffice/protected.docx") + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + defer func() { + err := in.Close() + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + }() + + out, err := os.Create(fmt.Sprintf("%s/protected.docx", fs.WorkingDirPath())) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + defer func() { + err := out.Close() + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + }() + + _, err = io.Copy(out, in) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + return fs + }(), + cancelledCtx: false, + start: true, + expectError: true, + expectedError: ErrRuntimeException, }, { scenario: "context done", @@ -360,6 +415,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) { return fs }(), options: Options{ + Password: "", // Ok, the only exception in this list. Landscape: true, PageRanges: "1", ExportFormFields: false, diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go index 1dde35f7..31d22d12 100644 --- a/pkg/modules/libreoffice/routes.go +++ b/pkg/modules/libreoffice/routes.go @@ -29,6 +29,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap // Let's get the data from the form and validate them. var ( inputPaths []string + password string landscape bool nativePageRanges string exportFormFields bool @@ -59,6 +60,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap err := ctx.FormData(). MandatoryPaths(libreOffice.Extensions(), &inputPaths). + String("password", &password, defaultOptions.Password). Bool("landscape", &landscape, defaultOptions.Landscape). String("nativePageRanges", &nativePageRanges, defaultOptions.PageRanges). Bool("exportFormFields", &exportFormFields, defaultOptions.ExportFormFields). @@ -146,6 +148,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap for i, inputPath := range inputPaths { outputPaths[i] = ctx.GeneratePath(".pdf") options := libreofficeapi.Options{ + Password: password, Landscape: landscape, PageRanges: nativePageRanges, ExportFormFields: exportFormFields, @@ -185,10 +188,17 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap ) } - if errors.Is(err, libreofficeapi.ErrMalformedPageRanges) { + if errors.Is(err, libreofficeapi.ErrUnoException) { return api.WrapError( fmt.Errorf("convert to PDF: %w", err), - api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Malformed page ranges '%s' (nativePageRanges)", options.PageRanges)), + api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice failed to process the document: possible causes include malformed page ranges '%s' (nativePageRanges) or the document might not be password-protected, but the exact cause is uncertain", options.PageRanges)), + ) + } + + if errors.Is(err, libreofficeapi.ErrRuntimeException) { + return api.WrapError( + fmt.Errorf("convert to PDF: %w", err), + api.NewSentinelHttpError(http.StatusBadRequest, "LibreOffice failed to process a document: a password may be invalid or required, but the exact cause is uncertain"), ) } diff --git a/pkg/modules/libreoffice/routes_test.go b/pkg/modules/libreoffice/routes_test.go index 56af4734..041e4165 100644 --- a/pkg/modules/libreoffice/routes_test.go +++ b/pkg/modules/libreoffice/routes_test.go @@ -194,14 +194,14 @@ func TestConvertRoute(t *testing.T) { expectOutputPathsCount: 0, }, { - scenario: "ErrMalformedPageRanges", + scenario: "ErrUnoException", ctx: func() *api.ContextMock { ctx := &api.ContextMock{Context: new(api.Context)} ctx.SetFiles(map[string]string{ "document.docx": "/document.docx", }) ctx.SetValues(map[string][]string{ - "pdfa": { + "nativePageRanges": { "foo", }, }) @@ -209,7 +209,34 @@ func TestConvertRoute(t *testing.T) { }(), libreOffice: &libreofficeapi.ApiMock{ PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { - return libreofficeapi.ErrMalformedPageRanges + return libreofficeapi.ErrUnoException + }, + ExtensionsMock: func() []string { + return []string{".docx"} + }, + }, + expectError: true, + expectHttpError: true, + expectHttpStatus: http.StatusBadRequest, + expectOutputPathsCount: 0, + }, + { + scenario: "ErrRuntimeException", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "document.docx": "/document.docx", + }) + ctx.SetValues(map[string][]string{ + "password": { + "invalid", + }, + }) + return ctx + }(), + libreOffice: &libreofficeapi.ApiMock{ + PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { + return libreofficeapi.ErrRuntimeException }, ExtensionsMock: func() []string { return []string{".docx"} diff --git a/test/testdata/libreoffice/protected.docx b/test/testdata/libreoffice/protected.docx new file mode 100644 index 0000000000000000000000000000000000000000..840d00d5632f950382b3403b8104d31174f62714 GIT binary patch literal 12288 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 literal 0 HcmV?d00001