Prechádzať zdrojové kódy

IMG-53: refactor stream.go (#1503)

* stream_test.go

* StreamHandler

* stream.go replaced with handlers/stream

* Small fixes

* Separate SetMaxAge/SetForceExpires

* Fallback image TTL

* Added existing TTL check to SetIsFallbackImage

* guard clause SetIsFallbackImage

* Removed old stream.go
Victor Sokolov 1 mesiac pred
rodič
commit
ec566ce1c0

+ 39 - 0
handlers/stream/config.go

@@ -0,0 +1,39 @@
+package stream
+
+import (
+	"github.com/imgproxy/imgproxy/v3/config"
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
+)
+
+// Config represents the configuration for the image streamer
+type Config struct {
+	// CookiePassthrough indicates whether cookies should be passed through to the image response
+	CookiePassthrough bool
+
+	// PassthroughRequestHeaders specifies the request headers to include in the passthrough response
+	PassthroughRequestHeaders []string
+
+	// PassthroughResponseHeaders specifies the response headers to copy from the response
+	PassthroughResponseHeaders []string
+}
+
+// NewConfigFromEnv creates a new Config instance from environment variables
+func NewConfigFromEnv() *Config {
+	return &Config{
+		CookiePassthrough: config.CookiePassthrough,
+		PassthroughRequestHeaders: []string{
+			httpheaders.IfNoneMatch,
+			httpheaders.IfModifiedSince,
+			httpheaders.AcceptEncoding,
+			httpheaders.Range,
+		},
+		PassthroughResponseHeaders: []string{
+			httpheaders.ContentType,
+			httpheaders.ContentEncoding,
+			httpheaders.ContentRange,
+			httpheaders.AcceptRanges,
+			httpheaders.LastModified,
+			httpheaders.Etag,
+		},
+	}
+}

+ 198 - 0
handlers/stream/handler.go

@@ -0,0 +1,198 @@
+package stream
+
+import (
+	"context"
+	"io"
+	"net/http"
+	"sync"
+
+	"github.com/imgproxy/imgproxy/v3/cookies"
+	"github.com/imgproxy/imgproxy/v3/headerwriter"
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
+	"github.com/imgproxy/imgproxy/v3/ierrors"
+	"github.com/imgproxy/imgproxy/v3/imagefetcher"
+	"github.com/imgproxy/imgproxy/v3/monitoring"
+	"github.com/imgproxy/imgproxy/v3/monitoring/stats"
+	"github.com/imgproxy/imgproxy/v3/options"
+	"github.com/imgproxy/imgproxy/v3/server"
+	log "github.com/sirupsen/logrus"
+)
+
+const (
+	streamBufferSize  = 4096        // Size of the buffer used for streaming
+	categoryStreaming = "streaming" // Streaming error category
+)
+
+var (
+	// streamBufPool is a sync.Pool for reusing byte slices used for streaming
+	streamBufPool = sync.Pool{
+		New: func() any {
+			buf := make([]byte, streamBufferSize)
+			return &buf
+		},
+	}
+)
+
+// Handler handles image passthrough requests, allowing images to be streamed directly
+type Handler struct {
+	fetcher  *imagefetcher.Fetcher // Fetcher instance to handle image fetching
+	config   *Config               // Configuration for the streamer
+	hwConfig *headerwriter.Config  // Configuration for header writing
+}
+
+// request holds the parameters and state for a single streaming request
+type request struct {
+	handler      *Handler
+	imageRequest *http.Request
+	imageURL     string
+	reqID        string
+	po           *options.ProcessingOptions
+	rw           http.ResponseWriter
+}
+
+// New creates new handler object
+func New(config *Config, hwConfig *headerwriter.Config, fetcher *imagefetcher.Fetcher) *Handler {
+	return &Handler{
+		fetcher:  fetcher,
+		config:   config,
+		hwConfig: hwConfig,
+	}
+}
+
+// Stream handles the image passthrough request, streaming the image directly to the response writer
+func (s *Handler) Execute(
+	ctx context.Context,
+	userRequest *http.Request,
+	imageURL string,
+	reqID string,
+	po *options.ProcessingOptions,
+	rw http.ResponseWriter,
+) error {
+	stream := &request{
+		handler:      s,
+		imageRequest: userRequest,
+		imageURL:     imageURL,
+		reqID:        reqID,
+		po:           po,
+		rw:           rw,
+	}
+
+	return stream.execute(ctx)
+}
+
+// execute handles the actual streaming logic
+func (s *request) execute(ctx context.Context) error {
+	stats.IncImagesInProgress()
+	defer stats.DecImagesInProgress()
+	defer monitoring.StartStreamingSegment(ctx)()
+
+	// Passthrough request headers from the original request
+	requestHeaders := s.getImageRequestHeaders()
+	cookieJar, err := s.getCookieJar()
+	if err != nil {
+		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryStreaming))
+	}
+
+	// Build the request to fetch the image
+	r, err := s.handler.fetcher.BuildRequest(ctx, s.imageURL, requestHeaders, cookieJar)
+	defer r.Cancel()
+	if err != nil {
+		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryStreaming))
+	}
+
+	// Send the request to fetch the image
+	res, err := r.Send()
+	if res != nil {
+		defer res.Body.Close()
+	}
+	if err != nil {
+		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryStreaming))
+	}
+
+	// Output streaming response headers
+	hw := headerwriter.New(s.handler.hwConfig, res.Header, s.imageURL)
+	hw.Passthrough(s.handler.config.PassthroughResponseHeaders) // NOTE: priority? This is lowest as it was
+	hw.SetContentLength(int(res.ContentLength))
+	hw.SetCanonical()
+	hw.SetExpires(s.po.Expires)
+	hw.Write(s.rw)
+
+	// Write Content-Disposition header
+	s.writeContentDisposition(r.URL().Path, res)
+
+	// Copy the status code from the original response
+	s.rw.WriteHeader(res.StatusCode)
+
+	// Write the actual data
+	s.streamData(res)
+
+	return nil
+}
+
+// getCookieJar returns non-empty cookie jar if cookie passthrough is enabled
+func (s *request) getCookieJar() (http.CookieJar, error) {
+	if !s.handler.config.CookiePassthrough {
+		return nil, nil
+	}
+
+	return cookies.JarFromRequest(s.imageRequest)
+}
+
+// getImageRequestHeaders returns a new http.Header containing only
+// the headers that should be passed through from the user request
+func (s *request) getImageRequestHeaders() http.Header {
+	h := make(http.Header)
+
+	for _, key := range s.handler.config.PassthroughRequestHeaders {
+		values := s.imageRequest.Header.Values(key)
+
+		for _, value := range values {
+			h.Add(key, value)
+		}
+	}
+
+	return h
+}
+
+// writeContentDisposition writes the headers to the response writer
+func (s *request) writeContentDisposition(imagePath string, serverResponse *http.Response) {
+	// Try to set correct Content-Disposition file name and extension
+	if serverResponse.StatusCode < 200 || serverResponse.StatusCode >= 300 {
+		return
+	}
+
+	ct := serverResponse.Header.Get(httpheaders.ContentType)
+
+	// Try to best guess the file name and extension
+	cd := httpheaders.ContentDispositionValue(
+		imagePath,
+		s.po.Filename,
+		"",
+		ct,
+		s.po.ReturnAttachment,
+	)
+
+	// Write the Content-Disposition header
+	s.rw.Header().Set(httpheaders.ContentDisposition, cd)
+}
+
+// streamData copies the image data from the response body to the response writer
+func (s *request) streamData(res *http.Response) {
+	buf := streamBufPool.Get().(*[]byte)
+	defer streamBufPool.Put(buf)
+
+	_, copyerr := io.CopyBuffer(s.rw, res.Body, *buf)
+
+	server.LogResponse(
+		s.reqID, s.imageRequest, res.StatusCode, nil,
+		log.Fields{
+			"image_url":          s.imageURL,
+			"processing_options": s.po,
+		},
+	)
+
+	// We've got to skip logging here
+	if copyerr != nil {
+		panic(http.ErrAbortHandler)
+	}
+}

