diff --git a/pkg/modules/chromium/browser.go b/pkg/modules/chromium/browser.go index c7b105ae..4e895203 100644 --- a/pkg/modules/chromium/browser.go +++ b/pkg/modules/chromium/browser.go @@ -228,7 +228,6 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript), setCookiesActionFunc(logger, options.Cookies), userAgentOverride(logger, options.UserAgent), - extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders), navigateActionFunc(logger, url, options.SkipNetworkIdleEvent), hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground), forceExactColorsActionFunc(), @@ -252,7 +251,6 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript), setCookiesActionFunc(logger, options.Cookies), userAgentOverride(logger, options.UserAgent), - extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders), navigateActionFunc(logger, url, options.SkipNetworkIdleEvent), hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true), forceExactColorsActionFunc(), @@ -291,8 +289,14 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string defer taskCancel() // We validate all others requests against our allow / deny lists. - // If a request does not pass the validation, we make it fail. - listenForEventRequestPaused(taskCtx, logger, b.arguments.allowList, b.arguments.denyList) + // If a request does not pass the validation, we make it fail. It also set + // the extra HTTP headers, if any. + // See https://github.com/gotenberg/gotenberg/issues/1011. + listenForEventRequestPaused(taskCtx, logger, eventRequestPausedOptions{ + allowList: b.arguments.allowList, + denyList: b.arguments.denyList, + extraHttpHeaders: options.ExtraHttpHeaders, + }) var ( invalidHttpStatusCode error diff --git a/pkg/modules/chromium/browser_test.go b/pkg/modules/chromium/browser_test.go index dfee69ab..9cc4bcb0 100644 --- a/pkg/modules/chromium/browser_test.go +++ b/pkg/modules/chromium/browser_test.go @@ -702,8 +702,21 @@ func TestChromiumBrowser_pdf(t *testing.T) { return fs }(), options: PdfOptions{ - Options: Options{ExtraHttpHeaders: map[string]string{ - "X-Foo": "Bar", + Options: Options{ExtraHttpHeaders: []ExtraHttpHeader{ + { + Name: "X-Foo", + Value: "foo", + }, + { + Name: "X-Bar", + Value: "bar", + Scope: regexp2.MustCompile(`.*index\.html.*`, 0), + }, + { + Name: "X-Baz", + Value: "baz", + Scope: regexp2.MustCompile(`.*another\.html.*`, 0), + }, }}, }, noDeadline: false, @@ -711,6 +724,10 @@ func TestChromiumBrowser_pdf(t *testing.T) { expectError: false, expectedLogEntries: []string{ "extra HTTP headers:", + "extra HTTP header 'X-Foo' will be set for request URL", + "extra HTTP header 'X-Bar' (scoped) will be set for request URL", + "extra HTTP header 'X-Baz' (scoped) will not be set for request URL", + "setting extra HTTP headers for request URL", }, }, { @@ -1716,6 +1733,41 @@ func TestChromiumBrowser_screenshot(t *testing.T) { "set cookie", }, }, + { + scenario: "user agent override", + browser: newChromiumBrowser( + browserArguments{ + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + wsUrlReadTimeout: 5 * time.Second, + allowList: regexp2.MustCompile("", 0), + denyList: regexp2.MustCompile("", 0), + }, + ), + 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("

User-Agent override

