Przeglądaj źródła

Add client features detector

DarthSim 3 miesięcy temu
rodzic
commit
e162c9496e

+ 51 - 0
clientfeatures/config.go

@@ -0,0 +1,51 @@
+package clientfeatures
+
+import (
+	"errors"
+
+	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+)
+
+var (
+	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
+type Config struct {
+	AutoWebp    bool // Whether to automatically serve WebP when supported
+	EnforceWebp bool // Whether to enforce WebP format
+	AutoAvif    bool // Whether to automatically serve AVIF when supported
+	EnforceAvif bool // Whether to enforce AVIF format
+	AutoJxl     bool // Whether to automatically serve JXL when supported
+	EnforceJxl  bool // Whether to enforce JXL format
+
+	EnableClientHints bool // Whether to enable client hints support
+}
+
+// NewDefaultConfig returns a new Config instance with default values.
+func NewDefaultConfig() Config {
+	return Config{} // All features disabled by default
+}
+
+// LoadConfigFromEnv overrides configuration variables from environment
+func LoadConfigFromEnv(c *Config) (*Config, error) {
+	c = ensure.Ensure(c, NewDefaultConfig)
+
+	err := errors.Join(
+		env.Bool(&c.AutoWebp, IMGPROXY_AUTO_WEBP),
+		env.Bool(&c.EnforceWebp, IMGPROXY_ENFORCE_WEBP),
+		env.Bool(&c.AutoAvif, IMGPROXY_AUTO_AVIF),
+		env.Bool(&c.EnforceAvif, IMGPROXY_ENFORCE_AVIF),
+		env.Bool(&c.AutoJxl, IMGPROXY_AUTO_JXL),
+		env.Bool(&c.EnforceJxl, IMGPROXY_ENFORCE_JXL),
+	)
+
+	return c, err
+}

+ 99 - 0
clientfeatures/detector.go

@@ -0,0 +1,99 @@
+package clientfeatures
+
+import (
+	"net/http"
+	"strconv"
+	"strings"
+
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
+)
+
+// Maximum supported DPR from client hints
+const maxClientHintDPR = 8
+
+// Detector detects client features from request headers
+type Detector struct {
+	config *Config
+	vary   string
+}
+
+// NewDetector creates a new Detector instance
+func NewDetector(config *Config) *Detector {
+	vary := make([]string, 0, 5)
+
+	if config.AutoWebp || config.EnforceWebp ||
+		config.AutoAvif || config.EnforceAvif ||
+		config.AutoJxl || config.EnforceJxl {
+		vary = append(vary, httpheaders.Accept)
+	}
+
+	if config.EnableClientHints {
+		vary = append(
+			vary,
+			httpheaders.SecChDpr, httpheaders.Dpr, httpheaders.SecChWidth, httpheaders.Width,
+		)
+	}
+
+	return &Detector{
+		config: config,
+		vary:   strings.Join(vary, ", "),
+	}
+}
+
+// Features detects client features from HTTP headers
+func (d *Detector) Features(header http.Header) Features {
+	var f Features
+
+	headerAccept := header.Get("Accept")
+
+	if (d.config.AutoWebp || d.config.EnforceWebp) && strings.Contains(headerAccept, "image/webp") {
+		f.PreferWebP = true
+		f.EnforceWebP = d.config.EnforceWebp
+	}
+
+	if (d.config.AutoAvif || d.config.EnforceAvif) && strings.Contains(headerAccept, "image/avif") {
+		f.PreferAvif = true
+		f.EnforceAvif = d.config.EnforceAvif
+	}
+
+	if (d.config.AutoJxl || d.config.EnforceJxl) && strings.Contains(headerAccept, "image/jxl") {
+		f.PreferJxl = true
+		f.EnforceJxl = d.config.EnforceJxl
+	}
+
+	if !d.config.EnableClientHints {
+		return f
+	}
+	for _, key := range []string{httpheaders.SecChDpr, httpheaders.Dpr} {
+		val := header.Get(key)
+		if len(val) == 0 {
+			continue
+		}
+
+		if d, err := strconv.ParseFloat(val, 64); err == nil && (d > 0 && d <= maxClientHintDPR) {
+			f.ClientHintsDPR = d
+			break
+		}
+	}
+
+	for _, key := range []string{httpheaders.SecChWidth, httpheaders.Width} {
+		val := header.Get(key)
+		if len(val) == 0 {
+			continue
+		}
+
+		if w, err := strconv.Atoi(val); err == nil && w > 0 {
+			f.ClientHintsWidth = w
+			break
+		}
+	}
+
+	return f
+}
+
+// SetVary sets the Vary header value based on enabled features
+func (d *Detector) SetVary(header http.Header) {
+	if len(d.vary) > 0 {
+		header.Set(httpheaders.Vary, d.vary)
+	}
+}

+ 436 - 0
clientfeatures/detector_test.go

@@ -0,0 +1,436 @@
+package clientfeatures
+
+import (
+	"net/http"
+	"testing"
+
+	"github.com/stretchr/testify/suite"
+
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
+	"github.com/imgproxy/imgproxy/v3/logger"
+)
+
+type detectorTestCase struct {
+	name     string
+	config   Config
+	header   map[string]string
+	expected Features
+}
+
+type ClientFeaturesDetectorSuite struct {
+	suite.Suite
+}
+
+func (s *ClientFeaturesDetectorSuite) SetupSuite() {
+	logger.Mute()
+}
+
+func (s *ClientFeaturesDetectorSuite) TearDownSuite() {
+	logger.Unmute()
+}
+
+func (s *ClientFeaturesDetectorSuite) runTestCases(testCases []detectorTestCase) {
+	for _, tc := range testCases {
+		s.Run(tc.name, func() {
+			detector := NewDetector(&tc.config)
+
+			header := make(http.Header)
+			for k, v := range tc.header {
+				header.Set(k, v)
+			}
+
+			features := detector.Features(header)
+			s.Require().Equal(tc.expected, features)
+		})
+	}
+}
+
+func (s *ClientFeaturesDetectorSuite) TestFeaturesAutoFormats() {
+	s.runTestCases([]detectorTestCase{
+		{
+			name: "AutoWebP_ConainsWebP",
+			config: Config{
+				AutoWebp: true,
+			},
+			header: map[string]string{
+				"Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
+			},
+			expected: Features{
+				PreferWebP: true,
+			},
+		},
+		{
+			name: "AutoWebP_DoesNotContainWebP",
+			config: Config{
+				AutoWebp: true,
+			},
+			header: map[string]string{
+				"Accept": "image/apng,image/*,*/*;q=0.8",
+			},
+			expected: Features{},
+		},
+		{
+			name: "EnforceWebP_ContainsWebP",
+			config: Config{
+				EnforceWebp: true,
+			},
+			header: map[string]string{
+				"Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
+			},
+			expected: Features{
+				PreferWebP:  true,
+				EnforceWebP: true,
+			},
+		},
+		{
+			name: "EnforceWebP_DoesNotContainWebP",
+			config: Config{
+				EnforceWebp: true,
+			},
+			header: map[string]string{
+				"Accept": "image/apng,image/*,*/*;q=0.8",
+			},
+			expected: Features{},
+		},
+		{
+			name: "AutoAvif_ContainsAvif",
+			config: Config{
+				AutoAvif: true,
+			},
+			header: map[string]string{
+				"Accept": "image/avif,image/apng,image/*,*/*;q=0.8",
+			},
+			expected: Features{
+				PreferAvif: true,
+			},
+		},
+		{
+			name: "AutoAvif_DoesNotContainAvif",
+			config: Config{
+				AutoAvif: true,
+			},
+			header: map[string]string{
+				"Accept": "image/apng,image/*,*/*;q=0.8",
+			},
+			expected: Features{},
+		},
+		{
+			name: "EnforceAvif_ContainsAvif",
+			config: Config{
+				EnforceAvif: true,
+			},
+			header: map[string]string{
+				"Accept": "image/avif,image/apng,image/*,*/*;q=0.8",
+			},
+			expected: Features{
+				PreferAvif:  true,
+				EnforceAvif: true,
+			},
+		},
+		{
+			name: "EnforceAvif_DoesNotContainAvif",
+			config: Config{
+				EnforceAvif: true,
+			},
+			header: map[string]string{
+				"Accept": "image/apng,image/*,*/*;q=0.8",
+			},
+			expected: Features{},
+		},
+		{
+			name: "AutoJXL_ContainsJXL",
+			config: Config{
+				AutoJxl: true,
+			},
+			header: map[string]string{
+				"Accept": "image/jxl,image/apng,image/*,*/*;q=0.8",
+			},
+			expected: Features{
+				PreferJxl: true,
+			},
+		},
+		{
+			name: "AutoJXL_DoesNotContainJXL",
+			config: Config{
+				AutoJxl: true,
+			},
+			header: map[string]string{
+				"Accept": "image/apng,image/*,*/*;q=0.8",
+			},
+			expected: Features{},
+		},
+		{
+			name: "EnforceJXL_ContainsJXL",
+			config: Config{
+				EnforceJxl: true,
+			},
+			header: map[string]string{
+				"Accept": "image/jxl,image/apng,image/*,*/*;q=0.8",
+			},
+			expected: Features{
+				PreferJxl:  true,
+				EnforceJxl: true,
+			},
+		},
+		{
+			name: "EnforceJXL_DoesNotContainJXL",
+			config: Config{
+				EnforceJxl: true,
+			},
+			header: map[string]string{
+				"Accept": "image/apng,image/*,*/*;q=0.8",
+			},
+			expected: Features{},
+		},
+		{
+			name: "NoneEnabled_ContainsAll",
+			config: Config{
+				AutoWebp:    false,
+				EnforceWebp: false,
+				AutoAvif:    false,
+				EnforceAvif: false,
+				AutoJxl:     false,
+				EnforceJxl:  false,
+			},
+			header: map[string]string{
+				"Accept": "image/webp,image/avif,image/jxl,image/apng,image/*,*/*;q=0.8",
+			},
+			expected: Features{},
+		},
+	})
+}
+
+func (s *ClientFeaturesDetectorSuite) TestFeaturesClientHintsDPR() {
+	s.runTestCases([]detectorTestCase{
+		{
+			name: "ClientHintsEnabled_ValidDPR",
+			config: Config{
+				EnableClientHints: true,
+			},
+			header: map[string]string{
+				"DPR": "1.5",
+			},
+			expected: Features{
+				ClientHintsDPR: 1.5,
+			},
+		},
+		{
+			name: "ClientHintsEnabled_ValidSecChDPR",
+			config: Config{
+				EnableClientHints: true,
+			},
+			header: map[string]string{
+				"Sec-CH-DPR": "2.0",
+			},
+			expected: Features{
+				ClientHintsDPR: 2.0,
+			},
+		},
+		{
+			name: "ClientHintsEnabled_ValidDprAndSecChDPR",
+			config: Config{
+				EnableClientHints: true,
+			},
+			header: map[string]string{
+				"DPR":        "3.0",
+				"Sec-CH-DPR": "2.5",
+			},
+			expected: Features{
+				ClientHintsDPR: 2.5,
+			},
+		},
+		{
+			name: "ClientHintsEnabled_InvalidDPR_Negative",
+			config: Config{
+				EnableClientHints: true,
+			},
+			header: map[string]string{
+				"DPR": "-1.0",
+			},
+			expected: Features{},
+		},
+		{
+			name: "ClientHintsEnabled_InvalidDPR_TooHigh",
+			config: Config{
+				EnableClientHints: true,
+			},
+			header: map[string]string{
+				"DPR": "10.0",
+			},
+			expected: Features{},
+		},
+		{
+			name: "ClientHintsEnabled_InvalidDPR_NonNumeric",
+			config: Config{
+				EnableClientHints: true,
+			},
+			header: map[string]string{
+				"DPR": "abc",
+			},
+			expected: Features{},
+		},
+		{
+			name: "ClientHintsDisabled",
+			config: Config{
+				EnableClientHints: false,
+			},
+			header: map[string]string{
+				"DPR":        "2.0",
+				"Sec-CH-DPR": "3.0",
+			},
+			expected: Features{},
+		},
+	})
+}
+
+func (s *ClientFeaturesDetectorSuite) TestFeaturesClientHintsWidth() {
+	s.runTestCases([]detectorTestCase{
+		{
+			name: "ClientHintsEnabled_ValidWidth",
+			config: Config{
+				EnableClientHints: true,
+			},
+			header: map[string]string{
+				"Width": "800",
+			},
+			expected: Features{
+				ClientHintsWidth: 800,
+			},
+		},
+		{
+			name: "ClientHintsEnabled_ValidSecChWidth",
+			config: Config{
+				EnableClientHints: true,
+			},
+			header: map[string]string{
+				"Sec-CH-Width": "1024",
+			},
+			expected: Features{
+				ClientHintsWidth: 1024,
+			},
+		},
+		{
+			name: "ClientHintsEnabled_ValidWidthAndSecChWidth",
+			config: Config{
+				EnableClientHints: true,
+			},
+			header: map[string]string{
+				"Width":        "1280",
+				"Sec-CH-Width": "1440",
+			},
+			expected: Features{
+				ClientHintsWidth: 1440,
+			},
+		},
+		{
+			name: "ClientHintsEnabled_InvalidWidth_Negative",
+			config: Config{
+				EnableClientHints: true,
+			},
+			header: map[string]string{
+				"Width": "-800",
+			},
+			expected: Features{},
+		},
+		{
+			name: "ClientHintsEnabled_InvalidWidth_NonNumeric",
+			config: Config{
+				EnableClientHints: true,
+			},
+			header: map[string]string{
+				"Width": "abc",
+			},
+			expected: Features{},
+		},
+		{
+			name: "ClientHintsDisabled",
+			config: Config{
+				EnableClientHints: false,
+			},
+			header: map[string]string{
+				"Width":        "800",
+				"Sec-CH-Width": "1024",
+			},
+			expected: Features{},
+		},
+	})
+}
+
+func (s *ClientFeaturesDetectorSuite) TestSetVary() {
+	testCases := []struct {
+		name     string
+		config   Config
+		expected string
+	}{
+		{
+			name: "AutoWebP_Enabled",
+			config: Config{
+				AutoWebp: true,
+			},
+			expected: "Accept",
+		},
+		{
+			name: "EnforceWebP_Enabled",
+			config: Config{
+				EnforceWebp: true,
+			},
+			expected: "Accept",
+		},
+		{
+			name: "AutoAvif_Enabled",
+			config: Config{
+				AutoAvif: true,
+			},
+			expected: "Accept",
+		},
+		{
+			name: "EnforceAvif_Enabled",
+			config: Config{
+				EnforceAvif: true,
+			},
+			expected: "Accept",
+		},
+		{
+			name: "AutoJXL_Enabled",
+			config: Config{
+				AutoJxl: true,
+			},
+			expected: "Accept",
+		},
+		{
+			name: "EnforceJXL_Enabled",
+			config: Config{
+				EnforceJxl: true,
+			},
+			expected: "Accept",
+		},
+		{
+			name: "EnableClientHints_Enabled",
+			config: Config{
+				EnableClientHints: true,
+			},
+			expected: "Sec-Ch-Dpr, Dpr, Sec-Ch-Width, Width",
+		},
+		{
+			name: "Combined",
+			config: Config{
+				AutoWebp:          true,
+				EnableClientHints: true,
+			},
+			expected: "Accept, Sec-Ch-Dpr, Dpr, Sec-Ch-Width, Width",
+		},
+	}
+
+	for _, tc := range testCases {
+		s.Run(tc.name, func() {
+			detector := NewDetector(&tc.config)
+			header := http.Header{}
+			detector.SetVary(header)
+			s.Require().Equal(tc.expected, header.Get(httpheaders.Vary))
+		})
+	}
+}
+
+func TestClientFeaturesDetector(t *testing.T) {
+	suite.Run(t, new(ClientFeaturesDetectorSuite))
+}

+ 16 - 0
clientfeatures/features.go

@@ -0,0 +1,16 @@
+package clientfeatures
+
+// Features holds information about features extracted from HTTP request
+type Features struct {
+	PreferWebP  bool // Whether to prefer WebP format when resulting image format is unknown
+	EnforceWebP bool // Whether to enforce WebP format even if resulting image format is set
+
+	PreferAvif  bool // Whether to prefer AVIF format when resulting image format is unknown
+	EnforceAvif bool // Whether to enforce AVIF format even if resulting image format is set
+
+	PreferJxl  bool // Whether to prefer JXL format when resulting image format is unknown
+	EnforceJxl bool // Whether to enforce JXL format even if resulting image format is set
+
+	ClientHintsWidth int     // Client hint width
+	ClientHintsDPR   float64 // Client hint device pixel ratio
+}

+ 7 - 0
config.go

@@ -2,6 +2,7 @@ package imgproxy
 
 import (
 	"github.com/imgproxy/imgproxy/v3/auximageprovider"
+	"github.com/imgproxy/imgproxy/v3/clientfeatures"
 	"github.com/imgproxy/imgproxy/v3/cookies"
 	"github.com/imgproxy/imgproxy/v3/ensure"
 	"github.com/imgproxy/imgproxy/v3/errorreport"
@@ -29,6 +30,7 @@ type Config struct {
 	FallbackImage  auximageprovider.StaticConfig
 	WatermarkImage auximageprovider.StaticConfig
 	Fetcher        fetcher.Config
+	ClientFeatures clientfeatures.Config
 	Handlers       HandlerConfigs
 	Server         server.Config
 	Security       security.Config
@@ -46,6 +48,7 @@ func NewDefaultConfig() Config {
 		FallbackImage:  auximageprovider.NewDefaultStaticConfig(),
 		WatermarkImage: auximageprovider.NewDefaultStaticConfig(),
 		Fetcher:        fetcher.NewDefaultConfig(),
+		ClientFeatures: clientfeatures.NewDefaultConfig(),
 		Handlers: HandlerConfigs{
 			Processing: processinghandler.NewDefaultConfig(),
 			Stream:     streamhandler.NewDefaultConfig(),
@@ -86,6 +89,10 @@ func LoadConfigFromEnv(c *Config) (*Config, error) {
 		return nil, err
 	}
 
+	if _, err = clientfeatures.LoadConfigFromEnv(&c.ClientFeatures); err != nil {
+		return nil, err
+	}
+
 	if _, err = processinghandler.LoadConfigFromEnv(&c.Handlers.Processing); err != nil {
 		return nil, err
 	}

+ 4 - 1
handlers/processing/handler.go

@@ -6,6 +6,7 @@ import (
 	"net/url"
 
 	"github.com/imgproxy/imgproxy/v3/auximageprovider"
+	"github.com/imgproxy/imgproxy/v3/clientfeatures"
 	"github.com/imgproxy/imgproxy/v3/cookies"
 	"github.com/imgproxy/imgproxy/v3/errorreport"
 	"github.com/imgproxy/imgproxy/v3/handlers"
@@ -25,6 +26,7 @@ import (
 // HandlerContext provides access to shared handler dependencies
 type HandlerContext interface {
 	Workers() *workers.Workers
+	ClientFeaturesDetector() *clientfeatures.Detector
 	FallbackImage() auximageprovider.Provider
 	ImageDataFactory() *imagedata.Factory
 	Security() *security.Checker
@@ -116,7 +118,8 @@ func (h *Handler) newRequest(
 	}
 
 	// parse image url and processing options
-	o, imageURL, err := h.OptionsParser().ParsePath(path, req.Header)
+	features := h.ClientFeaturesDetector().Features(req.Header)
+	o, imageURL, err := h.OptionsParser().ParsePath(path, &features)
 	if err != nil {
 		return "", nil, nil, ierrors.Wrap(err, 0, ierrors.WithCategory(handlers.CategoryPathParsing))
 	}

+ 4 - 2
handlers/processing/request_methods.go

@@ -183,7 +183,6 @@ func (r *request) writeDebugHeaders(result *processing.Result, originData imaged
 // respondWithNotModified writes not-modified response
 func (r *request) respondWithNotModified() error {
 	r.rw.SetExpires(r.opts.GetTime(keys.Expires))
-	r.rw.SetVary()
 
 	if r.config.LastModifiedEnabled {
 		r.rw.Passthrough(httpheaders.LastModified)
@@ -193,6 +192,8 @@ func (r *request) respondWithNotModified() error {
 		r.rw.Passthrough(httpheaders.Etag)
 	}
 
+	r.ClientFeaturesDetector().SetVary(r.rw.Header())
+
 	r.rw.WriteHeader(http.StatusNotModified)
 
 	server.LogResponse(
@@ -223,9 +224,10 @@ func (r *request) respondWithImage(statusCode int, resultData imagedata.ImageDat
 		r.opts.GetBool(keys.ReturnAttachment, false),
 	)
 	r.rw.SetExpires(r.opts.GetTime(keys.Expires))
-	r.rw.SetVary()
 	r.rw.SetCanonical(r.imageURL)
 
+	r.ClientFeaturesDetector().SetVary(r.rw.Header())
+
 	if r.config.LastModifiedEnabled {
 		r.rw.Passthrough(httpheaders.LastModified)
 	}

+ 4 - 0
httpheaders/headers.go

@@ -30,6 +30,7 @@ const (
 	Cookie                          = "Cookie"
 	Date                            = "Date"
 	Dnt                             = "Dnt"
+	Dpr                             = "Dpr"
 	Etag                            = "Etag"
 	Expect                          = "Expect"
 	ExpectCt                        = "Expect-Ct"
@@ -50,6 +51,8 @@ const (
 	Referer                         = "Referer"
 	RequestId                       = "Request-Id"
 	RetryAfter                      = "Retry-After"
+	SecChDpr                        = "Sec-Ch-Dpr"
+	SecChWidth                      = "Sec-Ch-Width"
 	Server                          = "Server"
 	SetCookie                       = "Set-Cookie"
 	StrictTransportSecurity         = "Strict-Transport-Security"
@@ -57,6 +60,7 @@ const (
 	UserAgent                       = "User-Agent"
 	Vary                            = "Vary"
 	Via                             = "Via"
+	Width                           = "Width"
 	WwwAuthenticate                 = "Www-Authenticate"
 	XAmznRequestContextHeader       = "x-amzn-request-context"
 	XContentTypeOptions             = "X-Content-Type-Options"

+ 34 - 25
imgproxy.go

@@ -6,6 +6,7 @@ import (
 	"time"
 
 	"github.com/imgproxy/imgproxy/v3/auximageprovider"
+	"github.com/imgproxy/imgproxy/v3/clientfeatures"
 	"github.com/imgproxy/imgproxy/v3/cookies"
 	"github.com/imgproxy/imgproxy/v3/errorreport"
 	"github.com/imgproxy/imgproxy/v3/fetcher"
@@ -38,19 +39,20 @@ type ImgproxyHandlers struct {
 
 // Imgproxy holds all the components needed for imgproxy to function
 type Imgproxy struct {
-	workers          *workers.Workers
-	fallbackImage    auximageprovider.Provider
-	watermarkImage   auximageprovider.Provider
-	fetcher          *fetcher.Fetcher
-	imageDataFactory *imagedata.Factory
-	handlers         ImgproxyHandlers
-	security         *security.Checker
-	optionsParser    *optionsparser.Parser
-	processor        *processing.Processor
-	cookies          *cookies.Cookies
-	monitoring       *monitoring.Monitoring
-	config           *Config
-	errorReporter    *errorreport.Reporter
+	workers                *workers.Workers
+	fallbackImage          auximageprovider.Provider
+	watermarkImage         auximageprovider.Provider
+	fetcher                *fetcher.Fetcher
+	imageDataFactory       *imagedata.Factory
+	clientFeaturesDetector *clientfeatures.Detector
+	handlers               ImgproxyHandlers
+	security               *security.Checker
+	optionsParser          *optionsparser.Parser
+	processor              *processing.Processor
+	cookies                *cookies.Cookies
+	monitoring             *monitoring.Monitoring
+	config                 *Config
+	errorReporter          *errorreport.Reporter
 }
 
 // New creates a new imgproxy instance
@@ -66,6 +68,8 @@ func New(ctx context.Context, config *Config) (*Imgproxy, error) {
 
 	idf := imagedata.NewFactory(fetcher)
 
+	clientFeaturesDetector := clientfeatures.NewDetector(&config.ClientFeatures)
+
 	fallbackImage, err := auximageprovider.NewStaticProvider(ctx, &config.FallbackImage, "fallback", idf)
 	if err != nil {
 		return nil, err
@@ -112,18 +116,19 @@ func New(ctx context.Context, config *Config) (*Imgproxy, error) {
 	}
 
 	imgproxy := &Imgproxy{
-		workers:          workers,
-		fallbackImage:    fallbackImage,
-		watermarkImage:   watermarkImage,
-		fetcher:          fetcher,
-		imageDataFactory: idf,
-		config:           config,
-		security:         security,
-		optionsParser:    optionsParser,
-		processor:        processor,
-		cookies:          cookies,
-		monitoring:       monitoring,
-		errorReporter:    errorReporter,
+		workers:                workers,
+		fallbackImage:          fallbackImage,
+		watermarkImage:         watermarkImage,
+		fetcher:                fetcher,
+		imageDataFactory:       idf,
+		clientFeaturesDetector: clientFeaturesDetector,
+		config:                 config,
+		security:               security,
+		optionsParser:          optionsParser,
+		processor:              processor,
+		cookies:                cookies,
+		monitoring:             monitoring,
+		errorReporter:          errorReporter,
 	}
 
 	imgproxy.handlers.Health = healthhandler.New()
@@ -249,6 +254,10 @@ func (i *Imgproxy) ImageDataFactory() *imagedata.Factory {
 	return i.imageDataFactory
 }
 
+func (i *Imgproxy) ClientFeaturesDetector() *clientfeatures.Detector {
+	return i.clientFeaturesDetector
+}
+
 func (i *Imgproxy) Security() *security.Checker {
 	return i.security
 }

+ 3 - 3
integration/processing_handler_test.go

@@ -477,9 +477,9 @@ func (s *ProcessingHandlerTestSuite) TestAlwaysRasterizeSvg() {
 	s.Require().Equal("image/png", res.Header.Get(httpheaders.ContentType))
 }
 
-func (s *ProcessingHandlerTestSuite) TestAlwaysRasterizeSvgWithEnforceAvif() {
+func (s *ProcessingHandlerTestSuite) TestAlwaysRasterizeSvgWithEnforceWebP() {
 	s.Config().Processing.AlwaysRasterizeSvg = true
-	s.Config().OptionsParser.EnforceWebp = true
+	s.Config().ClientFeatures.EnforceWebp = true
 
 	res := s.GET("/unsafe/plain/local:///test1.svg", http.Header{"Accept": []string{"image/webp"}})
 
@@ -489,7 +489,7 @@ func (s *ProcessingHandlerTestSuite) TestAlwaysRasterizeSvgWithEnforceAvif() {
 
 func (s *ProcessingHandlerTestSuite) TestAlwaysRasterizeSvgDisabled() {
 	s.Config().Processing.AlwaysRasterizeSvg = false
-	s.Config().OptionsParser.EnforceWebp = true
+	s.Config().ClientFeatures.EnforceWebp = true
 
 	res := s.GET("/unsafe/plain/local:///test1.svg")
 

+ 0 - 40
options/parser/config.go

@@ -20,13 +20,6 @@ var (
 	IMGPROXY_ONLY_PRESETS                 = env.Describe("IMGPROXY_ONLY_PRESETS", "boolean")
 	IMGPROXY_ALLOWED_PROCESSING_OPTIONS   = env.Describe("IMGPROXY_ALLOWED_PROCESSING_OPTIONS", "comma-separated list of strings")
 	IMGPROXY_ALLOW_SECURITY_OPTIONS       = env.Describe("IMGPROXY_ALLOW_SECURITY_OPTIONS", "boolean")
-	IMGPROXY_AUTO_WEBP                    = env.Describe("IMGPROXY_AUTO_WEBP", "boolean")
-	IMGPROXY_ENFORCE_WEBP                 = env.Describe("IMGPROXY_ENFORCE_WEBP", "boolean")
-	IMGPROXY_AUTO_AVIF                    = env.Describe("IMGPROXY_AUTO_AVIF", "boolean")
-	IMGPROXY_ENFORCE_AVIF                 = env.Describe("IMGPROXY_ENFORCE_AVIF", "boolean")
-	IMGPROXY_AUTO_JXL                     = env.Describe("IMGPROXY_AUTO_JXL", "boolean")
-	IMGPROXY_ENFORCE_JXL                  = env.Describe("IMGPROXY_ENFORCE_JXL", "boolean")
-	IMGPROXY_ENABLE_CLIENT_HINTS          = env.Describe("IMGPROXY_ENABLE_CLIENT_HINTS", "boolean")
 	IMGPROXY_ARGUMENTS_SEPARATOR          = env.Describe("IMGPROXY_ARGUMENTS_SEPARATOR", "string")
 	IMGPROXY_BASE_URL                     = env.Describe("IMGPROXY_BASE_URL", "string")
 	IMGPROXY_URL_REPLACEMENTS             = env.Describe("IMGPROXY_URL_REPLACEMENTS", "comma-separated list of key=value pairs")
@@ -46,17 +39,6 @@ type Config struct {
 	AllowedProcessingOptions []string // List of allowed processing options
 	AllowSecurityOptions     bool     // Whether to allow security options in URLs
 
-	// Format preference and enforcement
-	AutoWebp    bool // Whether to automatically serve WebP when supported
-	EnforceWebp bool // Whether to enforce WebP format
-	AutoAvif    bool // Whether to automatically serve AVIF when supported
-	EnforceAvif bool // Whether to enforce AVIF format
-	AutoJxl     bool // Whether to automatically serve JXL when supported
-	EnforceJxl  bool // Whether to enforce JXL format
-
-	// Client hints
-	EnableClientHints bool // Whether to enable client hints support
-
 	// URL processing
 	ArgumentsSeparator        string           // Separator for URL arguments
 	BaseURL                   string           // Base URL for relative URLs
@@ -73,17 +55,6 @@ func NewDefaultConfig() Config {
 		// Security and validation
 		AllowSecurityOptions: false,
 
-		// Format preference and enforcement (copied from global config defaults)
-		AutoWebp:    false,
-		EnforceWebp: false,
-		AutoAvif:    false,
-		EnforceAvif: false,
-		AutoJxl:     false,
-		EnforceJxl:  false,
-
-		// Client hints
-		EnableClientHints: false,
-
 		// URL processing (copied from global config defaults)
 		ArgumentsSeparator:        ":",
 		BaseURL:                   "",
@@ -120,17 +91,6 @@ func LoadConfigFromEnv(c *Config) (*Config, error) {
 		env.StringSlice(&c.AllowedProcessingOptions, IMGPROXY_ALLOWED_PROCESSING_OPTIONS),
 		env.Bool(&c.AllowSecurityOptions, IMGPROXY_ALLOW_SECURITY_OPTIONS),
 
-		// Format preference and enforcement
-		env.Bool(&c.AutoWebp, IMGPROXY_AUTO_WEBP),
-		env.Bool(&c.EnforceWebp, IMGPROXY_ENFORCE_WEBP),
-		env.Bool(&c.AutoAvif, IMGPROXY_AUTO_AVIF),
-		env.Bool(&c.EnforceAvif, IMGPROXY_ENFORCE_AVIF),
-		env.Bool(&c.AutoJxl, IMGPROXY_AUTO_JXL),
-		env.Bool(&c.EnforceJxl, IMGPROXY_ENFORCE_JXL),
-
-		// Client hints
-		env.Bool(&c.EnableClientHints, IMGPROXY_ENABLE_CLIENT_HINTS),
-
 		// URL processing
 		env.String(&c.ArgumentsSeparator, IMGPROXY_ARGUMENTS_SEPARATOR),
 		env.String(&c.BaseURL, IMGPROXY_BASE_URL),

+ 32 - 44
options/parser/processing_options.go

@@ -1,11 +1,10 @@
 package optionsparser
 
 import (
-	"net/http"
 	"slices"
-	"strconv"
 	"strings"
 
+	"github.com/imgproxy/imgproxy/v3/clientfeatures"
 	"github.com/imgproxy/imgproxy/v3/ierrors"
 	"github.com/imgproxy/imgproxy/v3/imath"
 	"github.com/imgproxy/imgproxy/v3/options"
@@ -13,8 +12,6 @@ import (
 	"github.com/imgproxy/imgproxy/v3/processing"
 )
 
-const maxClientHintDPR = 8
-
 func (p *Parser) applyURLOption(
 	o *options.Options,
 	name string,
@@ -138,57 +135,45 @@ func (p *Parser) applyURLOptions(
 	return nil
 }
 
-func (p *Parser) defaultProcessingOptions(headers http.Header) (*options.Options, error) {
+func (p *Parser) defaultProcessingOptions(
+	features *clientfeatures.Features,
+) (*options.Options, error) {
 	o := options.New()
 
-	headerAccept := headers.Get("Accept")
-
-	if (p.config.AutoWebp || p.config.EnforceWebp) && strings.Contains(headerAccept, "image/webp") {
-		o.Set(keys.PreferWebP, true)
+	if features != nil {
+		if features.PreferWebP || features.EnforceWebP {
+			o.Set(keys.PreferWebP, true)
+		}
 
-		if p.config.EnforceWebp {
+		if features.EnforceWebP {
 			o.Set(keys.EnforceWebP, true)
 		}
-	}
 
-	if (p.config.AutoAvif || p.config.EnforceAvif) && strings.Contains(headerAccept, "image/avif") {
-		o.Set(keys.PreferAvif, true)
+		if features.PreferAvif || features.EnforceAvif {
+			o.Set(keys.PreferAvif, true)
+		}
 
-		if p.config.EnforceAvif {
+		if features.EnforceAvif {
 			o.Set(keys.EnforceAvif, true)
 		}
-	}
 
-	if (p.config.AutoJxl || p.config.EnforceJxl) && strings.Contains(headerAccept, "image/jxl") {
-		o.Set(keys.PreferJxl, true)
+		if features.PreferJxl || features.EnforceJxl {
+			o.Set(keys.PreferJxl, true)
+		}
 
-		if p.config.EnforceJxl {
+		if features.EnforceJxl {
 			o.Set(keys.EnforceJxl, true)
 		}
-	}
 
-	if p.config.EnableClientHints {
 		dpr := 1.0
 
-		headerDPR := headers.Get("Sec-CH-DPR")
-		if len(headerDPR) == 0 {
-			headerDPR = headers.Get("DPR")
-		}
-		if len(headerDPR) > 0 {
-			if d, err := strconv.ParseFloat(headerDPR, 64); err == nil && (d > 0 && d <= maxClientHintDPR) {
-				dpr = d
-				o.Set(keys.Dpr, dpr)
-			}
+		if features.ClientHintsDPR > 0 {
+			o.Set(keys.Dpr, features.ClientHintsDPR)
+			dpr = features.ClientHintsDPR
 		}
 
-		headerWidth := headers.Get("Sec-CH-Width")
-		if len(headerWidth) == 0 {
-			headerWidth = headers.Get("Width")
-		}
-		if len(headerWidth) > 0 {
-			if w, err := strconv.Atoi(headerWidth); err == nil {
-				o.Set(keys.Width, imath.Shrink(w, dpr))
-			}
+		if features.ClientHintsWidth > 0 {
+			o.Set(keys.Width, imath.Shrink(features.ClientHintsWidth, dpr))
 		}
 	}
 
@@ -204,7 +189,7 @@ func (p *Parser) defaultProcessingOptions(headers http.Header) (*options.Options
 // ParsePath parses the given request path and returns the processing options and image URL
 func (p *Parser) ParsePath(
 	path string,
-	headers http.Header,
+	features *clientfeatures.Features,
 ) (o *options.Options, imageURL string, err error) {
 	if path == "" || path == "/" {
 		return nil, "", newInvalidURLError("invalid path: %s", path)
@@ -213,9 +198,9 @@ func (p *Parser) ParsePath(
 	parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
 
 	if p.config.OnlyPresets {
-		o, imageURL, err = p.parsePathPresets(parts, headers)
+		o, imageURL, err = p.parsePathPresets(parts, features)
 	} else {
-		o, imageURL, err = p.parsePathOptions(parts, headers)
+		o, imageURL, err = p.parsePathOptions(parts, features)
 	}
 
 	if err != nil {
@@ -228,13 +213,13 @@ func (p *Parser) ParsePath(
 // parsePathOptions parses processing options from the URL path
 func (p *Parser) parsePathOptions(
 	parts []string,
-	headers http.Header,
+	features *clientfeatures.Features,
 ) (*options.Options, string, error) {
 	if _, ok := processing.ResizeTypes[parts[0]]; ok {
 		return nil, "", newInvalidURLError("It looks like you're using the deprecated basic URL format")
 	}
 
-	o, err := p.defaultProcessingOptions(headers)
+	o, err := p.defaultProcessingOptions(features)
 	if err != nil {
 		return nil, "", err
 	}
@@ -260,8 +245,11 @@ func (p *Parser) parsePathOptions(
 }
 
 // parsePathPresets parses presets from the URL path
-func (p *Parser) parsePathPresets(parts []string, headers http.Header) (*options.Options, string, error) {
-	o, err := p.defaultProcessingOptions(headers)
+func (p *Parser) parsePathPresets(
+	parts []string,
+	features *clientfeatures.Features,
+) (*options.Options, string, error) {
+	o, err := p.defaultProcessingOptions(features)
 	if err != nil {
 		return nil, "", err
 	}

+ 133 - 133
options/parser/processing_options_test.go

@@ -3,13 +3,13 @@ package optionsparser
 import (
 	"encoding/base64"
 	"fmt"
-	"net/http"
 	"net/url"
 	"regexp"
 	"strings"
 	"testing"
 	"time"
 
+	"github.com/imgproxy/imgproxy/v3/clientfeatures"
 	"github.com/imgproxy/imgproxy/v3/imagetype"
 	"github.com/imgproxy/imgproxy/v3/options"
 	"github.com/imgproxy/imgproxy/v3/options/keys"
@@ -50,7 +50,7 @@ func (s *ProcessingOptionsTestSuite) SetupSubTest() {
 func (s *ProcessingOptionsTestSuite) TestParseBase64URL() {
 	originURL := "http://images.dev/lorem/ipsum.jpg?param=value"
 	path := fmt.Sprintf("/size:100:100/%s.png", base64.RawURLEncoding.EncodeToString([]byte(originURL)))
-	o, imageURL, err := s.parser().ParsePath(path, make(http.Header))
+	o, imageURL, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 	s.Require().Equal(originURL, imageURL)
@@ -62,7 +62,7 @@ func (s *ProcessingOptionsTestSuite) TestParseBase64URLWithFilename() {
 
 	originURL := "http://images.dev/lorem/ipsum.jpg?param=value"
 	path := fmt.Sprintf("/size:100:100/%s.png/puppy.jpg", base64.RawURLEncoding.EncodeToString([]byte(originURL)))
-	o, imageURL, err := s.parser().ParsePath(path, make(http.Header))
+	o, imageURL, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 	s.Require().Equal(originURL, imageURL)
@@ -72,7 +72,7 @@ func (s *ProcessingOptionsTestSuite) TestParseBase64URLWithFilename() {
 func (s *ProcessingOptionsTestSuite) TestParseBase64URLWithoutExtension() {
 	originURL := "http://images.dev/lorem/ipsum.jpg?param=value"
 	path := fmt.Sprintf("/size:100:100/%s", base64.RawURLEncoding.EncodeToString([]byte(originURL)))
-	o, imageURL, err := s.parser().ParsePath(path, make(http.Header))
+	o, imageURL, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 	s.Require().Equal(originURL, imageURL)
@@ -84,7 +84,7 @@ func (s *ProcessingOptionsTestSuite) TestParseBase64URLWithBase() {
 
 	originURL := "lorem/ipsum.jpg?param=value"
 	path := fmt.Sprintf("/size:100:100/%s.png", base64.RawURLEncoding.EncodeToString([]byte(originURL)))
-	o, imageURL, err := s.parser().ParsePath(path, make(http.Header))
+	o, imageURL, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 	s.Require().Equal(fmt.Sprintf("%s%s", s.config().BaseURL, originURL), imageURL)
@@ -99,7 +99,7 @@ func (s *ProcessingOptionsTestSuite) TestParseBase64URLWithReplacement() {
 
 	originURL := "test://lorem/ipsum.jpg?param=value"
 	path := fmt.Sprintf("/size:100:100/%s.png", base64.RawURLEncoding.EncodeToString([]byte(originURL)))
-	o, imageURL, err := s.parser().ParsePath(path, make(http.Header))
+	o, imageURL, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 	s.Require().Equal("http://images.dev/lorem/dolor/ipsum.jpg?param=value", imageURL)
@@ -109,7 +109,7 @@ func (s *ProcessingOptionsTestSuite) TestParseBase64URLWithReplacement() {
 func (s *ProcessingOptionsTestSuite) TestParsePlainURL() {
 	originURL := "http://images.dev/lorem/ipsum.jpg"
 	path := fmt.Sprintf("/size:100:100/plain/%s@png", originURL)
-	o, imageURL, err := s.parser().ParsePath(path, make(http.Header))
+	o, imageURL, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 	s.Require().Equal(originURL, imageURL)
@@ -120,7 +120,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePlainURLWithoutExtension() {
 	originURL := "http://images.dev/lorem/ipsum.jpg"
 	path := fmt.Sprintf("/size:100:100/plain/%s", originURL)
 
-	o, imageURL, err := s.parser().ParsePath(path, make(http.Header))
+	o, imageURL, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 	s.Require().Equal(originURL, imageURL)
@@ -129,7 +129,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePlainURLWithoutExtension() {
 func (s *ProcessingOptionsTestSuite) TestParsePlainURLEscaped() {
 	originURL := "http://images.dev/lorem/ipsum.jpg?param=value"
 	path := fmt.Sprintf("/size:100:100/plain/%s@png", url.PathEscape(originURL))
-	o, imageURL, err := s.parser().ParsePath(path, make(http.Header))
+	o, imageURL, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 	s.Require().Equal(originURL, imageURL)
@@ -141,7 +141,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePlainURLWithBase() {
 
 	originURL := "lorem/ipsum.jpg"
 	path := fmt.Sprintf("/size:100:100/plain/%s@png", originURL)
-	o, imageURL, err := s.parser().ParsePath(path, make(http.Header))
+	o, imageURL, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 	s.Require().Equal(fmt.Sprintf("%s%s", s.config().BaseURL, originURL), imageURL)
@@ -156,7 +156,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePlainURLWithReplacement() {
 
 	originURL := "test://lorem/ipsum.jpg"
 	path := fmt.Sprintf("/size:100:100/plain/%s@png", originURL)
-	o, imageURL, err := s.parser().ParsePath(path, make(http.Header))
+	o, imageURL, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 	s.Require().Equal("http://images.dev/lorem/dolor/ipsum.jpg", imageURL)
@@ -168,7 +168,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePlainURLEscapedWithBase() {
 
 	originURL := "lorem/ipsum.jpg?param=value"
 	path := fmt.Sprintf("/size:100:100/plain/%s@png", url.PathEscape(originURL))
-	o, imageURL, err := s.parser().ParsePath(path, make(http.Header))
+	o, imageURL, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 	s.Require().Equal(fmt.Sprintf("%s%s", s.config().BaseURL, originURL), imageURL)
@@ -179,7 +179,7 @@ func (s *ProcessingOptionsTestSuite) TestParseWithArgumentsSeparator() {
 	s.config().ArgumentsSeparator = ","
 
 	path := "/size,100,100,1/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -190,7 +190,7 @@ func (s *ProcessingOptionsTestSuite) TestParseWithArgumentsSeparator() {
 
 func (s *ProcessingOptionsTestSuite) TestParsePathFormat() {
 	path := "/format:webp/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -199,7 +199,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePathFormat() {
 
 func (s *ProcessingOptionsTestSuite) TestParsePathResize() {
 	path := "/resize:fill:100:200:1/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -211,7 +211,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePathResize() {
 
 func (s *ProcessingOptionsTestSuite) TestParsePathResizingType() {
 	path := "/resizing_type:fill/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -220,7 +220,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePathResizingType() {
 
 func (s *ProcessingOptionsTestSuite) TestParsePathSize() {
 	path := "/size:100:200:1/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -231,7 +231,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePathSize() {
 
 func (s *ProcessingOptionsTestSuite) TestParsePathWidth() {
 	path := "/width:100/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -240,7 +240,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePathWidth() {
 
 func (s *ProcessingOptionsTestSuite) TestParsePathHeight() {
 	path := "/height:100/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -249,7 +249,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePathHeight() {
 
 func (s *ProcessingOptionsTestSuite) TestParsePathEnlarge() {
 	path := "/enlarge:1/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -258,7 +258,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePathEnlarge() {
 
 func (s *ProcessingOptionsTestSuite) TestParsePathExtend() {
 	path := "/extend:1:so:10:20/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -273,21 +273,21 @@ func (s *ProcessingOptionsTestSuite) TestParsePathExtend() {
 
 func (s *ProcessingOptionsTestSuite) TestParsePathExtendSmartGravity() {
 	path := "/extend:1:sm/plain/http://images.dev/lorem/ipsum.jpg"
-	_, _, err := s.parser().ParsePath(path, make(http.Header))
+	_, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().Error(err)
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathExtendReplicateGravity() {
 	path := "/extend:1:re/plain/http://images.dev/lorem/ipsum.jpg"
-	_, _, err := s.parser().ParsePath(path, make(http.Header))
+	_, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().Error(err)
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathGravity() {
 	path := "/gravity:soea/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -299,7 +299,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePathGravity() {
 
 func (s *ProcessingOptionsTestSuite) TestParsePathGravityFocusPoint() {
 	path := "/gravity:fp:0.5:0.75/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -310,14 +310,14 @@ func (s *ProcessingOptionsTestSuite) TestParsePathGravityFocusPoint() {
 
 func (s *ProcessingOptionsTestSuite) TestParsePathGravityReplicate() {
 	path := "/gravity:re/plain/http://images.dev/lorem/ipsum.jpg"
-	_, _, err := s.parser().ParsePath(path, make(http.Header))
+	_, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().Error(err)
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathCrop() {
 	path := "/crop:100:200/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -333,7 +333,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePathCrop() {
 
 func (s *ProcessingOptionsTestSuite) TestParsePathCropGravity() {
 	path := "/crop:100:200:nowe:10:20/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -349,14 +349,14 @@ func (s *ProcessingOptionsTestSuite) TestParsePathCropGravity() {
 
 func (s *ProcessingOptionsTestSuite) TestParsePathCropGravityReplicate() {
 	path := "/crop:100:200:re/plain/http://images.dev/lorem/ipsum.jpg"
-	_, _, err := s.parser().ParsePath(path, make(http.Header))
+	_, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().Error(err)
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathQuality() {
 	path := "/quality:55/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -365,7 +365,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePathQuality() {
 
 func (s *ProcessingOptionsTestSuite) TestParsePathBackground() {
 	path := "/background:128:129:130/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -377,7 +377,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePathBackground() {
 
 func (s *ProcessingOptionsTestSuite) TestParsePathBackgroundHex() {
 	path := "/background:ffddee/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -389,7 +389,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePathBackgroundHex() {
 
 func (s *ProcessingOptionsTestSuite) TestParsePathBackgroundDisable() {
 	path := "/background:fff/background:/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -398,7 +398,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePathBackgroundDisable() {
 
 func (s *ProcessingOptionsTestSuite) TestParsePathBlur() {
 	path := "/blur:0.2/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -407,7 +407,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePathBlur() {
 
 func (s *ProcessingOptionsTestSuite) TestParsePathSharpen() {
 	path := "/sharpen:0.2/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -416,7 +416,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePathSharpen() {
 
 func (s *ProcessingOptionsTestSuite) TestParsePathDpr() {
 	path := "/dpr:2/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -425,7 +425,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePathDpr() {
 
 func (s *ProcessingOptionsTestSuite) TestParsePathWatermark() {
 	path := "/watermark:0.5:soea:10:20:0.6/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -446,7 +446,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePathPreset() {
 	}
 
 	path := "/preset:test1:test2/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -462,7 +462,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePathPresetDefault() {
 	}
 
 	path := "/quality:70/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -479,7 +479,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePathPresetLoopDetection() {
 	}
 
 	path := "/preset:test1/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -488,7 +488,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePathPresetLoopDetection() {
 
 func (s *ProcessingOptionsTestSuite) TestParsePathCachebuster() {
 	path := "/cachebuster:123/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -497,7 +497,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePathCachebuster() {
 
 func (s *ProcessingOptionsTestSuite) TestParsePathStripMetadata() {
 	path := "/strip_metadata:true/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -505,159 +505,159 @@ func (s *ProcessingOptionsTestSuite) TestParsePathStripMetadata() {
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathWebpDetection() {
-	s.config().AutoWebp = true
-
 	path := "/plain/http://images.dev/lorem/ipsum.jpg"
-	headers := http.Header{"Accept": []string{"image/webp"}}
-	o, _, err := s.parser().ParsePath(path, headers)
+	features := clientfeatures.Features{PreferWebP: true}
+	o, _, err := s.parser().ParsePath(path, &features)
 
 	s.Require().NoError(err)
 
 	s.Require().True(o.GetBool(keys.PreferWebP, false))
 	s.Require().False(o.GetBool(keys.EnforceWebP, false))
+	s.Require().False(o.GetBool(keys.PreferAvif, false))
+	s.Require().False(o.GetBool(keys.EnforceAvif, false))
+	s.Require().False(o.GetBool(keys.PreferJxl, false))
+	s.Require().False(o.GetBool(keys.EnforceJxl, false))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathWebpEnforce() {
-	s.config().EnforceWebp = true
-
 	path := "/plain/http://images.dev/lorem/ipsum.jpg@png"
-	headers := http.Header{"Accept": []string{"image/webp"}}
-	o, _, err := s.parser().ParsePath(path, headers)
+	features := clientfeatures.Features{EnforceWebP: true}
+	o, _, err := s.parser().ParsePath(path, &features)
 
 	s.Require().NoError(err)
 
 	s.Require().True(o.GetBool(keys.PreferWebP, false))
 	s.Require().True(o.GetBool(keys.EnforceWebP, false))
+	s.Require().False(o.GetBool(keys.PreferAvif, false))
+	s.Require().False(o.GetBool(keys.EnforceAvif, false))
+	s.Require().False(o.GetBool(keys.PreferJxl, false))
+	s.Require().False(o.GetBool(keys.EnforceJxl, false))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathAvifDetection() {
-	s.config().AutoAvif = true
-
 	path := "/plain/http://images.dev/lorem/ipsum.jpg"
-	headers := http.Header{"Accept": []string{"image/avif"}}
-	o, _, err := s.parser().ParsePath(path, headers)
+	features := clientfeatures.Features{PreferAvif: true}
+	o, _, err := s.parser().ParsePath(path, &features)
 
 	s.Require().NoError(err)
 
+	s.Require().False(o.GetBool(keys.PreferWebP, false))
+	s.Require().False(o.GetBool(keys.EnforceWebP, false))
 	s.Require().True(o.GetBool(keys.PreferAvif, false))
 	s.Require().False(o.GetBool(keys.EnforceAvif, false))
+	s.Require().False(o.GetBool(keys.PreferJxl, false))
+	s.Require().False(o.GetBool(keys.EnforceJxl, false))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathAvifEnforce() {
-	s.config().EnforceAvif = true
-
 	path := "/plain/http://images.dev/lorem/ipsum.jpg@png"
-	headers := http.Header{"Accept": []string{"image/avif"}}
-	o, _, err := s.parser().ParsePath(path, headers)
+	features := clientfeatures.Features{EnforceAvif: true}
+	o, _, err := s.parser().ParsePath(path, &features)
 
 	s.Require().NoError(err)
 
+	s.Require().False(o.GetBool(keys.PreferWebP, false))
+	s.Require().False(o.GetBool(keys.EnforceWebP, false))
 	s.Require().True(o.GetBool(keys.PreferAvif, false))
 	s.Require().True(o.GetBool(keys.EnforceAvif, false))
+	s.Require().False(o.GetBool(keys.PreferJxl, false))
+	s.Require().False(o.GetBool(keys.EnforceJxl, false))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathJxlDetection() {
-	s.config().AutoJxl = true
-
 	path := "/plain/http://images.dev/lorem/ipsum.jpg"
-	headers := http.Header{"Accept": []string{"image/jxl"}}
-	o, _, err := s.parser().ParsePath(path, headers)
+	features := clientfeatures.Features{PreferJxl: true}
+	o, _, err := s.parser().ParsePath(path, &features)
 
 	s.Require().NoError(err)
 
+	s.Require().False(o.GetBool(keys.PreferWebP, false))
+	s.Require().False(o.GetBool(keys.EnforceWebP, false))
+	s.Require().False(o.GetBool(keys.PreferAvif, false))
+	s.Require().False(o.GetBool(keys.EnforceAvif, false))
 	s.Require().True(o.GetBool(keys.PreferJxl, false))
 	s.Require().False(o.GetBool(keys.EnforceJxl, false))
 }
 
 func (s *ProcessingOptionsTestSuite) TestParsePathJxlEnforce() {
-	s.config().EnforceJxl = true
-
 	path := "/plain/http://images.dev/lorem/ipsum.jpg@png"
-	headers := http.Header{"Accept": []string{"image/jxl"}}
-	o, _, err := s.parser().ParsePath(path, headers)
+	features := clientfeatures.Features{EnforceJxl: true}
+	o, _, err := s.parser().ParsePath(path, &features)
 
 	s.Require().NoError(err)
 
+	s.Require().False(o.GetBool(keys.PreferWebP, false))
+	s.Require().False(o.GetBool(keys.EnforceWebP, false))
+	s.Require().False(o.GetBool(keys.PreferAvif, false))
+	s.Require().False(o.GetBool(keys.EnforceAvif, false))
 	s.Require().True(o.GetBool(keys.PreferJxl, false))
 	s.Require().True(o.GetBool(keys.EnforceJxl, false))
 }
 
-func (s *ProcessingOptionsTestSuite) TestParsePathWidthHeader() {
-	s.config().EnableClientHints = true
-
+func (s *ProcessingOptionsTestSuite) TestParsePathClientHints() {
 	path := "/plain/http://images.dev/lorem/ipsum.jpg@png"
-	headers := http.Header{"Width": []string{"100"}}
-	o, _, err := s.parser().ParsePath(path, headers)
-
-	s.Require().NoError(err)
-
-	s.Require().Equal(100, o.GetInt(keys.Width, 0))
-}
-
-func (s *ProcessingOptionsTestSuite) TestParsePathWidthHeaderDisabled() {
-	path := "/plain/http://images.dev/lorem/ipsum.jpg@png"
-	headers := http.Header{"Width": []string{"100"}}
-	o, _, err := s.parser().ParsePath(path, headers)
-
-	s.Require().NoError(err)
-
-	s.Require().Equal(0, o.GetInt(keys.Width, 0))
-}
-
-func (s *ProcessingOptionsTestSuite) TestParsePathWidthHeaderRedefine() {
-	s.config().EnableClientHints = true
 
-	path := "/width:150/plain/http://images.dev/lorem/ipsum.jpg@png"
-	headers := http.Header{"Width": []string{"100"}}
-	o, _, err := s.parser().ParsePath(path, headers)
-
-	s.Require().NoError(err)
-
-	s.Require().Equal(150, o.GetInt(keys.Width, 0))
-}
-
-func (s *ProcessingOptionsTestSuite) TestParsePathDprHeader() {
-	s.config().EnableClientHints = true
-
-	path := "/plain/http://images.dev/lorem/ipsum.jpg@png"
-	headers := http.Header{"Dpr": []string{"2"}}
-	o, _, err := s.parser().ParsePath(path, headers)
-
-	s.Require().NoError(err)
-
-	s.Require().InDelta(2.0, o.GetFloat(keys.Dpr, 1.0), 0.0001)
-}
-
-func (s *ProcessingOptionsTestSuite) TestParsePathDprHeaderDisabled() {
-	path := "/plain/http://images.dev/lorem/ipsum.jpg@png"
-	headers := http.Header{"Dpr": []string{"2"}}
-	o, _, err := s.parser().ParsePath(path, headers)
+	testCases := []struct {
+		name     string
+		features clientfeatures.Features
+		width    int
+		dpr      float64
+	}{
+		{
+			name:     "NoClientHints",
+			features: clientfeatures.Features{},
+			width:    0,
+			dpr:      1.0,
+		},
+		{
+			name:     "WidthOnly",
+			features: clientfeatures.Features{ClientHintsWidth: 100},
+			width:    100,
+			dpr:      1.0,
+		},
+		{
+			name:     "DprOnly",
+			features: clientfeatures.Features{ClientHintsDPR: 2.0},
+			width:    0,
+			dpr:      2.0,
+		},
+		{
+			name:     "WidthAndDpr",
+			features: clientfeatures.Features{ClientHintsWidth: 100, ClientHintsDPR: 2.0},
+			width:    50,
+			dpr:      2.0,
+		},
+	}
 
-	s.Require().NoError(err)
+	for _, tc := range testCases {
+		s.Run(tc.name, func() {
+			o, _, err := s.parser().ParsePath(path, &tc.features)
 
-	s.Require().InDelta(1.0, o.GetFloat(keys.Dpr, 1.0), 0.0001)
+			s.Require().NoError(err)
+			s.Require().Equal(tc.width, o.GetInt(keys.Width, 0))
+			s.Require().InDelta(tc.dpr, o.GetFloat(keys.Dpr, 1.0), 0.0001)
+		})
+	}
 }
 
-func (s *ProcessingOptionsTestSuite) TestParsePathWidthAndDprHeaderCombined() {
-	s.config().EnableClientHints = true
-
-	path := "/plain/http://images.dev/lorem/ipsum.jpg@png"
-	headers := http.Header{
-		"Width": []string{"100"},
-		"Dpr":   []string{"2"},
+func (s *ProcessingOptionsTestSuite) TestParsePathClientHintsRedefine() {
+	path := "/width:150/dpr:3.0/plain/http://images.dev/lorem/ipsum.jpg@png"
+	features := clientfeatures.Features{
+		ClientHintsWidth: 100,
+		ClientHintsDPR:   2.0,
 	}
-	o, _, err := s.parser().ParsePath(path, headers)
+	o, _, err := s.parser().ParsePath(path, &features)
 
 	s.Require().NoError(err)
 
-	s.Require().Equal(50, o.GetInt(keys.Width, 0))
-	s.Require().InDelta(2.0, o.GetFloat(keys.Dpr, 1.0), 0.0001)
+	s.Require().Equal(150, o.GetInt(keys.Width, 0))
+	s.Require().InDelta(3.0, o.GetFloat(keys.Dpr, 1.0), 0.0001)
 }
 
 func (s *ProcessingOptionsTestSuite) TestParseSkipProcessing() {
 	path := "/skp:jpg:png/plain/http://images.dev/lorem/ipsum.jpg"
 
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -670,7 +670,7 @@ func (s *ProcessingOptionsTestSuite) TestParseSkipProcessing() {
 func (s *ProcessingOptionsTestSuite) TestParseSkipProcessingInvalid() {
 	path := "/skp:jpg:png:bad_format/plain/http://images.dev/lorem/ipsum.jpg"
 
-	_, _, err := s.parser().ParsePath(path, make(http.Header))
+	_, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().Error(err)
 	s.Require().Equal("Invalid image format in skip_processing: bad_format", err.Error())
@@ -678,7 +678,7 @@ func (s *ProcessingOptionsTestSuite) TestParseSkipProcessingInvalid() {
 
 func (s *ProcessingOptionsTestSuite) TestParseExpires() {
 	path := "/exp:32503669200/plain/http://images.dev/lorem/ipsum.jpg"
-	o, _, err := s.parser().ParsePath(path, make(http.Header))
+	o, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 	s.Require().Equal(time.Unix(32503669200, 0), o.GetTime(keys.Expires))
@@ -686,7 +686,7 @@ func (s *ProcessingOptionsTestSuite) TestParseExpires() {
 
 func (s *ProcessingOptionsTestSuite) TestParseExpiresExpired() {
 	path := "/exp:1609448400/plain/http://images.dev/lorem/ipsum.jpg"
-	_, _, err := s.parser().ParsePath(path, make(http.Header))
+	_, _, err := s.parser().ParsePath(path, nil)
 
 	s.Require().Error(err, "Expired URL")
 }
@@ -701,7 +701,7 @@ func (s *ProcessingOptionsTestSuite) TestParsePathOnlyPresets() {
 	originURL := "http://images.dev/lorem/ipsum.jpg"
 	path := "/test1:test2/plain/" + originURL + "@png"
 
-	o, imageURL, err := s.parser().ParsePath(path, make(http.Header))
+	o, imageURL, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -721,7 +721,7 @@ func (s *ProcessingOptionsTestSuite) TestParseBase64URLOnlyPresets() {
 	originURL := "http://images.dev/lorem/ipsum.jpg?param=value"
 	path := fmt.Sprintf("/test1:test2/%s.png", base64.RawURLEncoding.EncodeToString([]byte(originURL)))
 
-	o, imageURL, err := s.parser().ParsePath(path, make(http.Header))
+	o, imageURL, err := s.parser().ParsePath(path, nil)
 
 	s.Require().NoError(err)
 
@@ -752,7 +752,7 @@ func (s *ProcessingOptionsTestSuite) TestParseAllowedOptions() {
 			}
 
 			path := fmt.Sprintf("/%s/%s.png", tc.options, base64.RawURLEncoding.EncodeToString([]byte(originURL)))
-			_, _, err := s.parser().ParsePath(path, make(http.Header))
+			_, _, err := s.parser().ParsePath(path, nil)
 
 			if len(tc.expectedError) > 0 {
 				s.Require().Error(err)

+ 1 - 69
server/responsewriter/config.go

@@ -2,7 +2,6 @@ package responsewriter
 
 import (
 	"errors"
-	"strings"
 	"time"
 
 	"github.com/imgproxy/imgproxy/v3/ensure"
@@ -15,16 +14,6 @@ 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
@@ -33,7 +22,6 @@ type Config struct {
 	DefaultTTL              int           // Default Cache-Control max-age= value for cached images
 	FallbackImageTTL        int           // TTL for images served as fallbacks
 	CacheControlPassthrough bool          // Passthrough the Cache-Control from the original response
-	VaryValue               string        // Value for Vary header
 	WriteResponseTimeout    time.Duration // Timeout for response write operations
 }
 
@@ -44,7 +32,6 @@ func NewDefaultConfig() Config {
 		DefaultTTL:              31_536_000,
 		FallbackImageTTL:        0,
 		CacheControlPassthrough: false,
-		VaryValue:               "",
 		WriteResponseTimeout:    10 * time.Second,
 	}
 }
@@ -60,63 +47,8 @@ func LoadConfigFromEnv(c *Config) (*Config, error) {
 		env.Bool(&c.CacheControlPassthrough, IMGPROXY_CACHE_CONTROL_PASSTHROUGH),
 		env.Duration(&c.WriteResponseTimeout, IMGPROXY_WRITE_RESPONSE_TIMEOUT),
 	)
-	if err != nil {
-		return nil, err
-	}
-
-	vary := make([]string, 0)
-
-	var ok bool
-
-	if err, ok = c.envEnableFormatDetection(); err != nil {
-		return nil, err
-	}
-	if ok {
-		vary = append(vary, "Accept")
-	}
-
-	if err, ok = c.envEnableClientHints(); err != nil {
-		return nil, err
-	}
-	if ok {
-		vary = append(vary, "Sec-CH-DPR", "DPR", "Sec-CH-Width", "Width")
-	}
-
-	c.VaryValue = strings.Join(vary, ", ")
-
-	return c, nil
-}
-
-// 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
-}
 
-// envEnableClientHints checks if client hints are enabled
-func (c *Config) envEnableClientHints() (err error, ok bool) {
-	err = env.Bool(&ok, IMGPROXY_ENABLE_CLIENT_HINTS)
-	return
+	return c, err
 }
 
 // Validate checks config for errors

+ 0 - 113
server/responsewriter/config_test.go

@@ -1,113 +0,0 @@
-package responsewriter
-
-import (
-	"fmt"
-	"testing"
-
-	"github.com/stretchr/testify/suite"
-
-	"github.com/imgproxy/imgproxy/v3/config"
-	"github.com/imgproxy/imgproxy/v3/logger"
-)
-
-type ResponseWriterConfigSuite struct {
-	suite.Suite
-}
-
-func (s *ResponseWriterConfigSuite) SetupSuite() {
-	logger.Mute()
-}
-
-func (s *ResponseWriterConfigSuite) TearDownSuite() {
-	logger.Unmute()
-}
-
-func (s *ResponseWriterConfigSuite) TestLoadingVaryValueFromEnv() {
-	defaultEnv := map[string]string{
-		"IMGPROXY_AUTO_WEBP":           "",
-		"IMGPROXY_ENFORCE_WEBP":        "",
-		"IMGPROXY_AUTO_AVIF":           "",
-		"IMGPROXY_ENFORCE_AVIF":        "",
-		"IMGPROXY_AUTO_JXL":            "",
-		"IMGPROXY_ENFORCE_JXL":         "",
-		"IMGPROXY_ENABLE_CLIENT_HINTS": "",
-	}
-
-	testCases := []struct {
-		name     string
-		env      map[string]string
-		expected string
-	}{
-		{
-			name:     "AutoWebP",
-			env:      map[string]string{"IMGPROXY_AUTO_WEBP": "true"},
-			expected: "Accept",
-		},
-		{
-			name:     "EnforceWebP",
-			env:      map[string]string{"IMGPROXY_ENFORCE_WEBP": "true"},
-			expected: "Accept",
-		},
-		{
-			name:     "AutoAVIF",
-			env:      map[string]string{"IMGPROXY_AUTO_AVIF": "true"},
-			expected: "Accept",
-		},
-		{
-			name:     "EnforceAVIF",
-			env:      map[string]string{"IMGPROXY_ENFORCE_AVIF": "true"},
-			expected: "Accept",
-		},
-		{
-			name:     "AutoJXL",
-			env:      map[string]string{"IMGPROXY_AUTO_JXL": "true"},
-			expected: "Accept",
-		},
-		{
-			name:     "EnforceJXL",
-			env:      map[string]string{"IMGPROXY_ENFORCE_JXL": "true"},
-			expected: "Accept",
-		},
-		{
-			name:     "EnableClientHints",
-			env:      map[string]string{"IMGPROXY_ENABLE_CLIENT_HINTS": "true"},
-			expected: "Sec-CH-DPR, DPR, Sec-CH-Width, Width",
-		},
-		{
-			name: "Combined",
-			env: map[string]string{
-				"IMGPROXY_AUTO_WEBP":           "true",
-				"IMGPROXY_ENABLE_CLIENT_HINTS": "true",
-			},
-			expected: "Accept, Sec-CH-DPR, DPR, Sec-CH-Width, Width",
-		},
-	}
-
-	for _, tc := range testCases {
-		s.Run(fmt.Sprintf("%v", tc.env), func() {
-			// Set default environment variables
-			for key, value := range defaultEnv {
-				s.T().Setenv(key, value)
-			}
-			// Set environment variables
-			for key, value := range tc.env {
-				s.T().Setenv(key, value)
-			}
-
-			// TODO: Remove when we removed global config
-			config.Reset()
-			config.Configure()
-
-			// Load config
-			cfg, err := LoadConfigFromEnv(nil)
-
-			// Assert expected values
-			s.Require().NoError(err)
-			s.Require().Equal(tc.expected, cfg.VaryValue)
-		})
-	}
-}
-
-func TestResponseWriterConfig(t *testing.T) {
-	suite.Run(t, new(ResponseWriterConfigSuite))
-}

+ 0 - 7
server/responsewriter/writer.go

@@ -75,13 +75,6 @@ func (w *Writer) SetExpires(expires time.Time) {
 	}
 }
 
-// SetVary sets the Vary header
-func (w *Writer) SetVary() {
-	if val := w.config.VaryValue; len(val) > 0 {
-		w.result.Set(httpheaders.Vary, val)
-	}
-}
-
 // SetContentDisposition sets the Content-Disposition header, passthrough to ContentDispositionValue
 func (w *Writer) SetContentDisposition(originURL, filename, ext, contentType string, returnAttachment bool) {
 	value := httpheaders.ContentDispositionValue(

+ 0 - 16
server/responsewriter/writer_test.go

@@ -188,22 +188,6 @@ func (s *ResponseWriterSuite) TestHeaderCases() {
 				w.SetExpires(shortExpires)
 			},
 		},
-		{
-			name: "SetVaryHeader",
-			req:  http.Header{},
-			res: http.Header{
-				httpheaders.Vary:                  []string{"Accept, Sec-CH-DPR, DPR, Sec-CH-Width, Width"},
-				httpheaders.CacheControl:          []string{"no-cache"},
-				httpheaders.ContentSecurityPolicy: []string{"script-src 'none'"},
-			},
-			config: Config{
-				VaryValue:            "Accept, Sec-CH-DPR, DPR, Sec-CH-Width, Width",
-				WriteResponseTimeout: writeResponseTimeout,
-			},
-			fn: func(w *Writer) {
-				w.SetVary()
-			},
-		},
 		{
 			name: "PassthroughHeaders",
 			req: http.Header{