1
0
mirror of https://github.com/imgproxy/imgproxy.git synced 2025-10-30 23:08:02 +02:00

Add client features detector

This commit is contained in:
DarthSim
2025-10-28 18:42:32 +03:00
parent 516c8ec6c2
commit 49d3949c85
17 changed files with 825 additions and 454 deletions

51
clientfeatures/config.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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))
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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))
}

View File

@@ -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)
}

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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")

View File

@@ -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),

View File

@@ -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 features != nil {
if features.PreferWebP || features.EnforceWebP {
o.Set(keys.PreferWebP, true)
}
if (p.config.AutoWebp || p.config.EnforceWebp) && strings.Contains(headerAccept, "image/webp") {
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
}

View File

@@ -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)
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().Equal(100, o.GetInt(keys.Width, 0))
for _, tc := range testCases {
s.Run(tc.name, func() {
o, _, err := s.parser().ParsePath(path, &tc.features)
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) 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)
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, &features)
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)
s.Require().NoError(err)
s.Require().InDelta(1.0, 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"},
}
o, _, err := s.parser().ParsePath(path, headers)
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().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)

View File

@@ -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

View File

@@ -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))
}

View File

@@ -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(

View File

@@ -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{