Преглед изворни кода

IMG-26: transport.Common, cookies, errorreport (#1544)

* transport.Common relies on config

* cookies instance

* errorreport package turned into instance
Victor Sokolov пре 4 месеци
родитељ
комит
7abda277ab
38 измењених фајлова са 673 додато и 215 уклоњено
  1. 6 0
      config.go
  2. 49 0
      cookies/config.go
  3. 45 38
      cookies/cookies.go
  4. 5 0
      cookies/errors.go
  5. 23 21
      errorreport/airbrake/airbrake.go
  6. 49 0
      errorreport/airbrake/config.go
  7. 27 21
      errorreport/bugsnag/bugsnag.go
  8. 45 0
      errorreport/bugsnag/config.go
  9. 55 0
      errorreport/config.go
  10. 54 10
      errorreport/errorreport.go
  11. 45 0
      errorreport/honeybadger/config.go
  12. 20 14
      errorreport/honeybadger/honeybadger.go
  13. 51 0
      errorreport/sentry/config.go
  14. 26 19
      errorreport/sentry/sentry.go
  15. 5 4
      fetcher/transport/azure/azure.go
  16. 1 1
      fetcher/transport/azure/azure_test.go
  17. 3 5
      fetcher/transport/common/common.go
  18. 28 14
      fetcher/transport/config.go
  19. 5 4
      fetcher/transport/fs/fs.go
  20. 1 1
      fetcher/transport/fs/fs_test.go
  21. 5 4
      fetcher/transport/gcs/gcs.go
  22. 1 1
      fetcher/transport/gcs/gcs_test.go
  23. 5 3
      fetcher/transport/s3/s3.go
  24. 1 1
      fetcher/transport/s3/s3_test.go
  25. 5 4
      fetcher/transport/swift/swift.go
  26. 1 1
      fetcher/transport/swift/swift_test.go
  27. 7 5
      fetcher/transport/transport.go
  28. 0 4
      handlers/processing/config.go
  29. 2 0
      handlers/processing/handler.go
  30. 3 6
      handlers/processing/request_methods.go
  31. 1 8
      handlers/stream/config.go
  32. 4 6
      handlers/stream/handler.go
  33. 21 2
      handlers/stream/handler_test.go
  34. 1 0
      httpheaders/headers.go
  35. 13 1
      imgproxy.go
  36. 11 4
      init.go
  37. 48 13
      server/responsewriter/config.go
  38. 1 0
      server/server_test.go

+ 6 - 0
config.go

@@ -2,6 +2,7 @@ package imgproxy
 
 import (
 	"github.com/imgproxy/imgproxy/v3/auximageprovider"
+	"github.com/imgproxy/imgproxy/v3/cookies"
 	"github.com/imgproxy/imgproxy/v3/ensure"
 	"github.com/imgproxy/imgproxy/v3/fetcher"
 	processinghandler "github.com/imgproxy/imgproxy/v3/handlers/processing"
@@ -30,6 +31,7 @@ type Config struct {
 	Security       security.Config
 	Processing     processing.Config
 	OptionsParser  optionsparser.Config
+	Cookies        cookies.Config
 }
 
 // NewDefaultConfig creates a new default configuration
@@ -96,5 +98,9 @@ func LoadConfigFromEnv(c *Config) (*Config, error) {
 		return nil, err
 	}
 
+	if _, err = cookies.LoadConfigFromEnv(&c.Cookies); err != nil {
+		return nil, err
+	}
+
 	return c, nil
 }

+ 49 - 0
cookies/config.go

@@ -0,0 +1,49 @@
+package cookies
+
+import (
+	"errors"
+
+	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+)
+
+var (
+	IMGPROXY_COOKIE_PASSTHROUGH     = env.Describe("IMGPROXY_COOKIE_PASSTHROUGH", "boolean")
+	IMGPROXY_COOKIE_PASSTHROUGH_ALL = env.Describe("IMGPROXY_COOKIE_PASSTHROUGH_ALL", "boolean")
+	IMGPROXY_COOKIE_BASE_URL        = env.Describe("IMGPROXY_COOKIE_BASE_URL", "string")
+)
+
+// Config holds cookie-related configuration.
+type Config struct {
+	CookiePassthrough    bool
+	CookiePassthroughAll bool
+	CookieBaseURL        string
+}
+
+// NewDefaultConfig creates a new Config instance with default values.
+func NewDefaultConfig() Config {
+	return Config{
+		CookiePassthroughAll: false,
+		CookieBaseURL:        "",
+		CookiePassthrough:    false,
+	}
+}
+
+// LoadConfigFromEnv creates a new Config instance loading values from environment variables.
+func LoadConfigFromEnv(c *Config) (*Config, error) {
+	c = ensure.Ensure(c, NewDefaultConfig)
+
+	err := errors.Join(
+		env.Bool(&c.CookiePassthrough, IMGPROXY_COOKIE_PASSTHROUGH),
+		env.Bool(&c.CookiePassthroughAll, IMGPROXY_COOKIE_PASSTHROUGH_ALL),
+		env.String(&c.CookieBaseURL, IMGPROXY_COOKIE_BASE_URL),
+	)
+
+	return c, err
+}
+
+// Validate checks if the configuration is valid
+func (c *Config) Validate() error {
+	// No validation needed for cookie config currently
+	return nil
+}

+ 45 - 38
cookies/cookies.go

@@ -5,65 +5,72 @@ import (
 	"net/http"
 	"net/http/cookiejar"
 	"net/url"
+	"slices"
 	"sync"
 
 	"golang.org/x/net/publicsuffix"
 
-	"github.com/imgproxy/imgproxy/v3/config"
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
 	"github.com/imgproxy/imgproxy/v3/ierrors"
 )
 
-type cookieError string
-
-func (e cookieError) Error() string { return string(e) }
-
-type anyCookieJarEntry struct {
-	Name   string
-	Value  string
-	Quoted bool
+// Cookies represents a cookies manager
+type Cookies struct {
+	config *Config
 }
 
-// anyCookieJar is a cookie jar that stores all cookies in memory
+// cookieJar is a cookie jar that stores all cookies in memory
 // and doesn't care about domains and paths
-type anyCookieJar struct {
-	entries []anyCookieJarEntry
+type cookieJar struct {
+	entries []*http.Cookie
 	mu      sync.RWMutex
 }
 
-func (j *anyCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
+// New creates a new Cookies instance
+func New(config *Config) (*Cookies, error) {
+	if err := config.Validate(); err != nil {
+		return nil, err
+	}
+
+	cookies := &Cookies{config: config}
+
+	return cookies, nil
+}
+
+// SetCookies stores the cookies in the jar. For each source cookie it creates
+// a new cookie with only Name, Value and Quoted fields set.
+func (j *cookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
 	j.mu.Lock()
 	defer j.mu.Unlock()
 
+	// Remove all unimportant cookie params
 	for _, c := range cookies {
-		entry := anyCookieJarEntry{
+		j.entries = append(j.entries, &http.Cookie{
 			Name:   c.Name,
 			Value:  c.Value,
 			Quoted: c.Quoted,
-		}
-		j.entries = append(j.entries, entry)
+		})
 	}
 }
 
-func (j *anyCookieJar) Cookies(u *url.URL) []*http.Cookie {
+// Cookies returns all stored cookies
+func (j *cookieJar) Cookies(u *url.URL) []*http.Cookie {
 	j.mu.RLock()
 	defer j.mu.RUnlock()
 
-	cookies := make([]*http.Cookie, 0, len(j.entries))
-	for _, e := range j.entries {
-		c := http.Cookie{
-			Name:   e.Name,
-			Value:  e.Value,
-			Quoted: e.Quoted,
-		}
-		cookies = append(cookies, &c)
-	}
-
-	return cookies
+	// NOTE: do we need to clone, or we could just return a ref?
+	return slices.Clone(j.entries)
 }
 
-func JarFromRequest(r *http.Request) (jar http.CookieJar, err error) {
-	if config.CookiePassthroughAll {
-		jar = &anyCookieJar{}
+// JarFromRequest creates a cookie jar from the given HTTP request
+func (c *Cookies) JarFromRequest(r *http.Request) (jar http.CookieJar, err error) {
+	// If cookie passthrough is disabled, return nil jar
+	if !c.config.CookiePassthrough {
+		return nil, nil
+	}
+
+	if c.config.CookiePassthroughAll {
+		jar = &cookieJar{}
 	} else {
 		jar, err = cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
 		if err != nil {
@@ -77,22 +84,22 @@ func JarFromRequest(r *http.Request) (jar http.CookieJar, err error) {
 
 	var cookieBase *url.URL
 
-	if !config.CookiePassthroughAll {
-		if len(config.CookieBaseURL) > 0 {
-			if cookieBase, err = url.Parse(config.CookieBaseURL); err != nil {
+	if !c.config.CookiePassthroughAll {
+		if len(c.config.CookieBaseURL) > 0 {
+			if cookieBase, err = url.Parse(c.config.CookieBaseURL); err != nil {
 				return nil, ierrors.Wrap(cookieError(fmt.Sprintf("can't parse cookie base URL: %s", err)), 0)
 			}
 		}
 
 		if cookieBase == nil {
-			scheme := r.Header.Get("X-Forwarded-Proto")
+			scheme := r.Header.Get(httpheaders.XForwardedProto)
 			if len(scheme) == 0 {
 				scheme = "http"
 			}
 
-			host := r.Header.Get("X-Forwarded-Host")
+			host := r.Header.Get(httpheaders.XForwardedHost)
 			if len(host) == 0 {
-				host = r.Header.Get("Host")
+				host = r.Header.Get(httpheaders.Host)
 			}
 			if len(host) == 0 {
 				host = r.Host
@@ -102,7 +109,7 @@ func JarFromRequest(r *http.Request) (jar http.CookieJar, err error) {
 				return jar, nil
 			}
 
-			port := r.Header.Get("X-Forwarded-Port")
+			port := r.Header.Get(httpheaders.XForwardedPort)
 			if len(port) > 0 {
 				host = host + ":" + port
 			}

+ 5 - 0
cookies/errors.go

@@ -0,0 +1,5 @@
+package cookies
+
+type cookieError string
+
+func (e cookieError) Error() string { return string(e) }

+ 23 - 21
errorreport/airbrake/airbrake.go

@@ -5,43 +5,45 @@ import (
 	"strings"
 
 	"github.com/airbrake/gobrake/v5"
-
-	"github.com/imgproxy/imgproxy/v3/config"
 )
 
 var (
-	notifier *gobrake.Notifier
-
 	metaReplacer = strings.NewReplacer(" ", "-")
 )
 
-func Init() {
-	if len(config.AirbrakeProjectKey) > 0 {
-		notifier = gobrake.NewNotifierWithOptions(&gobrake.NotifierOptions{
-			ProjectId:   int64(config.AirbrakeProjectID),
-			ProjectKey:  config.AirbrakeProjectKey,
-			Environment: config.AirbrakeEnv,
-		})
-	}
+type reporter struct {
+	notifier *gobrake.Notifier
 }
 
-func Report(err error, req *http.Request, meta map[string]any) {
-	if notifier == nil {
-		return
+func New(config *Config) (*reporter, error) {
+	if err := config.Validate(); err != nil {
+		return nil, err
 	}
 
-	notice := notifier.Notice(err, req, 2)
+	if len(config.ProjectKey) == 0 {
+		return nil, nil
+	}
+
+	notifier := gobrake.NewNotifierWithOptions(&gobrake.NotifierOptions{
+		ProjectId:   int64(config.ProjectID),
+		ProjectKey:  config.ProjectKey,
+		Environment: config.Env,
+	})
+
+	return &reporter{notifier}, nil
+}
+
+func (r *reporter) Report(err error, req *http.Request, meta map[string]any) {
+	notice := r.notifier.Notice(err, req, 2)
 
 	for k, v := range meta {
 		key := metaReplacer.Replace(strings.ToLower(k))
 		notice.Context[key] = v
 	}
 
-	notifier.SendNoticeAsync(notice)
+	r.notifier.SendNoticeAsync(notice)
 }
 
-func Close() {
-	if notifier != nil {
-		notifier.Close()
-	}
+func (r *reporter) Close() {
+	r.notifier.Close()
 }

+ 49 - 0
errorreport/airbrake/config.go

@@ -0,0 +1,49 @@
+package airbrake
+
+import (
+	"errors"
+
+	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+)
+
+var (
+	IMGPROXY_AIRBRAKE_PROJECT_ID  = env.Describe("IMGPROXY_AIRBRAKE_PROJECT_ID", "integer")
+	IMGPROXY_AIRBRAKE_PROJECT_KEY = env.Describe("IMGPROXY_AIRBRAKE_PROJECT_KEY", "string")
+	IMGPROXY_AIRBRAKE_ENV         = env.Describe("IMGPROXY_AIRBRAKE_ENV", "string")
+)
+
+// Config holds Airbrake-related configuration.
+type Config struct {
+	ProjectID  int
+	ProjectKey string
+	Env        string
+}
+
+// NewDefaultConfig creates a new Config instance with default values.
+func NewDefaultConfig() Config {
+	return Config{
+		ProjectID:  0,
+		ProjectKey: "",
+		Env:        "production",
+	}
+}
+
+// LoadConfigFromEnv creates a new Config instance loading values from environment variables.
+func LoadConfigFromEnv(c *Config) (*Config, error) {
+	c = ensure.Ensure(c, NewDefaultConfig)
+
+	err := errors.Join(
+		env.Int(&c.ProjectID, IMGPROXY_AIRBRAKE_PROJECT_ID),
+		env.String(&c.ProjectKey, IMGPROXY_AIRBRAKE_PROJECT_KEY),
+		env.String(&c.Env, IMGPROXY_AIRBRAKE_ENV),
+	)
+
+	return c, err
+}
+
+// Validate checks if the configuration is valid
+func (c *Config) Validate() error {
+	// No validation needed for airbrake config currently
+	return nil
+}

+ 27 - 21
errorreport/bugsnag/bugsnag.go

@@ -6,14 +6,32 @@ import (
 	"net/http"
 
 	"github.com/bugsnag/bugsnag-go/v2"
-
-	"github.com/imgproxy/imgproxy/v3/config"
 )
 
-var enabled bool
-
+// logger is the logger forwarder for bugsnag
 type logger struct{}
 
+type reporter struct{}
+
+func New(config *Config) (*reporter, error) {
+	if err := config.Validate(); err != nil {
+		return nil, err
+	}
+
+	if len(config.Key) == 0 {
+		return nil, nil
+	}
+
+	bugsnag.Configure(bugsnag.Configuration{
+		APIKey:       config.Key,
+		ReleaseStage: config.Stage,
+		PanicHandler: func() {}, // Disable forking the process
+		Logger:       logger{},
+	})
+
+	return &reporter{}, nil
+}
+
 func (l logger) Printf(format string, v ...interface{}) {
 	slog.Debug(
 		fmt.Sprintf(format, v...),
@@ -21,23 +39,7 @@ func (l logger) Printf(format string, v ...interface{}) {
 	)
 }
 
-func Init() {
-	if len(config.BugsnagKey) > 0 {
-		bugsnag.Configure(bugsnag.Configuration{
-			APIKey:       config.BugsnagKey,
-			ReleaseStage: config.BugsnagStage,
-			PanicHandler: func() {}, // Disable forking the process
-			Logger:       logger{},
-		})
-		enabled = true
-	}
-}
-
-func Report(err error, req *http.Request, meta map[string]any) {
-	if !enabled {
-		return
-	}
-
+func (r *reporter) Report(err error, req *http.Request, meta map[string]any) {
 	extra := make(bugsnag.MetaData)
 	for k, v := range meta {
 		extra.Add("Processing Context", k, v)
@@ -45,3 +47,7 @@ func Report(err error, req *http.Request, meta map[string]any) {
 
 	bugsnag.Notify(err, req, extra)
 }
+
+func (r *reporter) Close() {
+	// noop
+}

+ 45 - 0
errorreport/bugsnag/config.go

@@ -0,0 +1,45 @@
+package bugsnag
+
+import (
+	"errors"
+
+	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+)
+
+var (
+	IMGPROXY_BUGSNAG_KEY   = env.Describe("IMGPROXY_BUGSNAG_KEY", "string")
+	IMGPROXY_BUGSNAG_STAGE = env.Describe("IMGPROXY_BUGSNAG_STAGE", "string")
+)
+
+// Config holds Bugsnag-related configuration.
+type Config struct {
+	Key   string
+	Stage string
+}
+
+// NewDefaultConfig creates a new Config instance with default values.
+func NewDefaultConfig() Config {
+	return Config{
+		Key:   "",
+		Stage: "production",
+	}
+}
+
+// LoadConfigFromEnv creates a new Config instance loading values from environment variables.
+func LoadConfigFromEnv(c *Config) (*Config, error) {
+	c = ensure.Ensure(c, NewDefaultConfig)
+
+	err := errors.Join(
+		env.String(&c.Key, IMGPROXY_BUGSNAG_KEY),
+		env.String(&c.Stage, IMGPROXY_BUGSNAG_STAGE),
+	)
+
+	return c, err
+}
+
+// Validate checks if the configuration is valid
+func (c *Config) Validate() error {
+	// No validation needed for bugsnag config currently
+	return nil
+}

+ 55 - 0
errorreport/config.go

@@ -0,0 +1,55 @@
+package errorreport
+
+import (
+	"errors"
+
+	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/errorreport/airbrake"
+	"github.com/imgproxy/imgproxy/v3/errorreport/bugsnag"
+	"github.com/imgproxy/imgproxy/v3/errorreport/honeybadger"
+	"github.com/imgproxy/imgproxy/v3/errorreport/sentry"
+)
+
+// Config holds error reporting-related configuration for all providers.
+type Config struct {
+	Airbrake    airbrake.Config
+	Bugsnag     bugsnag.Config
+	Honeybadger honeybadger.Config
+	Sentry      sentry.Config
+}
+
+// NewDefaultConfig creates a new Config instance with default values.
+func NewDefaultConfig() Config {
+	return Config{
+		Airbrake:    airbrake.NewDefaultConfig(),
+		Bugsnag:     bugsnag.NewDefaultConfig(),
+		Honeybadger: honeybadger.NewDefaultConfig(),
+		Sentry:      sentry.NewDefaultConfig(),
+	}
+}
+
+// LoadConfigFromEnv creates a new Config instance loading values from environment variables.
+func LoadConfigFromEnv(c *Config) (*Config, error) {
+	c = ensure.Ensure(c, NewDefaultConfig)
+
+	var airbErr, bugErr, honeyErr, sentErr error
+
+	_, airbErr = airbrake.LoadConfigFromEnv(&c.Airbrake)
+	_, bugErr = bugsnag.LoadConfigFromEnv(&c.Bugsnag)
+	_, honeyErr = honeybadger.LoadConfigFromEnv(&c.Honeybadger)
+	_, sentErr = sentry.LoadConfigFromEnv(&c.Sentry)
+
+	err := errors.Join(airbErr, bugErr, honeyErr, sentErr)
+
+	return c, err
+}
+
+// Validate checks if the configuration is valid
+func (c *Config) Validate() error {
+	return errors.Join(
+		c.Airbrake.Validate(),
+		c.Bugsnag.Validate(),
+		c.Honeybadger.Validate(),
+		c.Sentry.Validate(),
+	)
+}

+ 54 - 10
errorreport/errorreport.go

@@ -10,20 +10,61 @@ import (
 	"github.com/imgproxy/imgproxy/v3/errorreport/sentry"
 )
 
+// reporter is an interface that all error reporters must implement.
+// most of our reporters are singletons, so in most cases close is noop.
+type reporter interface {
+	Report(err error, req *http.Request, meta map[string]any)
+	Close()
+}
+
+// metaCtxKey is the context.Context key for request metadata
 type metaCtxKey struct{}
 
-func Init() {
-	bugsnag.Init()
-	honeybadger.Init()
-	sentry.Init()
-	airbrake.Init()
+// initialized reporters
+var reporters []reporter
+
+// Init initializes all configured error reporters and returns a Reporter instance.
+func Init(config *Config) error {
+	if err := config.Validate(); err != nil {
+		return err
+	}
+
+	reporters = make([]reporter, 0)
+
+	if r, err := bugsnag.New(&config.Bugsnag); err != nil {
+		return err
+	} else if r != nil {
+		reporters = append(reporters, r)
+	}
+
+	if r, err := honeybadger.New(&config.Honeybadger); err != nil {
+		return err
+	} else if r != nil {
+		reporters = append(reporters, r)
+	}
+
+	if r, err := sentry.New(&config.Sentry); err != nil {
+		return err
+	} else if r != nil {
+		reporters = append(reporters, r)
+	}
+
+	if r, err := airbrake.New(&config.Airbrake); err != nil {
+		return err
+	} else if r != nil {
+		reporters = append(reporters, r)
+	}
+
+	return nil
 }
 
+// StartRequest initializes metadata storage in the request context.
 func StartRequest(req *http.Request) context.Context {
 	meta := make(map[string]any)
 	return context.WithValue(req.Context(), metaCtxKey{}, meta)
 }
 
+// SetMetadata sets a metadata key-value pair in the request context.
 func SetMetadata(req *http.Request, key string, value any) {
 	meta, ok := req.Context().Value(metaCtxKey{}).(map[string]any)
 	if !ok || meta == nil {
@@ -33,18 +74,21 @@ func SetMetadata(req *http.Request, key string, value any) {
 	meta[key] = value
 }
 
+// Report reports an error to all configured reporters with the request and its metadata.
 func Report(err error, req *http.Request) {
 	meta, ok := req.Context().Value(metaCtxKey{}).(map[string]any)
 	if !ok {
 		meta = nil
 	}
 
-	bugsnag.Report(err, req, meta)
-	honeybadger.Report(err, req, meta)
-	sentry.Report(err, req, meta)
-	airbrake.Report(err, req, meta)
+	for _, reporter := range reporters {
+		reporter.Report(err, req, meta)
+	}
 }
 
+// Close closes all reporters
 func Close() {
-	airbrake.Close()
+	for _, reporter := range reporters {
+		reporter.Close()
+	}
 }

+ 45 - 0
errorreport/honeybadger/config.go

@@ -0,0 +1,45 @@
+package honeybadger
+
+import (
+	"errors"
+
+	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+)
+
+var (
+	IMGPROXY_HONEYBADGER_KEY = env.Describe("IMGPROXY_HONEYBADGER_KEY", "string")
+	IMGPROXY_HONEYBADGER_ENV = env.Describe("IMGPROXY_HONEYBADGER_ENV", "string")
+)
+
+// Config holds Honeybadger-related configuration.
+type Config struct {
+	Key string
+	Env string
+}
+
+// NewDefaultConfig creates a new Config instance with default values.
+func NewDefaultConfig() Config {
+	return Config{
+		Key: "",
+		Env: "production",
+	}
+}
+
+// LoadConfigFromEnv creates a new Config instance loading values from environment variables.
+func LoadConfigFromEnv(c *Config) (*Config, error) {
+	c = ensure.Ensure(c, NewDefaultConfig)
+
+	err := errors.Join(
+		env.String(&c.Key, IMGPROXY_HONEYBADGER_KEY),
+		env.String(&c.Env, IMGPROXY_HONEYBADGER_ENV),
+	)
+
+	return c, err
+}
+
+// Validate checks if the configuration is valid
+func (c *Config) Validate() error {
+	// No validation needed for honeybadger config currently
+	return nil
+}

+ 20 - 14
errorreport/honeybadger/honeybadger.go

@@ -7,31 +7,33 @@ import (
 
 	"github.com/honeybadger-io/honeybadger-go"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/ierrors"
 )
 
 var (
-	enabled bool
-
 	metaReplacer = strings.NewReplacer("-", "_", " ", "_")
 )
 
-func Init() {
-	if len(config.HoneybadgerKey) > 0 {
-		honeybadger.Configure(honeybadger.Configuration{
-			APIKey: config.HoneybadgerKey,
-			Env:    config.HoneybadgerEnv,
-		})
-		enabled = true
+type reporter struct{}
+
+func New(config *Config) (*reporter, error) {
+	if err := config.Validate(); err != nil {
+		return nil, err
 	}
-}
 
-func Report(err error, req *http.Request, meta map[string]any) {
-	if !enabled {
-		return
+	if len(config.Key) == 0 {
+		return nil, nil
 	}
 
+	honeybadger.Configure(honeybadger.Configuration{
+		APIKey: config.Key,
+		Env:    config.Env,
+	})
+
+	return &reporter{}, nil
+}
+
+func (r *reporter) Report(err error, req *http.Request, meta map[string]any) {
 	extra := make(honeybadger.CGIData, len(req.Header)+len(meta))
 
 	for k, v := range req.Header {
@@ -52,3 +54,7 @@ func Report(err error, req *http.Request, meta map[string]any) {
 
 	honeybadger.Notify(hbErr, req.URL, extra)
 }
+
+func (r *reporter) Close() {
+	// noop
+}

+ 51 - 0
errorreport/sentry/config.go

@@ -0,0 +1,51 @@
+package sentry
+
+import (
+	"errors"
+	"fmt"
+
+	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+	"github.com/imgproxy/imgproxy/v3/version"
+)
+
+var (
+	IMGPROXY_SENTRY_DSN         = env.Describe("IMGPROXY_SENTRY_DSN", "string")
+	IMGPROXY_SENTRY_RELEASE     = env.Describe("IMGPROXY_SENTRY_RELEASE", "string")
+	IMGPROXY_SENTRY_ENVIRONMENT = env.Describe("IMGPROXY_SENTRY_ENVIRONMENT", "string")
+)
+
+// Config holds Sentry-related configuration.
+type Config struct {
+	DSN         string
+	Release     string
+	Environment string
+}
+
+// NewDefaultConfig creates a new Config instance with default values.
+func NewDefaultConfig() Config {
+	return Config{
+		DSN:         "",
+		Release:     fmt.Sprintf("imgproxy@%s", version.Version),
+		Environment: "production",
+	}
+}
+
+// LoadConfigFromEnv creates a new Config instance loading values from environment variables.
+func LoadConfigFromEnv(c *Config) (*Config, error) {
+	c = ensure.Ensure(c, NewDefaultConfig)
+
+	err := errors.Join(
+		env.String(&c.DSN, IMGPROXY_SENTRY_DSN),
+		env.String(&c.Release, IMGPROXY_SENTRY_RELEASE),
+		env.String(&c.Environment, IMGPROXY_SENTRY_ENVIRONMENT),
+	)
+
+	return c, err
+}
+
+// Validate checks if the configuration is valid
+func (c *Config) Validate() error {
+	// No validation needed for sentry config currently
+	return nil
+}

+ 26 - 19
errorreport/sentry/sentry.go

@@ -5,33 +5,36 @@ import (
 	"time"
 
 	"github.com/getsentry/sentry-go"
-
-	"github.com/imgproxy/imgproxy/v3/config"
 )
 
-var (
-	enabled bool
-
-	timeout = 5 * time.Second
+const (
+	// flushTimeout is the maximum time to wait for Sentry to send events
+	flushTimeout = 5 * time.Second
 )
 
-func Init() {
-	if len(config.SentryDSN) > 0 {
-		sentry.Init(sentry.ClientOptions{
-			Dsn:         config.SentryDSN,
-			Release:     config.SentryRelease,
-			Environment: config.SentryEnvironment,
-		})
+// reporter is a Sentry error reporter
+type reporter struct{}
 
-		enabled = true
+// New creates and configures a new Sentry reporter
+func New(config *Config) (*reporter, error) {
+	if err := config.Validate(); err != nil {
+		return nil, err
 	}
-}
 
-func Report(err error, req *http.Request, meta map[string]any) {
-	if !enabled {
-		return
+	if len(config.DSN) == 0 {
+		return nil, nil
 	}
 
+	sentry.Init(sentry.ClientOptions{
+		Dsn:         config.DSN,
+		Release:     config.Release,
+		Environment: config.Environment,
+	})
+
+	return &reporter{}, nil
+}
+
+func (r *reporter) Report(err error, req *http.Request, meta map[string]any) {
 	hub := sentry.CurrentHub().Clone()
 	hub.Scope().SetRequest(req)
 	hub.Scope().SetLevel(sentry.LevelError)
@@ -55,7 +58,11 @@ func Report(err error, req *http.Request, meta map[string]any) {
 
 		eventID := hub.CaptureEvent(event)
 		if eventID != nil {
-			hub.Flush(timeout)
+			hub.Flush(flushTimeout)
 		}
 	}
 }
+
+func (r *reporter) Close() {
+	sentry.Flush(flushTimeout)
+}

+ 5 - 4
fetcher/transport/azure/azure.go

@@ -22,10 +22,11 @@ import (
 )
 
 type transport struct {
-	client *azblob.Client
+	client         *azblob.Client
+	querySeparator string
 }
 
-func New(config *Config, trans *http.Transport) (http.RoundTripper, error) {
+func New(config *Config, trans *http.Transport, querySeparator string) (http.RoundTripper, error) {
 	if err := config.Validate(); err != nil {
 		return nil, err
 	}
@@ -73,11 +74,11 @@ func New(config *Config, trans *http.Transport) (http.RoundTripper, error) {
 		return nil, err
 	}
 
-	return transport{client}, nil
+	return transport{client, querySeparator}, nil
 }
 
 func (t transport) RoundTrip(req *http.Request) (*http.Response, error) {
-	container, key, _ := common.GetBucketAndKey(req.URL)
+	container, key, _ := common.GetBucketAndKey(req.URL, t.querySeparator)
 
 	if len(container) == 0 || len(key) == 0 {
 		body := strings.NewReader("Invalid ABS URL: container name or object key is empty")

+ 1 - 1
fetcher/transport/azure/azure_test.go

@@ -51,7 +51,7 @@ func (s *AzureTestSuite) SetupSuite() {
 	s.Require().NoError(gerr)
 
 	var err error
-	s.transport, err = New(&config, trans)
+	s.transport, err = New(&config, trans, "?")
 	s.Require().NoError(err)
 }
 

+ 3 - 5
fetcher/transport/common/common.go

@@ -3,8 +3,6 @@ package common
 import (
 	"net/url"
 	"strings"
-
-	"github.com/imgproxy/imgproxy/v3/config"
 )
 
 func EscapeURL(u string) string {
@@ -28,7 +26,7 @@ func EscapeURL(u string) string {
 	return u
 }
 
-func GetBucketAndKey(u *url.URL) (bucket, key, query string) {
+func GetBucketAndKey(u *url.URL, sep string) (bucket, key, query string) {
 	bucket = u.Host
 
 	// We can't use u.Path here because `url.Parse` unescapes the original URL's path.
@@ -60,8 +58,8 @@ func GetBucketAndKey(u *url.URL) (bucket, key, query string) {
 	// Since we replaced `?` with `%3F` in `EscapeURL`, `url.Parse` will treat query
 	// string as a part of the path.
 	// Also, query string separator may be different from `?`, so we can't rely on `url.URL.RawQuery`.
-	if len(config.SourceURLQuerySeparator) > 0 {
-		key, query, _ = strings.Cut(key, config.SourceURLQuerySeparator)
+	if len(sep) > 0 {
+		key, query, _ = strings.Cut(key, sep)
 	}
 
 	return

+ 28 - 14
fetcher/transport/config.go

@@ -4,6 +4,7 @@ package transport
 
 import (
 	"errors"
+	"os"
 
 	"github.com/imgproxy/imgproxy/v3/ensure"
 	"github.com/imgproxy/imgproxy/v3/env"
@@ -16,10 +17,11 @@ import (
 )
 
 var (
-	IMGPROXY_USE_ABS   = env.Describe("IMGPROXY_USE_ABS", "boolean")
-	IMGPROXY_USE_GCS   = env.Describe("IMGPROXY_GCS_ENABLED", "boolean")
-	IMGPROXY_USE_S3    = env.Describe("IMGPROXY_USE_S3", "boolean")
-	IMGPROXY_USE_SWIFT = env.Describe("IMGPROXY_USE_SWIFT", "boolean")
+	IMGPROXY_USE_ABS                    = env.Describe("IMGPROXY_USE_ABS", "boolean")
+	IMGPROXY_USE_GCS                    = env.Describe("IMGPROXY_GCS_ENABLED", "boolean")
+	IMGPROXY_USE_S3                     = env.Describe("IMGPROXY_USE_S3", "boolean")
+	IMGPROXY_USE_SWIFT                  = env.Describe("IMGPROXY_USE_SWIFT", "boolean")
+	IMGPROXY_SOURCE_URL_QUERY_SEPARATOR = env.Describe("IMGPROXY_SOURCE_URL_QUERY_SEPARATOR", "string")
 )
 
 // Config represents configuration of the transport package
@@ -39,21 +41,27 @@ type Config struct {
 
 	SwiftEnabled bool
 	Swift        swift.Config
+
+	// query string separator (see docs). Unfortunately, we'll have to pass this
+	// to each transport which needs it as the consturctor parameter. Otherwise,
+	// we would have to add it to each transport config struct.
+	SourceURLQuerySeparator string
 }
 
 // NewDefaultConfig returns a new default transport configuration
 func NewDefaultConfig() Config {
 	return Config{
-		HTTP:         generichttp.NewDefaultConfig(),
-		Local:        fs.NewDefaultConfig(),
-		ABSEnabled:   false,
-		ABS:          azure.NewDefaultConfig(),
-		GCSEnabled:   false,
-		GCS:          gcs.NewDefaultConfig(),
-		S3Enabled:    false,
-		S3:           s3.NewDefaultConfig(),
-		SwiftEnabled: false,
-		Swift:        swift.NewDefaultConfig(),
+		HTTP:                    generichttp.NewDefaultConfig(),
+		Local:                   fs.NewDefaultConfig(),
+		ABSEnabled:              false,
+		ABS:                     azure.NewDefaultConfig(),
+		GCSEnabled:              false,
+		GCS:                     gcs.NewDefaultConfig(),
+		S3Enabled:               false,
+		S3:                      s3.NewDefaultConfig(),
+		SwiftEnabled:            false,
+		Swift:                   swift.NewDefaultConfig(),
+		SourceURLQuerySeparator: "?", // default is ?, but can be overriden with empty
 	}
 }
 
@@ -81,6 +89,12 @@ func LoadConfigFromEnv(c *Config) (*Config, error) {
 		env.Bool(&c.SwiftEnabled, IMGPROXY_USE_SWIFT),
 	)
 
+	// empty value is a valid value for this separator, we can't rely on env.String,
+	// which skips empty values
+	if s, ok := os.LookupEnv(IMGPROXY_SOURCE_URL_QUERY_SEPARATOR.Name); ok {
+		c.SourceURLQuerySeparator = s
+	}
+
 	return c, err
 }
 

+ 5 - 4
fetcher/transport/fs/fs.go

@@ -20,21 +20,22 @@ import (
 )
 
 type transport struct {
-	fs http.Dir
+	fs             http.Dir
+	querySeparator string
 }
 
-func New(config *Config) (transport, error) {
+func New(config *Config, querySeparator string) (transport, error) {
 	if err := config.Validate(); err != nil {
 		return transport{}, err
 	}
 
-	return transport{fs: http.Dir(config.Root)}, nil
+	return transport{fs: http.Dir(config.Root), querySeparator: querySeparator}, nil
 }
 
 func (t transport) RoundTrip(req *http.Request) (resp *http.Response, err error) {
 	header := make(http.Header)
 
-	_, path, _ := common.GetBucketAndKey(req.URL)
+	_, path, _ := common.GetBucketAndKey(req.URL, t.querySeparator)
 	path = "/" + path
 
 	f, err := t.fs.Open(path)

+ 1 - 1
fetcher/transport/fs/fs_test.go

@@ -31,7 +31,7 @@ func (s *FsTestSuite) SetupSuite() {
 
 	s.etag = BuildEtag("/test1.png", fi)
 	s.modTime = fi.ModTime()
-	s.transport, _ = New(&Config{Root: fsRoot})
+	s.transport, _ = New(&Config{Root: fsRoot}, "?")
 }
 
 func (s *FsTestSuite) TestRoundTripWithETagEnabled() {

+ 5 - 4
fetcher/transport/gcs/gcs.go

@@ -25,7 +25,8 @@ import (
 var noAuth bool = false
 
 type transport struct {
-	client *storage.Client
+	client      *storage.Client
+	qsSeparator string
 }
 
 func buildHTTPClient(config *Config, trans *http.Transport, opts ...option.ClientOption) (*http.Client, error) {
@@ -41,7 +42,7 @@ func buildHTTPClient(config *Config, trans *http.Transport, opts ...option.Clien
 	return &http.Client{Transport: htrans}, nil
 }
 
-func New(config *Config, trans *http.Transport) (http.RoundTripper, error) {
+func New(config *Config, trans *http.Transport, sep string) (http.RoundTripper, error) {
 	var client *storage.Client
 
 	opts := []option.ClientOption{
@@ -72,11 +73,11 @@ func New(config *Config, trans *http.Transport) (http.RoundTripper, error) {
 		return nil, ierrors.Wrap(err, 0, ierrors.WithPrefix("Can't create GCS client"))
 	}
 
-	return transport{client}, nil
+	return transport{client, sep}, nil
 }
 
 func (t transport) RoundTrip(req *http.Request) (*http.Response, error) {
-	bucket, key, query := common.GetBucketAndKey(req.URL)
+	bucket, key, query := common.GetBucketAndKey(req.URL, t.qsSeparator)
 
 	if len(bucket) == 0 || len(key) == 0 {
 		body := strings.NewReader("Invalid GCS URL: bucket name or object key is empty")

+ 1 - 1
fetcher/transport/gcs/gcs_test.go

@@ -77,7 +77,7 @@ func (s *GCSTestSuite) SetupSuite() {
 	trans, gerr := generichttp.New(false, &tc)
 	s.Require().NoError(gerr)
 
-	s.transport, err = New(&config, trans)
+	s.transport, err = New(&config, trans, "?")
 	s.Require().NoError(err)
 }
 

+ 5 - 3
fetcher/transport/s3/s3.go

@@ -41,10 +41,11 @@ type transport struct {
 
 	mu sync.RWMutex
 
-	config *Config
+	config         *Config
+	querySeparator string
 }
 
-func New(config *Config, trans *http.Transport) (http.RoundTripper, error) {
+func New(config *Config, trans *http.Transport, querySeparator string) (http.RoundTripper, error) {
 	if err := config.Validate(); err != nil {
 		return nil, err
 	}
@@ -102,11 +103,12 @@ func New(config *Config, trans *http.Transport) (http.RoundTripper, error) {
 		clientsByRegion: map[string]s3Client{conf.Region: client},
 		clientsByBucket: make(map[string]s3Client),
 		config:          config,
+		querySeparator:  querySeparator,
 	}, nil
 }
 
 func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) {
-	bucket, key, query := common.GetBucketAndKey(req.URL)
+	bucket, key, query := common.GetBucketAndKey(req.URL, t.querySeparator)
 
 	if len(bucket) == 0 || len(key) == 0 {
 		body := strings.NewReader("Invalid S3 URL: bucket name or object key is empty")

+ 1 - 1
fetcher/transport/s3/s3_test.go

@@ -46,7 +46,7 @@ func (s *S3TestSuite) SetupSuite() {
 	s.Require().NoError(gerr)
 
 	var err error
-	s.transport, err = New(&config, trans)
+	s.transport, err = New(&config, trans, "?")
 	s.Require().NoError(err)
 
 	err = backend.CreateBucket("test")

+ 5 - 4
fetcher/transport/swift/swift.go

@@ -15,10 +15,11 @@ import (
 )
 
 type transport struct {
-	con *swift.Connection
+	con            *swift.Connection
+	querySeparator string
 }
 
-func New(config *Config, trans *http.Transport) (http.RoundTripper, error) {
+func New(config *Config, trans *http.Transport, querySeparator string) (http.RoundTripper, error) {
 	if err := config.Validate(); err != nil {
 		return nil, err
 	}
@@ -43,11 +44,11 @@ func New(config *Config, trans *http.Transport) (http.RoundTripper, error) {
 		return nil, ierrors.Wrap(err, 0, ierrors.WithPrefix("swift authentication error"))
 	}
 
-	return transport{con: c}, nil
+	return transport{con: c, querySeparator: querySeparator}, nil
 }
 
 func (t transport) RoundTrip(req *http.Request) (resp *http.Response, err error) {
-	container, objectName, _ := common.GetBucketAndKey(req.URL)
+	container, objectName, _ := common.GetBucketAndKey(req.URL, t.querySeparator)
 
 	if len(container) == 0 || len(objectName) == 0 {
 		body := strings.NewReader("Invalid Swift URL: container name or object name is empty")

+ 1 - 1
fetcher/transport/swift/swift_test.go

@@ -45,7 +45,7 @@ func (s *SwiftTestSuite) SetupSuite() {
 	s.Require().NoError(gerr)
 
 	var err error
-	s.transport, err = New(&config, trans)
+	s.transport, err = New(&config, trans, "?")
 	s.Require().NoError(err, "failed to initialize swift transport")
 }
 

+ 7 - 5
fetcher/transport/transport.go

@@ -71,13 +71,15 @@ func (t *Transport) IsProtocolRegistered(scheme string) bool {
 
 // RegisterAllProtocols registers all enabled protocols in the given transport
 func (t *Transport) registerAllProtocols() error {
+	sep := t.config.SourceURLQuerySeparator // shortcut
+
 	transp, err := generichttp.New(false, &t.config.HTTP)
 	if err != nil {
 		return err
 	}
 
 	if t.config.Local.Root != "" {
-		p, err := fsTransport.New(&t.config.Local)
+		p, err := fsTransport.New(&t.config.Local, sep)
 		if err != nil {
 			return err
 		}
@@ -85,7 +87,7 @@ func (t *Transport) registerAllProtocols() error {
 	}
 
 	if t.config.S3Enabled {
-		tr, err := s3Transport.New(&t.config.S3, transp)
+		tr, err := s3Transport.New(&t.config.S3, transp, sep)
 		if err != nil {
 			return err
 		}
@@ -93,7 +95,7 @@ func (t *Transport) registerAllProtocols() error {
 	}
 
 	if t.config.GCSEnabled {
-		tr, err := gcsTransport.New(&t.config.GCS, transp)
+		tr, err := gcsTransport.New(&t.config.GCS, transp, sep)
 		if err != nil {
 			return err
 		}
@@ -101,7 +103,7 @@ func (t *Transport) registerAllProtocols() error {
 	}
 
 	if t.config.ABSEnabled {
-		tr, err := azureTransport.New(&t.config.ABS, transp)
+		tr, err := azureTransport.New(&t.config.ABS, transp, sep)
 		if err != nil {
 			return err
 		}
@@ -109,7 +111,7 @@ func (t *Transport) registerAllProtocols() error {
 	}
 
 	if t.config.SwiftEnabled {
-		tr, err := swiftTransport.New(&t.config.Swift, transp)
+		tr, err := swiftTransport.New(&t.config.Swift, transp, sep)
 		if err != nil {
 			return err
 		}

+ 0 - 4
handlers/processing/config.go

@@ -9,7 +9,6 @@ import (
 )
 
 var (
-	IMGPROXY_COOKIE_PASSTHROUGH        = env.Describe("IMGPROXY_COOKIE_PASSTHROUGH", "boolean")
 	IMGPROXY_REPORT_DOWNLOADING_ERRORS = env.Describe("IMGPROXY_REPORT_DOWNLOADING_ERRORS", "boolean")
 	IMGPROXY_LAST_MODIFIED_ENABLED     = env.Describe("IMGPROXY_LAST_MODIFIED_ENABLED", "boolean")
 	IMGPROXY_ETAG_ENABLED              = env.Describe("IMGPROXY_ETAG_ENABLED", "boolean")
@@ -20,7 +19,6 @@ var (
 
 // Config represents handler config
 type Config struct {
-	CookiePassthrough       bool // Whether to passthrough cookies
 	ReportDownloadingErrors bool // Whether to report downloading errors
 	LastModifiedEnabled     bool // Whether to enable Last-Modified
 	ETagEnabled             bool // Whether to enable ETag
@@ -32,7 +30,6 @@ type Config struct {
 // NewDefaultConfig creates a new configuration with defaults
 func NewDefaultConfig() Config {
 	return Config{
-		CookiePassthrough:       false,
 		ReportDownloadingErrors: true,
 		LastModifiedEnabled:     true,
 		ETagEnabled:             true,
@@ -47,7 +44,6 @@ func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c = ensure.Ensure(c, NewDefaultConfig)
 
 	err := errors.Join(
-		env.Bool(&c.CookiePassthrough, IMGPROXY_COOKIE_PASSTHROUGH),
 		env.Bool(&c.ReportDownloadingErrors, IMGPROXY_REPORT_DOWNLOADING_ERRORS),
 		env.Bool(&c.LastModifiedEnabled, IMGPROXY_LAST_MODIFIED_ENABLED),
 		env.Bool(&c.ETagEnabled, IMGPROXY_ETAG_ENABLED),

+ 2 - 0
handlers/processing/handler.go

@@ -6,6 +6,7 @@ import (
 	"net/url"
 
 	"github.com/imgproxy/imgproxy/v3/auximageprovider"
+	"github.com/imgproxy/imgproxy/v3/cookies"
 	"github.com/imgproxy/imgproxy/v3/errorreport"
 	"github.com/imgproxy/imgproxy/v3/handlers"
 	"github.com/imgproxy/imgproxy/v3/handlers/stream"
@@ -30,6 +31,7 @@ type HandlerContext interface {
 	Security() *security.Checker
 	OptionsParser() *optionsparser.Parser
 	Processor() *processing.Processor
+	Cookies() *cookies.Cookies
 }
 
 // Handler handles image processing requests

+ 3 - 6
handlers/processing/request_methods.go

@@ -7,7 +7,6 @@ import (
 	"net/http"
 	"strconv"
 
-	"github.com/imgproxy/imgproxy/v3/cookies"
 	"github.com/imgproxy/imgproxy/v3/errorreport"
 	"github.com/imgproxy/imgproxy/v3/handlers"
 	"github.com/imgproxy/imgproxy/v3/httpheaders"
@@ -75,11 +74,9 @@ func (r *request) makeDownloadOptions(ctx context.Context, h http.Header) imaged
 func (r *request) fetchImage(ctx context.Context, do imagedata.DownloadOptions) (imagedata.ImageData, http.Header, error) {
 	var err error
 
-	if r.config.CookiePassthrough {
-		do.CookieJar, err = cookies.JarFromRequest(r.req)
-		if err != nil {
-			return nil, nil, ierrors.Wrap(err, 0, ierrors.WithCategory(handlers.CategoryDownload))
-		}
+	do.CookieJar, err = r.Cookies().JarFromRequest(r.req)
+	if err != nil {
+		return nil, nil, ierrors.Wrap(err, 0, ierrors.WithCategory(handlers.CategoryDownload))
 	}
 
 	return r.ImageDataFactory().DownloadAsync(ctx, r.imageURL, "source image", do)

+ 1 - 8
handlers/stream/config.go

@@ -15,9 +15,6 @@ var (
 
 // Config represents the configuration for the image streamer
 type Config struct {
-	// CookiePassthrough indicates whether cookies should be passed through to the image response
-	CookiePassthrough bool
-
 	// PassthroughRequestHeaders specifies the request headers to include in the passthrough response
 	PassthroughRequestHeaders []string
 
@@ -28,7 +25,6 @@ type Config struct {
 // NewDefaultConfig returns a new Config instance with default values.
 func NewDefaultConfig() Config {
 	return Config{
-		CookiePassthrough: false,
 		PassthroughRequestHeaders: []string{
 			httpheaders.IfNoneMatch,
 			httpheaders.IfModifiedSince,
@@ -49,10 +45,7 @@ func NewDefaultConfig() Config {
 // LoadConfigFromEnv loads config variables from environment
 func LoadConfigFromEnv(c *Config) (*Config, error) {
 	c = ensure.Ensure(c, NewDefaultConfig)
-
-	err := env.Bool(&c.CookiePassthrough, IMGPROXY_COOKIE_PASSTHROUGH)
-
-	return c, err
+	return c, nil
 }
 
 // Validate checks config for errors

+ 4 - 6
handlers/stream/handler.go

@@ -37,6 +37,7 @@ var (
 type Handler struct {
 	config  *Config          // Configuration for the streamer
 	fetcher *fetcher.Fetcher // Fetcher instance to handle image fetching
+	cookies *cookies.Cookies // Cookies manager
 }
 
 // request holds the parameters and state for a single streaming request
@@ -50,7 +51,7 @@ type request struct {
 }
 
 // New creates new handler object
-func New(config *Config, fetcher *fetcher.Fetcher) (*Handler, error) {
+func New(config *Config, fetcher *fetcher.Fetcher, cookies *cookies.Cookies) (*Handler, error) {
 	if err := config.Validate(); err != nil {
 		return nil, err
 	}
@@ -58,6 +59,7 @@ func New(config *Config, fetcher *fetcher.Fetcher) (*Handler, error) {
 	return &Handler{
 		fetcher: fetcher,
 		config:  config,
+		cookies: cookies,
 	}, nil
 }
 
@@ -134,11 +136,7 @@ func (s *request) execute(ctx context.Context) error {
 
 // getCookieJar returns non-empty cookie jar if cookie passthrough is enabled
 func (s *request) getCookieJar() (http.CookieJar, error) {
-	if !s.handler.config.CookiePassthrough {
-		return nil, nil
-	}
-
-	return cookies.JarFromRequest(s.imageRequest)
+	return s.handler.cookies.JarFromRequest(s.imageRequest)
 }
 
 // getImageRequestHeaders returns a new http.Header containing only

+ 21 - 2
handlers/stream/handler_test.go

@@ -12,6 +12,7 @@ import (
 	"github.com/stretchr/testify/suite"
 
 	"github.com/imgproxy/imgproxy/v3/config"
+	"github.com/imgproxy/imgproxy/v3/cookies"
 	"github.com/imgproxy/imgproxy/v3/fetcher"
 	"github.com/imgproxy/imgproxy/v3/httpheaders"
 	"github.com/imgproxy/imgproxy/v3/logger"
@@ -29,6 +30,9 @@ type HandlerTestSuite struct {
 	rwConf    testutil.LazyObj[*responsewriter.Config]
 	rwFactory testutil.LazyObj[*responsewriter.Factory]
 
+	cookieConf testutil.LazyObj[*cookies.Config]
+	cookies    testutil.LazyObj[*cookies.Cookies]
+
 	config  testutil.LazyObj[*Config]
 	handler testutil.LazyObj[*Handler]
 
@@ -55,6 +59,21 @@ func (s *HandlerTestSuite) SetupSuite() {
 		},
 	)
 
+	s.cookieConf, _ = testutil.NewLazySuiteObj(
+		s,
+		func() (*cookies.Config, error) {
+			c := cookies.NewDefaultConfig()
+			return &c, nil
+		},
+	)
+
+	s.cookies, _ = testutil.NewLazySuiteObj(
+		s,
+		func() (*cookies.Cookies, error) {
+			return cookies.New(s.cookieConf())
+		},
+	)
+
 	s.config, _ = testutil.NewLazySuiteObj(
 		s,
 		func() (*Config, error) {
@@ -72,7 +91,7 @@ func (s *HandlerTestSuite) SetupSuite() {
 			fetcher, err := fetcher.New(&fc)
 			s.Require().NoError(err)
 
-			return New(s.config(), fetcher)
+			return New(s.config(), fetcher, s.cookies())
 		},
 	)
 
@@ -391,7 +410,7 @@ func (s *HandlerTestSuite) TestHandlerErrorResponse() {
 
 // TestHandlerCookiePassthrough tests the cookie passthrough behavior of the streaming service.
 func (s *HandlerTestSuite) TestHandlerCookiePassthrough() {
-	s.config().CookiePassthrough = true
+	s.cookieConf().CookiePassthrough = true
 
 	data := s.testData.Read("test1.png")
 

+ 1 - 0
httpheaders/headers.go

@@ -62,6 +62,7 @@ const (
 	XContentTypeOptions             = "X-Content-Type-Options"
 	XForwardedFor                   = "X-Forwarded-For"
 	XForwardedHost                  = "X-Forwarded-Host"
+	XForwardedPort                  = "X-Forwarded-Port"
 	XForwardedProto                 = "X-Forwarded-Proto"
 	XFrameOptions                   = "X-Frame-Options"
 	XOriginWidth                    = "X-Origin-Width"

+ 13 - 1
imgproxy.go

@@ -6,6 +6,7 @@ import (
 	"time"
 
 	"github.com/imgproxy/imgproxy/v3/auximageprovider"
+	"github.com/imgproxy/imgproxy/v3/cookies"
 	"github.com/imgproxy/imgproxy/v3/fetcher"
 	healthhandler "github.com/imgproxy/imgproxy/v3/handlers/health"
 	landinghandler "github.com/imgproxy/imgproxy/v3/handlers/landing"
@@ -45,6 +46,7 @@ type Imgproxy struct {
 	security         *security.Checker
 	optionsParser    *optionsparser.Parser
 	processor        *processing.Processor
+	cookies          *cookies.Cookies
 	config           *Config
 }
 
@@ -87,6 +89,11 @@ func New(ctx context.Context, config *Config) (*Imgproxy, error) {
 		return nil, err
 	}
 
+	cookies, err := cookies.New(&config.Cookies)
+	if err != nil {
+		return nil, err
+	}
+
 	imgproxy := &Imgproxy{
 		workers:          workers,
 		fallbackImage:    fallbackImage,
@@ -97,12 +104,13 @@ func New(ctx context.Context, config *Config) (*Imgproxy, error) {
 		security:         security,
 		optionsParser:    optionsParser,
 		processor:        processor,
+		cookies:          cookies,
 	}
 
 	imgproxy.handlers.Health = healthhandler.New()
 	imgproxy.handlers.Landing = landinghandler.New()
 
-	imgproxy.handlers.Stream, err = streamhandler.New(&config.Handlers.Stream, fetcher)
+	imgproxy.handlers.Stream, err = streamhandler.New(&config.Handlers.Stream, fetcher, cookies)
 	if err != nil {
 		return nil, err
 	}
@@ -223,3 +231,7 @@ func (i *Imgproxy) OptionsParser() *optionsparser.Parser {
 func (i *Imgproxy) Processor() *processing.Processor {
 	return i.processor
 }
+
+func (i *Imgproxy) Cookies() *cookies.Cookies {
+	return i.cookies
+}

+ 11 - 4
init.go

@@ -54,11 +54,18 @@ func Init() error {
 	if err != nil {
 		return err
 	}
-	if err := vips.Init(vipsCfg); err != nil {
-		return err
+	if vipsErr := vips.Init(vipsCfg); vipsErr != nil {
+		return vipsErr
 	}
 
-	errorreport.Init()
+	errCfg, errErr := errorreport.LoadConfigFromEnv(nil)
+	if errErr != nil {
+		return errErr
+	}
+
+	if err := errorreport.Init(errCfg); err != nil {
+		return err
+	}
 
 	return nil
 }
@@ -66,6 +73,6 @@ func Init() error {
 // Shutdown performs global cleanup
 func Shutdown() {
 	monitoring.Stop()
-	errorreport.Close()
 	vips.Shutdown()
+	errorreport.Close()
 }

+ 48 - 13
server/responsewriter/config.go

@@ -5,7 +5,6 @@ import (
 	"strings"
 	"time"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/ensure"
 	"github.com/imgproxy/imgproxy/v3/env"
 )
@@ -16,6 +15,16 @@ var (
 	IMGPROXY_FALLBACK_IMAGE_TTL        = env.Describe("IMGPROXY_FALLBACK_IMAGE_TTL", "seconds >= 0")
 	IMGPROXY_CACHE_CONTROL_PASSTHROUGH = env.Describe("IMGPROXY_CACHE_CONTROL_PASSTHROUGH", "boolean")
 	IMGPROXY_WRITE_RESPONSE_TIMEOUT    = env.Describe("IMGPROXY_WRITE_RESPONSE_TIMEOUT", "seconds > 0")
+
+	// NOTE: These are referenced here to determine if we need to set the Vary header
+	// Unfotunately, we can not reuse them from optionsparser package due to import cycle
+	IMGPROXY_AUTO_WEBP           = env.Describe("IMGPROXY_AUTO_WEBP", "boolean")
+	IMGPROXY_AUTO_AVIF           = env.Describe("IMGPROXY_AUTO_AVIF", "boolean")
+	IMGPROXY_AUTO_JXL            = env.Describe("IMGPROXY_AUTO_JXL", "boolean")
+	IMGPROXY_ENFORCE_WEBP        = env.Describe("IMGPROXY_ENFORCE_WEBP", "boolean")
+	IMGPROXY_ENFORCE_AVIF        = env.Describe("IMGPROXY_ENFORCE_AVIF", "boolean")
+	IMGPROXY_ENFORCE_JXL         = env.Describe("IMGPROXY_ENFORCE_JXL", "boolean")
+	IMGPROXY_ENABLE_CLIENT_HINTS = env.Describe("IMGPROXY_ENABLE_CLIENT_HINTS", "boolean")
 )
 
 // Config holds configuration for response writer
@@ -57,11 +66,19 @@ func LoadConfigFromEnv(c *Config) (*Config, error) {
 
 	vary := make([]string, 0)
 
-	if c.envEnableFormatDetection() {
+	var ok bool
+
+	if err, ok = c.envEnableFormatDetection(); err != nil {
+		return nil, err
+	}
+	if ok {
 		vary = append(vary, "Accept")
 	}
 
-	if c.envEnableClientHints() {
+	if err, ok = c.envEnableClientHints(); err != nil {
+		return nil, err
+	}
+	if ok {
 		vary = append(vary, "Sec-CH-DPR", "DPR", "Sec-CH-Width", "Width")
 	}
 
@@ -70,18 +87,36 @@ func LoadConfigFromEnv(c *Config) (*Config, error) {
 	return c, nil
 }
 
-// TODO: REMOVE REFERENCE TO GLOBAL CONFIG
-func (c *Config) envEnableFormatDetection() bool {
-	return config.AutoWebp ||
-		config.EnforceWebp ||
-		config.AutoAvif ||
-		config.EnforceAvif ||
-		config.AutoJxl ||
-		config.EnforceJxl
+// envEnableFormatDetection checks if any of the format detection options are enabled
+func (c *Config) envEnableFormatDetection() (error, bool) {
+	var autoWebp, enforceWebp, autoAvif, enforceAvif, autoJxl, enforceJxl bool
+
+	// We won't need those variables in runtime, hence, we could
+	// read them here once into local variables
+	err := errors.Join(
+		env.Bool(&autoWebp, IMGPROXY_AUTO_WEBP),
+		env.Bool(&enforceWebp, IMGPROXY_ENFORCE_WEBP),
+		env.Bool(&autoAvif, IMGPROXY_AUTO_AVIF),
+		env.Bool(&enforceAvif, IMGPROXY_ENFORCE_AVIF),
+		env.Bool(&autoJxl, IMGPROXY_AUTO_JXL),
+		env.Bool(&enforceJxl, IMGPROXY_ENFORCE_JXL),
+	)
+	if err != nil {
+		return err, false
+	}
+
+	return nil, autoWebp ||
+		enforceWebp ||
+		autoAvif ||
+		enforceAvif ||
+		autoJxl ||
+		enforceJxl
 }
 
-func (c *Config) envEnableClientHints() bool {
-	return config.EnableClientHints
+// envEnableClientHints checks if client hints are enabled
+func (c *Config) envEnableClientHints() (err error, ok bool) {
+	err = env.Bool(&ok, IMGPROXY_ENABLE_CLIENT_HINTS)
+	return
 }
 
 // Validate checks config for errors

+ 1 - 0
server/server_test.go

@@ -24,6 +24,7 @@ func (s *ServerTestSuite) SetupTest() {
 
 	s.config = &c
 	s.config.Bind = "127.0.0.1:0" // Use port 0 for auto-assignment
+
 	r, err := NewRouter(s.config)
 	s.Require().NoError(err)
 	s.blankRouter = r