From b2f6e865a700e41189ee080c9590d27aceb0b00c Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Thu, 4 Jan 2024 19:14:38 +0100 Subject: [PATCH] feat(chromium): capture screenshot (#765) --- go.mod | 10 +- go.sum | 22 +- pkg/modules/chromium/browser.go | 70 +- pkg/modules/chromium/browser_test.go | 901 +++++++++++++++++++++++++- pkg/modules/chromium/chromium.go | 155 +++-- pkg/modules/chromium/chromium_test.go | 54 +- pkg/modules/chromium/mocks.go | 18 +- pkg/modules/chromium/mocks_test.go | 24 +- pkg/modules/chromium/routes.go | 549 +++++++++++----- pkg/modules/chromium/routes_test.go | 825 +++++++++++++++++++++-- pkg/modules/chromium/tasks.go | 57 +- 11 files changed, 2334 insertions(+), 351 deletions(-) diff --git a/go.mod b/go.mod index 4aedbf31..aa70d824 100644 --- a/go.mod +++ b/go.mod @@ -13,21 +13,21 @@ require ( github.com/hashicorp/go-retryablehttp v0.7.5 github.com/klauspost/compress v1.17.4 // indirect github.com/klauspost/pgzip v1.2.6 // indirect - github.com/labstack/echo/v4 v4.11.3 - github.com/labstack/gommon v0.4.1 + github.com/labstack/echo/v4 v4.11.4 + github.com/labstack/gommon v0.4.2 github.com/mattn/go-isatty v0.0.20 // indirect github.com/mholt/archiver/v3 v3.5.1 github.com/microcosm-cc/bluemonday v1.0.26 github.com/nwaples/rardecode v1.1.3 // indirect github.com/pdfcpu/pdfcpu v0.6.0 github.com/pierrec/lz4/v4 v4.1.19 // indirect - github.com/prometheus/client_golang v1.17.0 + github.com/prometheus/client_golang v1.18.0 github.com/russross/blackfriday/v2 v2.1.0 github.com/spf13/pflag v1.0.5 github.com/ulikunitz/xz v0.5.11 // indirect go.uber.org/multierr v1.11.0 go.uber.org/zap v1.26.0 - golang.org/x/crypto v0.16.0 // indirect + golang.org/x/crypto v0.17.0 // indirect golang.org/x/image v0.14.0 // indirect golang.org/x/net v0.19.0 golang.org/x/sync v0.5.0 @@ -61,6 +61,6 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect - google.golang.org/protobuf v1.31.0 // indirect + google.golang.org/protobuf v1.32.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 1a967cc5..92cdd0b9 100644 --- a/go.sum +++ b/go.sum @@ -28,7 +28,6 @@ github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6Wezm github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= github.com/gobwas/ws v1.3.1 h1:Qi34dfLMWJbiKaNbDVzM9x27nZBjmkaW6i4+Ku+pGVU= github.com/gobwas/ws v1.3.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= @@ -63,10 +62,10 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/labstack/echo/v4 v4.11.3 h1:Upyu3olaqSHkCjs1EJJwQ3WId8b8b1hxbogyommKktM= -github.com/labstack/echo/v4 v4.11.3/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws= -github.com/labstack/gommon v0.4.1 h1:gqEff0p/hTENGMABzezPoPSRtIh1Cvw0ueMOe0/dfOk= -github.com/labstack/gommon v0.4.1/go.mod h1:TyTrpPqxR5KMk8LKVtLmfMjeQ5FEkBYdxLYPw/WfrOM= +github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8= +github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= @@ -98,8 +97,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= -github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= +github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= +github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA= github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= @@ -136,8 +135,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= -golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= -golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4= golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= @@ -153,9 +152,8 @@ golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= +google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/pkg/modules/chromium/browser.go b/pkg/modules/chromium/browser.go index 5dc79651..9fd164fb 100644 --- a/pkg/modules/chromium/browser.go +++ b/pkg/modules/chromium/browser.go @@ -22,7 +22,8 @@ import ( type browser interface { gotenberg.Process - pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error + pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error + screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error } type browserArguments struct { @@ -213,9 +214,53 @@ func (b *chromiumBrowser) Healthy(logger *zap.Logger) bool { return true } -func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { +func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { + // Note: no error wrapping because it leaks on errors we want to display to + // the end user. + return b.do(ctx, logger, url, options.Options, chromedp.Tasks{ + network.Enable(), + fetch.Enable(), + runtime.Enable(), + clearCacheActionFunc(logger, b.arguments.clearCache), + clearCookiesActionFunc(logger, b.arguments.clearCookies), + disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript), + extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders), + navigateActionFunc(logger, url, options.SkipNetworkIdleEvent), + hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground), + forceExactColorsActionFunc(), + emulateMediaTypeActionFunc(logger, options.EmulatedMediaType), + waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay), + waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression), + // PDF specific. + printToPdfActionFunc(logger, outputPath, options), + }) +} + +func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { + // Note: no error wrapping because it leaks on errors we want to display to + // the end user. + return b.do(ctx, logger, url, options.Options, chromedp.Tasks{ + network.Enable(), + fetch.Enable(), + runtime.Enable(), + clearCacheActionFunc(logger, b.arguments.clearCache), + clearCookiesActionFunc(logger, b.arguments.clearCookies), + disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript), + extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders), + navigateActionFunc(logger, url, options.SkipNetworkIdleEvent), + hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true), + forceExactColorsActionFunc(), + emulateMediaTypeActionFunc(logger, options.EmulatedMediaType), + waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay), + waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression), + // Screenshot specific. + captureScreenshotActionFunc(logger, outputPath, options), + }) +} + +func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string, options Options, tasks chromedp.Tasks) error { if !b.isStarted.Load() { - return errors.New("browser not started, cannot handle PDF conversion") + return errors.New("browser not started, cannot handle tasks") } // We validate the "main" URL against our allow / deny lists. @@ -265,23 +310,6 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp listenForEventExceptionThrown(taskCtx, logger, &consoleExceptions, &consoleExceptionsMu) } - tasks := chromedp.Tasks{ - network.Enable(), - fetch.Enable(), - runtime.Enable(), - clearCacheActionFunc(logger, b.arguments.clearCache), - clearCookiesActionFunc(logger, b.arguments.clearCookies), - disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript), - extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders), - navigateActionFunc(logger, url, options.SkipNetworkIdleEvent), - hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground), - forceExactColorsActionFunc(), - emulateMediaTypeActionFunc(logger, options.EmulatedMediaType), - waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay), - waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression), - printToPdfActionFunc(logger, outputPath, options), - } - err := chromedp.Run(taskCtx, tasks...) if err != nil { errMessage := err.Error() @@ -298,7 +326,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp return ErrRpccMessageTooLarge } - return fmt.Errorf("print to PDF: %w", err) + return fmt.Errorf("handle tasks: %w", err) } // See https://github.com/gotenberg/gotenberg/issues/613. diff --git a/pkg/modules/chromium/browser_test.go b/pkg/modules/chromium/browser_test.go index 9c782e14..59b6ce50 100644 --- a/pkg/modules/chromium/browser_test.go +++ b/pkg/modules/chromium/browser_test.go @@ -248,7 +248,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { scenario string browser browser fs *gotenberg.FileSystem - options Options + options PdfOptions noDeadline bool start bool expectError bool @@ -405,8 +405,8 @@ func TestChromiumBrowser_pdf(t *testing.T) { return fs }(), - options: Options{ - SkipNetworkIdleEvent: true, + options: PdfOptions{ + Options: Options{SkipNetworkIdleEvent: true}, }, noDeadline: false, start: true, @@ -440,8 +440,8 @@ func TestChromiumBrowser_pdf(t *testing.T) { return fs }(), - options: Options{ - FailOnHttpStatusCodes: []int64{299}, + options: PdfOptions{ + Options: Options{FailOnHttpStatusCodes: []int64{299}}, }, noDeadline: false, start: true, @@ -473,8 +473,8 @@ func TestChromiumBrowser_pdf(t *testing.T) { return fs }(), - options: Options{ - FailOnConsoleExceptions: true, + options: PdfOptions{ + Options: Options{FailOnConsoleExceptions: true}, }, noDeadline: false, start: true, @@ -607,10 +607,10 @@ func TestChromiumBrowser_pdf(t *testing.T) { return fs }(), - options: Options{ - ExtraHttpHeaders: map[string]string{ + options: PdfOptions{ + Options: Options{ExtraHttpHeaders: map[string]string{ "X-Foo": "Bar", - }, + }}, }, noDeadline: false, start: true, @@ -644,8 +644,8 @@ func TestChromiumBrowser_pdf(t *testing.T) { return fs }(), - options: Options{ - OmitBackground: true, + options: PdfOptions{ + Options: Options{OmitBackground: true}, }, noDeadline: false, start: true, @@ -677,8 +677,8 @@ func TestChromiumBrowser_pdf(t *testing.T) { return fs }(), - options: Options{ - OmitBackground: true, + options: PdfOptions{ + Options: Options{OmitBackground: true}, PrintBackground: true, }, noDeadline: false, @@ -713,8 +713,8 @@ func TestChromiumBrowser_pdf(t *testing.T) { return fs }(), - options: Options{ - EmulatedMediaType: "foo", + options: PdfOptions{ + Options: Options{EmulatedMediaType: "foo"}, }, noDeadline: false, start: true, @@ -746,8 +746,8 @@ func TestChromiumBrowser_pdf(t *testing.T) { return fs }(), - options: Options{ - EmulatedMediaType: "screen", + options: PdfOptions{ + Options: Options{EmulatedMediaType: "screen"}, }, noDeadline: false, start: true, @@ -781,8 +781,8 @@ func TestChromiumBrowser_pdf(t *testing.T) { return fs }(), - options: Options{ - WaitDelay: time.Duration(10) * time.Second, + options: PdfOptions{ + Options: Options{WaitDelay: time.Duration(10) * time.Second}, }, noDeadline: false, start: true, @@ -816,8 +816,8 @@ func TestChromiumBrowser_pdf(t *testing.T) { return fs }(), - options: Options{ - WaitDelay: time.Duration(1) * time.Millisecond, + options: PdfOptions{ + Options: Options{WaitDelay: time.Duration(1) * time.Millisecond}, }, noDeadline: false, start: true, @@ -862,8 +862,8 @@ func TestChromiumBrowser_pdf(t *testing.T) { return fs }(), - options: Options{ - WaitForExpression: "window.status === 'ready'", + options: PdfOptions{ + Options: Options{WaitForExpression: "window.status === 'ready'"}, }, noDeadline: false, start: true, @@ -897,8 +897,8 @@ func TestChromiumBrowser_pdf(t *testing.T) { return fs }(), - options: Options{ - WaitForExpression: "return undefined", + options: PdfOptions{ + Options: Options{WaitForExpression: "return undefined"}, }, noDeadline: false, start: true, @@ -943,8 +943,8 @@ func TestChromiumBrowser_pdf(t *testing.T) { return fs }(), - options: Options{ - WaitForExpression: "window.globalVar === 'ready'", + options: PdfOptions{ + Options: Options{WaitForExpression: "window.globalVar === 'ready'"}, }, noDeadline: false, start: true, @@ -978,7 +978,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { return fs }(), - options: Options{ + options: PdfOptions{ HeaderTemplate: "

Header

", FooterTemplate: "

Footer

", }, @@ -1014,7 +1014,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { return fs }(), - options: Options{ + options: PdfOptions{ PaperWidth: 0, PaperHeight: 0, MarginTop: 1000000, @@ -1052,7 +1052,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { return fs }(), - options: Options{ + options: PdfOptions{ PageRanges: "foo", }, noDeadline: false, @@ -1085,7 +1085,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { return fs }(), - options: DefaultOptions(), + options: DefaultPdfOptions(), noDeadline: false, start: true, expectError: false, @@ -1176,3 +1176,840 @@ func TestChromiumBrowser_pdf(t *testing.T) { }) } } + +func TestChromiumBrowser_screenshot(t *testing.T) { + for _, tc := range []struct { + scenario string + browser browser + fs *gotenberg.FileSystem + options ScreenshotOptions + noDeadline bool + start bool + expectError bool + expectedError error + expectedLogEntries []string + }{ + { + scenario: "browser not started", + browser: func() browser { + b := new(chromiumBrowser) + b.isStarted.Store(false) + return b + }(), + fs: gotenberg.NewFileSystem(), + noDeadline: false, + start: false, + expectError: true, + }, + { + scenario: "ErrUrlNotAuthorized: main URL does not match the allowed list", + browser: func() browser { + b := new(chromiumBrowser) + b.arguments = browserArguments{ + allowList: regexp.MustCompile("^file:///[^tmp].*"), + } + b.isStarted.Store(true) + return b + }(), + fs: gotenberg.NewFileSystem(), + noDeadline: false, + start: false, + expectError: true, + expectedError: ErrUrlNotAuthorized, + }, + { + scenario: "ErrUrlNotAuthorized: main URL does match the denied list", + browser: func() browser { + b := new(chromiumBrowser) + b.arguments = browserArguments{ + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile("^file:///tmp.*"), + } + b.isStarted.Store(true) + return b + }(), + fs: gotenberg.NewFileSystem(), + noDeadline: false, + start: false, + expectError: true, + expectedError: ErrUrlNotAuthorized, + }, + { + scenario: "ErrUrlNotAuthorized: main URL does match the denied list", + browser: func() browser { + b := new(chromiumBrowser) + b.arguments = browserArguments{ + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + } + b.isStarted.Store(true) + return b + }(), + fs: gotenberg.NewFileSystem(), + noDeadline: true, + start: false, + expectError: true, + }, + { + scenario: "a request does not match the allowed list", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile("^file:///tmp.*"), + denyList: regexp.MustCompile(""), + }, + ), + 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)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + noDeadline: false, + start: true, + expectError: false, + expectedLogEntries: []string{ + "'file:///etc/passwd' does not match the expression from the allowed list", + }, + }, + { + scenario: "a request does match the denied list", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile("^file:///[^tmp].*"), + }, + ), + 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)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + noDeadline: false, + start: true, + expectError: false, + expectedLogEntries: []string{ + "'file:///etc/passwd' matches the expression from the denied list", + }, + }, + { + scenario: "skip networkIdle event", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + }, + ), + 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)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Skip networkIdle event

"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: ScreenshotOptions{ + Options: Options{SkipNetworkIdleEvent: true}, + }, + noDeadline: false, + start: true, + expectError: false, + expectedLogEntries: []string{ + "skipping network idle event", + }, + }, + { + scenario: "ErrInvalidHttpStatusCode", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + }, + ), + 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)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

ErrInvalidHttpStatusCode

"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: ScreenshotOptions{ + Options: Options{FailOnHttpStatusCodes: []int64{299}}, + }, + noDeadline: false, + start: true, + expectError: true, + expectedError: ErrInvalidHttpStatusCode, + }, + { + scenario: "ErrConsoleExceptions", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + }, + ), + 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)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: ScreenshotOptions{ + Options: Options{FailOnConsoleExceptions: true}, + }, + noDeadline: false, + start: true, + expectError: true, + expectedError: ErrConsoleExceptions, + }, + { + scenario: "clear cache", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + clearCache: true, + }, + ), + 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)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Clear cache

"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + noDeadline: false, + start: true, + expectError: false, + expectedLogEntries: []string{ + "clear cache", + }, + }, + { + scenario: "clear cookies", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + clearCookies: true, + }, + ), + 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)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Clear cookies

"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + noDeadline: false, + start: true, + expectError: false, + expectedLogEntries: []string{ + "clear cookies", + }, + }, + { + scenario: "disable JavaScript", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + disableJavaScript: true, + }, + ), + 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)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + noDeadline: false, + start: true, + expectError: false, + expectedLogEntries: []string{ + "disable JavaScript", + "JavaScript disabled, skipping wait delay", + "JavaScript disabled, skipping wait expression", + }, + }, + { + scenario: "extra HTTP headers", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + }, + ), + 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)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Extra HTTP headers

"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: ScreenshotOptions{ + Options: Options{ExtraHttpHeaders: map[string]string{ + "X-Foo": "Bar", + }}, + }, + noDeadline: false, + start: true, + expectError: false, + expectedLogEntries: []string{ + "extra HTTP headers:", + }, + }, + { + scenario: "ErrOmitBackgroundWithoutPrintBackground", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + }, + ), + 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)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

ErrOmitBackgroundWithoutPrintBackground

"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: ScreenshotOptions{ + Options: Options{OmitBackground: true}, + }, + noDeadline: false, + start: true, + expectError: false, + expectedLogEntries: []string{ + "hide default white background", + }, + }, + { + scenario: "ErrInvalidEmulatedMediaType", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + }, + ), + 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)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

ErrInvalidEmulatedMediaType

"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: ScreenshotOptions{ + Options: Options{EmulatedMediaType: "foo"}, + }, + noDeadline: false, + start: true, + expectError: true, + expectedError: ErrInvalidEmulatedMediaType, + }, + { + scenario: "emulate a media type", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + }, + ), + 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)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Screen media type

"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: ScreenshotOptions{ + Options: Options{EmulatedMediaType: "screen"}, + }, + noDeadline: false, + start: true, + expectError: false, + expectedLogEntries: []string{ + "emulate media type 'screen'", + }, + }, + { + scenario: "wait delay: context done", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + }, + ), + 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)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: ScreenshotOptions{ + Options: Options{WaitDelay: time.Duration(10) * time.Second}, + }, + noDeadline: false, + start: true, + expectError: true, + expectedLogEntries: []string{ + "wait '10s' before print", + }, + }, + { + scenario: "wait delay", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + }, + ), + 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)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(""), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: ScreenshotOptions{ + Options: Options{WaitDelay: time.Duration(1) * time.Millisecond}, + }, + noDeadline: false, + start: true, + expectError: false, + expectedLogEntries: []string{ + "wait '1ms' before print", + }, + }, + { + scenario: "wait for expression: context done", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + }, + ), + 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)) + } + + html := ` + +` + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(html), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: ScreenshotOptions{ + Options: Options{WaitForExpression: "window.status === 'ready'"}, + }, + noDeadline: false, + start: true, + expectError: true, + expectedLogEntries: []string{ + "wait until 'window.status === 'ready'' is true before print", + }, + }, + { + scenario: "ErrInvalidEvaluationExpression", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + }, + ), + 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)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

ErrInvalidEvaluationExpression

"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: ScreenshotOptions{ + Options: Options{WaitForExpression: "return undefined"}, + }, + noDeadline: false, + start: true, + expectError: true, + expectedLogEntries: []string{ + "wait until 'return undefined' is true before print", + }, + }, + { + scenario: "wait for expression", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + }, + ), + 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)) + } + + html := ` + +` + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte(html), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: ScreenshotOptions{ + Options: Options{WaitForExpression: "window.globalVar === 'ready'"}, + }, + noDeadline: false, + start: true, + expectError: false, + expectedLogEntries: []string{ + "wait until 'window.globalVar === 'ready'' is true before print", + }, + }, + { + scenario: "success (default options)", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + }, + ), + 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)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Default options

"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: DefaultScreenshotOptions(), + noDeadline: false, + start: true, + expectError: false, + expectedLogEntries: []string{ + "cache not cleared", + "cookies not cleared", + "JavaScript not disabled", + "no extra HTTP headers", + "navigate to", + "default white background not hidden", + "no emulated media type", + "no wait delay", + "no wait expression", + }, + }, + { + scenario: "success (jpeg)", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp.MustCompile(""), + denyList: regexp.MustCompile(""), + }, + ), + 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)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("

Default options

"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: func() ScreenshotOptions { + options := DefaultScreenshotOptions() + options.Format = "jpeg" + return options + }(), + noDeadline: false, + start: true, + expectError: false, + expectedLogEntries: []string{ + "cache not cleared", + "cookies not cleared", + "JavaScript not disabled", + "no extra HTTP headers", + "navigate to", + "default white background not hidden", + "no emulated media type", + "no wait delay", + "no wait expression", + }, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + core, recorded := observer.New(zapcore.DebugLevel) + logger := zap.New(core) + + defer func() { + err := os.RemoveAll(tc.fs.WorkingDirPath()) + if err != nil { + t.Fatalf("expected no error while cleaning up, but got: %v", err) + } + }() + + if tc.start { + err := tc.browser.Start(logger) + if err != nil { + t.Fatalf("setup error: %v", err) + } + + defer func(b browser, logger *zap.Logger) { + err = b.Stop(logger) + if err != nil { + t.Fatalf("expected no error while cleaning up, but got: %v", err) + } + }(tc.browser, logger) + } + + var ( + ctx context.Context + cancel context.CancelFunc + ) + + if tc.noDeadline { + ctx = context.Background() + } else { + ctx, cancel = context.WithTimeout(context.Background(), time.Duration(5)*time.Second) + defer cancel() + } + + err := tc.browser.screenshot( + ctx, + logger, + fmt.Sprintf("file://%s/index.html", tc.fs.WorkingDirPath()), + fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()), + tc.options, + ) + + 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") + } + + if tc.expectedError != nil && !errors.Is(err, tc.expectedError) { + t.Fatalf("expected error %v but got: %v", tc.expectedError, err) + } + + for _, entry := range tc.expectedLogEntries { + doExist := true + for _, log := range recorded.All() { + doExist = strings.Contains(log.Message, entry) + if doExist { + break + } + } + + if !doExist { + t.Errorf("expected '%s' to exist as log entry", entry) + } + } + }) + } +} diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go index 2ea1e6a2..202bcadc 100644 --- a/pkg/modules/chromium/chromium.go +++ b/pkg/modules/chromium/chromium.go @@ -24,10 +24,6 @@ var ( // allowed/denied lists. ErrUrlNotAuthorized = errors.New("URL not authorized") - // ErrOmitBackgroundWithoutPrintBackground happens if - // Options.OmitBackground is set to true but not Options.PrintBackground. - ErrOmitBackgroundWithoutPrintBackground = errors.New("omit background without print background") - // ErrInvalidEmulatedMediaType happens if the emulated media type is not // "screen" nor "print". Empty value are allowed though. ErrInvalidEmulatedMediaType = errors.New("invalid emulated media type") @@ -36,14 +32,6 @@ var ( // returns an exception or undefined. ErrInvalidEvaluationExpression = errors.New("invalid evaluation expression") - // ErrInvalidPrinterSettings happens if the Options have one or more - // aberrant values. - ErrInvalidPrinterSettings = errors.New("invalid printer settings") - - // ErrPageRangesSyntaxError happens if the Options have an invalid page - // ranges. - ErrPageRangesSyntaxError = errors.New("page ranges syntax error") - // ErrRpccMessageTooLarge happens when the messages received by // ChromeDevTools are larger than 100 MB. ErrRpccMessageTooLarge = errors.New("rpcc message too large") @@ -56,6 +44,20 @@ var ( // console. It also happens only if the [Options.FailOnConsoleExceptions] // is set to true. ErrConsoleExceptions = errors.New("console exceptions") + + // PDF specific. + + // ErrOmitBackgroundWithoutPrintBackground happens if + // PdfOptions.OmitBackground is set to true but not PdfOptions.PrintBackground. + ErrOmitBackgroundWithoutPrintBackground = errors.New("omit background without print background") + + // ErrInvalidPrinterSettings happens if the PdfOptions have one or more + // aberrant values. + ErrInvalidPrinterSettings = errors.New("invalid printer settings") + + // ErrPageRangesSyntaxError happens if the PdfOptions have an invalid page + // ranges. + ErrPageRangesSyntaxError = errors.New("page ranges syntax error") ) // Chromium is a module which provides both an [Api] and routes for converting @@ -71,7 +73,7 @@ type Chromium struct { engine gotenberg.PdfEngine } -// Options are the available expectedOptions for converting HTML document to PDF. +// Options are the common options for all conversions. type Options struct { // SkipNetworkIdleEvent set if the conversion should wait for the // "networkIdle" event, drastically improving the conversion speed. It may @@ -91,17 +93,17 @@ type Options struct { FailOnConsoleExceptions bool // WaitDelay is the duration to wait when loading an HTML document before - // converting it to PDF. + // converting it. // Optional. WaitDelay time.Duration // WaitWindowStatus is the window.status value to wait for before - // converting an HTML document to PDF. + // converting an HTML document. // Optional. WaitWindowStatus string // WaitForExpression is the custom JavaScript expression to wait before - // converting an HTML document to PDF until it returns true + // converting an HTML document until it returns true // Optional. WaitForExpression string @@ -115,6 +117,31 @@ type Options struct { // Optional. EmulatedMediaType string + // OmitBackground hides default white background and allows generating PDFs + // with transparency. + // Optional. + OmitBackground bool +} + +// DefaultOptions returns the default values for Options. +func DefaultOptions() Options { + return Options{ + SkipNetworkIdleEvent: false, + FailOnHttpStatusCodes: []int64{499, 599}, + FailOnConsoleExceptions: false, + WaitDelay: 0, + WaitWindowStatus: "", + WaitForExpression: "", + ExtraHttpHeaders: nil, + EmulatedMediaType: "", + OmitBackground: false, + } +} + +// PdfOptions are the available options for converting an HTML document to PDF. +type PdfOptions struct { + Options + // Landscape sets the paper orientation. // Optional. Landscape bool @@ -123,11 +150,6 @@ type Options struct { // Optional. PrintBackground bool - // OmitBackground hides default white background and allows generating PDFs - // with transparency. - // Optional. - OmitBackground bool - // Scale is the scale of the page rendering. // Optional. Scale float64 @@ -184,37 +206,60 @@ type Options struct { PreferCssPageSize bool } -// DefaultOptions returns the default values for Options. -func DefaultOptions() Options { - return Options{ - SkipNetworkIdleEvent: false, - FailOnHttpStatusCodes: []int64{499, 599}, - FailOnConsoleExceptions: false, - WaitDelay: 0, - WaitWindowStatus: "", - WaitForExpression: "", - ExtraHttpHeaders: nil, - EmulatedMediaType: "", - Landscape: false, - PrintBackground: false, - OmitBackground: false, - Scale: 1.0, - PaperWidth: 8.5, - PaperHeight: 11, - MarginTop: 0.39, - MarginBottom: 0.39, - MarginLeft: 0.39, - MarginRight: 0.39, - PageRanges: "", - HeaderTemplate: "", - FooterTemplate: "", - PreferCssPageSize: false, +// DefaultPdfOptions returns the default values for PdfOptions. +func DefaultPdfOptions() PdfOptions { + return PdfOptions{ + Options: DefaultOptions(), + Landscape: false, + PrintBackground: false, + Scale: 1.0, + PaperWidth: 8.5, + PaperHeight: 11, + MarginTop: 0.39, + MarginBottom: 0.39, + MarginLeft: 0.39, + MarginRight: 0.39, + PageRanges: "", + HeaderTemplate: "", + FooterTemplate: "", + PreferCssPageSize: false, + } +} + +// ScreenshotOptions are the available options for capturing a screenshot from +// an HTML document. +type ScreenshotOptions struct { + Options + + // Format is the image compression format, either "png" or "jpeg" or + // "webp". + // Optional. + Format string + + // Quality is the compression quality from range [0..100] (jpeg only). + // Optional. + Quality int + + // OptimizeForSpeed defines whether to optimize image encoding for speed, + // not for resulting size. + // Optional. + OptimizeForSpeed bool +} + +// DefaultScreenshotOptions returns the default values for ScreenshotOptions. +func DefaultScreenshotOptions() ScreenshotOptions { + return ScreenshotOptions{ + Options: DefaultOptions(), + Format: "png", + Quality: 100, + OptimizeForSpeed: false, } } // Api helps to interact with Chromium for converting HTML documents to PDF. type Api interface { - Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error + Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error + Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error } // Provider is a module interface which exposes a method for creating an [Api] @@ -443,19 +488,31 @@ func (mod *Chromium) Routes() ([]api.Route, error) { return []api.Route{ convertUrlRoute(mod, mod.engine), + screenshotUrlRoute(mod), convertHtmlRoute(mod, mod.engine), + screenshotHtmlRoute(mod), convertMarkdownRoute(mod, mod.engine), + screenshotMarkdownRoute(mod), }, nil } // Pdf converts a URL to PDF. -func (mod *Chromium) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { - // Note: no error wrapping because it leaks on console exceptions output. +func (mod *Chromium) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { + // Note: no error wrapping because it leaks on errors we want to display to + // the end user. return mod.supervisor.Run(ctx, logger, func() error { return mod.browser.pdf(ctx, logger, url, outputPath, options) }) } +func (mod *Chromium) Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { + // Note: no error wrapping because it leaks on errors we want to display to + // the end user. + return mod.supervisor.Run(ctx, logger, func() error { + return mod.browser.screenshot(ctx, logger, url, outputPath, options) + }) +} + // Interface guards. var ( _ gotenberg.Module = (*Chromium)(nil) diff --git a/pkg/modules/chromium/chromium_test.go b/pkg/modules/chromium/chromium_test.go index b947df78..840781e5 100644 --- a/pkg/modules/chromium/chromium_test.go +++ b/pkg/modules/chromium/chromium_test.go @@ -15,8 +15,8 @@ import ( ) func TestDefaultOptions(t *testing.T) { - actual := DefaultOptions() - notExpect := Options{} + actual := DefaultPdfOptions() + notExpect := PdfOptions{} if reflect.DeepEqual(actual, notExpect) { t.Errorf("expected %v and got identical %v", actual, notExpect) @@ -461,7 +461,7 @@ func TestChromium_Routes(t *testing.T) { }{ { scenario: "routes not disabled", - expectRoutes: 3, + expectRoutes: 6, disableRoutes: false, }, { @@ -495,14 +495,14 @@ func TestChromium_Pdf(t *testing.T) { }{ { scenario: "PDF task success", - browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return nil }}, expectError: false, }, { scenario: "PDF task error", - browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return errors.New("PDF task error") }}, expectError: true, @@ -515,7 +515,49 @@ func TestChromium_Pdf(t *testing.T) { }} mod.browser = tc.browser - err := mod.Pdf(context.Background(), zap.NewNop(), "", "", Options{}) + err := mod.Pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{}) + + 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 TestChromium_Screenshot(t *testing.T) { + for _, tc := range []struct { + scenario string + supervisor gotenberg.ProcessSupervisor + browser browser + expectError bool + }{ + { + scenario: "Screenshot task success", + browser: &browserMock{screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { + return nil + }}, + expectError: false, + }, + { + scenario: "Screenshot task error", + browser: &browserMock{screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { + return errors.New("screenshot task error") + }}, + expectError: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + mod := new(Chromium) + mod.supervisor = &gotenberg.ProcessSupervisorMock{RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error { + return task() + }} + mod.browser = tc.browser + + err := mod.Screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{}) if !tc.expectError && err != nil { t.Fatalf("expected no error but got: %v", err) diff --git a/pkg/modules/chromium/mocks.go b/pkg/modules/chromium/mocks.go index 1f1c15f7..9effed77 100644 --- a/pkg/modules/chromium/mocks.go +++ b/pkg/modules/chromium/mocks.go @@ -10,23 +10,33 @@ import ( // ApiMock is a mock for the [Api] interface. type ApiMock struct { - PdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error + PdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error + ScreenshotMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error } -func (api *ApiMock) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { +func (api *ApiMock) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return api.PdfMock(ctx, logger, url, outputPath, options) } +func (api *ApiMock) Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { + return api.ScreenshotMock(ctx, logger, url, outputPath, options) +} + // browserMock is a mock for the [browser] interface. type browserMock struct { gotenberg.ProcessMock - pdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error + pdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error + screenshotMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error } -func (b *browserMock) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { +func (b *browserMock) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return b.pdfMock(ctx, logger, url, outputPath, options) } +func (b *browserMock) screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { + return b.screenshotMock(ctx, logger, url, outputPath, options) +} + // Interface guards. var ( _ Api = (*ApiMock)(nil) diff --git a/pkg/modules/chromium/mocks_test.go b/pkg/modules/chromium/mocks_test.go index 2fe73a2d..1f361a8a 100644 --- a/pkg/modules/chromium/mocks_test.go +++ b/pkg/modules/chromium/mocks_test.go @@ -9,26 +9,42 @@ import ( func TestApiMock(t *testing.T) { mock := &ApiMock{ - PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { + return nil + }, + ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { return nil }, } - err := mock.Pdf(context.Background(), zap.NewNop(), "", "", Options{}) + err := mock.Pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{}) if err != nil { t.Errorf("expected no error from ApiMock.Pdf, but got: %v", err) } + + err = mock.Screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{}) + if err != nil { + t.Errorf("expected no error from ApiMock.Screenshot, but got: %v", err) + } } func TestBrowserMock(t *testing.T) { mock := &browserMock{ - pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { + return nil + }, + screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { return nil }, } - err := mock.pdf(context.Background(), zap.NewNop(), "", "", Options{}) + err := mock.pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{}) if err != nil { t.Errorf("expected no error from browserMock.pdf, but got: %v", err) } + + err = mock.screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{}) + if err != nil { + t.Errorf("expected no error from browserMock.screenshot, but got: %v", err) + } } diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go index 3d4d6300..bb5b38c5 100644 --- a/pkg/modules/chromium/routes.go +++ b/pkg/modules/chromium/routes.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" "time" @@ -21,26 +22,21 @@ import ( "github.com/gotenberg/gotenberg/v7/pkg/modules/api" ) -// FormDataChromiumPdfOptions creates [Options] from the form data. Fallback to +// FormDataChromiumOptions creates [Options] from the form data. Fallback to // default value if the considered key is not present. -func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, Options) { +func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) { defaultOptions := DefaultOptions() var ( - skipNetworkIdleEvent bool - failOnHttpStatusCodes []int64 - failOnConsoleExceptions bool - waitDelay time.Duration - waitWindowStatus string - waitForExpression string - extraHttpHeaders map[string]string - emulatedMediaType string - landscape, printBackground, omitBackground bool - scale, paperWidth, paperHeight float64 - marginTop, marginBottom, marginLeft, marginRight float64 - pageRanges string - headerTemplate, footerTemplate string - preferCssPageSize bool + skipNetworkIdleEvent bool + failOnHttpStatusCodes []int64 + failOnConsoleExceptions bool + waitDelay time.Duration + waitWindowStatus string + waitForExpression string + extraHttpHeaders map[string]string + emulatedMediaType string + omitBackground bool ) form := ctx.FormData(). @@ -89,20 +85,7 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, Options) { return nil }). - Bool("landscape", &landscape, defaultOptions.Landscape). - Bool("printBackground", &printBackground, defaultOptions.PrintBackground). - Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground). - Float64("scale", &scale, defaultOptions.Scale). - Float64("paperWidth", &paperWidth, defaultOptions.PaperWidth). - Float64("paperHeight", &paperHeight, defaultOptions.PaperHeight). - Float64("marginTop", &marginTop, defaultOptions.MarginTop). - Float64("marginBottom", &marginBottom, defaultOptions.MarginBottom). - Float64("marginLeft", &marginLeft, defaultOptions.MarginLeft). - Float64("marginRight", &marginRight, defaultOptions.MarginRight). - String("nativePageRanges", &pageRanges, defaultOptions.PageRanges). - Content("header.html", &headerTemplate, defaultOptions.HeaderTemplate). - Content("footer.html", &footerTemplate, defaultOptions.FooterTemplate). - Bool("preferCssPageSize", &preferCssPageSize, defaultOptions.PreferCssPageSize) + Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground) options := Options{ SkipNetworkIdleEvent: skipNetworkIdleEvent, @@ -113,34 +96,132 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, Options) { WaitForExpression: waitForExpression, ExtraHttpHeaders: extraHttpHeaders, EmulatedMediaType: emulatedMediaType, - Landscape: landscape, - PrintBackground: printBackground, OmitBackground: omitBackground, - Scale: scale, - PaperWidth: paperWidth, - PaperHeight: paperHeight, - MarginTop: marginTop, - MarginBottom: marginBottom, - MarginLeft: marginLeft, - MarginRight: marginRight, - PageRanges: pageRanges, - HeaderTemplate: headerTemplate, - FooterTemplate: footerTemplate, - PreferCssPageSize: preferCssPageSize, } return form, options } +// FormDataChromiumPdfOptions creates [PdfOptions] from the form data. Fallback to +// default value if the considered key is not present. +func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, PdfOptions) { + form, options := FormDataChromiumOptions(ctx) + defaultPdfOptions := DefaultPdfOptions() + + var ( + landscape, printBackground bool + scale, paperWidth, paperHeight float64 + marginTop, marginBottom, marginLeft, marginRight float64 + pageRanges string + headerTemplate, footerTemplate string + preferCssPageSize bool + ) + + form. + Bool("landscape", &landscape, defaultPdfOptions.Landscape). + Bool("printBackground", &printBackground, defaultPdfOptions.PrintBackground). + Float64("scale", &scale, defaultPdfOptions.Scale). + Float64("paperWidth", &paperWidth, defaultPdfOptions.PaperWidth). + Float64("paperHeight", &paperHeight, defaultPdfOptions.PaperHeight). + Float64("marginTop", &marginTop, defaultPdfOptions.MarginTop). + Float64("marginBottom", &marginBottom, defaultPdfOptions.MarginBottom). + Float64("marginLeft", &marginLeft, defaultPdfOptions.MarginLeft). + Float64("marginRight", &marginRight, defaultPdfOptions.MarginRight). + String("nativePageRanges", &pageRanges, defaultPdfOptions.PageRanges). + Content("header.html", &headerTemplate, defaultPdfOptions.HeaderTemplate). + Content("footer.html", &footerTemplate, defaultPdfOptions.FooterTemplate). + Bool("preferCssPageSize", &preferCssPageSize, defaultPdfOptions.PreferCssPageSize) + + pdfOptions := PdfOptions{ + Options: options, + Landscape: landscape, + PrintBackground: printBackground, + Scale: scale, + PaperWidth: paperWidth, + PaperHeight: paperHeight, + MarginTop: marginTop, + MarginBottom: marginBottom, + MarginLeft: marginLeft, + MarginRight: marginRight, + PageRanges: pageRanges, + HeaderTemplate: headerTemplate, + FooterTemplate: footerTemplate, + PreferCssPageSize: preferCssPageSize, + } + + return form, pdfOptions +} + +// FormDataChromiumScreenshotOptions creates [ScreenshotOptions] from the form +// data. Fallback to default value if the considered key is not present. +func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, ScreenshotOptions) { + form, options := FormDataChromiumOptions(ctx) + defaultScreenshotOptions := DefaultScreenshotOptions() + + var ( + format string + quality int + optimizeForSpeed bool + ) + + form. + Custom("format", func(value string) error { + if value == "" { + format = defaultScreenshotOptions.Format + return nil + } + + if value != "png" && value != "jpeg" && value != "" { + return fmt.Errorf("wrong value, expected either 'png', 'jpeg' or 'webp'") + } + + format = value + + return nil + }). + Custom("quality", func(value string) error { + if value == "" { + quality = defaultScreenshotOptions.Quality + return nil + } + + intValue, err := strconv.Atoi(value) + if err != nil { + return err + } + + if intValue < 0 { + return errors.New("value is negative") + } + + if intValue > 100 { + return errors.New("value is superior to 100") + } + + quality = intValue + return nil + }). + Bool("optimizeForSpeed", &optimizeForSpeed, defaultScreenshotOptions.OptimizeForSpeed) + + screenshotOptions := ScreenshotOptions{ + Options: options, + Format: format, + Quality: quality, + OptimizeForSpeed: optimizeForSpeed, + } + + return form, screenshotOptions +} + // FormDataChromiumPdfFormats creates [gotenberg.PdfFormats] from the form // data. Fallback to default value if the considered key is not present. -func FormDataChromiumPdfFormats(ctx *api.Context) gotenberg.PdfFormats { +func FormDataChromiumPdfFormats(form *api.FormData) gotenberg.PdfFormats { var ( pdfa string pdfua bool ) - ctx.FormData(). + form. String("pdfa", &pdfa, ""). Bool("pdfua", &pdfua, false) @@ -159,7 +240,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { Handler: func(c echo.Context) error { ctx := c.Get("context").(*api.Context) form, options := FormDataChromiumPdfOptions(ctx) - pdfFormats := FormDataChromiumPdfFormats(ctx) + pdfFormats := FormDataChromiumPdfFormats(form) var url string err := form. @@ -179,6 +260,35 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { } } +// screenshotUrlRoute returns an [api.Route] which can take a screenshot from a +// URL. +func screenshotUrlRoute(chromium Api) api.Route { + return api.Route{ + Method: http.MethodPost, + Path: "/forms/chromium/screenshot/url", + IsMultipart: true, + Handler: func(c echo.Context) error { + ctx := c.Get("context").(*api.Context) + form, options := FormDataChromiumScreenshotOptions(ctx) + + var url string + err := form. + MandatoryString("url", &url). + Validate() + if err != nil { + return fmt.Errorf("validate form data: %w", err) + } + + err = screenshotUrl(ctx, chromium, url, options) + if err != nil { + return fmt.Errorf("URL screenshot: %w", err) + } + + return nil + }, + } +} + // convertHtmlRoute returns an [api.Route] which can convert an HTML file to // PDF. func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { @@ -189,7 +299,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { Handler: func(c echo.Context) error { ctx := c.Get("context").(*api.Context) form, options := FormDataChromiumPdfOptions(ctx) - pdfFormats := FormDataChromiumPdfFormats(ctx) + pdfFormats := FormDataChromiumPdfFormats(form) var inputPath string err := form. @@ -210,6 +320,36 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { } } +// screenshotHtmlRoute returns an [api.Route] which can take a screenshot from +// an HTML file. +func screenshotHtmlRoute(chromium Api) api.Route { + return api.Route{ + Method: http.MethodPost, + Path: "/forms/chromium/screenshot/html", + IsMultipart: true, + Handler: func(c echo.Context) error { + ctx := c.Get("context").(*api.Context) + form, options := FormDataChromiumScreenshotOptions(ctx) + + var inputPath string + err := form. + MandatoryPath("index.html", &inputPath). + Validate() + if err != nil { + return fmt.Errorf("validate form data: %w", err) + } + + url := fmt.Sprintf("file://%s", inputPath) + err = screenshotUrl(ctx, chromium, url, options) + if err != nil { + return fmt.Errorf("HTML screenshot: %w", err) + } + + return nil + }, + } +} + // convertMarkdownRoute returns an [api.Route] which can convert markdown files // to PDF. func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { @@ -220,7 +360,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { Handler: func(c echo.Context) error { ctx := c.Get("context").(*api.Context) form, options := FormDataChromiumPdfOptions(ctx) - pdfFormats := FormDataChromiumPdfFormats(ctx) + pdfFormats := FormDataChromiumPdfFormats(form) var ( inputPath string @@ -235,79 +375,11 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate form data: %w", err) } - // We have to convert each markdown file referenced in the HTML - // file to... HTML. Thanks to the "html/template" package, we are - // able to provide the "toHTML" function which the user may call - // directly inside the HTML file. - - var markdownFilesNotFoundErr error - - tmpl, err := template. - New(filepath.Base(inputPath)). - Funcs(template.FuncMap{ - "toHTML": func(filename string) (template.HTML, error) { - var path string - - for _, markdownPath := range markdownPaths { - markdownFilename := filepath.Base(markdownPath) - - if filename == markdownFilename { - path = markdownPath - break - } - } - - if path == "" { - markdownFilesNotFoundErr = multierr.Append( - markdownFilesNotFoundErr, - fmt.Errorf("'%s'", filename), - ) - - return "", nil - } - - b, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("read markdown file '%s': %w", filename, err) - } - - unsafe := blackfriday.Run(b) - sanitized := bluemonday.UGCPolicy().SanitizeBytes(unsafe) - - // #nosec - return template.HTML(sanitized), nil - }, - }).ParseFiles(inputPath) + url, err := markdownToHtml(ctx, inputPath, markdownPaths) if err != nil { - return fmt.Errorf("parse template file: %w", err) + return fmt.Errorf("transform markdown file(s) to HTML: %w", err) } - var buffer bytes.Buffer - - err = tmpl.Execute(&buffer, &struct{}{}) - if err != nil { - return fmt.Errorf("execute template: %w", err) - } - - if markdownFilesNotFoundErr != nil { - return api.WrapError( - fmt.Errorf("markdown files not found: %w", markdownFilesNotFoundErr), - api.NewSentinelHttpError( - http.StatusBadRequest, - fmt.Sprintf("Markdown file(s) not found: %s", markdownFilesNotFoundErr), - ), - ) - } - - inputPath = ctx.GeneratePath(".html") - - err = os.WriteFile(inputPath, buffer.Bytes(), 0o600) - if err != nil { - return fmt.Errorf("write template result: %w", err) - } - - url := fmt.Sprintf("file://%s", inputPath) - err = convertUrl(ctx, chromium, engine, url, pdfFormats, options) if err != nil { return fmt.Errorf("convert markdown to PDF: %w", err) @@ -318,26 +390,129 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { } } -// convertUrl is a stub which is called by the other methods of this file. -func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, pdfFormats gotenberg.PdfFormats, options Options) error { +// screenshotMarkdownRoute returns an [api.Route] which can take a screenshot +// from markdown files. +func screenshotMarkdownRoute(chromium Api) api.Route { + return api.Route{ + Method: http.MethodPost, + Path: "/forms/chromium/screenshot/markdown", + IsMultipart: true, + Handler: func(c echo.Context) error { + ctx := c.Get("context").(*api.Context) + form, options := FormDataChromiumScreenshotOptions(ctx) + + var ( + inputPath string + markdownPaths []string + ) + + err := form. + MandatoryPath("index.html", &inputPath). + MandatoryPaths([]string{".md"}, &markdownPaths). + Validate() + if err != nil { + return fmt.Errorf("validate form data: %w", err) + } + + url, err := markdownToHtml(ctx, inputPath, markdownPaths) + if err != nil { + return fmt.Errorf("transform markdown file(s) to HTML: %w", err) + } + + err = screenshotUrl(ctx, chromium, url, options) + if err != nil { + return fmt.Errorf("markdown screenshot: %w", err) + } + + return nil + }, + } +} + +func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string) (string, error) { + // We have to convert each markdown file referenced in the HTML + // file to... HTML. Thanks to the "html/template" package, we are + // able to provide the "toHTML" function which the user may call + // directly inside the HTML file. + + var markdownFilesNotFoundErr error + + tmpl, err := template. + New(filepath.Base(inputPath)). + Funcs(template.FuncMap{ + "toHTML": func(filename string) (template.HTML, error) { + var path string + + for _, markdownPath := range markdownPaths { + markdownFilename := filepath.Base(markdownPath) + + if filename == markdownFilename { + path = markdownPath + break + } + } + + if path == "" { + markdownFilesNotFoundErr = multierr.Append( + markdownFilesNotFoundErr, + fmt.Errorf("'%s'", filename), + ) + + return "", nil + } + + b, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read markdown file '%s': %w", filename, err) + } + + unsafe := blackfriday.Run(b) + sanitized := bluemonday.UGCPolicy().SanitizeBytes(unsafe) + + // #nosec + return template.HTML(sanitized), nil + }, + }).ParseFiles(inputPath) + if err != nil { + return "", fmt.Errorf("parse template file: %w", err) + } + + var buffer bytes.Buffer + + err = tmpl.Execute(&buffer, &struct{}{}) + if err != nil { + return "", fmt.Errorf("execute template: %w", err) + } + + if markdownFilesNotFoundErr != nil { + return "", api.WrapError( + fmt.Errorf("markdown files not found: %w", markdownFilesNotFoundErr), + api.NewSentinelHttpError( + http.StatusBadRequest, + fmt.Sprintf("Markdown file(s) not found: %s", markdownFilesNotFoundErr), + ), + ) + } + + inputPath = ctx.GeneratePath(".html") + + err = os.WriteFile(inputPath, buffer.Bytes(), 0o600) + if err != nil { + return "", fmt.Errorf("write template result: %w", err) + } + + return fmt.Sprintf("file://%s", inputPath), nil +} + +func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, pdfFormats gotenberg.PdfFormats, options PdfOptions) error { outputPath := ctx.GeneratePath(".pdf") err := chromium.Pdf(ctx, ctx.Log(), url, outputPath, options) + err = handleChromiumError(err, url, options.Options) if err != nil { - - if errors.Is(err, ErrUrlNotAuthorized) { - return api.WrapError( - fmt.Errorf("convert to PDF: %w", err), - api.NewSentinelHttpError( - http.StatusForbidden, - fmt.Sprintf("'%s' does not match the authorized URLs", url), - ), - ) - } - if errors.Is(err, ErrOmitBackgroundWithoutPrintBackground) { return api.WrapError( - fmt.Errorf("convert to PDF: %w", err), + err, api.NewSentinelHttpError( http.StatusBadRequest, "omitBackground requires printBackground set to true", @@ -345,23 +520,6 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url ) } - if errors.Is(err, ErrInvalidEvaluationExpression) { - if options.WaitForExpression == "" { - // We do not expect the 'waitWindowStatus' form field to return - // an ErrInvalidEvaluationExpression error. In such a scenario, - // we return a 500. - return fmt.Errorf("convert to PDF: %w", err) - } - - return api.WrapError( - fmt.Errorf("convert to PDF: %w", err), - api.NewSentinelHttpError( - http.StatusBadRequest, - fmt.Sprintf("The expression '%s' (waitForExpression) returned an exception or undefined", options.WaitForExpression), - ), - ) - } - if errors.Is(err, ErrInvalidPrinterSettings) { return api.WrapError( fmt.Errorf("convert to PDF: %w", err), @@ -382,26 +540,6 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url ) } - if errors.Is(err, ErrInvalidHttpStatusCode) { - return api.WrapError( - fmt.Errorf("convert to PDF: %w", err), - api.NewSentinelHttpError( - http.StatusConflict, - fmt.Sprintf("Invalid HTTP status code from the main page: %s", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrInvalidHttpStatusCode.Error()), "")), - ), - ) - } - - if errors.Is(err, ErrConsoleExceptions) { - return api.WrapError( - fmt.Errorf("convert to PDF: %w", err), - api.NewSentinelHttpError( - http.StatusConflict, - fmt.Sprintf("Chromium console exceptions:\n %s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")), - ), - ) - } - return fmt.Errorf("convert to PDF: %w", err) } @@ -440,3 +578,76 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url return nil } + +func screenshotUrl(ctx *api.Context, chromium Api, url string, options ScreenshotOptions) error { + ext := fmt.Sprintf(".%s", options.Format) + outputPath := ctx.GeneratePath(ext) + + err := chromium.Screenshot(ctx, ctx.Log(), url, outputPath, options) + err = handleChromiumError(err, url, options.Options) + if err != nil { + return fmt.Errorf("screenshot: %w", err) + } + + err = ctx.AddOutputPaths(outputPath) + if err != nil { + return fmt.Errorf("add output path: %w", err) + } + + return nil +} + +func handleChromiumError(err error, url string, options Options) error { + if err == nil { + return nil + } + + if errors.Is(err, ErrUrlNotAuthorized) { + return api.WrapError( + err, + api.NewSentinelHttpError( + http.StatusForbidden, + fmt.Sprintf("'%s' does not match the authorized URLs", url), + ), + ) + } + + if errors.Is(err, ErrInvalidEvaluationExpression) { + if options.WaitForExpression == "" { + // We do not expect the 'waitWindowStatus' form field to return + // an ErrInvalidEvaluationExpression error. In such a scenario, + // we return a 500. + return err + } + + return api.WrapError( + err, + api.NewSentinelHttpError( + http.StatusBadRequest, + fmt.Sprintf("The expression '%s' (waitForExpression) returned an exception or undefined", options.WaitForExpression), + ), + ) + } + + if errors.Is(err, ErrInvalidHttpStatusCode) { + return api.WrapError( + err, + api.NewSentinelHttpError( + http.StatusConflict, + fmt.Sprintf("Invalid HTTP status code from the main page: %s", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrInvalidHttpStatusCode.Error()), "")), + ), + ) + } + + if errors.Is(err, ErrConsoleExceptions) { + return api.WrapError( + err, + api.NewSentinelHttpError( + http.StatusConflict, + fmt.Sprintf("Chromium console exceptions:\n %s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")), + ), + ) + } + + return err +} diff --git a/pkg/modules/chromium/routes_test.go b/pkg/modules/chromium/routes_test.go index 2222c170..2d79f992 100644 --- a/pkg/modules/chromium/routes_test.go +++ b/pkg/modules/chromium/routes_test.go @@ -17,7 +17,7 @@ import ( "github.com/gotenberg/gotenberg/v7/pkg/modules/api" ) -func TestFormDataChromiumPdfOptions(t *testing.T) { +func TestFormDataChromiumOptions(t *testing.T) { for _, tc := range []struct { scenario string ctx *api.ContextMock @@ -124,6 +124,50 @@ func TestFormDataChromiumPdfOptions(t *testing.T) { return options }(), }, + } { + t.Run(tc.scenario, func(t *testing.T) { + tc.ctx.SetLogger(zap.NewNop()) + _, actual := FormDataChromiumOptions(tc.ctx.Context) + + if !reflect.DeepEqual(actual, tc.expectedOptions) { + t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual) + } + }) + } +} + +func TestFormDataChromiumPdfOptions(t *testing.T) { + for _, tc := range []struct { + scenario string + ctx *api.ContextMock + expectedOptions PdfOptions + }{ + { + scenario: "no custom form fields", + ctx: &api.ContextMock{Context: new(api.Context)}, + expectedOptions: DefaultPdfOptions(), + }, + { + scenario: "custom form fields (Options & PdfOptions)", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetValues(map[string][]string{ + "landscape": { + "true", + }, + "emulatedMediaType": { + "screen", + }, + }) + return ctx + }(), + expectedOptions: func() PdfOptions { + options := DefaultPdfOptions() + options.Landscape = true + options.EmulatedMediaType = "screen" + return options + }(), + }, } { t.Run(tc.scenario, func(t *testing.T) { tc.ctx.SetLogger(zap.NewNop()) @@ -136,6 +180,152 @@ func TestFormDataChromiumPdfOptions(t *testing.T) { } } +func TestFormDataChromiumScreenshotOptions(t *testing.T) { + for _, tc := range []struct { + scenario string + ctx *api.ContextMock + expectedOptions ScreenshotOptions + }{ + { + scenario: "no custom form fields", + ctx: &api.ContextMock{Context: new(api.Context)}, + expectedOptions: DefaultScreenshotOptions(), + }, + { + scenario: "invalid format form field", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetValues(map[string][]string{ + "format": { + "gif", + }, + }) + return ctx + }(), + expectedOptions: func() ScreenshotOptions { + options := DefaultScreenshotOptions() + options.Format = "" + return options + }(), + }, + { + scenario: "valid format form field", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetValues(map[string][]string{ + "format": { + "jpeg", + }, + }) + return ctx + }(), + expectedOptions: func() ScreenshotOptions { + options := DefaultScreenshotOptions() + options.Format = "jpeg" + return options + }(), + }, + { + scenario: "invalid quality form field (not an integer)", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetValues(map[string][]string{ + "quality": { + "foo", + }, + }) + return ctx + }(), + expectedOptions: func() ScreenshotOptions { + options := DefaultScreenshotOptions() + options.Quality = 0 + return options + }(), + }, + { + scenario: "invalid quality form field (< 0)", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetValues(map[string][]string{ + "quality": { + "-1", + }, + }) + return ctx + }(), + expectedOptions: func() ScreenshotOptions { + options := DefaultScreenshotOptions() + options.Quality = 0 + return options + }(), + }, + { + scenario: "invalid quality form field (> 100)", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetValues(map[string][]string{ + "quality": { + "101", + }, + }) + return ctx + }(), + expectedOptions: func() ScreenshotOptions { + options := DefaultScreenshotOptions() + options.Quality = 0 + return options + }(), + }, + { + scenario: "valid quality form field", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetValues(map[string][]string{ + "quality": { + "50", + }, + }) + return ctx + }(), + expectedOptions: func() ScreenshotOptions { + options := DefaultScreenshotOptions() + options.Quality = 50 + return options + }(), + }, + { + scenario: "custom form fields (Options & ScreenshotOptions)", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetValues(map[string][]string{ + "optimizeForSpeed": { + "true", + }, + "emulatedMediaType": { + "screen", + }, + }) + return ctx + }(), + expectedOptions: func() ScreenshotOptions { + options := DefaultScreenshotOptions() + options.OptimizeForSpeed = true + options.EmulatedMediaType = "screen" + return options + }(), + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + tc.ctx.SetLogger(zap.NewNop()) + _, actual := FormDataChromiumScreenshotOptions(tc.ctx.Context) + + if !reflect.DeepEqual(actual, tc.expectedOptions) { + t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual) + } + }) + } +} + func TestFormDataChromiumPdfFormats(t *testing.T) { for _, tc := range []struct { scenario string @@ -166,7 +356,7 @@ func TestFormDataChromiumPdfFormats(t *testing.T) { } { t.Run(tc.scenario, func(t *testing.T) { tc.ctx.SetLogger(zap.NewNop()) - actual := FormDataChromiumPdfFormats(tc.ctx.Context) + actual := FormDataChromiumPdfFormats(tc.ctx.Context.FormData()) if !reflect.DeepEqual(actual, tc.expectedPdfFormats) { t.Fatalf("expected %+v but got: %+v", tc.expectedPdfFormats, actual) @@ -220,7 +410,7 @@ func TestConvertUrlRoute(t *testing.T) { }) return ctx }(), - api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return errors.New("foo") }}, expectError: true, @@ -238,7 +428,7 @@ func TestConvertUrlRoute(t *testing.T) { }) return ctx }(), - api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return nil }}, expectError: false, @@ -286,6 +476,117 @@ func TestConvertUrlRoute(t *testing.T) { } } +func TestScreenshotUrlRoute(t *testing.T) { + for _, tc := range []struct { + scenario string + ctx *api.ContextMock + api Api + expectError bool + expectHttpError bool + expectHttpStatus int + expectOutputPathsCount int + }{ + { + scenario: "missing mandatory url form field", + ctx: &api.ContextMock{Context: new(api.Context)}, + expectError: true, + expectHttpError: true, + expectHttpStatus: http.StatusBadRequest, + expectOutputPathsCount: 0, + }, + { + scenario: "empty url form field", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetValues(map[string][]string{ + "url": { + "", + }, + }) + return ctx + }(), + expectError: true, + expectHttpError: true, + expectHttpStatus: http.StatusBadRequest, + expectOutputPathsCount: 0, + }, + { + scenario: "error from Chromium", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetValues(map[string][]string{ + "url": { + "foo", + }, + }) + return ctx + }(), + api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { + return errors.New("foo") + }}, + expectError: true, + expectHttpError: false, + expectOutputPathsCount: 0, + }, + { + scenario: "success", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetValues(map[string][]string{ + "url": { + "foo", + }, + }) + return ctx + }(), + api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { + return nil + }}, + expectError: false, + expectHttpError: false, + expectOutputPathsCount: 1, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + tc.ctx.SetLogger(zap.NewNop()) + c := echo.New().NewContext(nil, nil) + c.Set("context", tc.ctx.Context) + + err := screenshotUrlRoute(tc.api).Handler(c) + + if tc.expectError && err == nil { + t.Fatal("expected error but got none", err) + } + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + var httpErr api.HttpError + isHttpError := errors.As(err, &httpErr) + + if tc.expectHttpError && !isHttpError { + t.Errorf("expected an HTTP error but got: %v", err) + } + + if !tc.expectHttpError && isHttpError { + t.Errorf("expected no HTTP error but got one: %v", httpErr) + } + + if err != nil && tc.expectHttpError && isHttpError { + status, _ := httpErr.HttpError() + if status != tc.expectHttpStatus { + t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status) + } + } + + if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) { + t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) + } + }) + } +} + func TestConvertHtmlRoute(t *testing.T) { for _, tc := range []struct { scenario string @@ -313,7 +614,7 @@ func TestConvertHtmlRoute(t *testing.T) { }) return ctx }(), - api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return errors.New("foo") }}, expectError: true, @@ -329,7 +630,7 @@ func TestConvertHtmlRoute(t *testing.T) { }) return ctx }(), - api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return nil }}, expectError: false, @@ -377,6 +678,97 @@ func TestConvertHtmlRoute(t *testing.T) { } } +func TestScreenshotHtmlRoute(t *testing.T) { + for _, tc := range []struct { + scenario string + ctx *api.ContextMock + api Api + expectError bool + expectHttpError bool + expectHttpStatus int + expectOutputPathsCount int + }{ + { + scenario: "missing mandatory index.html form file", + ctx: &api.ContextMock{Context: new(api.Context)}, + expectError: true, + expectHttpError: true, + expectHttpStatus: http.StatusBadRequest, + expectOutputPathsCount: 0, + }, + { + scenario: "error from Chromium", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "index.html": "/index.html", + }) + return ctx + }(), + api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { + return errors.New("foo") + }}, + expectError: true, + expectHttpError: false, + expectOutputPathsCount: 0, + }, + { + scenario: "success", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "index.html": "/index.html", + }) + return ctx + }(), + api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { + return nil + }}, + expectError: false, + expectHttpError: false, + expectOutputPathsCount: 1, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + tc.ctx.SetLogger(zap.NewNop()) + c := echo.New().NewContext(nil, nil) + c.Set("context", tc.ctx.Context) + + err := screenshotHtmlRoute(tc.api).Handler(c) + + if tc.expectError && err == nil { + t.Fatal("expected error but got none", err) + } + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + var httpErr api.HttpError + isHttpError := errors.As(err, &httpErr) + + if tc.expectHttpError && !isHttpError { + t.Errorf("expected an HTTP error but got: %v", err) + } + + if !tc.expectHttpError && isHttpError { + t.Errorf("expected no HTTP error but got one: %v", httpErr) + } + + if err != nil && tc.expectHttpError && isHttpError { + status, _ := httpErr.HttpError() + if status != tc.expectHttpStatus { + t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status) + } + } + + if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) { + t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) + } + }) + } +} + func TestConvertMarkdownRoute(t *testing.T) { for _, tc := range []struct { scenario string @@ -492,7 +884,7 @@ func TestConvertMarkdownRoute(t *testing.T) { return ctx }(), - api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return errors.New("foo") }}, expectError: true, @@ -527,7 +919,7 @@ func TestConvertMarkdownRoute(t *testing.T) { return ctx }(), - api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return nil }}, expectError: false, @@ -584,6 +976,213 @@ func TestConvertMarkdownRoute(t *testing.T) { } } +func TestScreenshotMarkdownRoute(t *testing.T) { + for _, tc := range []struct { + scenario string + ctx *api.ContextMock + api Api + expectError bool + expectHttpError bool + expectHttpStatus int + expectOutputPathsCount int + }{ + { + scenario: "missing mandatory index.html form file", + ctx: &api.ContextMock{Context: new(api.Context)}, + expectError: true, + expectHttpError: true, + expectHttpStatus: http.StatusBadRequest, + expectOutputPathsCount: 0, + }, + { + scenario: "missing mandatory markdown form files", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "index.html": "/index.html", + }) + return ctx + }(), + expectError: true, + expectHttpError: true, + expectHttpStatus: http.StatusBadRequest, + expectOutputPathsCount: 0, + }, + { + scenario: "markdown file requested in index.html not found", + ctx: func() *api.ContextMock { + dirPath := fmt.Sprintf("%s/%s", os.TempDir(), uuid.NewString()) + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetDirPath(dirPath) + ctx.SetFiles(map[string]string{ + "index.html": fmt.Sprintf("%s/index.html", dirPath), + "wrong_name.md": fmt.Sprintf("%s/wrong_name.md", dirPath), + }) + + err := os.MkdirAll(dirPath, 0o755) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("
{{ toHTML \"markdown.md\" }}
"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return ctx + }(), + expectError: true, + expectHttpError: true, + expectHttpStatus: http.StatusBadRequest, + expectOutputPathsCount: 0, + }, + { + scenario: "non-existing markdown file", + ctx: func() *api.ContextMock { + dirPath := fmt.Sprintf("%s/%s", os.TempDir(), uuid.NewString()) + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetDirPath(dirPath) + ctx.SetFiles(map[string]string{ + "index.html": fmt.Sprintf("%s/index.html", dirPath), + "markdown.md": fmt.Sprintf("%s/markdown.md", dirPath), + }) + + err := os.MkdirAll(dirPath, 0o755) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("
{{ toHTML \"markdown.md\" }}
"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return ctx + }(), + expectError: true, + expectHttpError: false, + expectOutputPathsCount: 0, + }, + { + scenario: "error from Chromium", + ctx: func() *api.ContextMock { + dirPath := fmt.Sprintf("%s/%s", os.TempDir(), uuid.NewString()) + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetDirPath(dirPath) + ctx.SetFiles(map[string]string{ + "index.html": fmt.Sprintf("%s/index.html", dirPath), + "markdown.md": fmt.Sprintf("%s/markdown.md", dirPath), + }) + + err := os.MkdirAll(dirPath, 0o755) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("
{{ toHTML \"markdown.md\" }}
"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + err = os.WriteFile(fmt.Sprintf("%s/markdown.md", dirPath), []byte("# Hello World!"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return ctx + }(), + api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { + return errors.New("foo") + }}, + expectError: true, + expectHttpError: false, + expectOutputPathsCount: 0, + }, + { + scenario: "success", + ctx: func() *api.ContextMock { + dirPath := fmt.Sprintf("%s/%s", os.TempDir(), uuid.NewString()) + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetDirPath(dirPath) + ctx.SetFiles(map[string]string{ + "index.html": fmt.Sprintf("%s/index.html", dirPath), + "markdown.md": fmt.Sprintf("%s/markdown.md", dirPath), + }) + + err := os.MkdirAll(dirPath, 0o755) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + err = os.WriteFile(fmt.Sprintf("%s/index.html", dirPath), []byte("
{{ toHTML \"markdown.md\" }}
"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + err = os.WriteFile(fmt.Sprintf("%s/markdown.md", dirPath), []byte("# Hello World!"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return ctx + }(), + api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { + return nil + }}, + expectError: false, + expectHttpError: false, + expectOutputPathsCount: 1, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + if tc.ctx.DirPath() != "" { + defer func() { + err := os.RemoveAll(tc.ctx.DirPath()) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + }() + } + + tc.ctx.SetLogger(zap.NewNop()) + c := echo.New().NewContext(nil, nil) + c.Set("context", tc.ctx.Context) + + err := screenshotMarkdownRoute(tc.api).Handler(c) + + if tc.expectError && err == nil { + t.Fatal("expected error but got none", err) + } + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + var httpErr api.HttpError + isHttpError := errors.As(err, &httpErr) + + if tc.expectHttpError && !isHttpError { + t.Errorf("expected an HTTP error but got: %v", err) + } + + if !tc.expectHttpError && isHttpError { + t.Errorf("expected no HTTP error but got one: %v", httpErr) + } + + if err != nil && tc.expectHttpError && isHttpError { + status, _ := httpErr.HttpError() + if status != tc.expectHttpStatus { + t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status) + } + } + + if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) { + t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) + } + }) + } +} + func TestConvertUrl(t *testing.T) { for _, tc := range []struct { scenario string @@ -591,7 +1190,7 @@ func TestConvertUrl(t *testing.T) { api Api engine gotenberg.PdfEngine pdfFormats gotenberg.PdfFormats - options Options + options PdfOptions expectError bool expectHttpError bool expectHttpStatus int @@ -600,10 +1199,10 @@ func TestConvertUrl(t *testing.T) { { scenario: "ErrUrlNotAuthorized", ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return ErrUrlNotAuthorized }}, - options: DefaultOptions(), + options: DefaultPdfOptions(), expectError: true, expectHttpError: true, expectHttpStatus: http.StatusForbidden, @@ -612,10 +1211,10 @@ func TestConvertUrl(t *testing.T) { { scenario: "ErrOmitBackgroundWithoutPrintBackground", ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return ErrOmitBackgroundWithoutPrintBackground }}, - options: DefaultOptions(), + options: DefaultPdfOptions(), expectError: true, expectHttpError: true, expectHttpStatus: http.StatusBadRequest, @@ -624,10 +1223,10 @@ func TestConvertUrl(t *testing.T) { { scenario: "ErrInvalidEvaluationExpression (without waitForExpression form field)", ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return ErrInvalidEvaluationExpression }}, - options: DefaultOptions(), + options: DefaultPdfOptions(), expectError: true, expectHttpError: false, expectOutputPathsCount: 0, @@ -635,11 +1234,11 @@ func TestConvertUrl(t *testing.T) { { scenario: "ErrInvalidEvaluationExpression (with waitForExpression form field)", ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return ErrInvalidEvaluationExpression }}, - options: func() Options { - options := DefaultOptions() + options: func() PdfOptions { + options := DefaultPdfOptions() options.WaitForExpression = "foo" return options @@ -652,10 +1251,10 @@ func TestConvertUrl(t *testing.T) { { scenario: "ErrInvalidPrinterSettings", ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return ErrInvalidPrinterSettings }}, - options: DefaultOptions(), + options: DefaultPdfOptions(), expectError: true, expectHttpError: true, expectHttpStatus: http.StatusBadRequest, @@ -664,10 +1263,10 @@ func TestConvertUrl(t *testing.T) { { scenario: "ErrPageRangesSyntaxError", ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return ErrPageRangesSyntaxError }}, - options: DefaultOptions(), + options: DefaultPdfOptions(), expectError: true, expectHttpError: true, expectHttpStatus: http.StatusBadRequest, @@ -676,10 +1275,10 @@ func TestConvertUrl(t *testing.T) { { scenario: "ErrInvalidHttpStatusCode", ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return ErrInvalidHttpStatusCode }}, - options: DefaultOptions(), + options: DefaultPdfOptions(), expectError: true, expectHttpError: true, expectHttpStatus: http.StatusConflict, @@ -688,10 +1287,10 @@ func TestConvertUrl(t *testing.T) { { scenario: "ErrConsoleExceptions", ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return ErrConsoleExceptions }}, - options: DefaultOptions(), + options: DefaultPdfOptions(), expectError: true, expectHttpError: true, expectHttpStatus: http.StatusConflict, @@ -700,10 +1299,10 @@ func TestConvertUrl(t *testing.T) { { scenario: "error from Chromium", ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return errors.New("foo") }}, - options: DefaultOptions(), + options: DefaultPdfOptions(), expectError: true, expectHttpError: false, expectOutputPathsCount: 0, @@ -711,14 +1310,14 @@ func TestConvertUrl(t *testing.T) { { scenario: "ErrPdfFormatNotSupported", ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return nil }}, engine: &gotenberg.PdfEngineMock{ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { return gotenberg.ErrPdfFormatNotSupported }}, pdfFormats: gotenberg.PdfFormats{PdfA: "foo"}, - options: DefaultOptions(), + options: DefaultPdfOptions(), expectError: true, expectHttpError: true, expectHttpStatus: http.StatusBadRequest, @@ -727,14 +1326,14 @@ func TestConvertUrl(t *testing.T) { { scenario: "error from PDF engine", ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return nil }}, engine: &gotenberg.PdfEngineMock{ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { return errors.New("foo") }}, pdfFormats: gotenberg.PdfFormats{PdfA: "foo"}, - options: DefaultOptions(), + options: DefaultPdfOptions(), expectError: true, expectHttpError: false, expectOutputPathsCount: 0, @@ -742,14 +1341,14 @@ func TestConvertUrl(t *testing.T) { { scenario: "success with pdfa form field", ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return nil }}, engine: &gotenberg.PdfEngineMock{ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { return nil }}, pdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1b}, - options: DefaultOptions(), + options: DefaultPdfOptions(), expectError: false, expectHttpError: false, expectOutputPathsCount: 1, @@ -761,10 +1360,10 @@ func TestConvertUrl(t *testing.T) { ctx.SetCancelled(true) return ctx }(), - api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return nil }}, - options: DefaultOptions(), + options: DefaultPdfOptions(), expectError: true, expectHttpError: false, expectOutputPathsCount: 0, @@ -772,10 +1371,10 @@ func TestConvertUrl(t *testing.T) { { scenario: "success", ctx: &api.ContextMock{Context: new(api.Context)}, - api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { return nil }}, - options: DefaultOptions(), + options: DefaultPdfOptions(), expectError: false, expectHttpError: false, expectOutputPathsCount: 1, @@ -817,3 +1416,153 @@ func TestConvertUrl(t *testing.T) { }) } } + +func TestScreenshotUrl(t *testing.T) { + for _, tc := range []struct { + scenario string + ctx *api.ContextMock + api Api + options ScreenshotOptions + expectError bool + expectHttpError bool + expectHttpStatus int + expectOutputPathsCount int + }{ + { + scenario: "ErrUrlNotAuthorized", + ctx: &api.ContextMock{Context: new(api.Context)}, + api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { + return ErrUrlNotAuthorized + }}, + options: DefaultScreenshotOptions(), + expectError: true, + expectHttpError: true, + expectHttpStatus: http.StatusForbidden, + expectOutputPathsCount: 0, + }, + { + scenario: "ErrInvalidEvaluationExpression (without waitForExpression form field)", + ctx: &api.ContextMock{Context: new(api.Context)}, + api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { + return ErrInvalidEvaluationExpression + }}, + options: DefaultScreenshotOptions(), + expectError: true, + expectHttpError: false, + expectOutputPathsCount: 0, + }, + { + scenario: "ErrInvalidEvaluationExpression (with waitForExpression form field)", + ctx: &api.ContextMock{Context: new(api.Context)}, + api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { + return ErrInvalidEvaluationExpression + }}, + options: func() ScreenshotOptions { + options := DefaultScreenshotOptions() + options.WaitForExpression = "foo" + + return options + }(), + expectError: true, + expectHttpError: true, + expectHttpStatus: http.StatusBadRequest, + expectOutputPathsCount: 0, + }, + { + scenario: "ErrInvalidHttpStatusCode", + ctx: &api.ContextMock{Context: new(api.Context)}, + api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { + return ErrInvalidHttpStatusCode + }}, + options: DefaultScreenshotOptions(), + expectError: true, + expectHttpError: true, + expectHttpStatus: http.StatusConflict, + expectOutputPathsCount: 0, + }, + { + scenario: "ErrConsoleExceptions", + ctx: &api.ContextMock{Context: new(api.Context)}, + api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { + return ErrConsoleExceptions + }}, + options: DefaultScreenshotOptions(), + expectError: true, + expectHttpError: true, + expectHttpStatus: http.StatusConflict, + expectOutputPathsCount: 0, + }, + { + scenario: "error from Chromium", + ctx: &api.ContextMock{Context: new(api.Context)}, + api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { + return errors.New("foo") + }}, + options: DefaultScreenshotOptions(), + expectError: true, + expectHttpError: false, + expectOutputPathsCount: 0, + }, + { + scenario: "cannot add output paths", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetCancelled(true) + return ctx + }(), + api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { + return nil + }}, + options: DefaultScreenshotOptions(), + expectError: true, + expectHttpError: false, + expectOutputPathsCount: 0, + }, + { + scenario: "success", + ctx: &api.ContextMock{Context: new(api.Context)}, + api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { + return nil + }}, + options: DefaultScreenshotOptions(), + expectError: false, + expectHttpError: false, + expectOutputPathsCount: 1, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + tc.ctx.SetLogger(zap.NewNop()) + err := screenshotUrl(tc.ctx.Context, tc.api, "", tc.options) + + if tc.expectError && err == nil { + t.Fatal("expected error but got none", err) + } + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + var httpErr api.HttpError + isHttpError := errors.As(err, &httpErr) + + if tc.expectHttpError && !isHttpError { + t.Errorf("expected an HTTP error but got: %v", err) + } + + if !tc.expectHttpError && isHttpError { + t.Errorf("expected no HTTP error but got one: %v", httpErr) + } + + if err != nil && tc.expectHttpError && isHttpError { + status, _ := httpErr.HttpError() + if status != tc.expectHttpStatus { + t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status) + } + } + + if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) { + t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) + } + }) + } +} diff --git a/pkg/modules/chromium/tasks.go b/pkg/modules/chromium/tasks.go index 5dea5f53..d77688b6 100644 --- a/pkg/modules/chromium/tasks.go +++ b/pkg/modules/chromium/tasks.go @@ -15,7 +15,7 @@ import ( "go.uber.org/zap" ) -func printToPdfActionFunc(logger *zap.Logger, outputPath string, options Options) chromedp.ActionFunc { +func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc { return func(ctx context.Context) error { printToPdf := page.PrintToPDF(). WithTransferMode(page.PrintToPDFTransferModeReturnAsStream). @@ -31,8 +31,8 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options Options WithPageRanges(options.PageRanges). WithPreferCSSPageSize(options.PreferCssPageSize) - hasCustomHeaderFooter := options.HeaderTemplate != DefaultOptions().HeaderTemplate || - options.FooterTemplate != DefaultOptions().FooterTemplate + hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate || + options.FooterTemplate != DefaultPdfOptions().FooterTemplate if !hasCustomHeaderFooter { logger.Debug("no custom header nor footer") @@ -63,7 +63,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options Options } defer func() { - err := reader.Close() + err = reader.Close() if err != nil { logger.Error(fmt.Sprintf("close reader: %s", err)) } @@ -75,7 +75,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options Options } defer func() { - err := file.Close() + err = file.Close() if err != nil { logger.Error(fmt.Sprintf("close output path: %s", err)) } @@ -92,6 +92,47 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options Options } } +func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options ScreenshotOptions) chromedp.ActionFunc { + return func(ctx context.Context) error { + captureScreenshot := page.CaptureScreenshot(). + WithCaptureBeyondViewport(true). + WithFromSurface(true). + WithOptimizeForSpeed(options.OptimizeForSpeed). + WithFormat(page.CaptureScreenshotFormat(options.Format)) + + if options.Format == "jpeg" { + captureScreenshot = captureScreenshot. + WithQuality(int64(options.Quality)) + } + + logger.Debug(fmt.Sprintf("capture screenshot with: %+v", captureScreenshot)) + + buffer, err := captureScreenshot.Do(ctx) + if err != nil { + return fmt.Errorf("capture screenshot: %w", err) + } + + file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("open output path: %w", err) + } + + defer func() { + err = file.Close() + if err != nil { + logger.Error(fmt.Sprintf("close output path: %s", err)) + } + }() + + _, err = file.Write(buffer) + if err != nil { + return fmt.Errorf("write result to output path: %w", err) + } + + return nil + } +} + func clearCacheActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc { return func(ctx context.Context) error { // See https://github.com/gotenberg/gotenberg/issues/753. @@ -211,7 +252,6 @@ func hideDefaultWhiteBackgroundActionFunc(logger *zap.Logger, omitBackground, pr // See https://github.com/gotenberg/gotenberg/issues/226. if !omitBackground { logger.Debug("default white background not hidden") - return nil } @@ -270,7 +310,6 @@ func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string) chromedp.A return func(ctx context.Context) error { if mediaType == "" { logger.Debug("no emulated media type") - return nil } @@ -294,13 +333,11 @@ func waitDelayBeforePrintActionFunc(logger *zap.Logger, disableJavaScript bool, return func(ctx context.Context) error { if disableJavaScript { logger.Debug("JavaScript disabled, skipping wait delay") - return nil } if delay <= 0 { logger.Debug("no wait delay") - return nil } @@ -321,13 +358,11 @@ func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScrip return func(ctx context.Context) error { if disableJavaScript { logger.Debug("JavaScript disabled, skipping wait expression") - return nil } if expression == "" { logger.Debug("no wait expression") - return nil }