From 1d66b43da1c24f1f4ebbfe01eb520544022f9d8a Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Wed, 3 Apr 2024 11:53:40 +0200 Subject: [PATCH] feat(chromium): set cookies --- pkg/modules/chromium/browser.go | 2 + pkg/modules/chromium/browser_test.go | 72 ++++++++++++++++++++++++++++ pkg/modules/chromium/chromium.go | 38 +++++++++++++++ pkg/modules/chromium/routes.go | 21 ++++++++ pkg/modules/chromium/routes_test.go | 53 ++++++++++++++++++++ pkg/modules/chromium/tasks.go | 50 +++++++++++++++++++ 6 files changed, 236 insertions(+) diff --git a/pkg/modules/chromium/browser.go b/pkg/modules/chromium/browser.go index df0516cf..060f2acb 100644 --- a/pkg/modules/chromium/browser.go +++ b/pkg/modules/chromium/browser.go @@ -226,6 +226,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp clearCacheActionFunc(logger, b.arguments.clearCache), clearCookiesActionFunc(logger, b.arguments.clearCookies), disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript), + setCookiesActionFunc(logger, options.Cookies), extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders), navigateActionFunc(logger, url, options.SkipNetworkIdleEvent), hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground), @@ -248,6 +249,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur clearCacheActionFunc(logger, b.arguments.clearCache), clearCookiesActionFunc(logger, b.arguments.clearCookies), disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript), + setCookiesActionFunc(logger, options.Cookies), extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders), navigateActionFunc(logger, url, options.SkipNetworkIdleEvent), hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true), diff --git a/pkg/modules/chromium/browser_test.go b/pkg/modules/chromium/browser_test.go index b33b5ae3..89d8a937 100644 --- a/pkg/modules/chromium/browser_test.go +++ b/pkg/modules/chromium/browser_test.go @@ -579,6 +579,41 @@ func TestChromiumBrowser_pdf(t *testing.T) { "JavaScript disabled, skipping wait expression", }, }, + { + scenario: "set cookies", + 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("

Set cookies

"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: PdfOptions{ + Options: Options{Cookies: []Cookie{{Name: "foo", Value: "bar", Domain: ".foo.bar"}}}, + }, + noDeadline: false, + start: true, + expectError: false, + expectedLogEntries: []string{ + "set cookie", + }, + }, { scenario: "extra HTTP headers", browser: newChromiumBrowser( @@ -1125,6 +1160,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { "cache not cleared", "cookies not cleared", "JavaScript not disabled", + "no cookies to set", "no extra HTTP headers", "navigate to", "default white background not hidden", @@ -1549,6 +1585,41 @@ func TestChromiumBrowser_screenshot(t *testing.T) { "JavaScript disabled, skipping wait expression", }, }, + { + scenario: "set cookies", + 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("

Set cookies

"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: ScreenshotOptions{ + Options: Options{Cookies: []Cookie{{Name: "fpp", Value: "bar", Domain: ".foo.bar"}}}, + }, + noDeadline: false, + start: true, + expectError: false, + expectedLogEntries: []string{ + "set cookie", + }, + }, { scenario: "extra HTTP headers", browser: newChromiumBrowser( @@ -1964,6 +2035,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) { "cache not cleared", "cookies not cleared", "JavaScript not disabled", + "no cookies to set", "no extra HTTP headers", "navigate to", "default white background not hidden", diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go index 0ab4ad36..1b6fd79d 100644 --- a/pkg/modules/chromium/chromium.go +++ b/pkg/modules/chromium/chromium.go @@ -8,6 +8,7 @@ import ( "time" "github.com/alexliesenfeld/health" + "github.com/chromedp/cdproto/network" flag "github.com/spf13/pflag" "go.uber.org/zap" @@ -103,6 +104,10 @@ type Options struct { // Optional. WaitForExpression string + // Cookies are the cookies to put in the Chromium cookies' jar. + // Optional + Cookies []Cookie + // ExtraHttpHeaders are the HTTP headers to send by Chromium while loading // the HTML document. // Optional. @@ -128,6 +133,7 @@ func DefaultOptions() Options { WaitDelay: 0, WaitWindowStatus: "", WaitForExpression: "", + Cookies: nil, ExtraHttpHeaders: nil, EmulatedMediaType: "", OmitBackground: false, @@ -258,6 +264,38 @@ func DefaultScreenshotOptions() ScreenshotOptions { } } +// Cookie gathers the available entries for setting a cookie in the Chromium +// cookies' jar. +type Cookie struct { + // Name is the cookie name. + // Required. + Name string `json:"name"` + + // Value is the cookie value. + // Required. + Value string `json:"value"` + + // Domain is the cookie domain. + // Required. + Domain string `json:"domain"` + + // Path is the cookie path. + // Optional. + Path string `json:"path,omitempty"` + + // Secure sets the cookie secure if true. + // Optional. + Secure bool `json:"secure,omitempty"` + + // HttpOnly sets the cookie as HTTP-only if true. + // Optional. + HttpOnly bool `json:"httpOnly,omitempty"` + + // SameSite is cookie 'Same-Site' status. + // Optional. + SameSite network.CookieSameSite `json:"sameSite,omitempty"` +} + // 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/routes.go b/pkg/modules/chromium/routes.go index 51409e93..d45203e5 100644 --- a/pkg/modules/chromium/routes.go +++ b/pkg/modules/chromium/routes.go @@ -34,6 +34,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) { waitDelay time.Duration waitWindowStatus string waitForExpression string + cookies []Cookie extraHttpHeaders map[string]string emulatedMediaType string omitBackground bool @@ -58,6 +59,25 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) { Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay). String("waitWindowStatus", &waitWindowStatus, defaultOptions.WaitWindowStatus). String("waitForExpression", &waitForExpression, defaultOptions.WaitForExpression). + Custom("cookies", func(value string) error { + if value == "" { + cookies = defaultOptions.Cookies + return nil + } + + err := json.Unmarshal([]byte(value), &cookies) + if err != nil { + return fmt.Errorf("unmarshal cookies: %w", err) + } + + for i, cookie := range cookies { + if strings.TrimSpace(cookie.Name) == "" || strings.TrimSpace(cookie.Value) == "" || strings.TrimSpace(cookie.Domain) == "" { + err = multierr.Append(err, fmt.Errorf("cookie %d must have its name, value and domain set", i)) + } + } + + return err + }). Custom("extraHttpHeaders", func(value string) error { if value == "" { extraHttpHeaders = defaultOptions.ExtraHttpHeaders @@ -94,6 +114,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) { WaitDelay: waitDelay, WaitWindowStatus: waitWindowStatus, WaitForExpression: waitForExpression, + Cookies: cookies, ExtraHttpHeaders: extraHttpHeaders, EmulatedMediaType: emulatedMediaType, OmitBackground: omitBackground, diff --git a/pkg/modules/chromium/routes_test.go b/pkg/modules/chromium/routes_test.go index 56e1a69d..af0d4885 100644 --- a/pkg/modules/chromium/routes_test.go +++ b/pkg/modules/chromium/routes_test.go @@ -62,6 +62,59 @@ func TestFormDataChromiumOptions(t *testing.T) { return options }(), }, + { + scenario: "invalid cookies form field", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetValues(map[string][]string{ + "cookies": { + "foo", + }, + }) + return ctx + }(), + expectedOptions: DefaultOptions(), + }, + { + scenario: "invalid cookies form field (missing required values)", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetValues(map[string][]string{ + "cookies": { + "[{}]", + }, + }) + return ctx + }(), + expectedOptions: func() Options { + options := DefaultOptions() + // No validation in this method, so it still instantiates + // an empty item. + options.Cookies = []Cookie{{}} + return options + }(), + }, + { + scenario: "valid cookies form field", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetValues(map[string][]string{ + "cookies": { + `[{"name":"foo","value":"bar","domain":".foo.bar"}]`, + }, + }) + return ctx + }(), + expectedOptions: func() Options { + options := DefaultOptions() + options.Cookies = []Cookie{{ + Name: "foo", + Value: "bar", + Domain: ".foo.bar", + }} + return options + }(), + }, { scenario: "invalid extraHttpHeaders form field", ctx: func() *api.ContextMock { diff --git a/pkg/modules/chromium/tasks.go b/pkg/modules/chromium/tasks.go index d1d4b8f4..4047dde7 100644 --- a/pkg/modules/chromium/tasks.go +++ b/pkg/modules/chromium/tasks.go @@ -3,6 +3,7 @@ package chromium import ( "bufio" "context" + "errors" "fmt" "os" "time" @@ -210,6 +211,55 @@ func disableJavaScriptActionFunc(logger *zap.Logger, disable bool) chromedp.Acti } } +func setCookiesActionFunc(logger *zap.Logger, cookies []Cookie) chromedp.ActionFunc { + return func(ctx context.Context) error { + if len(cookies) == 0 { + logger.Debug("no cookies to set") + return nil + } + + deadline, ok := ctx.Deadline() + if !ok { + return errors.New("context has no deadline, cannot set cookies") + } + epochTime := cdp.TimeSinceEpoch(deadline) + + cookiePretty := func(c *network.SetCookieParams) string { + return fmt.Sprintf( + "Name: '%s', Value: '%s', Domain: '%s', Path: '%s', Secure: %t, HTTPOnly: %t, SameSite: '%s', Expires: %s", + c.Name, + c.Value, + c.Domain, + c.Path, + c.Secure, + c.HTTPOnly, + c.SameSite.String(), + c.Expires.Time().String(), + ) + } + + for _, cookie := range cookies { + cookieParams := network. + SetCookie(cookie.Name, cookie.Value). + WithDomain(cookie.Domain). + WithPath(cookie.Path). + WithSecure(cookie.Secure). + WithHTTPOnly(cookie.HttpOnly). + WithSameSite(cookie.SameSite). + WithExpires(&epochTime) + + err := cookieParams.Do(ctx) + if err != nil { + return fmt.Errorf("set cookie %s: %w", cookiePretty(cookieParams), err) + } + + logger.Debug(fmt.Sprintf("set cookie %s", cookiePretty(cookieParams))) + } + + return nil + } +} + func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc { return func(ctx context.Context) error { if len(extraHttpHeaders) == 0 {