+ 512 - 0
handlers/stream/handler_test.go

@@ -0,0 +1,512 @@
+package stream
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"strconv"
+	"testing"
+	"time"
+
+	"github.com/sirupsen/logrus"
+	"github.com/stretchr/testify/suite"
+
+	"github.com/imgproxy/imgproxy/v3/config"
+	"github.com/imgproxy/imgproxy/v3/headerwriter"
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
+	"github.com/imgproxy/imgproxy/v3/imagefetcher"
+	"github.com/imgproxy/imgproxy/v3/options"
+	"github.com/imgproxy/imgproxy/v3/transport"
+)
+
+const (
+	testDataPath = "../../testdata"
+)
+
+type HandlerTestSuite struct {
+	suite.Suite
+	handler *Handler
+}
+
+func (s *HandlerTestSuite) SetupSuite() {
+	config.Reset()
+	config.AllowLoopbackSourceAddresses = true
+
+	// Silence logs during tests
+	logrus.SetOutput(io.Discard)
+}
+
+func (s *HandlerTestSuite) TearDownSuite() {
+	config.Reset()
+	logrus.SetOutput(os.Stdout)
+}
+
+func (s *HandlerTestSuite) SetupTest() {
+	config.Reset()
+	config.AllowLoopbackSourceAddresses = true
+
+	tr, err := transport.NewTransport()
+	s.Require().NoError(err)
+
+	fetcher, err := imagefetcher.NewFetcher(tr, imagefetcher.NewConfigFromEnv())
+	s.Require().NoError(err)
+
+	s.handler = New(NewConfigFromEnv(), headerwriter.NewConfigFromEnv(), fetcher)
+}
+
+func (s *HandlerTestSuite) readTestFile(name string) []byte {
+	data, err := os.ReadFile(filepath.Join(testDataPath, name))
+	s.Require().NoError(err)
+	return data
+}
+
+// TestHandlerBasicRequest checks basic streaming request
+func (s *HandlerTestSuite) TestHandlerBasicRequest() {
+	data := s.readTestFile("test1.png")
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set(httpheaders.ContentType, "image/png")
+		w.WriteHeader(200)
+		w.Write(data)
+	}))
+	defer ts.Close()
+
+	req := httptest.NewRequest("GET", "/", nil)
+	rw := httptest.NewRecorder()
+	po := &options.ProcessingOptions{}
+
+	err := s.handler.Execute(context.Background(), req, ts.URL, "request-1", po, rw)
+	s.Require().NoError(err)
+
+	res := rw.Result()
+	s.Require().Equal(200, res.StatusCode)
+	s.Require().Equal("image/png", res.Header.Get(httpheaders.ContentType))
+
+	// Verify we get the original image data
+	actual := rw.Body.Bytes()
+	s.Require().Equal(data, actual)
+}
+
+// TestHandlerResponseHeadersPassthrough checks that original response headers are
+// passed through to the client
+func (s *HandlerTestSuite) TestHandlerResponseHeadersPassthrough() {
+	data := s.readTestFile("test1.png")
+	contentLength := len(data)
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set(httpheaders.ContentType, "image/png")
+		w.Header().Set(httpheaders.ContentLength, strconv.Itoa(contentLength))
+		w.Header().Set(httpheaders.AcceptRanges, "bytes")
+		w.Header().Set(httpheaders.Etag, "etag")
+		w.Header().Set(httpheaders.LastModified, "Wed, 21 Oct 2015 07:28:00 GMT")
+		w.WriteHeader(200)
+		w.Write(data)
+	}))
+	defer ts.Close()
+
+	req := httptest.NewRequest("GET", "/", nil)
+	rw := httptest.NewRecorder()
+	po := &options.ProcessingOptions{}
+
+	err := s.handler.Execute(context.Background(), req, ts.URL, "test-req-id", po, rw)
+	s.Require().NoError(err)
+
+	res := rw.Result()
+	s.Require().Equal(200, res.StatusCode)
+	s.Require().Equal("image/png", res.Header.Get(httpheaders.ContentType))
+	s.Require().Equal(strconv.Itoa(contentLength), res.Header.Get(httpheaders.ContentLength))
+	s.Require().Equal("bytes", res.Header.Get(httpheaders.AcceptRanges))
+	s.Require().Equal("etag", res.Header.Get(httpheaders.Etag))
+	s.Require().Equal("Wed, 21 Oct 2015 07:28:00 GMT", res.Header.Get(httpheaders.LastModified))
+}
+
+// TestHandlerRequestHeadersPassthrough checks that original request headers are passed through
+// to the server
+func (s *HandlerTestSuite) TestHandlerRequestHeadersPassthrough() {
+	etag := `"test-etag-123"`
+	data := s.readTestFile("test1.png")
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		// Verify that If-None-Match header is passed through
+		s.Equal(etag, r.Header.Get(httpheaders.IfNoneMatch))
+		s.Equal("gzip", r.Header.Get(httpheaders.AcceptEncoding))
+		s.Equal("bytes=*", r.Header.Get(httpheaders.Range))
+
+		w.Header().Set(httpheaders.Etag, etag)
+		w.WriteHeader(200)
+		w.Write(data)
+	}))
+	defer ts.Close()
+
+	req := httptest.NewRequest("GET", "/", nil)
+	req.Header.Set(httpheaders.IfNoneMatch, etag)
+	req.Header.Set(httpheaders.AcceptEncoding, "gzip")
+	req.Header.Set(httpheaders.Range, "bytes=*")
+
+	rw := httptest.NewRecorder()
+	po := &options.ProcessingOptions{}
+
+	err := s.handler.Execute(context.Background(), req, ts.URL, "test-req-id", po, rw)
+	s.Require().NoError(err)
+
+	res := rw.Result()
+	s.Require().Equal(200, res.StatusCode)
+	s.Require().Equal(etag, res.Header.Get(httpheaders.Etag))
+}
+
+// TestHandlerContentDisposition checks that Content-Disposition header is set correctly
+func (s *HandlerTestSuite) TestHandlerContentDisposition() {
+	data := s.readTestFile("test1.png")
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set(httpheaders.ContentType, "image/png")
+		w.WriteHeader(200)
+		w.Write(data)
+	}))
+	defer ts.Close()
+
+	req := httptest.NewRequest("GET", "/", nil)
+	rw := httptest.NewRecorder()
+	po := &options.ProcessingOptions{
+		Filename:         "custom_name",
+		ReturnAttachment: true,
+	}
+
+	// Use a URL with a .png extension to help content disposition logic
+	imageURL := ts.URL + "/test.png"
+	err := s.handler.Execute(context.Background(), req, imageURL, "test-req-id", po, rw)
+	s.Require().NoError(err)
+
+	res := rw.Result()
+	s.Require().Equal(200, res.StatusCode)
+	s.Require().Contains(res.Header.Get(httpheaders.ContentDisposition), "custom_name.png")
+	s.Require().Contains(res.Header.Get(httpheaders.ContentDisposition), "attachment")
+}
+
+// TestHandlerCacheControl checks that Cache-Control header is set correctly in different cases
+func (s *HandlerTestSuite) TestHandlerCacheControl() {
+	type testCase struct {
+		name                    string
+		cacheControlPassthrough bool
+		setupOriginHeaders      func(http.ResponseWriter)
+		timestampOffset         *time.Duration // nil for no timestamp, otherwise the offset from now
+		expectedStatusCode      int
+		validate                func(*testing.T, *http.Response)
+	}
+
+	// Duration variables for test cases
+	var (
+		oneHour          = time.Hour
+		thirtyMinutes    = 30 * time.Minute
+		fortyFiveMinutes = 45 * time.Minute
+		twoHours         = time.Hour * 2
+		oneMinuteDelta   = float64(time.Minute)
+	)
+
+	// Set this explicitly for testing purposes
+	config.TTL = 4242
+
+	testCases := []testCase{
+		{
+			name:                    "Passthrough",
+			cacheControlPassthrough: true,
+			setupOriginHeaders: func(w http.ResponseWriter) {
+				w.Header().Set(httpheaders.CacheControl, "max-age=3600, public")
+			},
+			timestampOffset:    nil,
+			expectedStatusCode: 200,
+			validate: func(t *testing.T, res *http.Response) {
+				s.Require().Equal("max-age=3600, public", res.Header.Get(httpheaders.CacheControl))
+			},
+		},
+		// Checks that expires gets convert to cache-control
+		{
+			name:                    "ExpiresPassthrough",
+			cacheControlPassthrough: true,
+			setupOriginHeaders: func(w http.ResponseWriter) {
+				w.Header().Set(httpheaders.Expires, time.Now().Add(oneHour).UTC().Format(http.TimeFormat))
+			},
+			timestampOffset:    nil,
+			expectedStatusCode: 200,
+			validate: func(t *testing.T, res *http.Response) {
+				// When expires is converted to cache-control, the expires header should be empty
+				s.Require().Empty(res.Header.Get(httpheaders.Expires))
+				s.Require().InDelta(oneHour, s.maxAgeValue(res), oneMinuteDelta)
+			},
+		},
+		// It would be set to something like default ttl
+		{
+			name:                    "PassthroughDisabled",
+			cacheControlPassthrough: false,
+			setupOriginHeaders: func(w http.ResponseWriter) {
+				w.Header().Set(httpheaders.CacheControl, "max-age=3600, public")
+			},
+			timestampOffset:    nil,
+			expectedStatusCode: 200,
+			validate: func(t *testing.T, res *http.Response) {
+				s.Require().Equal(s.maxAgeValue(res), time.Duration(config.TTL)*time.Second)
+			},
+		},
+		// When expires is set in processing options, but not present in the response
+		{
+			name:                    "WithProcessingOptionsExpires",
+			cacheControlPassthrough: false,
+			setupOriginHeaders:      func(w http.ResponseWriter) {}, // No origin headers
+			timestampOffset:         &oneHour,
+			expectedStatusCode:      200,
+			validate: func(t *testing.T, res *http.Response) {
+				s.Require().InDelta(oneHour, s.maxAgeValue(res), oneMinuteDelta)
+			},
+		},
+		// When expires is set in processing options, and is present in the response,
+		// and passthrough is enabled
+		{
+			name:                    "ProcessingOptionsOverridesOrigin",
+			cacheControlPassthrough: true,
+			setupOriginHeaders: func(w http.ResponseWriter) {
+				// Origin has a longer cache time
+				w.Header().Set(httpheaders.CacheControl, "max-age=7200, public")
+			},
+			timestampOffset:    &thirtyMinutes,
+			expectedStatusCode: 200,
+			validate: func(t *testing.T, res *http.Response) {
+				s.Require().InDelta(thirtyMinutes, s.maxAgeValue(res), oneMinuteDelta)
+			},
+		},
+		// When expires is not set in po, but both expires and cc are present in response,
+		// and passthrough is enabled
+		{
+			name:                    "BothHeadersPassthroughEnabled",
+			cacheControlPassthrough: true,
+			setupOriginHeaders: func(w http.ResponseWriter) {
+				// Origin has both Cache-Control and Expires headers
+				w.Header().Set(httpheaders.CacheControl, "max-age=1800, public")
+				w.Header().Set(httpheaders.Expires, time.Now().Add(oneHour).UTC().Format(http.TimeFormat))
+			},
+			timestampOffset:    nil,
+			expectedStatusCode: 200,
+			validate: func(t *testing.T, res *http.Response) {
+				// Cache-Control should take precedence over Expires when both are present
+				s.Require().InDelta(thirtyMinutes, s.maxAgeValue(res), oneMinuteDelta)
+				s.Require().Empty(res.Header.Get(httpheaders.Expires))
+			},
+		},
+		// When expires is set in PO AND both cache-control and expires are present in response,
+		// and passthrough is enabled
+		{
+			name:                    "ProcessingOptionsOverridesBothOriginHeaders",
+			cacheControlPassthrough: true,
+			setupOriginHeaders: func(w http.ResponseWriter) {
+				// Origin has both Cache-Control and Expires headers with longer cache times
+				w.Header().Set(httpheaders.CacheControl, "max-age=7200, public")
+				w.Header().Set(httpheaders.Expires, time.Now().Add(twoHours).UTC().Format(http.TimeFormat))
+			},
+			timestampOffset:    &fortyFiveMinutes, // Shorter than origin headers
+			expectedStatusCode: 200,
+			validate: func(t *testing.T, res *http.Response) {
+				s.Require().InDelta(fortyFiveMinutes, s.maxAgeValue(res), oneMinuteDelta)
+				s.Require().Empty(res.Header.Get(httpheaders.Expires))
+			},
+		},
+		// No headers set
+		{
+			name:                    "NoOriginHeaders",
+			cacheControlPassthrough: false,
+			setupOriginHeaders:      func(w http.ResponseWriter) {}, // Origin has no cache headers
+			timestampOffset:         nil,
+			expectedStatusCode:      200,
+			validate: func(t *testing.T, res *http.Response) {
+				s.Require().Equal(s.maxAgeValue(res), time.Duration(config.TTL)*time.Second)
+			},
+		},
+	}
+
+	for _, tc := range testCases {
+		s.Run(tc.name, func() {
+			// Set config values for this test
+			config.CacheControlPassthrough = tc.cacheControlPassthrough
+			config.TTL = 4242 // Set consistent TTL for testing
+
+			data := s.readTestFile("test1.png")
+
+			ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				tc.setupOriginHeaders(w)
+				w.Header().Set(httpheaders.ContentType, "image/png")
+				w.WriteHeader(200)
+				w.Write(data)
+			}))
+			defer ts.Close()
+
+			// Create new handler with updated config for each test
+			tr, err := transport.NewTransport()
+			s.Require().NoError(err)
+
+			fetcher, err := imagefetcher.NewFetcher(tr, imagefetcher.NewConfigFromEnv())
+			s.Require().NoError(err)
+
+			handler := New(NewConfigFromEnv(), headerwriter.NewConfigFromEnv(), fetcher)
+
+			req := httptest.NewRequest("GET", "/", nil)
+			rw := httptest.NewRecorder()
+			po := &options.ProcessingOptions{}
+
+			if tc.timestampOffset != nil {
+				expires := time.Now().Add(*tc.timestampOffset)
+				po.Expires = &expires
+			}
+
+			err = handler.Execute(context.Background(), req, ts.URL, "test-req-id", po, rw)
+			s.Require().NoError(err)
+
+			res := rw.Result()
+			s.Require().Equal(tc.expectedStatusCode, res.StatusCode)
+			tc.validate(s.T(), res)
+		})
+	}
+}
+
+// maxAgeValue parses max-age from cache-control
+func (s *HandlerTestSuite) maxAgeValue(res *http.Response) time.Duration {
+	cacheControl := res.Header.Get(httpheaders.CacheControl)
+	if cacheControl == "" {
+		return 0
+	}
+	var maxAge int
+	fmt.Sscanf(cacheControl, "max-age=%d", &maxAge)
+	return time.Duration(maxAge) * time.Second
+}
+
+// TestHandlerSecurityHeaders tests the security headers set by the streaming service.
+func (s *HandlerTestSuite) TestHandlerSecurityHeaders() {
+	data := s.readTestFile("test1.png")
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set(httpheaders.ContentType, "image/png")
+		w.WriteHeader(200)
+		w.Write(data)
+	}))
+	defer ts.Close()
+
+	req := httptest.NewRequest("GET", "/", nil)
+	rw := httptest.NewRecorder()
+	po := &options.ProcessingOptions{}
+
+	err := s.handler.Execute(context.Background(), req, ts.URL, "test-req-id", po, rw)
+	s.Require().NoError(err)
+
+	res := rw.Result()
+	s.Require().Equal(200, res.StatusCode)
+	s.Require().Equal("script-src 'none'", res.Header.Get(httpheaders.ContentSecurityPolicy))
+}
+
+// TestHandlerErrorResponse tests the error responses from the streaming service.
+func (s *HandlerTestSuite) TestHandlerErrorResponse() {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(404)
+		w.Write([]byte("Not Found"))
+	}))
+	defer ts.Close()
+
+	req := httptest.NewRequest("GET", "/", nil)
+	rw := httptest.NewRecorder()
+	po := &options.ProcessingOptions{}
+
+	err := s.handler.Execute(context.Background(), req, ts.URL, "test-req-id", po, rw)
+	s.Require().NoError(err)
+
+	res := rw.Result()
+	s.Require().Equal(404, res.StatusCode)
+}
+
+// TestHandlerCookiePassthrough tests the cookie passthrough behavior of the streaming service.
+func (s *HandlerTestSuite) TestHandlerCookiePassthrough() {
+	// Enable cookie passthrough for this test
+	config.CookiePassthrough = true
+	defer func() {
+		config.CookiePassthrough = false // Reset after test
+	}()
+
+	// Create new handler with updated config
+	tr, err := transport.NewTransport()
+	s.Require().NoError(err)
+
+	fetcher, err := imagefetcher.NewFetcher(tr, imagefetcher.NewConfigFromEnv())
+	s.Require().NoError(err)
+
+	handler := New(NewConfigFromEnv(), headerwriter.NewConfigFromEnv(), fetcher)
+
+	data := s.readTestFile("test1.png")
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		// Verify cookies are passed through
+		cookie, cerr := r.Cookie("test_cookie")
+		if cerr == nil {
+			s.Equal("test_value", cookie.Value)
+		}
+
+		w.Header().Set(httpheaders.ContentType, "image/png")
+		w.WriteHeader(200)
+		w.Write(data)
+	}))
+	defer ts.Close()
+
+	req := httptest.NewRequest("GET", "/", nil)
+	req.Header.Set(httpheaders.Cookie, "test_cookie=test_value")
+	rw := httptest.NewRecorder()
+	po := &options.ProcessingOptions{}
+
+	err = handler.Execute(context.Background(), req, ts.URL, "test-req-id", po, rw)
+	s.Require().NoError(err)
+
+	res := rw.Result()
+	s.Require().Equal(200, res.StatusCode)
+}
+
+// TestHandlerCanonicalHeader tests that the canonical header is set correctly
+func (s *HandlerTestSuite) TestHandlerCanonicalHeader() {
+	data := s.readTestFile("test1.png")
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set(httpheaders.ContentType, "image/png")
+		w.WriteHeader(200)
+		w.Write(data)
+	}))
+	defer ts.Close()
+
+	for _, sc := range []bool{true, false} {
+		config.SetCanonicalHeader = sc
+
+		// Create new handler with updated config
+		tr, err := transport.NewTransport()
+		s.Require().NoError(err)
+
+		fetcher, err := imagefetcher.NewFetcher(tr, imagefetcher.NewConfigFromEnv())
+		s.Require().NoError(err)
+
+		handler := New(NewConfigFromEnv(), headerwriter.NewConfigFromEnv(), fetcher)
+
+		req := httptest.NewRequest("GET", "/", nil)
+		rw := httptest.NewRecorder()
+		po := &options.ProcessingOptions{}
+
+		err = handler.Execute(context.Background(), req, ts.URL, "test-req-id", po, rw)
+		s.Require().NoError(err)
+
+		res := rw.Result()
+		s.Require().Equal(200, res.StatusCode)
+
+		if sc {
+			s.Require().Contains(res.Header.Get(httpheaders.Link), fmt.Sprintf(`<%s>; rel="canonical"`, ts.URL))
+		} else {
+			s.Require().Empty(res.Header.Get(httpheaders.Link))
+		}
+	}
+}
+
+func TestHandler(t *testing.T) {
+	suite.Run(t, new(HandlerTestSuite))
+}

