Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 26 additions & 16 deletions cmd/project/project_image_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve raw queries when using Rewrite

For requests whose image URL contains query parameters that Go considers unparsable, such as semicolon-separated transform options (/img.svg?w=100;h=100&fmt=webp) or a literal bad escape, ReverseProxy cleans req.Out.URL.RawQuery before invoking Rewrite; SetURL then forwards only the cleaned query. The old Director path preserved the raw query unchanged, so these requests now reach the upstream with parameters silently dropped unless req.In.URL.RawQuery is restored after rewriting.

Useful? React with 👍 / 👎.

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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
})

Expand Down
43 changes: 31 additions & 12 deletions cmd/project/project_image_proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
Expand All @@ -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 := `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">` + strings.Repeat(`<circle cx="50" cy="50" r="40" fill="red"/>`, 100) + `</svg>`

Expand All @@ -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 {
Expand Down Expand Up @@ -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)
})

Expand Down
Loading