diff --git a/cmd/project/project_image_proxy.go b/cmd/project/project_image_proxy.go index 39a652f5..c0d1a784 100644 --- a/cmd/project/project_image_proxy.go +++ b/cmd/project/project_image_proxy.go @@ -28,6 +28,31 @@ var ( imageProxySkipConfig bool ) +// newImageReverseProxy creates a reverse proxy for the image proxy upstream. +func newImageReverseProxy(upstream *url.URL) *httputil.ReverseProxy { + return &httputil.ReverseProxy{ + Rewrite: func(req *httputil.ProxyRequest) { + req.SetURL(upstream) + req.Out.URL.RawQuery = joinRawQuery(upstream.RawQuery, req.In.URL.RawQuery) + req.SetXForwarded() + + // Strip Accept-Encoding from the outgoing request so Go's Transport + // transparently decompresses upstream responses. This ensures the + // cache always stores identity-encoded (uncompressed) content, avoiding + // double-compression when serving cache hits through the gzip handler. + req.Out.Header.Del("Accept-Encoding") + }, + } +} + +func joinRawQuery(targetQuery, requestQuery string) string { + if targetQuery == "" || requestQuery == "" { + return targetQuery + requestQuery + } + + return targetQuery + "&" + requestQuery +} + // cacheReadCloser wraps a response body, teeing reads into a buffer. // When the body is closed, onClose is called to persist the captured bytes. type cacheReadCloser struct { @@ -100,22 +125,12 @@ If a file is not found locally, it proxies the request to the upstream server.`, } // Create reverse proxy that captures response bodies for caching - proxy := httputil.NewSingleHostReverseProxy(upstream) + proxy := newImageReverseProxy(upstream) proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { logging.FromContext(cmd.Context()).Errorf("proxy error: %v", err) http.Error(w, "Bad Gateway", http.StatusBadGateway) } - // Strip Accept-Encoding from the outgoing request so Go's Transport - // transparently decompresses upstream responses. This ensures the - // cache always stores identity-encoded (uncompressed) content, avoiding - // double-compression when serving cache hits through the gzip handler. - defaultDirector := proxy.Director //nolint:staticcheck // ReverseProxy.Director deprecated in Go 1.26; migrate to Rewrite - proxy.Director = func(req *http.Request) { //nolint:staticcheck // ReverseProxy.Director deprecated in Go 1.26; migrate to Rewrite - defaultDirector(req) - req.Header.Del("Accept-Encoding") - } - // ModifyResponse tees the response body so that bytes are captured for // caching as the reverse proxy streams them to the client. This avoids // buffering the entire response in memory before writing. @@ -197,11 +212,6 @@ If a file is not found locally, it proxies the request to the upstream server.`, // If not found locally or in cache, proxy to upstream logging.FromContext(cmd.Context()).Debugf("Proxying to upstream: %s", cleanPath) - // Preserve the original path in the proxied request - r.URL.Host = upstream.Host - r.URL.Scheme = upstream.Scheme - r.Host = upstream.Host - proxy.ServeHTTP(w, r) }) diff --git a/cmd/project/project_image_proxy_test.go b/cmd/project/project_image_proxy_test.go index 637b746e..02b90266 100644 --- a/cmd/project/project_image_proxy_test.go +++ b/cmd/project/project_image_proxy_test.go @@ -6,7 +6,6 @@ import ( "io" "net/http" "net/http/httptest" - "net/http/httputil" "net/url" "os" "path/filepath" @@ -17,6 +16,36 @@ import ( "github.com/stretchr/testify/assert" ) +func TestImageReverseProxyPreservesRawQuery(t *testing.T) { + upstreamRawQuery := make(chan string, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamRawQuery <- r.URL.RawQuery + w.WriteHeader(http.StatusNoContent) + })) + defer upstream.Close() + + upstreamURL, err := url.Parse(upstream.URL + "?base=1") + assert.NoError(t, err) + + server := httptest.NewServer(newImageReverseProxy(upstreamURL)) + defer server.Close() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, server.URL+"/test.svg?w=100;h=100&fmt=webp", nil) + assert.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + _ = resp.Body.Close() + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + select { + case rawQuery := <-upstreamRawQuery: + assert.Equal(t, "base=1&w=100;h=100&fmt=webp", rawQuery) + default: + assert.Fail(t, "upstream did not receive request") + } +} + func TestImageProxySVG(t *testing.T) { svgContent := `` + strings.Repeat(``, 100) + `` @@ -35,13 +64,7 @@ func TestImageProxySVG(t *testing.T) { cacheDir := t.TempDir() - proxy := httputil.NewSingleHostReverseProxy(upstreamURL) - - defaultDirector := proxy.Director //nolint:staticcheck // ReverseProxy.Director deprecated in Go 1.26; migrate to Rewrite - proxy.Director = func(req *http.Request) { //nolint:staticcheck // ReverseProxy.Director deprecated in Go 1.26; migrate to Rewrite - defaultDirector(req) - req.Header.Del("Accept-Encoding") - } + proxy := newImageReverseProxy(upstreamURL) proxy.ModifyResponse = func(res *http.Response) error { if res.StatusCode != http.StatusOK { @@ -86,10 +109,6 @@ func TestImageProxySVG(t *testing.T) { return } - r.URL.Host = upstreamURL.Host - r.URL.Scheme = upstreamURL.Scheme - r.Host = upstreamURL.Host - proxy.ServeHTTP(w, r) })