+ 34 - 0
headerwriter/config.go

@@ -0,0 +1,34 @@
+package headerwriter
+
+import (
+	"github.com/imgproxy/imgproxy/v3/config"
+)
+
+// Config is the package-local configuration
+type Config struct {
+	SetCanonicalHeader      bool // Indicates whether to set the canonical header
+	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
+	LastModifiedEnabled     bool // Set the Last-Modified header
+	EnableClientHints       bool // Enable Vary header
+	SetVaryAccept           bool // Whether to include Accept in Vary header
+}
+
+// NewConfigFromEnv creates a new Config instance from the current configuration
+func NewConfigFromEnv() *Config {
+	return &Config{
+		SetCanonicalHeader:      config.SetCanonicalHeader,
+		DefaultTTL:              config.TTL,
+		FallbackImageTTL:        config.FallbackImageTTL,
+		LastModifiedEnabled:     config.LastModifiedEnabled,
+		CacheControlPassthrough: config.CacheControlPassthrough,
+		EnableClientHints:       config.EnableClientHints,
+		SetVaryAccept: config.AutoWebp ||
+			config.EnforceWebp ||
+			config.AutoAvif ||
+			config.EnforceAvif ||
+			config.AutoJxl ||
+			config.EnforceJxl,
+	}
+}

