feat(libreoffice): add password form field for converting protected document

This commit is contained in:
Julien Neuhart
2024-09-20 20:36:45 +02:00
parent 2aa9cceb32
commit b06d7445d9
6 changed files with 126 additions and 16 deletions

View File

@@ -25,9 +25,11 @@ var (
// by LibreOffice. // by LibreOffice.
ErrInvalidPdfFormats = errors.New("invalid PDF formats") ErrInvalidPdfFormats = errors.New("invalid PDF formats")
// ErrMalformedPageRanges happens if the page ranges option cannot be // ErrUnoException happens when unoconverter returns an exit code 5.
// interpreted by LibreOffice. ErrUnoException = errors.New("uno exception")
ErrMalformedPageRanges = errors.New("page ranges are malformed")
// ErrRuntimeException happens when unoconverter returns an exit code 6.
ErrRuntimeException = errors.New("uno exception")
// ErrCoreDumped happens randomly; sometime a conversion will work as // ErrCoreDumped happens randomly; sometime a conversion will work as
// expected, and some other time the same conversion will fail. // 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. // Options gathers available options when converting a document to PDF.
// See: https://help.libreoffice.org/latest/en-US/text/shared/guide/pdf_params.html. // See: https://help.libreoffice.org/latest/en-US/text/shared/guide/pdf_params.html.
type Options struct { 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 allows to change the orientation of the resulting PDF.
Landscape bool Landscape bool
@@ -141,6 +146,7 @@ type Options struct {
// DefaultOptions returns the default values for Options. // DefaultOptions returns the default values for Options.
func DefaultOptions() Options { func DefaultOptions() Options {
return Options{ return Options{
Password: "",
Landscape: false, Landscape: false,
PageRanges: "", PageRanges: "",
ExportFormFields: true, 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. // See https://github.com/gotenberg/gotenberg/issues/639.
if errors.Is(err, ErrCoreDumped) { if errors.Is(err, ErrCoreDumped) {
logger.Debug(fmt.Sprintf("got a '%s' error, retry conversion", err))
return a.Pdf(ctx, logger, inputPath, outputPath, options) return a.Pdf(ctx, logger, inputPath, outputPath, options)
} }

View File

@@ -266,6 +266,10 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
args = append(args, "-vvv") args = append(args, "-vvv")
} }
if options.Password != "" {
args = append(args, "--password", options.Password)
}
if options.Landscape { if options.Landscape {
args = append(args, "--printer", "PaperOrientation=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. // LibreOffice's errors are not explicit.
// That's why we have to make an educated guess according to the exit code // For instance, an exit code 5 may be explained by a malformed page
// and given inputs. // ranges, but also by a not required password.
if exitCode == 5 && options.PageRanges != "" {
return ErrMalformedPageRanges
}
// We may want to retry in case of a core dumped event. // We may want to retry in case of a core dumped event.
// See https://github.com/gotenberg/gotenberg/issues/639. // 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 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: // Possible errors:
// 1. LibreOffice failed for some reason. // 1. LibreOffice failed for some reason.
// 2. Context done. // 2. Context done.

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"testing" "testing"
"time" "time"
@@ -250,7 +251,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
expectedError: ErrInvalidPdfFormats, expectedError: ErrInvalidPdfFormats,
}, },
{ {
scenario: "ErrMalformedPageRanges", scenario: "ErrUnoException",
libreOffice: newLibreOfficeProcess( libreOffice: newLibreOfficeProcess(
libreOfficeArguments{ libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), 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)) 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 { if err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
@@ -277,7 +278,61 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
cancelledCtx: false, cancelledCtx: false,
start: true, start: true,
expectError: 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", scenario: "context done",
@@ -360,6 +415,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
return fs return fs
}(), }(),
options: Options{ options: Options{
Password: "", // Ok, the only exception in this list.
Landscape: true, Landscape: true,
PageRanges: "1", PageRanges: "1",
ExportFormFields: false, ExportFormFields: false,

View File

@@ -29,6 +29,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
// Let's get the data from the form and validate them. // Let's get the data from the form and validate them.
var ( var (
inputPaths []string inputPaths []string
password string
landscape bool landscape bool
nativePageRanges string nativePageRanges string
exportFormFields bool exportFormFields bool
@@ -59,6 +60,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
err := ctx.FormData(). err := ctx.FormData().
MandatoryPaths(libreOffice.Extensions(), &inputPaths). MandatoryPaths(libreOffice.Extensions(), &inputPaths).
String("password", &password, defaultOptions.Password).
Bool("landscape", &landscape, defaultOptions.Landscape). Bool("landscape", &landscape, defaultOptions.Landscape).
String("nativePageRanges", &nativePageRanges, defaultOptions.PageRanges). String("nativePageRanges", &nativePageRanges, defaultOptions.PageRanges).
Bool("exportFormFields", &exportFormFields, defaultOptions.ExportFormFields). Bool("exportFormFields", &exportFormFields, defaultOptions.ExportFormFields).
@@ -146,6 +148,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
for i, inputPath := range inputPaths { for i, inputPath := range inputPaths {
outputPaths[i] = ctx.GeneratePath(".pdf") outputPaths[i] = ctx.GeneratePath(".pdf")
options := libreofficeapi.Options{ options := libreofficeapi.Options{
Password: password,
Landscape: landscape, Landscape: landscape,
PageRanges: nativePageRanges, PageRanges: nativePageRanges,
ExportFormFields: exportFormFields, 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( return api.WrapError(
fmt.Errorf("convert to PDF: %w", err), 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"),
) )
} }

View File

@@ -194,14 +194,14 @@ func TestConvertRoute(t *testing.T) {
expectOutputPathsCount: 0, expectOutputPathsCount: 0,
}, },
{ {
scenario: "ErrMalformedPageRanges", scenario: "ErrUnoException",
ctx: func() *api.ContextMock { ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)} ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{ ctx.SetFiles(map[string]string{
"document.docx": "/document.docx", "document.docx": "/document.docx",
}) })
ctx.SetValues(map[string][]string{ ctx.SetValues(map[string][]string{
"pdfa": { "nativePageRanges": {
"foo", "foo",
}, },
}) })
@@ -209,7 +209,34 @@ func TestConvertRoute(t *testing.T) {
}(), }(),
libreOffice: &libreofficeapi.ApiMock{ libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { 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 { ExtensionsMock: func() []string {
return []string{".docx"} return []string{".docx"}

BIN
test/testdata/libreoffice/protected.docx vendored Normal file

Binary file not shown.