فهرست منبع

IMG-13: http.Header, *ImageData -> ImageData (#1473)

* ImageData.Headers()

* *ImageData -> ImageData

* withmatt -> httpheaders of our own

* .Clone() headers, nil

* NewFromBytesWithFormat -> nil

* svg.go -> do not Clone()
Victor Sokolov 2 ماه پیش
والد
کامیت
3d14c85de3

+ 4 - 3
etag/etag.go

@@ -12,6 +12,7 @@ import (
 	"sync"
 
 	"github.com/imgproxy/imgproxy/v3/config"
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
 	"github.com/imgproxy/imgproxy/v3/imagedata"
 	"github.com/imgproxy/imgproxy/v3/options"
 )
@@ -106,10 +107,10 @@ func (h *Handler) ImageEtagExpected() string {
 	return h.imgEtagExpected
 }
 
-func (h *Handler) SetActualImageData(imgdata *imagedata.ImageData) (bool, error) {
+func (h *Handler) SetActualImageData(imgdata imagedata.ImageData) (bool, error) {
 	var haveActualImgETag bool
-	h.imgEtagActual, haveActualImgETag = imgdata.Headers["ETag"]
-	haveActualImgETag = haveActualImgETag && len(h.imgEtagActual) > 0
+	h.imgEtagActual = imgdata.Headers().Get(httpheaders.Etag)
+	haveActualImgETag = len(h.imgEtagActual) > 0
 
 	// Just in case server didn't check ETag properly and returned the same one
 	// as we expected

+ 7 - 6
etag/etag_test.go

@@ -2,7 +2,6 @@ package etag
 
 import (
 	"io"
-	"net/http"
 	"os"
 	"strings"
 	"testing"
@@ -11,6 +10,7 @@ import (
 	"github.com/stretchr/testify/suite"
 
 	"github.com/imgproxy/imgproxy/v3/config"
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
 	"github.com/imgproxy/imgproxy/v3/imagedata"
 	"github.com/imgproxy/imgproxy/v3/options"
 )
@@ -24,8 +24,8 @@ type EtagTestSuite struct {
 	suite.Suite
 
 	po             *options.ProcessingOptions
-	imgWithETag    *imagedata.ImageData
-	imgWithoutETag *imagedata.ImageData
+	imgWithETag    imagedata.ImageData
+	imgWithoutETag imagedata.ImageData
 
 	h Handler
 }
@@ -37,10 +37,11 @@ func (s *EtagTestSuite) SetupSuite() {
 	d, err := os.ReadFile("../testdata/test1.jpg")
 	s.Require().NoError(err)
 
-	imgWithETag, err := imagedata.NewFromBytes(d, http.Header{"ETag": []string{`"loremipsumdolor"`}})
+	imgWithETag, err := imagedata.NewFromBytes(d)
 	s.Require().NoError(err)
+	imgWithETag.Headers().Add(httpheaders.Etag, `"loremipsumdolor"`)
 
-	imgWithoutETag, err := imagedata.NewFromBytes(d, make(http.Header))
+	imgWithoutETag, err := imagedata.NewFromBytes(d)
 	s.Require().NoError(err)
 
 	s.imgWithETag = imgWithETag
@@ -101,7 +102,7 @@ func (s *EtagTestSuite) TestImageETagExpectedPresent() {
 	s.h.ParseExpectedETag(etagReq)
 
 	//nolint:testifylint // False-positive expected-actual
-	s.Require().Equal(s.imgWithETag.Headers["ETag"], s.h.ImageEtagExpected())
+	s.Require().Equal(s.imgWithETag.Headers().Get(httpheaders.Etag), s.h.ImageEtagExpected())
 }
 
 func (s *EtagTestSuite) TestImageETagExpectedBlank() {

+ 0 - 1
go.mod

@@ -205,7 +205,6 @@ require (
 	go.uber.org/atomic v1.11.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
 	go.uber.org/zap v1.27.0 // indirect
-	go.withmatt.com/httpheaders v1.0.0 // indirect
 	go.yaml.in/yaml/v2 v2.4.2 // indirect
 	go.yaml.in/yaml/v3 v3.0.4 // indirect
 	golang.org/x/crypto v0.39.0 // indirect

+ 0 - 2
go.sum

@@ -560,8 +560,6 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
 go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
 go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
 go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
-go.withmatt.com/httpheaders v1.0.0 h1:xZhtLWyIWCd8FT3CvUBRQLhQpgZaMmHNfIIT0wwNc1A=
-go.withmatt.com/httpheaders v1.0.0/go.mod h1:bKAYNgm9s2ViHIoGOnMKo4F2zJXBdvpfGuSEJQYF8pQ=
 go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
 go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
 go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=

+ 64 - 0
httpheaders/headers.go

@@ -0,0 +1,64 @@
+// Inspired by https://github.com/mattrobenolt/go-httpheaders
+// Thanks, Matt Robenolt!
+package httpheaders
+
+const (
+	Accept                          = "Accept"
+	AcceptCharset                   = "Accept-Charset"
+	AcceptEncoding                  = "Accept-Encoding"
+	AcceptLanguage                  = "Accept-Language"
+	AcceptRanges                    = "Accept-Ranges"
+	AccessControlAllowCredentials   = "Access-Control-Allow-Credentials"
+	AccessControlAllowHeaders       = "Access-Control-Allow-Headers"
+	AccessControlAllowMethods       = "Access-Control-Allow-Methods"
+	AccessControlAllowOrigin        = "Access-Control-Allow-Origin"
+	AccessControlMaxAge             = "Access-Control-Max-Age"
+	Age                             = "Age"
+	AltSvc                          = "Alt-Svc"
+	Authorization                   = "Authorization"
+	CacheControl                    = "Cache-Control"
+	Connection                      = "Connection"
+	ContentDisposition              = "Content-Disposition"
+	ContentEncoding                 = "Content-Encoding"
+	ContentLanguage                 = "Content-Language"
+	ContentLength                   = "Content-Length"
+	ContentRange                    = "Content-Range"
+	ContentSecurityPolicy           = "Content-Security-Policy"
+	ContentSecurityPolicyReportOnly = "Content-Security-Policy-Report-Only"
+	ContentType                     = "Content-Type"
+	Cookie                          = "Cookie"
+	Date                            = "Date"
+	Dnt                             = "Dnt"
+	Etag                            = "Etag"
+	Expect                          = "Expect"
+	ExpectCt                        = "Expect-Ct"
+	Expires                         = "Expires"
+	Forwarded                       = "Forwarded"
+	Host                            = "Host"
+	IfMatch                         = "If-Match"
+	IfModifiedSince                 = "If-Modified-Since"
+	IfNoneMatch                     = "If-None-Match"
+	IfUnmodifiedSince               = "If-Unmodified-Since"
+	KeepAlive                       = "Keep-Alive"
+	LastModified                    = "Last-Modified"
+	Link                            = "Link"
+	Location                        = "Location"
+	Origin                          = "Origin"
+	Pragma                          = "Pragma"
+	Referer                         = "Referer"
+	RequestId                       = "Request-Id"
+	RetryAfter                      = "Retry-After"
+	Server                          = "Server"
+	SetCookie                       = "Set-Cookie"
+	StrictTransportSecurity         = "Strict-Transport-Security"
+	Upgrade                         = "Upgrade"
+	UserAgent                       = "User-Agent"
+	Vary                            = "Vary"
+	Via                             = "Via"
+	WwwAuthenticate                 = "Www-Authenticate"
+	XContentTypeOptions             = "X-Content-Type-Options"
+	XForwardedFor                   = "X-Forwarded-For"
+	XForwardedHost                  = "X-Forwarded-Host"
+	XForwardedProto                 = "X-Forwarded-Proto"
+	XFrameOptions                   = "X-Frame-Options"
+)

+ 5 - 15
imagedata/download.go

@@ -37,7 +37,7 @@ func initDownloading() error {
 	return nil
 }
 
-func download(ctx context.Context, imageURL string, opts DownloadOptions, secopts security.Options) (*ImageData, error) {
+func download(ctx context.Context, imageURL string, opts DownloadOptions, secopts security.Options) (ImageData, error) {
 	// We use this for testing
 	if len(redirectAllRequestsTo) > 0 {
 		imageURL = redirectAllRequestsTo
@@ -70,23 +70,13 @@ func download(ctx context.Context, imageURL string, opts DownloadOptions, secopt
 		return nil, ierrors.Wrap(err, 0)
 	}
 
-	h := make(map[string]string)
-	for k := range res.Header {
-		value := res.Header.Get(k)
-		h[k] = value
-
-		// This is temporary workaround, will be addressed in the subsequent PR
-		if k == "Etag" {
-			h["ETag"] = value
-		}
-
-		if k == "ETag" {
-			h["Etag"] = value
+	// NOTE: This will be removed in the future in favor of headers/image data separation
+	for k, v := range res.Header {
+		for _, v := range v {
+			imgdata.Headers().Add(k, v)
 		}
 	}
 
-	imgdata.Headers = h
-
 	return imgdata, nil
 }
 

+ 80 - 14
imagedata/factory.go

@@ -2,15 +2,37 @@ package imagedata
 
 import (
 	"bytes"
+	"encoding/base64"
+	"io"
 	"net/http"
-	"strings"
+	"os"
 
 	"github.com/imgproxy/imgproxy/v3/imagemeta"
 	"github.com/imgproxy/imgproxy/v3/imagetype"
+	"github.com/imgproxy/imgproxy/v3/security"
 )
 
+// NewFromBytesWithFormat creates a new ImageData instance from the provided format,
+// http headers and byte slice.
+func NewFromBytesWithFormat(format imagetype.Type, b []byte, headers http.Header) ImageData {
+	var h http.Header
+
+	if headers == nil {
+		h = make(http.Header)
+	} else {
+		h = headers.Clone()
+	}
+
+	return &imageDataBytes{
+		data:    b,
+		format:  format,
+		headers: h,
+		cancel:  nil,
+	}
+}
+
 // NewFromBytes creates a new ImageData instance from the provided byte slice.
-func NewFromBytes(b []byte, headers http.Header) (*ImageData, error) {
+func NewFromBytes(b []byte) (ImageData, error) {
 	r := bytes.NewReader(b)
 
 	meta, err := imagemeta.DecodeMeta(r)
@@ -18,20 +40,64 @@ func NewFromBytes(b []byte, headers http.Header) (*ImageData, error) {
 		return nil, err
 	}
 
-	return NewFromBytesWithFormat(meta.Format(), b, headers)
+	return NewFromBytesWithFormat(meta.Format(), b, nil), nil
 }
 
-// NewFromBytesWithFormat creates a new ImageData instance from the provided format and byte slice.
-func NewFromBytesWithFormat(format imagetype.Type, b []byte, headers http.Header) (*ImageData, error) {
-	// Temporary workaround for the old ImageData interface
-	h := make(map[string]string, len(headers))
-	for k, v := range headers {
-		h[k] = strings.Join(v, ", ")
+// NewFromPath creates a new ImageData from an os.File
+func NewFromPath(path string, secopts security.Options) (ImageData, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return nil, err
 	}
+	defer f.Close()
 
-	return &ImageData{
-		data:    b,
-		format:  format,
-		Headers: h,
-	}, nil
+	fr, err := security.LimitFileSize(f, secopts)
+	if err != nil {
+		return nil, err
+	}
+
+	b, err := io.ReadAll(fr)
+	if err != nil {
+		return nil, err
+	}
+
+	r := bytes.NewReader(b)
+
+	// NOTE: This will be removed in the future in favor of VIPS metadata extraction
+	// It's here temporarily to maintain compatibility with existing code
+	meta, err := imagemeta.DecodeMeta(r)
+	if err != nil {
+		return nil, err
+	}
+
+	err = security.CheckMeta(meta, secopts)
+	if err != nil {
+		return nil, err
+	}
+
+	return NewFromBytes(b)
+}
+
+// NewFromBase64 creates a new ImageData from a base64 encoded byte slice
+func NewFromBase64(encoded string, secopts security.Options) (ImageData, error) {
+	b, err := base64.StdEncoding.DecodeString(encoded)
+	if err != nil {
+		return nil, err
+	}
+
+	r := bytes.NewReader(b)
+
+	// NOTE: This will be removed in the future in favor of VIPS metadata extraction
+	// It's here temporarily to maintain compatibility with existing code
+	meta, err := imagemeta.DecodeMeta(r)
+	if err != nil {
+		return nil, err
+	}
+
+	err = security.CheckMeta(meta, secopts)
+	if err != nil {
+		return nil, err
+	}
+
+	return NewFromBytes(b)
 }

+ 72 - 66
imagedata/image_data.go

@@ -3,11 +3,9 @@ package imagedata
 import (
 	"bytes"
 	"context"
-	"encoding/base64"
 	"fmt"
 	"io"
-	"os"
-	"strings"
+	"net/http"
 	"sync"
 
 	"github.com/imgproxy/imgproxy/v3/config"
@@ -17,23 +15,35 @@ import (
 )
 
 var (
-	Watermark     *ImageData
-	FallbackImage *ImageData
+	Watermark     ImageData
+	FallbackImage ImageData
 )
 
-type ImageData struct {
+type ImageData interface {
+	io.Closer                     // Close closes the image data and releases any resources held by it
+	Reader() io.ReadSeeker        // Reader returns a new ReadSeeker for the image data
+	Format() imagetype.Type       // Format returns the image format from the metadata (shortcut)
+	Size() (int, error)           // Size returns the size of the image data in bytes
+	AddCancel(context.CancelFunc) // AddCancel attaches a cancel function to the image data
+
+	// This will be removed in the future
+	Headers() http.Header // Headers returns the HTTP headers of the image data, will be removed in the future
+}
+
+// imageDataBytes represents image data stored in a byte slice in memory
+type imageDataBytes struct {
 	format  imagetype.Type
 	data    []byte
-	Headers map[string]string
+	headers http.Header
 
-	cancel     context.CancelFunc
+	cancel     []context.CancelFunc
 	cancelOnce sync.Once
 }
 
-func (d *ImageData) Close() error {
+func (d *imageDataBytes) Close() error {
 	d.cancelOnce.Do(func() {
-		if d.cancel != nil {
-			d.cancel()
+		for _, cancel := range d.cancel {
+			cancel()
 		}
 	})
 
@@ -41,23 +51,27 @@ func (d *ImageData) Close() error {
 }
 
 // Format returns the image format based on the metadata
-func (d *ImageData) Format() imagetype.Type {
+func (d *imageDataBytes) Format() imagetype.Type {
 	return d.format
 }
 
 // Reader returns an io.ReadSeeker for the image data
-func (d *ImageData) Reader() io.ReadSeeker {
+func (d *imageDataBytes) Reader() io.ReadSeeker {
 	return bytes.NewReader(d.data)
 }
 
 // Size returns the size of the image data in bytes.
 // NOTE: asyncbuffer implementation will .Wait() for the data to be fully read
-func (d *ImageData) Size() (int, error) {
+func (d *imageDataBytes) Size() (int, error) {
 	return len(d.data), nil
 }
 
-func (d *ImageData) SetCancel(cancel context.CancelFunc) {
-	d.cancel = cancel
+func (d *imageDataBytes) Headers() http.Header {
+	return d.headers
+}
+
+func (d *imageDataBytes) AddCancel(cancel context.CancelFunc) {
+	d.cancel = append(d.cancel, cancel)
 }
 
 func Init() error {
@@ -78,20 +92,34 @@ func Init() error {
 	return nil
 }
 
-func loadWatermark() (err error) {
-	if len(config.WatermarkData) > 0 {
-		Watermark, err = FromBase64(config.WatermarkData, "watermark", security.DefaultOptions())
-		return
-	}
+func loadWatermark() error {
+	var err error
 
-	if len(config.WatermarkPath) > 0 {
-		Watermark, err = FromFile(config.WatermarkPath, "watermark", security.DefaultOptions())
-		return
-	}
+	switch {
+	case len(config.WatermarkData) > 0:
+		Watermark, err = NewFromBase64(config.WatermarkData, security.DefaultOptions())
+
+		// NOTE: this should be something like err = ierrors.Wrap(err).WithStackDeep(0).WithPrefix("watermark")
+		// In the NewFromBase64 all errors should be wrapped to something like
+		// .WithPrefix("load from base64")
+		if err != nil {
+			return ierrors.Wrap(err, 0, ierrors.WithPrefix("can't load watermark from Base64"))
+		}
+
+	case len(config.WatermarkPath) > 0:
+		Watermark, err = NewFromPath(config.WatermarkPath, security.DefaultOptions())
+		if err != nil {
+			return ierrors.Wrap(err, 0, ierrors.WithPrefix("can't read watermark from file"))
+		}
 
-	if len(config.WatermarkURL) > 0 {
+	case len(config.WatermarkURL) > 0:
 		Watermark, err = Download(context.Background(), config.WatermarkURL, "watermark", DownloadOptions{Header: nil, CookieJar: nil}, security.DefaultOptions())
-		return
+		if err != nil {
+			return ierrors.Wrap(err, 0, ierrors.WithPrefix("can't download from URL"))
+		}
+
+	default:
+		Watermark = nil
 	}
 
 	return nil
@@ -100,57 +128,35 @@ func loadWatermark() (err error) {
 func loadFallbackImage() (err error) {
 	switch {
 	case len(config.FallbackImageData) > 0:
-		FallbackImage, err = FromBase64(config.FallbackImageData, "fallback image", security.DefaultOptions())
+		FallbackImage, err = NewFromBase64(config.FallbackImageData, security.DefaultOptions())
+		if err != nil {
+			return ierrors.Wrap(err, 0, ierrors.WithPrefix("can't load fallback image from Base64"))
+		}
+
 	case len(config.FallbackImagePath) > 0:
-		FallbackImage, err = FromFile(config.FallbackImagePath, "fallback image", security.DefaultOptions())
+		FallbackImage, err = NewFromPath(config.FallbackImagePath, security.DefaultOptions())
+		if err != nil {
+			return ierrors.Wrap(err, 0, ierrors.WithPrefix("can't read fallback image from file"))
+		}
+
 	case len(config.FallbackImageURL) > 0:
 		FallbackImage, err = Download(context.Background(), config.FallbackImageURL, "fallback image", DownloadOptions{Header: nil, CookieJar: nil}, security.DefaultOptions())
+		if err != nil {
+			return ierrors.Wrap(err, 0, ierrors.WithPrefix("can't download from URL"))
+		}
+
 	default:
-		FallbackImage, err = nil, nil
+		FallbackImage = nil
 	}
 
 	if FallbackImage != nil && err == nil && config.FallbackImageTTL > 0 {
-		if FallbackImage.Headers == nil {
-			FallbackImage.Headers = make(map[string]string)
-		}
-		FallbackImage.Headers["Fallback-Image"] = "1"
+		FallbackImage.Headers().Set("Fallback-Image", "1")
 	}
 
 	return err
 }
 
-func FromBase64(encoded, desc string, secopts security.Options) (*ImageData, error) {
-	dec := base64.NewDecoder(base64.StdEncoding, strings.NewReader(encoded))
-	size := 4 * (len(encoded)/3 + 1)
-
-	imgdata, err := readAndCheckImage(dec, size, secopts)
-	if err != nil {
-		return nil, fmt.Errorf("Can't decode %s: %s", desc, err)
-	}
-
-	return imgdata, nil
-}
-
-func FromFile(path, desc string, secopts security.Options) (*ImageData, error) {
-	f, err := os.Open(path)
-	if err != nil {
-		return nil, fmt.Errorf("Can't read %s: %s", desc, err)
-	}
-
-	fi, err := f.Stat()
-	if err != nil {
-		return nil, fmt.Errorf("Can't read %s: %s", desc, err)
-	}
-
-	imgdata, err := readAndCheckImage(f, int(fi.Size()), secopts)
-	if err != nil {
-		return nil, fmt.Errorf("Can't read %s: %s", desc, err)
-	}
-
-	return imgdata, nil
-}
-
-func Download(ctx context.Context, imageURL, desc string, opts DownloadOptions, secopts security.Options) (*ImageData, error) {
+func Download(ctx context.Context, imageURL, desc string, opts DownloadOptions, secopts security.Options) (ImageData, error) {
 	imgdata, err := download(ctx, imageURL, opts, secopts)
 	if err != nil {
 		return nil, ierrors.Wrap(

+ 0 - 22
imagedata/image_data_i.go

@@ -1,22 +0,0 @@
-package imagedata
-
-import (
-	"io"
-	"net/http"
-
-	"github.com/imgproxy/imgproxy/v3/imagemeta"
-	"github.com/imgproxy/imgproxy/v3/imagetype"
-)
-
-// NOTE: This is temporary naming, will be fixed in the subsequent PR
-// ImageDataI is an interface that defines methods for reading image data and metadata
-type ImageDataI interface {
-	io.Closer               // Close closes the image data and releases any resources held by it
-	Reader() io.ReadSeeker  // Reader returns a new ReadSeeker for the image data
-	Meta() imagemeta.Meta   // Meta returns the metadata of the image data
-	Format() imagetype.Type // Format returns the image format from the metadata (shortcut)
-	Size() (int, error)     // Size returns the size of the image data in bytes
-
-	// This will be removed in the future
-	Headers() http.Header // Headers returns the HTTP headers of the image data, will be removed in the future
-}

+ 8 - 7
imagedata/image_data_test.go

@@ -20,6 +20,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/ierrors"
 	"github.com/imgproxy/imgproxy/v3/imagetype"
 	"github.com/imgproxy/imgproxy/v3/security"
+	"github.com/imgproxy/imgproxy/v3/testutil"
 )
 
 type ImageDataTestSuite struct {
@@ -94,7 +95,7 @@ func (s *ImageDataTestSuite) TestDownloadStatusOK() {
 
 	s.Require().NoError(err)
 	s.Require().NotNil(imgdata)
-	s.Require().Equal(s.defaultData, imgdata.data)
+	s.Require().True(testutil.ReadersEqual(s.T(), bytes.NewReader(s.defaultData), imgdata.Reader()))
 	s.Require().Equal(imagetype.JPEG, imgdata.Format())
 }
 
@@ -165,7 +166,7 @@ func (s *ImageDataTestSuite) TestDownloadStatusPartialContent() {
 			} else {
 				s.Require().NoError(err)
 				s.Require().NotNil(imgdata)
-				s.Require().Equal(s.defaultData, imgdata.data)
+				s.Require().True(testutil.ReadersEqual(s.T(), bytes.NewReader(s.defaultData), imgdata.Reader()))
 				s.Require().Equal(imagetype.JPEG, imgdata.Format())
 			}
 		})
@@ -278,27 +279,27 @@ func (s *ImageDataTestSuite) TestDownloadGzip() {
 
 	s.Require().NoError(err)
 	s.Require().NotNil(imgdata)
-	s.Require().Equal(s.defaultData, imgdata.data)
+	s.Require().True(testutil.ReadersEqual(s.T(), bytes.NewReader(s.defaultData), imgdata.Reader()))
 	s.Require().Equal(imagetype.JPEG, imgdata.Format())
 }
 
 func (s *ImageDataTestSuite) TestFromFile() {
-	imgdata, err := FromFile("../testdata/test1.jpg", "Test image", security.DefaultOptions())
+	imgdata, err := NewFromPath("../testdata/test1.jpg", security.DefaultOptions())
 
 	s.Require().NoError(err)
 	s.Require().NotNil(imgdata)
-	s.Require().Equal(s.defaultData, imgdata.data)
+	s.Require().True(testutil.ReadersEqual(s.T(), bytes.NewReader(s.defaultData), imgdata.Reader()))
 	s.Require().Equal(imagetype.JPEG, imgdata.Format())
 }
 
 func (s *ImageDataTestSuite) TestFromBase64() {
 	b64 := base64.StdEncoding.EncodeToString(s.defaultData)
 
-	imgdata, err := FromBase64(b64, "Test image", security.DefaultOptions())
+	imgdata, err := NewFromBase64(b64, security.DefaultOptions())
 
 	s.Require().NoError(err)
 	s.Require().NotNil(imgdata)
-	s.Require().Equal(s.defaultData, imgdata.data)
+	s.Require().True(testutil.ReadersEqual(s.T(), bytes.NewReader(s.defaultData), imgdata.Reader()))
 	s.Require().Equal(imagetype.JPEG, imgdata.Format())
 }
 

+ 4 - 6
imagedata/read.go

@@ -19,7 +19,7 @@ func initRead() {
 	downloadBufPool = bufpool.New("download", config.Workers, config.DownloadBufferSize)
 }
 
-func readAndCheckImage(r io.Reader, contentLength int, secopts security.Options) (*ImageData, error) {
+func readAndCheckImage(r io.Reader, contentLength int, secopts security.Options) (ImageData, error) {
 	buf := downloadBufPool.Get(contentLength, false)
 	cancel := func() { downloadBufPool.Put(buf) }
 
@@ -49,11 +49,9 @@ func readAndCheckImage(r io.Reader, contentLength int, secopts security.Options)
 		return nil, imagefetcher.WrapError(err)
 	}
 
-	return &ImageData{
-		data:   buf.Bytes(),
-		format: meta.Format(),
-		cancel: cancel,
-	}, nil
+	i := NewFromBytesWithFormat(meta.Format(), buf.Bytes(), nil)
+	i.AddCancel(cancel)
+	return i, nil
 }
 
 func BorrowBuffer() (*bytes.Buffer, context.CancelFunc) {

+ 1 - 1
imagefetcher/fetcher.go

@@ -8,9 +8,9 @@ import (
 	"time"
 
 	"github.com/imgproxy/imgproxy/v3/config"
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
 	"github.com/imgproxy/imgproxy/v3/transport"
 	"github.com/imgproxy/imgproxy/v3/transport/common"
-	"go.withmatt.com/httpheaders"
 )
 
 const (

+ 1 - 1
imagefetcher/request.go

@@ -12,7 +12,7 @@ import (
 	"strings"
 	"time"
 
-	"go.withmatt.com/httpheaders"
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
 )
 
 var (

+ 1 - 1
processing/apply_filters.go

@@ -6,7 +6,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
-func applyFilters(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata *imagedata.ImageData) error {
+func applyFilters(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata imagedata.ImageData) error {
 	if po.Blur == 0 && po.Sharpen == 0 && po.Pixelate <= 1 {
 		return nil
 	}

+ 2 - 2
processing/crop.go

@@ -32,7 +32,7 @@ func cropImage(img *vips.Image, cropWidth, cropHeight int, gravity *options.Grav
 	return img.Crop(left, top, cropWidth, cropHeight)
 }
 
-func crop(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata *imagedata.ImageData) error {
+func crop(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata imagedata.ImageData) error {
 	width, height := pctx.cropWidth, pctx.cropHeight
 
 	opts := pctx.cropGravity
@@ -47,6 +47,6 @@ func crop(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions,
 	return cropImage(img, width, height, &opts, 1.0)
 }
 
-func cropToResult(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata *imagedata.ImageData) error {
+func cropToResult(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata imagedata.ImageData) error {
 	return cropImage(img, pctx.resultCropWidth, pctx.resultCropHeight, &po.Gravity, pctx.dprScale)
 }

+ 1 - 1
processing/export_color_profile.go

@@ -6,7 +6,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
-func exportColorProfile(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata *imagedata.ImageData) error {
+func exportColorProfile(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata imagedata.ImageData) error {
 	keepProfile := !po.StripColorProfile && po.Format.SupportsColourProfile()
 
 	if img.IsLinear() {

+ 2 - 2
processing/extend.go

@@ -25,7 +25,7 @@ func extendImage(img *vips.Image, width, height int, gravity *options.GravityOpt
 	return img.Embed(width, height, offX, offY)
 }
 
-func extend(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata *imagedata.ImageData) error {
+func extend(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata imagedata.ImageData) error {
 	if !po.Extend.Enabled {
 		return nil
 	}
@@ -34,7 +34,7 @@ func extend(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOption
 	return extendImage(img, width, height, &po.Extend.Gravity, pctx.dprScale)
 }
 
-func extendAspectRatio(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata *imagedata.ImageData) error {
+func extendAspectRatio(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata imagedata.ImageData) error {
 	if !po.ExtendAspectRatio.Enabled {
 		return nil
 	}

+ 1 - 1
processing/fix_size.go

@@ -91,7 +91,7 @@ func fixIcoSize(img *vips.Image) error {
 	return nil
 }
 
-func fixSize(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata *imagedata.ImageData) error {
+func fixSize(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata imagedata.ImageData) error {
 	switch po.Format {
 	case imagetype.WEBP:
 		return fixWebpSize(img)

+ 1 - 1
processing/flatten.go

@@ -6,7 +6,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
-func flatten(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata *imagedata.ImageData) error {
+func flatten(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata imagedata.ImageData) error {
 	if !po.Flatten && po.Format.SupportsAlpha() {
 		return nil
 	}

+ 1 - 1
processing/import_color_profile.go

@@ -7,7 +7,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
-func importColorProfile(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata *imagedata.ImageData) error {
+func importColorProfile(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata imagedata.ImageData) error {
 	if img.ColourProfileImported() {
 		return nil
 	}

+ 1 - 1
processing/padding.go

@@ -7,7 +7,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
-func padding(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata *imagedata.ImageData) error {
+func padding(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata imagedata.ImageData) error {
 	if !po.Padding.Enabled {
 		return nil
 	}

+ 2 - 2
processing/pipeline.go

@@ -60,10 +60,10 @@ type pipelineContext struct {
 	extendAspectRatioHeight int
 }
 
-type pipelineStep func(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata *imagedata.ImageData) error
+type pipelineStep func(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata imagedata.ImageData) error
 type pipeline []pipelineStep
 
-func (p pipeline) Run(ctx context.Context, img *vips.Image, po *options.ProcessingOptions, imgdata *imagedata.ImageData) error {
+func (p pipeline) Run(ctx context.Context, img *vips.Image, po *options.ProcessingOptions, imgdata imagedata.ImageData) error {
 	pctx := pipelineContext{
 		ctx: ctx,
 

+ 1 - 1
processing/prepare.go

@@ -248,7 +248,7 @@ func (pctx *pipelineContext) limitScale(widthToScale, heightToScale int, po *opt
 	}
 }
 
-func prepare(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata *imagedata.ImageData) error {
+func prepare(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata imagedata.ImageData) error {
 	pctx.imgtype = imagetype.Unknown
 	if imgdata != nil {
 		pctx.imgtype = imgdata.Format()

+ 8 - 11
processing/processing.go

@@ -97,7 +97,7 @@ func getImageSize(img *vips.Image) (int, int) {
 	return width, height
 }
 
-func transformAnimated(ctx context.Context, img *vips.Image, po *options.ProcessingOptions, imgdata *imagedata.ImageData) error {
+func transformAnimated(ctx context.Context, img *vips.Image, po *options.ProcessingOptions, imgdata imagedata.ImageData) error {
 	if po.Trim.Enabled {
 		log.Warning("Trim is not supported for animated images")
 		po.Trim.Enabled = false
@@ -207,7 +207,7 @@ func transformAnimated(ctx context.Context, img *vips.Image, po *options.Process
 	return nil
 }
 
-func saveImageToFitBytes(ctx context.Context, po *options.ProcessingOptions, img *vips.Image) (*imagedata.ImageData, error) {
+func saveImageToFitBytes(ctx context.Context, po *options.ProcessingOptions, img *vips.Image) (imagedata.ImageData, error) {
 	var diff float64
 	quality := po.GetQuality()
 
@@ -248,7 +248,7 @@ func saveImageToFitBytes(ctx context.Context, po *options.ProcessingOptions, img
 	}
 }
 
-func ProcessImage(ctx context.Context, imgdata *imagedata.ImageData, po *options.ProcessingOptions) (*imagedata.ImageData, error) {
+func ProcessImage(ctx context.Context, imgdata imagedata.ImageData, po *options.ProcessingOptions) (imagedata.ImageData, error) {
 	runtime.LockOSThread()
 	defer runtime.UnlockOSThread()
 
@@ -348,7 +348,7 @@ func ProcessImage(ctx context.Context, imgdata *imagedata.ImageData, po *options
 	}
 
 	var (
-		outData *imagedata.ImageData
+		outData imagedata.ImageData
 		err     error
 	)
 
@@ -359,13 +359,10 @@ func ProcessImage(ctx context.Context, imgdata *imagedata.ImageData, po *options
 	}
 
 	if err == nil {
-		if outData.Headers == nil {
-			outData.Headers = make(map[string]string)
-		}
-		outData.Headers["X-Origin-Width"] = strconv.Itoa(originWidth)
-		outData.Headers["X-Origin-Height"] = strconv.Itoa(originHeight)
-		outData.Headers["X-Result-Width"] = strconv.Itoa(img.Width())
-		outData.Headers["X-Result-Height"] = strconv.Itoa(img.Height())
+		outData.Headers().Set("X-Origin-Width", strconv.Itoa(originWidth))
+		outData.Headers().Set("X-Origin-Height", strconv.Itoa(originHeight))
+		outData.Headers().Set("X-Result-Width", strconv.Itoa(img.Width()))
+		outData.Headers().Set("X-Result-Height", strconv.Itoa(img.Height()))
 	}
 
 	return outData, err

+ 3 - 3
processing/processing_test.go

@@ -31,7 +31,7 @@ func (s *ProcessingTestSuite) SetupSuite() {
 	logrus.SetOutput(io.Discard)
 }
 
-func (s *ProcessingTestSuite) openFile(name string) *imagedata.ImageData {
+func (s *ProcessingTestSuite) openFile(name string) imagedata.ImageData {
 	secopts := security.Options{
 		MaxSrcResolution:            10 * 1024 * 1024,
 		MaxSrcFileSize:              10 * 1024 * 1024,
@@ -43,13 +43,13 @@ func (s *ProcessingTestSuite) openFile(name string) *imagedata.ImageData {
 	s.Require().NoError(err)
 	path := filepath.Join(wd, "..", "testdata", name)
 
-	imagedata, err := imagedata.FromFile(path, "test image", secopts)
+	imagedata, err := imagedata.NewFromPath(path, secopts)
 	s.Require().NoError(err)
 
 	return imagedata
 }
 
-func (s *ProcessingTestSuite) checkSize(imgdata *imagedata.ImageData, width, height int) {
+func (s *ProcessingTestSuite) checkSize(imgdata imagedata.ImageData, width, height int) {
 	img := new(vips.Image)
 	err := img.Load(imgdata, 1, 1, 1)
 	s.Require().NoError(err)

+ 1 - 1
processing/rotate_and_flip.go

@@ -6,7 +6,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
-func rotateAndFlip(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata *imagedata.ImageData) error {
+func rotateAndFlip(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata imagedata.ImageData) error {
 	if pctx.angle%360 == 0 && po.Rotate%360 == 0 && !pctx.flip {
 		return nil
 	}

+ 1 - 1
processing/scale.go

@@ -6,7 +6,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
-func scale(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata *imagedata.ImageData) error {
+func scale(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata imagedata.ImageData) error {
 	if pctx.wscale == 1 && pctx.hscale == 1 {
 		return nil
 	}

+ 2 - 2
processing/scale_on_load.go

@@ -13,7 +13,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
-func canScaleOnLoad(pctx *pipelineContext, imgdata *imagedata.ImageData, scale float64) bool {
+func canScaleOnLoad(pctx *pipelineContext, imgdata imagedata.ImageData, scale float64) bool {
 	if imgdata == nil || pctx.trimmed || scale == 1 {
 		return false
 	}
@@ -45,7 +45,7 @@ func calcJpegShink(shrink float64) int {
 	return 1
 }
 
-func scaleOnLoad(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata *imagedata.ImageData) error {
+func scaleOnLoad(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata imagedata.ImageData) error {
 	wshrink := float64(pctx.srcWidth) / float64(imath.Scale(pctx.srcWidth, pctx.wscale))
 	hshrink := float64(pctx.srcHeight) / float64(imath.Scale(pctx.srcHeight, pctx.hscale))
 	preshrink := math.Min(wshrink, hshrink)

+ 1 - 1
processing/strip_metadata.go

@@ -105,7 +105,7 @@ func stripXMP(img *vips.Image) []byte {
 	return xmpData
 }
 
-func stripMetadata(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata *imagedata.ImageData) error {
+func stripMetadata(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata imagedata.ImageData) error {
 	if !po.StripMetadata {
 		return nil
 	}

+ 1 - 1
processing/trim.go

@@ -8,7 +8,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
-func trim(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata *imagedata.ImageData) error {
+func trim(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata imagedata.ImageData) error {
 	if !po.Trim.Enabled {
 		return nil
 	}

+ 3 - 3
processing/watermark.go

@@ -20,7 +20,7 @@ var watermarkPipeline = pipeline{
 	padding,
 }
 
-func prepareWatermark(wm *vips.Image, wmData *imagedata.ImageData, opts *options.WatermarkOptions, imgWidth, imgHeight int, offsetScale float64, framesCount int) error {
+func prepareWatermark(wm *vips.Image, wmData imagedata.ImageData, opts *options.WatermarkOptions, imgWidth, imgHeight int, offsetScale float64, framesCount int) error {
 	if err := wm.Load(wmData, 1, 1.0, 1); err != nil {
 		return err
 	}
@@ -81,7 +81,7 @@ func prepareWatermark(wm *vips.Image, wmData *imagedata.ImageData, opts *options
 	return wm.StripAll()
 }
 
-func applyWatermark(img *vips.Image, wmData *imagedata.ImageData, opts *options.WatermarkOptions, offsetScale float64, framesCount int) error {
+func applyWatermark(img *vips.Image, wmData imagedata.ImageData, opts *options.WatermarkOptions, offsetScale float64, framesCount int) error {
 	wm := new(vips.Image)
 	defer wm.Clear()
 
@@ -162,7 +162,7 @@ func applyWatermark(img *vips.Image, wmData *imagedata.ImageData, opts *options.
 	return nil
 }
 
-func watermark(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata *imagedata.ImageData) error {
+func watermark(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata imagedata.ImageData) error {
 	if !po.Watermark.Enabled || imagedata.Watermark == nil {
 		return nil
 	}

+ 24 - 28
processing_handler.go

@@ -19,6 +19,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/cookies"
 	"github.com/imgproxy/imgproxy/v3/errorreport"
 	"github.com/imgproxy/imgproxy/v3/etag"
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
 	"github.com/imgproxy/imgproxy/v3/ierrors"
 	"github.com/imgproxy/imgproxy/v3/imagedata"
 	"github.com/imgproxy/imgproxy/v3/imagefetcher"
@@ -66,7 +67,7 @@ func initProcessingHandler() {
 	headerVaryValue = strings.Join(vary, ", ")
 }
 
-func setCacheControl(rw http.ResponseWriter, force *time.Time, originHeaders map[string]string) {
+func setCacheControl(rw http.ResponseWriter, force *time.Time, originHeaders http.Header) {
 	ttl := -1
 
 	if _, ok := originHeaders["Fallback-Image"]; ok && config.FallbackImageTTL > 0 {
@@ -78,12 +79,12 @@ func setCacheControl(rw http.ResponseWriter, force *time.Time, originHeaders map
 	}
 
 	if config.CacheControlPassthrough && ttl < 0 && originHeaders != nil {
-		if val, ok := originHeaders["Cache-Control"]; ok && len(val) > 0 {
-			rw.Header().Set("Cache-Control", val)
+		if val := originHeaders.Get(httpheaders.CacheControl); len(val) > 0 {
+			rw.Header().Set(httpheaders.CacheControl, val)
 			return
 		}
 
-		if val, ok := originHeaders["Expires"]; ok && len(val) > 0 {
+		if val := originHeaders.Get(httpheaders.Expires); len(val) > 0 {
 			if t, err := time.Parse(http.TimeFormat, val); err == nil {
 				ttl = imath.Max(0, int(time.Until(t).Seconds()))
 			}
@@ -95,23 +96,23 @@ func setCacheControl(rw http.ResponseWriter, force *time.Time, originHeaders map
 	}
 
 	if ttl > 0 {
-		rw.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d, public", ttl))
+		rw.Header().Set(httpheaders.CacheControl, fmt.Sprintf("max-age=%d, public", ttl))
 	} else {
-		rw.Header().Set("Cache-Control", "no-cache")
+		rw.Header().Set(httpheaders.CacheControl, "no-cache")
 	}
 }
 
-func setLastModified(rw http.ResponseWriter, originHeaders map[string]string) {
+func setLastModified(rw http.ResponseWriter, originHeaders http.Header) {
 	if config.LastModifiedEnabled {
-		if val, ok := originHeaders["Last-Modified"]; ok && len(val) != 0 {
-			rw.Header().Set("Last-Modified", val)
+		if val := originHeaders.Get(httpheaders.LastModified); len(val) != 0 {
+			rw.Header().Set(httpheaders.LastModified, val)
 		}
 	}
 }
 
 func setVary(rw http.ResponseWriter) {
 	if len(headerVaryValue) > 0 {
-		rw.Header().Set("Vary", headerVaryValue)
+		rw.Header().Set(httpheaders.Vary, headerVaryValue)
 	}
 }
 
@@ -124,7 +125,7 @@ func setCanonical(rw http.ResponseWriter, originURL string) {
 	}
 }
 
-func respondWithImage(reqID string, r *http.Request, rw http.ResponseWriter, statusCode int, resultData *imagedata.ImageData, po *options.ProcessingOptions, originURL string, originData *imagedata.ImageData) {
+func respondWithImage(reqID string, r *http.Request, rw http.ResponseWriter, statusCode int, resultData imagedata.ImageData, po *options.ProcessingOptions, originURL string, originData imagedata.ImageData) {
 	var contentDisposition string
 	if len(po.Filename) > 0 {
 		contentDisposition = resultData.Format().ContentDisposition(po.Filename, po.ReturnAttachment)
@@ -135,8 +136,8 @@ func respondWithImage(reqID string, r *http.Request, rw http.ResponseWriter, sta
 	rw.Header().Set("Content-Type", resultData.Format().Mime())
 	rw.Header().Set("Content-Disposition", contentDisposition)
 
-	setCacheControl(rw, po.Expires, originData.Headers)
-	setLastModified(rw, originData.Headers)
+	setCacheControl(rw, po.Expires, originData.Headers())
+	setLastModified(rw, originData.Headers())
 	setVary(rw)
 	setCanonical(rw, originURL)
 
@@ -147,10 +148,10 @@ func respondWithImage(reqID string, r *http.Request, rw http.ResponseWriter, sta
 		}
 
 		rw.Header().Set("X-Origin-Content-Length", strconv.Itoa(originSize))
-		rw.Header().Set("X-Origin-Width", resultData.Headers["X-Origin-Width"])
-		rw.Header().Set("X-Origin-Height", resultData.Headers["X-Origin-Height"])
-		rw.Header().Set("X-Result-Width", resultData.Headers["X-Result-Width"])
-		rw.Header().Set("X-Result-Height", resultData.Headers["X-Result-Height"])
+		rw.Header().Set("X-Origin-Width", resultData.Headers().Get("X-Origin-Width"))
+		rw.Header().Set("X-Origin-Height", resultData.Headers().Get("X-Origin-Height"))
+		rw.Header().Set("X-Result-Width", resultData.Headers().Get("X-Result-Width"))
+		rw.Header().Set("X-Result-Height", resultData.Headers().Get("X-Result-Height"))
 	}
 
 	rw.Header().Set("Content-Security-Policy", "script-src 'none'")
@@ -184,7 +185,7 @@ func respondWithImage(reqID string, r *http.Request, rw http.ResponseWriter, sta
 	)
 }
 
-func respondWithNotModified(reqID string, r *http.Request, rw http.ResponseWriter, po *options.ProcessingOptions, originURL string, originHeaders map[string]string) {
+func respondWithNotModified(reqID string, r *http.Request, rw http.ResponseWriter, po *options.ProcessingOptions, originURL string, originHeaders http.Header) {
 	setCacheControl(rw, po.Expires, originHeaders)
 	setVary(rw)
 
@@ -347,7 +348,7 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 
 	statusCode := http.StatusOK
 
-	originData, err := func() (*imagedata.ImageData, error) {
+	originData, err := func() (imagedata.ImageData, error) {
 		defer metrics.StartDownloadingSegment(ctx, metrics.Meta{
 			metrics.MetaSourceImageURL:    metricsMeta[metrics.MetaSourceImageURL],
 			metrics.MetaSourceImageOrigin: metricsMeta[metrics.MetaSourceImageOrigin],
@@ -374,15 +375,10 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 
 	case errors.As(err, &nmErr):
 		if config.ETagEnabled && len(etagHandler.ImageEtagExpected()) != 0 {
-			rw.Header().Set("ETag", etagHandler.GenerateExpectedETag())
+			rw.Header().Set(httpheaders.Etag, etagHandler.GenerateExpectedETag())
 		}
 
-		h := make(map[string]string)
-		for k := range nmErr.Headers() {
-			h[k] = nmErr.Headers().Get(k)
-		}
-
-		respondWithNotModified(reqID, r, rw, po, imageURL, h)
+		respondWithNotModified(reqID, r, rw, po, imageURL, nmErr.Headers())
 		return
 
 	default:
@@ -426,7 +422,7 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 			rw.Header().Set("ETag", etagHandler.GenerateActualETag())
 
 			if imgDataMatch && etagHandler.ProcessingOptionsMatch() {
-				respondWithNotModified(reqID, r, rw, po, imageURL, originData.Headers)
+				respondWithNotModified(reqID, r, rw, po, imageURL, originData.Headers())
 				return
 			}
 		}
@@ -471,7 +467,7 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 		))
 	}
 
-	resultData, err := func() (*imagedata.ImageData, error) {
+	resultData, err := func() (imagedata.ImageData, error) {
 		defer metrics.StartProcessingSegment(ctx, metrics.Meta{
 			metrics.MetaProcessingOptions: metricsMeta[metrics.MetaProcessingOptions],
 		})()

+ 10 - 9
processing_handler_test.go

@@ -18,6 +18,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/config/configurators"
 	"github.com/imgproxy/imgproxy/v3/etag"
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
 	"github.com/imgproxy/imgproxy/v3/imagedata"
 	"github.com/imgproxy/imgproxy/v3/imagemeta"
 	"github.com/imgproxy/imgproxy/v3/imagetype"
@@ -86,26 +87,26 @@ func (s *ProcessingHandlerTestSuite) readTestFile(name string) []byte {
 	return data
 }
 
-func (s *ProcessingHandlerTestSuite) readTestImageData(name string) *imagedata.ImageData {
+func (s *ProcessingHandlerTestSuite) readTestImageData(name string) imagedata.ImageData {
 	wd, err := os.Getwd()
 	s.Require().NoError(err)
 
 	data, err := os.ReadFile(filepath.Join(wd, "testdata", name))
 	s.Require().NoError(err)
 
-	imgdata, err := imagedata.NewFromBytes(data, make(http.Header))
+	imgdata, err := imagedata.NewFromBytes(data)
 	s.Require().NoError(err)
 
 	return imgdata
 }
 
-func (s *ProcessingHandlerTestSuite) readImageData(imgdata *imagedata.ImageData) []byte {
+func (s *ProcessingHandlerTestSuite) readImageData(imgdata imagedata.ImageData) []byte {
 	data, err := io.ReadAll(imgdata.Reader())
 	s.Require().NoError(err)
 	return data
 }
 
-func (s *ProcessingHandlerTestSuite) sampleETagData(imgETag string) (string, *imagedata.ImageData, string) {
+func (s *ProcessingHandlerTestSuite) sampleETagData(imgETag string) (string, imagedata.ImageData, string) {
 	poStr := "rs:fill:4:4"
 
 	po := options.NewProcessingOptions()
@@ -116,7 +117,7 @@ func (s *ProcessingHandlerTestSuite) sampleETagData(imgETag string) (string, *im
 	imgdata := s.readTestImageData("test1.png")
 
 	if len(imgETag) != 0 {
-		imgdata.Headers = map[string]string{"ETag": imgETag}
+		imgdata.Headers().Set(httpheaders.Etag, imgETag)
 	}
 
 	var h etag.Handler
@@ -416,7 +417,7 @@ func (s *ProcessingHandlerTestSuite) TestETagReqNoIfNotModified() {
 	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
 		s.Empty(r.Header.Get("If-None-Match"))
 
-		rw.Header().Set("ETag", imgdata.Headers["ETag"])
+		rw.Header().Set("ETag", imgdata.Headers().Get(httpheaders.Etag))
 		rw.WriteHeader(200)
 		rw.Write(s.readTestFile("test1.png"))
 	}))
@@ -455,7 +456,7 @@ func (s *ProcessingHandlerTestSuite) TestETagReqMatch() {
 	poStr, imgdata, etag := s.sampleETagData(`"loremipsumdolor"`)
 
 	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
-		s.Equal(imgdata.Headers["ETag"], r.Header.Get("If-None-Match"))
+		s.Equal(imgdata.Headers().Get(httpheaders.Etag), r.Header.Get(httpheaders.IfNoneMatch))
 
 		rw.WriteHeader(304)
 	}))
@@ -503,7 +504,7 @@ func (s *ProcessingHandlerTestSuite) TestETagReqNotMatch() {
 	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
 		s.Equal(`"loremipsum"`, r.Header.Get("If-None-Match"))
 
-		rw.Header().Set("ETag", imgdata.Headers["ETag"])
+		rw.Header().Set("ETag", imgdata.Headers().Get(httpheaders.Etag))
 		rw.WriteHeader(200)
 		rw.Write(s.readImageData(imgdata))
 	}))
@@ -554,7 +555,7 @@ func (s *ProcessingHandlerTestSuite) TestETagProcessingOptionsNotMatch() {
 	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
 		s.Empty(r.Header.Get("If-None-Match"))
 
-		rw.Header().Set("ETag", imgdata.Headers["ETag"])
+		rw.Header().Set("ETag", imgdata.Headers().Get(httpheaders.Etag))
 		rw.WriteHeader(200)
 		rw.Write(s.readImageData(imgdata))
 	}))

+ 5 - 0
security/image_size.go

@@ -1,6 +1,7 @@
 package security
 
 import (
+	"github.com/imgproxy/imgproxy/v3/imagemeta"
 	"github.com/imgproxy/imgproxy/v3/imath"
 )
 
@@ -19,3 +20,7 @@ func CheckDimensions(width, height, frames int, opts Options) error {
 
 	return nil
 }
+
+func CheckMeta(meta imagemeta.Meta, opts Options) error {
+	return CheckDimensions(meta.Width(), meta.Height(), 1, opts)
+}

+ 21 - 0
security/response_limit.go

@@ -3,6 +3,7 @@ package security
 import (
 	"io"
 	"net/http"
+	"os"
 )
 
 // hardLimitReadCloser is a wrapper around io.ReadCloser
@@ -49,3 +50,23 @@ func LimitResponseSize(r *http.Response, opts Options) (*http.Response, error) {
 
 	return r, nil
 }
+
+// LimitFileSize limits the size of the file to MaxSrcFileSize (if set).
+// It calls f.Stat() to get the file to get its size and returns an error
+// if the size exceeds MaxSrcFileSize.
+func LimitFileSize(f *os.File, opts Options) (*os.File, error) {
+	if opts.MaxSrcFileSize == 0 {
+		return f, nil
+	}
+
+	s, err := f.Stat()
+	if err != nil {
+		return nil, err
+	}
+
+	if int(s.Size()) > opts.MaxSrcFileSize {
+		return nil, newFileSizeError()
+	}
+
+	return f, nil
+}

+ 1 - 4
stream.go

@@ -113,10 +113,7 @@ func streamOriginImage(ctx context.Context, reqID string, r *http.Request, rw ht
 		rw.Header().Set("Content-Disposition", imagetype.ContentDisposition(filename, ext, po.ReturnAttachment))
 	}
 
-	setCacheControl(rw, po.Expires, map[string]string{
-		"Cache-Control": res.Header.Get("Cache-Control"),
-		"Expires":       res.Header.Get("Expires"),
-	})
+	setCacheControl(rw, po.Expires, res.Header)
 	setCanonical(rw, imageURL)
 	rw.Header().Set("Content-Security-Policy", "script-src 'none'")
 

+ 4 - 17
svg/svg.go

@@ -2,7 +2,6 @@ package svg
 
 import (
 	"io"
-	"net/http"
 	"strings"
 
 	"github.com/tdewolff/parse/v2"
@@ -12,16 +11,7 @@ import (
 	"github.com/imgproxy/imgproxy/v3/imagetype"
 )
 
-func cloneHeaders(src map[string]string) http.Header {
-	h := make(http.Header, len(src))
-	for k, v := range src {
-		h.Set(k, v)
-	}
-
-	return h
-}
-
-func Sanitize(data *imagedata.ImageData) (*imagedata.ImageData, error) {
+func Sanitize(data imagedata.ImageData) (imagedata.ImageData, error) {
 	r := data.Reader()
 	l := xml.NewLexer(parse.NewInput(r))
 
@@ -55,15 +45,12 @@ func Sanitize(data *imagedata.ImageData) (*imagedata.ImageData, error) {
 				return nil, l.Err()
 			}
 
-			newData, err := imagedata.NewFromBytesWithFormat(
+			newData := imagedata.NewFromBytesWithFormat(
 				imagetype.SVG,
 				buf.Bytes(),
-				cloneHeaders(data.Headers),
+				data.Headers(),
 			)
-			if err != nil {
-				return nil, err
-			}
-			newData.SetCancel(cancel)
+			newData.AddCancel(cancel)
 
 			return newData, nil
 		case xml.StartTagToken:

+ 6 - 9
svg/svg_test.go

@@ -1,15 +1,14 @@
 package svg
 
 import (
-	"net/http"
 	"os"
 	"path/filepath"
 	"testing"
 
 	"github.com/stretchr/testify/suite"
-	"go.withmatt.com/httpheaders"
 
 	"github.com/imgproxy/imgproxy/v3/config"
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
 	"github.com/imgproxy/imgproxy/v3/imagedata"
 	"github.com/imgproxy/imgproxy/v3/testutil"
 )
@@ -25,18 +24,16 @@ func (s *SvgTestSuite) SetupSuite() {
 	s.Require().NoError(err)
 }
 
-func (s *SvgTestSuite) readTestFile(name string) *imagedata.ImageData {
+func (s *SvgTestSuite) readTestFile(name string) imagedata.ImageData {
 	wd, err := os.Getwd()
 	s.Require().NoError(err)
 
 	data, err := os.ReadFile(filepath.Join(wd, "..", "testdata", name))
 	s.Require().NoError(err)
 
-	h := make(http.Header)
-	h.Set(httpheaders.ContentType, "image/svg+xml")
-	h.Set(httpheaders.CacheControl, "public, max-age=12345")
-
-	d, err := imagedata.NewFromBytes(data, h)
+	d, err := imagedata.NewFromBytes(data)
+	d.Headers().Set(httpheaders.ContentType, "image/svg+xml")
+	d.Headers().Set(httpheaders.CacheControl, "public, max-age=12345")
 	s.Require().NoError(err)
 
 	return d
@@ -49,7 +46,7 @@ func (s *SvgTestSuite) TestSanitize() {
 
 	s.Require().NoError(err)
 	s.Require().True(testutil.ReadersEqual(s.T(), expected.Reader(), actual.Reader()))
-	s.Require().Equal(origin.Headers, actual.Headers)
+	s.Require().Equal(origin.Headers(), actual.Headers())
 }
 
 func TestSvg(t *testing.T) {

+ 6 - 11
vips/vips.go

@@ -353,7 +353,7 @@ func (img *Image) Pages() int {
 	return p
 }
 
-func (img *Image) Load(imgdata *imagedata.ImageData, shrink int, scale float64, pages int) error {
+func (img *Image) Load(imgdata imagedata.ImageData, shrink int, scale float64, pages int) error {
 	var tmp *C.VipsImage
 
 	err := C.int(0)
@@ -403,7 +403,7 @@ func (img *Image) Load(imgdata *imagedata.ImageData, shrink int, scale float64,
 	return nil
 }
 
-func (img *Image) LoadThumbnail(imgdata *imagedata.ImageData) error {
+func (img *Image) LoadThumbnail(imgdata imagedata.ImageData) error {
 	if imgdata.Format() != imagetype.HEIC && imgdata.Format() != imagetype.AVIF {
 		return newVipsError("Usupported image type to load thumbnail")
 	}
@@ -423,7 +423,7 @@ func (img *Image) LoadThumbnail(imgdata *imagedata.ImageData) error {
 	return nil
 }
 
-func (img *Image) Save(imgtype imagetype.Type, quality int) (*imagedata.ImageData, error) {
+func (img *Image) Save(imgtype imagetype.Type, quality int) (imagedata.ImageData, error) {
 	target := C.vips_target_new_to_memory()
 
 	cancel := func() {
@@ -470,15 +470,10 @@ func (img *Image) Save(imgtype imagetype.Type, quality int) (*imagedata.ImageDat
 
 	b := ptrToBytes(ptr, int(imgsize))
 
-	imgdata, ierr := imagedata.NewFromBytesWithFormat(imgtype, b, make(http.Header))
-	if ierr != nil {
-		cancel()
-		return nil, ierr
-	}
-
-	imgdata.SetCancel(cancel)
+	i := imagedata.NewFromBytesWithFormat(imgtype, b, nil)
+	i.AddCancel(cancel)
 
-	return imgdata, nil
+	return i, nil
 }
 
 func (img *Image) Clear() {