+ 214 - 0
headerwriter/writer.go

@@ -0,0 +1,214 @@
+// headerwriter is responsible for writing processing/stream response headers
+package headerwriter
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
+)
+
+// Writer is a struct that builds HTTP response headers.
+type Writer struct {
+	config                  *Config
+	originalResponseHeaders http.Header // Original response headers
+	result                  http.Header // Headers to be written to the response
+	maxAge                  int         // Current max age for Cache-Control header
+	url                     string      // URL of the request, used for canonical header
+	varyValue               string      // Vary header value
+}
+
+// New creates a new HeaderBuilder instance with the provided origin headers and URL
+func New(config *Config, originalResponseHeaders http.Header, url string) *Writer {
+	vary := make([]string, 0)
+
+	if config.SetVaryAccept {
+		vary = append(vary, "Accept")
+	}
+
+	if config.EnableClientHints {
+		vary = append(vary, "Sec-CH-DPR", "DPR", "Sec-CH-Width", "Width")
+	}
+
+	varyValue := strings.Join(vary, ", ")
+
+	return &Writer{
+		config:                  config,
+		originalResponseHeaders: originalResponseHeaders,
+		url:                     url,
+		result:                  make(http.Header),
+		maxAge:                  -1,
+		varyValue:               varyValue,
+	}
+}
+
+// SetIsFallbackImage sets the Fallback-Image header to
+// indicate that the fallback image was used.
+func (w *Writer) SetIsFallbackImage() {
+	// We set maxAge to FallbackImageTTL if it's explicitly passed
+	if w.config.FallbackImageTTL < 0 {
+		return
+	}
+
+	// However, we should not overwrite existing value if set (or greater than ours)
+	if w.maxAge < 0 || w.maxAge > w.config.FallbackImageTTL {
+		w.maxAge = w.config.FallbackImageTTL
+	}
+}
+
+// SetExpires sets the TTL from time
+func (w *Writer) SetExpires(expires *time.Time) {
+	if expires == nil {
+		return
+	}
+
+	// Convert current maxAge to time
+	currentMaxAgeTime := time.Now().Add(time.Duration(w.maxAge) * time.Second)
+
+	// If maxAge outlives expires or was not set, we'll use expires as maxAge.
+	if w.maxAge < 0 || expires.Before(currentMaxAgeTime) {
+		w.maxAge = min(w.config.DefaultTTL, max(0, int(time.Until(*expires).Seconds())))
+	}
+}
+
+// SetLastModified sets the Last-Modified header from request
+func (w *Writer) SetLastModified() {
+	if !w.config.LastModifiedEnabled {
+		return
+	}
+
+	val := w.originalResponseHeaders.Get(httpheaders.LastModified)
+	if len(val) == 0 {
+		return
+	}
+
+	w.result.Set(httpheaders.LastModified, val)
+}
+
+// SetVary sets the Vary header
+func (w *Writer) SetVary() {
+	if len(w.varyValue) > 0 {
+		w.result.Set(httpheaders.Vary, w.varyValue)
+	}
+}
+
+// Passthrough copies specified headers from the original response headers to the response headers.
+func (w *Writer) Passthrough(only []string) {
+	for _, key := range only {
+		values := w.originalResponseHeaders.Values(key)
+
+		for _, value := range values {
+			w.result.Add(key, value)
+		}
+	}
+}
+
+// CopyFrom copies specified headers from the headers object. Please note that
+// all the past operations may overwrite those values.
+func (w *Writer) CopyFrom(headers http.Header, only []string) {
+	for _, key := range only {
+		values := headers.Values(key)
+
+		for _, value := range values {
+			w.result.Add(key, value)
+		}
+	}
+}
+
+// SetContentLength sets the Content-Length header
+func (w *Writer) SetContentLength(contentLength int) {
+	if contentLength < 0 {
+		return
+	}
+
+	w.result.Set(httpheaders.ContentLength, strconv.Itoa(contentLength))
+}
+
+// SetContentType sets the Content-Type header
+func (w *Writer) SetContentType(mime string) {
+	w.result.Set(httpheaders.ContentType, mime)
+}
+
+// writeCanonical sets the Link header with the canonical URL.
+// It is mandatory for any response if enabled in the configuration.
+func (b *Writer) SetCanonical() {
+	if !b.config.SetCanonicalHeader {
+		return
+	}
+
+	if strings.HasPrefix(b.url, "https://") || strings.HasPrefix(b.url, "http://") {
+		value := fmt.Sprintf(`<%s>; rel="canonical"`, b.url)
+		b.result.Set(httpheaders.Link, value)
+	}
+}
+
+// setCacheControl sets the Cache-Control header with the specified value.
+func (w *Writer) setCacheControl(value int) bool {
+	if value <= 0 {
+		return false
+	}
+
+	w.result.Set(httpheaders.CacheControl, fmt.Sprintf("max-age=%d, public", value))
+	return true
+}
+
+// setCacheControlNoCache sets the Cache-Control header to no-cache (default).
+func (w *Writer) setCacheControlNoCache() {
+	w.result.Set(httpheaders.CacheControl, "no-cache")
+}
+
+// setCacheControlPassthrough sets the Cache-Control header from the request
+// if passthrough is enabled in the configuration.
+func (w *Writer) setCacheControlPassthrough() bool {
+	if !w.config.CacheControlPassthrough || w.maxAge > 0 {
+		return false
+	}
+
+	if val := w.originalResponseHeaders.Get(httpheaders.CacheControl); val != "" {
+		w.result.Set(httpheaders.CacheControl, val)
+		return true
+	}
+
+	if val := w.originalResponseHeaders.Get(httpheaders.Expires); val != "" {
+		if t, err := time.Parse(http.TimeFormat, val); err == nil {
+			maxAge := max(0, int(time.Until(t).Seconds()))
+			return w.setCacheControl(maxAge)
+		}
+	}
+
+	return false
+}
+
+// setCSP sets the Content-Security-Policy header to prevent script execution.
+func (w *Writer) setCSP() {
+	w.result.Set(httpheaders.ContentSecurityPolicy, "script-src 'none'")
+}
+
+// Write writes the headers to the response writer. It does not overwrite
+// target headers, which were set outside the header writer.
+func (w *Writer) Write(rw http.ResponseWriter) {
+	// Then, let's try to set Cache-Control using priority order
+	switch {
+	case w.setCacheControl(w.maxAge): // First, try set explicit
+	case w.setCacheControlPassthrough(): // Try to pick up from request headers
+	case w.setCacheControl(w.config.DefaultTTL): // Fallback to default value
+	default:
+		w.setCacheControlNoCache() // By default we use no-cache
+	}
+
+	w.setCSP()
+
+	for key, values := range w.result {
+		// Do not overwrite existing headers which were set outside the header writer
+		if len(rw.Header().Get(key)) > 0 {
+			continue
+		}
+
+		for _, value := range values {
+			rw.Header().Add(key, value)
+		}
+	}
+}