"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: ScreenshotOptions{ + Options: Options{UserAgent: "foo"}, + }, + noDeadline: false, + start: true, + expectError: false, + expectedLogEntries: []string{ + fmt.Sprintf("user agent override: foo"), + }, + }, { scenario: "extra HTTP headers", browser: newChromiumBrowser( @@ -1742,8 +1794,21 @@ func TestChromiumBrowser_screenshot(t *testing.T) { return fs }(), options: ScreenshotOptions{ - Options: Options{ExtraHttpHeaders: map[string]string{ - "X-Foo": "Bar", + Options: Options{ExtraHttpHeaders: []ExtraHttpHeader{ + { + Name: "X-Foo", + Value: "foo", + }, + { + Name: "X-Bar", + Value: "bar", + Scope: regexp2.MustCompile(`.*index\.html.*`, 0), + }, + { + Name: "X-Baz", + Value: "baz", + Scope: regexp2.MustCompile(`.*another\.html.*`, 0), + }, }}, }, noDeadline: false, @@ -1751,6 +1816,10 @@ func TestChromiumBrowser_screenshot(t *testing.T) { expectError: false, expectedLogEntries: []string{ "extra HTTP headers:", + "extra HTTP header 'X-Foo' will be set for request URL", + "extra HTTP header 'X-Bar' (scoped) will be set for request URL", + "extra HTTP header 'X-Baz' (scoped) will not be set for request URL", + "setting extra HTTP headers for request URL", }, }, { diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go index 735b2700..0a8a8a2e 100644 --- a/pkg/modules/chromium/chromium.go +++ b/pkg/modules/chromium/chromium.go @@ -9,6 +9,7 @@ import ( "github.com/alexliesenfeld/health" "github.com/chromedp/cdproto/network" + "github.com/dlclark/regexp2" flag "github.com/spf13/pflag" "go.uber.org/zap" @@ -109,7 +110,7 @@ type Options struct { // ExtraHttpHeaders are extra HTTP headers to send by Chromium while // loading he HTML document. - ExtraHttpHeaders map[string]string + ExtraHttpHeaders []ExtraHttpHeader // EmulatedMediaType is the media type to emulate, either "screen" or // "print". @@ -289,6 +290,22 @@ type Cookie struct { SameSite network.CookieSameSite `json:"sameSite,omitempty"` } +// ExtraHttpHeader are extra HTTP headers to send by Chromium. +type ExtraHttpHeader struct { + // Name is the header name. + // Required. + Name string + + // Value is the header value. + // Required. + Value string + + // Scope is the header scope. If nil, the header will be applied to ALL + // requests from the page. + // Optional. + Scope *regexp2.Regexp +} + // 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 PdfOptions) error diff --git a/pkg/modules/chromium/events.go b/pkg/modules/chromium/events.go index 8a8ca1dd..85d71456 100644 --- a/pkg/modules/chromium/events.go +++ b/pkg/modules/chromium/events.go @@ -20,10 +20,22 @@ import ( "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) +type eventRequestPausedOptions struct { + allowList, denyList *regexp2.Regexp + extraHttpHeaders []ExtraHttpHeader +} + // listenForEventRequestPaused listens for requests to check if they are -// allowed or not.network.SetBlockedURLS() +// allowed or not. It also set the extra HTTP headers, if any. +// See https://github.com/gotenberg/gotenberg/issues/1011. // TODO: https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-setBlockedURLs (experimental for now). -func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowList *regexp2.Regexp, denyList *regexp2.Regexp) { +func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, options eventRequestPausedOptions) { + if len(options.extraHttpHeaders) == 0 { + logger.Debug("no extra HTTP headers") + } else { + logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders)) + } + chromedp.ListenTarget(ctx, func(ev interface{}) { switch e := ev.(type) { case *fetch.EventRequestPaused: @@ -37,7 +49,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowL return } - err := gotenberg.FilterDeadline(allowList, denyList, e.Request.URL, deadline) + err := gotenberg.FilterDeadline(options.allowList, options.denyList, e.Request.URL, deadline) if err != nil { logger.Warn(err.Error()) allow = false @@ -46,19 +58,78 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowL cctx := chromedp.FromContext(ctx) executorCtx := cdp.WithExecutor(ctx, cctx.Target) - if allow { - req := fetch.ContinueRequest(e.RequestID) + if !allow { + req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied) err = req.Do(executorCtx) if err != nil { - logger.Error(fmt.Sprintf("continue request: %s", err)) + logger.Error(fmt.Sprintf("fail request: %s", err)) } return } - req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied) + req := fetch.ContinueRequest(e.RequestID) + + var extraHttpHeadersToSet []ExtraHttpHeader + if len(options.extraHttpHeaders) > 0 { + // The user want to set extra HTTP headers. + + // First, we have to check if at least one header has to be + // set for current request. + for _, header := range options.extraHttpHeaders { + if header.Scope == nil { + // Non-scoped header. + logger.Debug(fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL)) + extraHttpHeadersToSet = append(extraHttpHeadersToSet, header) + continue + } + + ok, err := header.Scope.MatchString(e.Request.URL) + if err != nil { + logger.Error(fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err)) + } else if ok { + logger.Debug(fmt.Sprintf("extra HTTP header '%s' (scoped) will be set for request URL '%s'", header.Name, e.Request.URL)) + extraHttpHeadersToSet = append(extraHttpHeadersToSet, header) + } else { + logger.Debug(fmt.Sprintf("scoped extra HTTP header '%s' (scoped) will not be set for request URL '%s'", header.Name, e.Request.URL)) + } + } + } + + if len(extraHttpHeadersToSet) > 0 { + logger.Debug(fmt.Sprintf("setting extra HTTP headers for request URL '%s': %+v", e.Request.URL, extraHttpHeadersToSet)) + + originalHeaders := e.Request.Headers + headers := make(map[string]string) + + for key, value := range originalHeaders { + strValue, ok := value.(string) + if ok { + headers[key] = strValue + } else { + logger.Error(fmt.Sprintf("ignoring header '%s' for URL '%s' since it cannot be cast to a string", key, e.Request.URL)) + } + } + + var headersEntries []*fetch.HeaderEntry + for key, value := range headers { + headersEntries = append(headersEntries, &fetch.HeaderEntry{ + Name: key, + Value: value, + }) + } + for _, header := range extraHttpHeadersToSet { + headersEntries = append(headersEntries, &fetch.HeaderEntry{ + Name: header.Name, + Value: header.Value, + }) + } + + req.Headers = headersEntries + } + err = req.Do(executorCtx) if err != nil { - logger.Error(fmt.Sprintf("fail request: %s", err)) + logger.Error(fmt.Sprintf("continue request: %s", err)) } }() } diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go index ba1e7be2..a725c7cd 100644 --- a/pkg/modules/chromium/routes.go +++ b/pkg/modules/chromium/routes.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/dlclark/regexp2" "github.com/labstack/echo/v4" "github.com/microcosm-cc/bluemonday" "github.com/russross/blackfriday/v2" @@ -36,7 +37,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) { waitForExpression string cookies []Cookie userAgent string - extraHttpHeaders map[string]string + extraHttpHeaders []ExtraHttpHeader emulatedMediaType string omitBackground bool ) @@ -86,12 +87,59 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) { return nil } - err := json.Unmarshal([]byte(value), &extraHttpHeaders) + var headers map[string]string + err := json.Unmarshal([]byte(value), &headers) if err != nil { return fmt.Errorf("unmarshal extraHttpHeaders: %w", err) } - return nil + for k, v := range headers { + var scope string + var valueTokens []string + var invalidScopeToken bool + + tokens := strings.Split(v, ";") + for _, token := range tokens { + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(token)), "scope") { + tokenNoSpaces := strings.Join(strings.Fields(token), "") + parts := strings.SplitN(tokenNoSpaces, "=", 2) + + if len(parts) == 2 && strings.ToLower(parts[0]) == "scope" && parts[1] != "" { + scope = parts[1] + } else { + err = multierr.Append(err, fmt.Errorf("invalid scope '%s' for header '%s'", scope, k)) + invalidScopeToken = true + break + } + } else { + if token != "" { + valueTokens = append(valueTokens, token) + } + } + } + + if invalidScopeToken { + continue + } + + var scopeRegexp *regexp2.Regexp + if len(scope) > 0 { + p, errCompile := regexp2.Compile(scope, 0) + if errCompile != nil { + err = multierr.Append(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile)) + continue + } + scopeRegexp = p + } + + extraHttpHeaders = append(extraHttpHeaders, ExtraHttpHeader{ + Name: k, + Value: strings.Join(valueTokens, "; "), + Scope: scopeRegexp, + }) + } + + return err }). Custom("emulatedMediaType", func(value string) error { if value == "" { diff --git a/pkg/modules/chromium/routes_test.go b/pkg/modules/chromium/routes_test.go index ba957e89..29fb9d10 100644 --- a/pkg/modules/chromium/routes_test.go +++ b/pkg/modules/chromium/routes_test.go @@ -7,8 +7,10 @@ import ( "net/http" "os" "reflect" + "sort" "testing" + "github.com/dlclark/regexp2" "github.com/google/uuid" "github.com/labstack/echo/v4" "go.uber.org/zap" @@ -19,14 +21,18 @@ import ( func TestFormDataChromiumOptions(t *testing.T) { for _, tc := range []struct { - scenario string - ctx *api.ContextMock - expectedOptions Options + scenario string + ctx *api.ContextMock + expectedOptions Options + compareWithoutDeepEqual bool + expectValidationError bool }{ { - scenario: "no custom form fields", - ctx: &api.ContextMock{Context: new(api.Context)}, - expectedOptions: DefaultOptions(), + scenario: "no custom form fields", + ctx: &api.ContextMock{Context: new(api.Context)}, + expectedOptions: DefaultOptions(), + compareWithoutDeepEqual: false, + expectValidationError: false, }, { scenario: "invalid failOnHttpStatusCodes form field", @@ -44,6 +50,8 @@ func TestFormDataChromiumOptions(t *testing.T) { options.FailOnHttpStatusCodes = nil return options }(), + compareWithoutDeepEqual: false, + expectValidationError: true, }, { scenario: "valid failOnHttpStatusCodes form field", @@ -61,6 +69,8 @@ func TestFormDataChromiumOptions(t *testing.T) { options.FailOnHttpStatusCodes = []int64{399, 499, 599} return options }(), + compareWithoutDeepEqual: false, + expectValidationError: false, }, { scenario: "invalid cookies form field", @@ -73,7 +83,9 @@ func TestFormDataChromiumOptions(t *testing.T) { }) return ctx }(), - expectedOptions: DefaultOptions(), + expectedOptions: DefaultOptions(), + compareWithoutDeepEqual: false, + expectValidationError: true, }, { scenario: "invalid cookies form field (missing required values)", @@ -93,6 +105,8 @@ func TestFormDataChromiumOptions(t *testing.T) { options.Cookies = []Cookie{{}} return options }(), + compareWithoutDeepEqual: false, + expectValidationError: true, }, { scenario: "valid cookies form field", @@ -114,9 +128,11 @@ func TestFormDataChromiumOptions(t *testing.T) { }} return options }(), + compareWithoutDeepEqual: false, + expectValidationError: false, }, { - scenario: "invalid extraHttpHeaders form field", + scenario: "invalid extraHttpHeaders form field: cannot unmarshall", ctx: func() *api.ContextMock { ctx := &api.ContextMock{Context: new(api.Context)} ctx.SetValues(map[string][]string{ @@ -126,7 +142,39 @@ func TestFormDataChromiumOptions(t *testing.T) { }) return ctx }(), - expectedOptions: DefaultOptions(), + expectedOptions: DefaultOptions(), + compareWithoutDeepEqual: false, + expectValidationError: true, + }, + { + scenario: "invalid extraHttpHeaders form field: invalid scope", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetValues(map[string][]string{ + "extraHttpHeaders": { + `{"foo":"bar;scope;;"}`, + }, + }) + return ctx + }(), + expectedOptions: DefaultOptions(), + compareWithoutDeepEqual: false, + expectValidationError: true, + }, + { + scenario: "invalid extraHttpHeaders form field: invalid scope regex pattern", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetValues(map[string][]string{ + "extraHttpHeaders": { + `{"foo":"bar;scope=*."}`, + }, + }) + return ctx + }(), + expectedOptions: DefaultOptions(), + compareWithoutDeepEqual: false, + expectValidationError: true, }, { scenario: "valid extraHttpHeaders form field", @@ -134,18 +182,28 @@ func TestFormDataChromiumOptions(t *testing.T) { ctx := &api.ContextMock{Context: new(api.Context)} ctx.SetValues(map[string][]string{ "extraHttpHeaders": { - `{"foo":"bar"}`, + `{"foo":"bar","baz":"qux;scope=https?:\\/\\/([a-zA-Z0-9-]+\\.)*qux\\.com\\/.*"}`, }, }) return ctx }(), expectedOptions: func() Options { options := DefaultOptions() - options.ExtraHttpHeaders = map[string]string{ - "foo": "bar", + options.ExtraHttpHeaders = []ExtraHttpHeader{ + { + Name: "foo", + Value: "bar", + }, + { + Name: "baz", + Value: "qux", + Scope: regexp2.MustCompile(`https?:\/\/([a-zA-Z0-9-]+\.)*qux\.com\/.*`, 0), + }, } return options }(), + compareWithoutDeepEqual: true, + expectValidationError: false, }, { scenario: "invalid emulatedMediaType form field", @@ -158,7 +216,8 @@ func TestFormDataChromiumOptions(t *testing.T) { }) return ctx }(), - expectedOptions: DefaultOptions(), + expectedOptions: DefaultOptions(), + expectValidationError: true, }, { scenario: "valid emulatedMediaType form field", @@ -176,14 +235,61 @@ func TestFormDataChromiumOptions(t *testing.T) { options.EmulatedMediaType = "screen" return options }(), + expectValidationError: false, }, } { t.Run(tc.scenario, func(t *testing.T) { tc.ctx.SetLogger(zap.NewNop()) - _, actual := FormDataChromiumOptions(tc.ctx.Context) + form, actual := FormDataChromiumOptions(tc.ctx.Context) - if !reflect.DeepEqual(actual, tc.expectedOptions) { - t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual) + if tc.compareWithoutDeepEqual { + if len(tc.expectedOptions.ExtraHttpHeaders) != len(actual.ExtraHttpHeaders) { + t.Fatalf("expected %d extra HTTP headers, but got %d", len(tc.expectedOptions.ExtraHttpHeaders), len(actual.ExtraHttpHeaders)) + } + + sort.Slice(tc.expectedOptions.ExtraHttpHeaders, func(i, j int) bool { + return tc.expectedOptions.ExtraHttpHeaders[i].Name < tc.expectedOptions.ExtraHttpHeaders[j].Name + }) + sort.Slice(actual.ExtraHttpHeaders, func(i, j int) bool { + return actual.ExtraHttpHeaders[i].Name < actual.ExtraHttpHeaders[j].Name + }) + + for i := range tc.expectedOptions.ExtraHttpHeaders { + if tc.expectedOptions.ExtraHttpHeaders[i].Name != actual.ExtraHttpHeaders[i].Name { + t.Fatalf("expected '%s' extra HTTP header, but got '%s'", tc.expectedOptions.ExtraHttpHeaders[i].Name, tc.expectedOptions.ExtraHttpHeaders[i].Name) + } + + if tc.expectedOptions.ExtraHttpHeaders[i].Value != actual.ExtraHttpHeaders[i].Value { + t.Fatalf("expected '%s' as value for extra HTTP header '%s', but got '%s'", tc.expectedOptions.ExtraHttpHeaders[i].Value, tc.expectedOptions.ExtraHttpHeaders[i].Name, actual.ExtraHttpHeaders[i].Value) + } + + var expectedScope string + if tc.expectedOptions.ExtraHttpHeaders[i].Scope != nil { + expectedScope = tc.expectedOptions.ExtraHttpHeaders[i].Scope.String() + } + var actualScope string + if actual.ExtraHttpHeaders[i].Scope != nil { + actualScope = actual.ExtraHttpHeaders[i].Scope.String() + } + + if expectedScope != actualScope { + t.Fatalf("expected '%s' as scope for extra HTTP header '%s', but got '%s'", expectedScope, tc.expectedOptions.ExtraHttpHeaders[i].Name, actualScope) + } + } + } else { + if !reflect.DeepEqual(actual, tc.expectedOptions) { + t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual) + } + } + + err := form.Validate() + + if tc.expectValidationError && err == nil { + t.Fatal("expected validation error but got none", err) + } + + if !tc.expectValidationError && err != nil { + t.Fatalf("expected no validation error but got: %v", err) } }) } @@ -191,14 +297,16 @@ func TestFormDataChromiumOptions(t *testing.T) { func TestFormDataChromiumPdfOptions(t *testing.T) { for _, tc := range []struct { - scenario string - ctx *api.ContextMock - expectedOptions PdfOptions + scenario string + ctx *api.ContextMock + expectedOptions PdfOptions + expectValidationError bool }{ { - scenario: "no custom form fields", - ctx: &api.ContextMock{Context: new(api.Context)}, - expectedOptions: DefaultPdfOptions(), + scenario: "no custom form fields", + ctx: &api.ContextMock{Context: new(api.Context)}, + expectedOptions: DefaultPdfOptions(), + expectValidationError: false, }, { scenario: "custom form fields (Options & PdfOptions)", @@ -220,29 +328,42 @@ func TestFormDataChromiumPdfOptions(t *testing.T) { options.EmulatedMediaType = "screen" return options }(), + expectValidationError: false, }, } { t.Run(tc.scenario, func(t *testing.T) { tc.ctx.SetLogger(zap.NewNop()) - _, actual := FormDataChromiumPdfOptions(tc.ctx.Context) + form, actual := FormDataChromiumPdfOptions(tc.ctx.Context) if !reflect.DeepEqual(actual, tc.expectedOptions) { t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual) } + + err := form.Validate() + + if tc.expectValidationError && err == nil { + t.Fatal("expected validation error but got none", err) + } + + if !tc.expectValidationError && err != nil { + t.Fatalf("expected no validation error but got: %v", err) + } }) } } func TestFormDataChromiumScreenshotOptions(t *testing.T) { for _, tc := range []struct { - scenario string - ctx *api.ContextMock - expectedOptions ScreenshotOptions + scenario string + ctx *api.ContextMock + expectedOptions ScreenshotOptions + expectValidationError bool }{ { - scenario: "no custom form fields", - ctx: &api.ContextMock{Context: new(api.Context)}, - expectedOptions: DefaultScreenshotOptions(), + scenario: "no custom form fields", + ctx: &api.ContextMock{Context: new(api.Context)}, + expectedOptions: DefaultScreenshotOptions(), + expectValidationError: false, }, { scenario: "invalid format form field", @@ -260,6 +381,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) { options.Format = "" return options }(), + expectValidationError: true, }, { scenario: "valid png format form field", @@ -277,6 +399,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) { options.Format = "png" return options }(), + expectValidationError: false, }, { scenario: "valid jpeg format form field", @@ -294,6 +417,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) { options.Format = "jpeg" return options }(), + expectValidationError: false, }, { scenario: "valid webp format form field", @@ -311,6 +435,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) { options.Format = "webp" return options }(), + expectValidationError: false, }, { scenario: "invalid quality form field (not an integer)", @@ -328,6 +453,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) { options.Quality = 0 return options }(), + expectValidationError: true, }, { scenario: "invalid quality form field (< 0)", @@ -345,6 +471,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) { options.Quality = 0 return options }(), + expectValidationError: true, }, { scenario: "invalid quality form field (> 100)", @@ -362,6 +489,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) { options.Quality = 0 return options }(), + expectValidationError: true, }, { scenario: "valid quality form field", @@ -379,6 +507,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) { options.Quality = 50 return options }(), + expectValidationError: false, }, { scenario: "custom form fields (Options & ScreenshotOptions)", @@ -412,29 +541,42 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) { options.EmulatedMediaType = "screen" return options }(), + expectValidationError: false, }, } { t.Run(tc.scenario, func(t *testing.T) { tc.ctx.SetLogger(zap.NewNop()) - _, actual := FormDataChromiumScreenshotOptions(tc.ctx.Context) + form, actual := FormDataChromiumScreenshotOptions(tc.ctx.Context) if !reflect.DeepEqual(actual, tc.expectedOptions) { t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual) } + + err := form.Validate() + + if tc.expectValidationError && err == nil { + t.Fatal("expected validation error but got none", err) + } + + if !tc.expectValidationError && err != nil { + t.Fatalf("expected no validation error but got: %v", err) + } }) } } func TestFormDataChromiumPdfFormats(t *testing.T) { for _, tc := range []struct { - scenario string - ctx *api.ContextMock - expectedPdfFormats gotenberg.PdfFormats + scenario string + ctx *api.ContextMock + expectedPdfFormats gotenberg.PdfFormats + expectValidationError bool }{ { - scenario: "no custom form fields", - ctx: &api.ContextMock{Context: new(api.Context)}, - expectedPdfFormats: gotenberg.PdfFormats{}, + scenario: "no custom form fields", + ctx: &api.ContextMock{Context: new(api.Context)}, + expectedPdfFormats: gotenberg.PdfFormats{}, + expectValidationError: false, }, { scenario: "pdfa and pdfua form fields", @@ -450,30 +592,44 @@ func TestFormDataChromiumPdfFormats(t *testing.T) { }) return ctx }(), - expectedPdfFormats: gotenberg.PdfFormats{PdfA: "foo", PdfUa: true}, + expectedPdfFormats: gotenberg.PdfFormats{PdfA: "foo", PdfUa: true}, + expectValidationError: false, }, } { t.Run(tc.scenario, func(t *testing.T) { tc.ctx.SetLogger(zap.NewNop()) - actual := FormDataChromiumPdfFormats(tc.ctx.Context.FormData()) + form := tc.ctx.Context.FormData() + actual := FormDataChromiumPdfFormats(form) if !reflect.DeepEqual(actual, tc.expectedPdfFormats) { t.Fatalf("expected %+v but got: %+v", tc.expectedPdfFormats, actual) } + + err := form.Validate() + + if tc.expectValidationError && err == nil { + t.Fatal("expected validation error but got none", err) + } + + if !tc.expectValidationError && err != nil { + t.Fatalf("expected no validation error but got: %v", err) + } }) } } func TestFormDataPdfMetadata(t *testing.T) { for _, tc := range []struct { - scenario string - ctx *api.ContextMock - expectedMetadata map[string]interface{} + scenario string + ctx *api.ContextMock + expectedMetadata map[string]interface{} + expectValidationError bool }{ { - scenario: "no metadata form field", - ctx: &api.ContextMock{Context: new(api.Context)}, - expectedMetadata: nil, + scenario: "no metadata form field", + ctx: &api.ContextMock{Context: new(api.Context)}, + expectedMetadata: nil, + expectValidationError: false, }, { scenario: "invalid metadata form field", @@ -486,7 +642,8 @@ func TestFormDataPdfMetadata(t *testing.T) { }) return ctx }(), - expectedMetadata: nil, + expectedMetadata: nil, + expectValidationError: true, }, { scenario: "valid metadata form field", @@ -502,15 +659,27 @@ func TestFormDataPdfMetadata(t *testing.T) { expectedMetadata: map[string]interface{}{ "foo": "bar", }, + expectValidationError: false, }, } { t.Run(tc.scenario, func(t *testing.T) { tc.ctx.SetLogger(zap.NewNop()) - actual := FormDataPdfMetadata(tc.ctx.Context.FormData()) + form := tc.ctx.Context.FormData() + actual := FormDataPdfMetadata(form) if !reflect.DeepEqual(actual, tc.expectedMetadata) { t.Fatalf("expected %+v but got: %+v", tc.expectedMetadata, actual) } + + err := form.Validate() + + if tc.expectValidationError && err == nil { + t.Fatal("expected validation error but got none", err) + } + + if !tc.expectValidationError && err != nil { + t.Fatalf("expected no validation error but got: %v", err) + } }) } } diff --git a/pkg/modules/chromium/tasks.go b/pkg/modules/chromium/tasks.go index ac5d6e19..4e841d75 100644 --- a/pkg/modules/chromium/tasks.go +++ b/pkg/modules/chromium/tasks.go @@ -298,28 +298,33 @@ func userAgentOverride(logger *zap.Logger, userAgent string) chromedp.ActionFunc } } -func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc { - return func(ctx context.Context) error { - if len(extraHttpHeaders) == 0 { - logger.Debug("no extra HTTP headers") - return nil - } - - logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders)) - - headers := make(network.Headers, len(extraHttpHeaders)) - for key, value := range extraHttpHeaders { - headers[key] = value - } - - err := network.SetExtraHTTPHeaders(headers).Do(ctx) - if err == nil { - return nil - } - - return fmt.Errorf("set extra HTTP headers: %w", err) - } -} +// This code has been replaced with the listenForEventRequestPaused function. +// Indeed, the user may want to scope the headers per domain, but using +// network.SetExtraHTTPHeaders set the headers for ALL requests from the page. +// See https://github.com/gotenberg/gotenberg/issues/1011. +// +//func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc { +// return func(ctx context.Context) error { +// if len(extraHttpHeaders) == 0 { +// logger.Debug("no extra HTTP headers") +// return nil +// } +// +// logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders)) +// +// headers := make(network.Headers, len(extraHttpHeaders)) +// for key, value := range extraHttpHeaders { +// headers[key] = value +// } +// +// err := network.SetExtraHTTPHeaders(headers).Do(ctx) +// if err == nil { +// return nil +// } +// +// return fmt.Errorf("set extra HTTP headers: %w", err) +// } +//} func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent bool) chromedp.ActionFunc { return func(ctx context.Context) error {