+ 340 - 0
headerwriter/writer_test.go

@@ -0,0 +1,340 @@
+package headerwriter
+
+import (
+	"fmt"
+	"math"
+	"net/http"
+	"net/http/httptest"
+	"strconv"
+	"testing"
+	"time"
+
+	"github.com/imgproxy/imgproxy/v3/httpheaders"
+	"github.com/stretchr/testify/suite"
+)
+
+type HeaderWriterSuite struct {
+	suite.Suite
+}
+
+type writerTestCase struct {
+	name   string
+	url    string
+	req    http.Header
+	res    http.Header
+	config Config
+	fn     func(*Writer)
+}
+
+func (s *HeaderWriterSuite) TestHeaderCases() {
+	expires := time.Date(2030, 8, 1, 0, 0, 0, 0, time.UTC)
+	expiresSeconds := strconv.Itoa(int(time.Until(expires).Seconds()))
+
+	shortExpires := time.Now().Add(10 * time.Second)
+	shortExpiresSeconds := strconv.Itoa(int(time.Until(shortExpires).Seconds()))
+
+	tt := []writerTestCase{
+		{
+			name: "MinimalHeaders",
+			req:  http.Header{},
+			res: http.Header{
+				httpheaders.CacheControl:          []string{"no-cache"},
+				httpheaders.ContentSecurityPolicy: []string{"script-src 'none'"},
+			},
+			config: Config{
+				SetCanonicalHeader:      false,
+				DefaultTTL:              0,
+				CacheControlPassthrough: false,
+				LastModifiedEnabled:     false,
+				EnableClientHints:       false,
+				SetVaryAccept:           false,
+			},
+		},
+		{
+			name: "PassthroughCacheControl",
+			req: http.Header{
+				httpheaders.CacheControl: []string{"no-cache, no-store, must-revalidate"},
+			},
+			res: http.Header{
+				httpheaders.CacheControl:          []string{"no-cache, no-store, must-revalidate"},
+				httpheaders.ContentSecurityPolicy: []string{"script-src 'none'"},
+			},
+			config: Config{
+				CacheControlPassthrough: true,
+				DefaultTTL:              3600,
+			},
+		},
+		{
+			name: "PassthroughCacheControlExpires",
+			req: http.Header{
+				httpheaders.Expires: []string{expires.Format(http.TimeFormat)},
+			},
+			res: http.Header{
+				httpheaders.CacheControl:          []string{fmt.Sprintf("max-age=%s, public", expiresSeconds)},
+				httpheaders.ContentSecurityPolicy: []string{"script-src 'none'"},
+			},
+			config: Config{
+				CacheControlPassthrough: true,
+				DefaultTTL:              3600,
+			},
+		},
+		{
+			name: "PassthroughCacheControlExpiredInThePast",
+			req: http.Header{
+				httpheaders.Expires: []string{time.Now().Add(-1 * time.Hour).UTC().Format(http.TimeFormat)},
+			},
+			res: http.Header{
+				httpheaders.CacheControl:          []string{"max-age=3600, public"},
+				httpheaders.ContentSecurityPolicy: []string{"script-src 'none'"},
+			},
+			config: Config{
+				CacheControlPassthrough: true,
+				DefaultTTL:              3600,
+			},
+		},
+		{
+			name: "Canonical_ValidURL",
+			req:  http.Header{},
+			url:  "https://example.com/image.jpg",
+			res: http.Header{
+				httpheaders.Link:                  []string{"<https://example.com/image.jpg>; rel=\"canonical\""},
+				httpheaders.CacheControl:          []string{"max-age=3600, public"},
+				httpheaders.ContentSecurityPolicy: []string{"script-src 'none'"},
+			},
+			config: Config{
+				SetCanonicalHeader: true,
+				DefaultTTL:         3600,
+			},
+			fn: func(w *Writer) {
+				w.SetCanonical()
+			},
+		},
+		{
+			name: "Canonical_InvalidURL",
+			url:  "ftp://example.com/image.jpg",
+			req:  http.Header{},
+			res: http.Header{
+				httpheaders.CacheControl:          []string{"max-age=3600, public"},
+				httpheaders.ContentSecurityPolicy: []string{"script-src 'none'"},
+			},
+			config: Config{
+				SetCanonicalHeader: true,
+				DefaultTTL:         3600,
+			},
+		},
+		{
+			name: "WriteCanonical_Disabled",
+			req:  http.Header{},
+			url:  "https://example.com/image.jpg",
+			res: http.Header{
+				httpheaders.CacheControl:          []string{"max-age=3600, public"},
+				httpheaders.ContentSecurityPolicy: []string{"script-src 'none'"},
+			},
+			config: Config{
+				SetCanonicalHeader: false,
+				DefaultTTL:         3600,
+			},
+			fn: func(w *Writer) {
+				w.SetCanonical()
+			},
+		},
+		{
+			name: "LastModified",
+			req: http.Header{
+				httpheaders.LastModified: []string{expires.Format(http.TimeFormat)},
+			},
+			res: http.Header{
+				httpheaders.LastModified:          []string{expires.Format(http.TimeFormat)},
+				httpheaders.ContentSecurityPolicy: []string{"script-src 'none'"},
+				httpheaders.CacheControl:          []string{"max-age=3600, public"},
+			},
+			config: Config{
+				LastModifiedEnabled: true,
+				DefaultTTL:          3600,
+			},
+			fn: func(w *Writer) {
+				w.SetLastModified()
+			},
+		},
+		{
+			name: "SetMaxAgeTTL",
+			req:  http.Header{},
+			res: http.Header{
+				httpheaders.CacheControl:          []string{"max-age=1, public"},
+				httpheaders.ContentSecurityPolicy: []string{"script-src 'none'"},
+			},
+			config: Config{
+				DefaultTTL:       3600,
+				FallbackImageTTL: 1,
+			},
+			fn: func(w *Writer) {
+				w.SetIsFallbackImage()
+			},
+		},
+		{
+			name: "SetMaxAgeExpires",
+			req:  http.Header{},
+			res: http.Header{
+				httpheaders.CacheControl:          []string{fmt.Sprintf("max-age=%s, public", expiresSeconds)},
+				httpheaders.ContentSecurityPolicy: []string{"script-src 'none'"},
+			},
+			config: Config{
+				DefaultTTL: math.MaxInt32,
+			},
+			fn: func(w *Writer) {
+				w.SetExpires(&expires)
+			},
+		},
+		{
+			name: "SetMaxAgeTTLOutlivesExpires",
+			req:  http.Header{},
+			res: http.Header{
+				httpheaders.CacheControl:          []string{fmt.Sprintf("max-age=%s, public", shortExpiresSeconds)},
+				httpheaders.ContentSecurityPolicy: []string{"script-src 'none'"},
+			},
+			config: Config{
+				DefaultTTL:       math.MaxInt32,
+				FallbackImageTTL: 600,
+			},
+			fn: func(w *Writer) {
+				w.SetIsFallbackImage()
+				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{
+				EnableClientHints: true,
+				SetVaryAccept:     true,
+			},
+			fn: func(w *Writer) {
+				w.SetVary()
+			},
+		},
+		{
+			name: "PassthroughHeaders",
+			req: http.Header{
+				"X-Test": []string{"foo", "bar"},
+			},
+			res: http.Header{
+				"X-Test":                          []string{"foo", "bar"},
+				httpheaders.CacheControl:          []string{"no-cache"},
+				httpheaders.ContentSecurityPolicy: []string{"script-src 'none'"},
+			},
+			config: Config{},
+			fn: func(w *Writer) {
+				w.Passthrough([]string{"X-Test"})
+			},
+		},
+		{
+			name: "CopyFromHeaders",
+			req:  http.Header{},
+			res: http.Header{
+				"X-From":                          []string{"baz"},
+				httpheaders.CacheControl:          []string{"no-cache"},
+				httpheaders.ContentSecurityPolicy: []string{"script-src 'none'"},
+			},
+			config: Config{},
+			fn: func(w *Writer) {
+				h := http.Header{}
+				h.Set("X-From", "baz")
+				w.CopyFrom(h, []string{"X-From"})
+			},
+		},
+		{
+			name: "WriteContentLength",
+			req:  http.Header{},
+			res: http.Header{
+				httpheaders.ContentLength:         []string{"123"},
+				httpheaders.CacheControl:          []string{"no-cache"},
+				httpheaders.ContentSecurityPolicy: []string{"script-src 'none'"},
+			},
+			config: Config{},
+			fn: func(w *Writer) {
+				w.SetContentLength(123)
+			},
+		},
+		{
+			name: "WriteContentType",
+			req:  http.Header{},
+			res: http.Header{
+				httpheaders.ContentType:           []string{"image/png"},
+				httpheaders.CacheControl:          []string{"no-cache"},
+				httpheaders.ContentSecurityPolicy: []string{"script-src 'none'"},
+			},
+			config: Config{},
+			fn: func(w *Writer) {
+				w.SetContentType("image/png")
+			},
+		},
+		{
+			name: "SetMaxAgeFromExpiresNil",
+			req:  http.Header{},
+			res: http.Header{
+				httpheaders.CacheControl:          []string{"max-age=3600, public"},
+				httpheaders.ContentSecurityPolicy: []string{"script-src 'none'"},
+			},
+			config: Config{
+				DefaultTTL: 3600,
+			},
+			fn: func(w *Writer) {
+				w.SetExpires(nil)
+			},
+		},
+		{
+			name: "WriteVaryAcceptOnly",
+			req:  http.Header{},
+			res: http.Header{
+				httpheaders.Vary:                  []string{"Accept"},
+				httpheaders.CacheControl:          []string{"no-cache"},
+				httpheaders.ContentSecurityPolicy: []string{"script-src 'none'"},
+			},
+			config: Config{
+				SetVaryAccept: true,
+			},
+			fn: func(w *Writer) {
+				w.SetVary()
+			},
+		},
+		{
+			name: "WriteVaryClientHintsOnly",
+			req:  http.Header{},
+			res: http.Header{
+				httpheaders.Vary:                  []string{"Sec-CH-DPR, DPR, Sec-CH-Width, Width"},
+				httpheaders.CacheControl:          []string{"no-cache"},
+				httpheaders.ContentSecurityPolicy: []string{"script-src 'none'"},
+			},
+			config: Config{
+				EnableClientHints: true,
+			},
+			fn: func(w *Writer) {
+				w.SetVary()
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		s.Run(tc.name, func() {
+			writer := New(&tc.config, tc.req, tc.url)
+
+			if tc.fn != nil {
+				tc.fn(writer)
+			}
+
+			r := httptest.NewRecorder()
+			writer.Write(r)
+
+			s.Require().Equal(tc.res, r.Header())
+		})
+	}
+}
+
+func TestHeaderWriter(t *testing.T) {
+	suite.Run(t, new(HeaderWriterSuite))
+}

+ 1 - 1
httpheaders/cdv.go

@@ -53,7 +53,7 @@ func ContentDispositionValue(url, filename, ext, contentType string, returnAttac
 	// If ext is provided explicitly, use it
 	if len(ext) > 0 {
 		rExt = ext
-	} else if len(contentType) > 0 {
+	} else if len(contentType) > 0 && rExt == "" {
 		exts, err := mime.ExtensionsByType(contentType)
 		if err == nil && len(exts) != 0 {
 			rExt = exts[0]

+ 1 - 0
httpheaders/headers.go

@@ -46,6 +46,7 @@ const (
 	Location                        = "Location"
 	Origin                          = "Origin"
 	Pragma                          = "Pragma"
+	Range                           = "Range"
 	Referer                         = "Referer"
 	RequestId                       = "Request-Id"
 	RetryAfter                      = "Retry-After"

+ 7 - 1
processing_handler.go

@@ -17,6 +17,8 @@ import (
 	"github.com/imgproxy/imgproxy/v3/cookies"
 	"github.com/imgproxy/imgproxy/v3/errorreport"
 	"github.com/imgproxy/imgproxy/v3/etag"
+	"github.com/imgproxy/imgproxy/v3/handlers/stream"
+	"github.com/imgproxy/imgproxy/v3/headerwriter"
 	"github.com/imgproxy/imgproxy/v3/httpheaders"
 	"github.com/imgproxy/imgproxy/v3/ierrors"
 	"github.com/imgproxy/imgproxy/v3/imagedata"
@@ -275,7 +277,11 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) err
 	}
 
 	if po.Raw {
-		return streamOriginImage(ctx, reqID, r, rw, po, imageURL)
+		// TODO: Move this up
+		cfg := stream.NewConfigFromEnv()
+		hwCfg := headerwriter.NewConfigFromEnv()
+		handler := stream.New(cfg, hwCfg, imagedata.Fetcher)
+		return handler.Execute(ctx, r, imageURL, reqID, po, rw)
 	}
 
 	// SVG is a special case. Though saving to svg is not supported, SVG->SVG is.

+ 0 - 5
server/timeout_response.go

@@ -34,11 +34,6 @@ func (rw *timeoutResponse) Write(b []byte) (int, error) {
 	return n, err
 }
 
-// Header returns current HTTP headers
-func (rw *timeoutResponse) Header() http.Header {
-	return rw.ResponseWriter.Header()
-}
-
 // withWriteDeadline executes a Write* function with a deadline
 func (rw *timeoutResponse) withWriteDeadline(f func()) {
 	deadline := time.Now().Add(rw.timeout)

+ 0 - 138
stream.go

@@ -1,138 +0,0 @@
-package main
-
-import (
-	"context"
-	"io"
-	"net/http"
-	"strconv"
-	"sync"
-
-	log "github.com/sirupsen/logrus"
-
-	"github.com/imgproxy/imgproxy/v3/config"
-	"github.com/imgproxy/imgproxy/v3/cookies"
-	"github.com/imgproxy/imgproxy/v3/httpheaders"
-	"github.com/imgproxy/imgproxy/v3/ierrors"
-	"github.com/imgproxy/imgproxy/v3/imagedata"
-	"github.com/imgproxy/imgproxy/v3/monitoring"
-	"github.com/imgproxy/imgproxy/v3/monitoring/stats"
-	"github.com/imgproxy/imgproxy/v3/options"
-	"github.com/imgproxy/imgproxy/v3/server"
-)
-
-var (
-	streamReqHeaders = []string{
-		"If-None-Match",
-		"If-Modified-Since",
-		"Accept-Encoding",
-		"Range",
-	}
-
-	streamRespHeaders = []string{
-		"ETag",
-		"Content-Type",
-		"Content-Encoding",
-		"Content-Range",
-		"Accept-Ranges",
-		"Last-Modified",
-	}
-
-	streamBufPool = sync.Pool{
-		New: func() interface{} {
-			buf := make([]byte, 4096)
-			return &buf
-		},
-	}
-)
-
-func streamOriginImage(ctx context.Context, reqID string, r *http.Request, rw http.ResponseWriter, po *options.ProcessingOptions, imageURL string) error {
-	stats.IncImagesInProgress()
-	defer stats.DecImagesInProgress()
-	defer monitoring.StartStreamingSegment(ctx)()
-
-	var (
-		cookieJar http.CookieJar
-		err       error
-	)
-
-	imgRequestHeader := make(http.Header)
-
-	for _, k := range streamReqHeaders {
-		if v := r.Header.Get(k); len(v) != 0 {
-			imgRequestHeader.Set(k, v)
-		}
-	}
-
-	if config.CookiePassthrough {
-		cookieJar, err = cookies.JarFromRequest(r)
-		if err != nil {
-			return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryStreaming))
-		}
-	}
-
-	req, err := imagedata.Fetcher.BuildRequest(r.Context(), imageURL, imgRequestHeader, cookieJar)
-	defer req.Cancel()
-	if err != nil {
-		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryStreaming))
-	}
-
-	res, err := req.Send()
-	if res != nil {
-		defer res.Body.Close()
-	}
-	if err != nil {
-		return ierrors.Wrap(err, 0, ierrors.WithCategory(categoryStreaming))
-	}
-
-	for _, k := range streamRespHeaders {
-		vv := res.Header.Values(k)
-		for _, v := range vv {
-			rw.Header().Set(k, v)
-		}
-	}
-
-	if res.ContentLength >= 0 {
-		rw.Header().Set("Content-Length", strconv.Itoa(int(res.ContentLength)))
-	}
-
-	if res.StatusCode < 300 {
-		contentDisposition := httpheaders.ContentDispositionValue(
-			req.URL().Path,
-			po.Filename,
-			"",
-			rw.Header().Get(httpheaders.ContentType),
-			po.ReturnAttachment,
-		)
-		rw.Header().Set("Content-Disposition", contentDisposition)
-	}
-
-	setCacheControl(rw, po.Expires, res.Header)
-	setCanonical(rw, imageURL)
-	rw.Header().Set("Content-Security-Policy", "script-src 'none'")
-
-	rw.WriteHeader(res.StatusCode)
-
-	buf := streamBufPool.Get().(*[]byte)
-	defer streamBufPool.Put(buf)
-
-	_, copyerr := io.CopyBuffer(rw, res.Body, *buf)
-	if copyerr == http.ErrBodyNotAllowed {
-		// We can hit this for some statuses like 304 Not Modified.
-		// We can ignore this error.
-		copyerr = nil
-	}
-
-	server.LogResponse(
-		reqID, r, res.StatusCode, nil,
-		log.Fields{
-			"image_url":          imageURL,
-			"processing_options": po,
-		},
-	)
-
-	if copyerr != nil {
-		panic(http.ErrAbortHandler)
-	}
-
-	return nil
-}