mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
Adds PrecompressedStatic handler in internal/server/static_precompressed.go, wired by routes_static.go for /static/*filepath (bundled) and /c/static/*filepath (custom). When a .zst or .gz sibling is on disk and the client's Accept-Encoding allows it, the handler streams the sibling via http.ServeContent — preserving Last-Modified and If-Modified-Since revalidation, deriving Content-Type from the identity filename so service-worker registration and SRI continue to work, and setting an explicit Content-Length that http.ServeContent omits when Content-Encoding is in effect. Range requests always serve identity (the byte offsets in Content-Range correspond to identity bytes), and PHOTOPRISM_HTTP_COMPRESSION=none disables encoded-sibling selection so the operator switch stays consistent with the runtime middleware. Vary: Accept-Encoding is emitted only when the response could realistically vary by encoding. Both /static/* and /c/static/* are added to NewShouldCompressFn's exclusion list so the runtime middleware never re-encodes an already-encoded body.
443 lines
16 KiB
Go
443 lines
16 KiB
Go
package server
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/klauspost/compress/zstd"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/photoprism/photoprism/internal/config"
|
|
)
|
|
|
|
// newCompressTestRouter builds a router with the configured compression
|
|
// preferences applied via NewCompressMiddleware. The optional handler is
|
|
// registered at /ok for round-trip assertions; additional routes that share
|
|
// the standard handler can be registered by the caller.
|
|
func newCompressTestRouter(t *testing.T, prefs string) (*gin.Engine, *config.Config) {
|
|
t.Helper()
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
conf := config.TestConfig()
|
|
conf.Options().HttpCompression = prefs
|
|
|
|
r := gin.New()
|
|
r.Use(NewCompressMiddleware(conf))
|
|
r.GET("/ok", func(c *gin.Context) {
|
|
c.String(http.StatusOK, "hello world")
|
|
})
|
|
return r, conf
|
|
}
|
|
|
|
func TestNewCompressMiddleware_Zstd(t *testing.T) {
|
|
r, conf := newCompressTestRouter(t, "zstd")
|
|
|
|
excludedPath := conf.BaseUri(config.ApiUri + "/dl/test")
|
|
r.GET(excludedPath, func(c *gin.Context) {
|
|
c.String(http.StatusOK, "download")
|
|
})
|
|
|
|
doRequest := func(path, accept string) *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
if accept != "" {
|
|
req.Header.Set("Accept-Encoding", accept)
|
|
}
|
|
r.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
t.Run("EncodesAndRoundTrips", func(t *testing.T) {
|
|
w := doRequest("/ok", "zstd")
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
assert.Equal(t, "zstd", w.Header().Get("Content-Encoding"))
|
|
assert.Contains(t, w.Header().Get("Vary"), "Accept-Encoding")
|
|
|
|
zr, err := zstd.NewReader(strings.NewReader(w.Body.String()))
|
|
require.NoError(t, err)
|
|
defer zr.Close()
|
|
decoded, err := io.ReadAll(zr)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "hello world", string(decoded))
|
|
})
|
|
|
|
t.Run("ClientWithoutZstdGetsIdentityWithVary", func(t *testing.T) {
|
|
w := doRequest("/ok", "gzip")
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
assert.Empty(t, w.Header().Get("Content-Encoding"))
|
|
assert.Contains(t, w.Header().Get("Vary"), "Accept-Encoding")
|
|
assert.Equal(t, "hello world", w.Body.String())
|
|
})
|
|
|
|
t.Run("MissingAcceptEncodingFallsThrough", func(t *testing.T) {
|
|
w := doRequest("/ok", "")
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
assert.Empty(t, w.Header().Get("Content-Encoding"))
|
|
assert.Contains(t, w.Header().Get("Vary"), "Accept-Encoding")
|
|
assert.Equal(t, "hello world", w.Body.String())
|
|
})
|
|
|
|
t.Run("ExcludedPathIsNeverCompressed", func(t *testing.T) {
|
|
w := doRequest(excludedPath, "zstd")
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
assert.Empty(t, w.Header().Get("Content-Encoding"))
|
|
assert.Empty(t, w.Header().Get("Vary"))
|
|
assert.Equal(t, "download", w.Body.String())
|
|
})
|
|
|
|
t.Run("ConnectionUpgradeBypassesCompression", func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/ok", nil)
|
|
req.Header.Set("Accept-Encoding", "zstd")
|
|
req.Header.Set("Connection", "Upgrade")
|
|
req.Header.Set("Upgrade", "websocket")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
assert.Empty(t, w.Header().Get("Content-Encoding"))
|
|
assert.Empty(t, w.Header().Get("Vary"))
|
|
})
|
|
}
|
|
|
|
func TestNewCompressMiddleware_PrefersZstdOverGzip(t *testing.T) {
|
|
r, _ := newCompressTestRouter(t, "zstd,gzip")
|
|
|
|
t.Run("BothAcceptedZstdWins", func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/ok", nil)
|
|
req.Header.Set("Accept-Encoding", "gzip, zstd")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
assert.Equal(t, "zstd", w.Header().Get("Content-Encoding"))
|
|
assert.Contains(t, w.Header().Get("Vary"), "Accept-Encoding")
|
|
})
|
|
|
|
t.Run("OnlyGzipAcceptedFallsBack", func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/ok", nil)
|
|
req.Header.Set("Accept-Encoding", "gzip")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
assert.Equal(t, "gzip", w.Header().Get("Content-Encoding"))
|
|
})
|
|
|
|
t.Run("UnknownEncodingYieldsIdentityWithVary", func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/ok", nil)
|
|
req.Header.Set("Accept-Encoding", "br")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
assert.Empty(t, w.Header().Get("Content-Encoding"))
|
|
assert.Contains(t, w.Header().Get("Vary"), "Accept-Encoding")
|
|
assert.Equal(t, "hello world", w.Body.String())
|
|
})
|
|
}
|
|
|
|
func TestNewCompressMiddleware_BypassesPartialContent(t *testing.T) {
|
|
// 206 Partial Content responses serve a slice of the identity
|
|
// representation. Compressing them would scramble the byte offsets the
|
|
// client asked for and contradict the Content-Range header. The bundled
|
|
// `/static/*` route is now served by PrecompressedStatic and excluded
|
|
// from the runtime middleware, so this test uses a non-excluded path
|
|
// to verify the middleware-side 206 bypass for any future compressible
|
|
// route that may produce a 206 (e.g. routes_webapp.go).
|
|
for _, prefs := range []string{"gzip", "zstd"} {
|
|
t.Run("Prefs="+prefs, func(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
conf := config.TestConfig()
|
|
conf.Options().HttpCompression = prefs
|
|
|
|
r := gin.New()
|
|
r.Use(NewCompressMiddleware(conf))
|
|
// Mimic what http.ServeContent does for a Range request: set
|
|
// Content-Range, status 206, and write only the requested slice.
|
|
r.GET("/bundle/app.js", func(c *gin.Context) {
|
|
c.Header("Content-Range", "bytes 0-9/100")
|
|
c.Header("Accept-Ranges", "bytes")
|
|
c.Data(http.StatusPartialContent, "application/javascript", []byte("0123456789"))
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/bundle/app.js", nil)
|
|
req.Header.Set("Range", "bytes=0-9")
|
|
req.Header.Set("Accept-Encoding", prefs)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusPartialContent, w.Code)
|
|
assert.Empty(t, w.Header().Get("Content-Encoding"),
|
|
"206 must not carry Content-Encoding — the slice corresponds to identity bytes")
|
|
assert.Equal(t, "bytes 0-9/100", w.Header().Get("Content-Range"))
|
|
assert.Equal(t, "0123456789", w.Body.String(),
|
|
"206 body must be the raw slice the handler wrote, not encoded bytes")
|
|
assert.Contains(t, w.Header().Get("Vary"), "Accept-Encoding",
|
|
"Vary still belongs on the response so caches don't reuse the slice for a non-Range request")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewCompressMiddleware_BypassesPartialContentViaGinStatic(t *testing.T) {
|
|
// End-to-end check that the bypass also works when 206 is produced by
|
|
// gin.Static (i.e. http.ServeFile / http.ServeContent), not just by a
|
|
// hand-crafted handler. The bundled `/static/*` and `/c/static/*` routes
|
|
// are now served by PrecompressedStatic and excluded from the runtime
|
|
// middleware via NewShouldCompressFn, so this test uses a route that is
|
|
// still in the runtime middleware's scope to exercise the same code
|
|
// path on any future compressible static-file route.
|
|
for _, prefs := range []string{"gzip", "zstd"} {
|
|
t.Run("Prefs="+prefs, func(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
conf := config.TestConfig()
|
|
conf.Options().HttpCompression = prefs
|
|
|
|
// Write a real file with deterministic content so we can assert
|
|
// the byte slice that comes back through the wire.
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "app.js")
|
|
alphabet := "0123456789abcdefghijklmnopqrstuvwxyz" // 36 bytes
|
|
content := []byte(strings.Repeat(alphabet, 3))[:100]
|
|
require.Len(t, content, 100)
|
|
require.NoError(t, os.WriteFile(path, content, 0o600))
|
|
|
|
r := gin.New()
|
|
r.Use(NewCompressMiddleware(conf))
|
|
// Use a route that is not on the predicate's exclusion list so
|
|
// the middleware's 206 bypass — not the static-route exclusion —
|
|
// is what we're verifying here.
|
|
r.Static("/bundle", dir)
|
|
|
|
t.Run("RangeRequestReturns206RawBytes", func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/bundle/app.js", nil)
|
|
req.Header.Set("Range", "bytes=10-19")
|
|
req.Header.Set("Accept-Encoding", prefs)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusPartialContent, w.Code,
|
|
"http.ServeContent should produce 206 for a satisfiable Range request")
|
|
assert.Empty(t, w.Header().Get("Content-Encoding"),
|
|
"206 from gin.Static must not be re-encoded by the compression middleware")
|
|
assert.Equal(t, "bytes 10-19/100", w.Header().Get("Content-Range"))
|
|
assert.Equal(t, "abcdefghij", w.Body.String(),
|
|
"206 body must be the raw byte slice that http.ServeContent wrote, not encoded")
|
|
assert.Contains(t, w.Header().Get("Vary"), "Accept-Encoding",
|
|
"Vary still belongs on the response so caches don't reuse the slice for non-Range requests")
|
|
})
|
|
|
|
t.Run("FullRequestStillCompresses", func(t *testing.T) {
|
|
// Sanity check: a non-Range GET on the same route still
|
|
// goes through the encoder, so we know the bypass is targeted
|
|
// at 206 rather than blanket-disabling compression for the path.
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/bundle/app.js", nil)
|
|
req.Header.Set("Accept-Encoding", prefs)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
assert.Equal(t, prefs, w.Header().Get("Content-Encoding"),
|
|
"non-Range GET on a compressible static asset should still be encoded")
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewCompressMiddleware_BypassesErrorResponses(t *testing.T) {
|
|
// Both encoders should bypass for 4xx/5xx and bodyless statuses so the
|
|
// server doesn't burn CPU compressing tiny error payloads (e.g. 429 during
|
|
// a rate-limit storm) and so the encoder doesn't emit a stray frame
|
|
// trailer when the handler writes no body (e.g. 204).
|
|
for _, prefs := range []string{"gzip", "zstd"} {
|
|
t.Run("Prefs="+prefs, func(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
conf := config.TestConfig()
|
|
conf.Options().HttpCompression = prefs
|
|
r := gin.New()
|
|
r.Use(NewCompressMiddleware(conf))
|
|
r.GET("/rate-limited", func(c *gin.Context) {
|
|
c.String(http.StatusTooManyRequests, `{"error":"too many requests"}`)
|
|
})
|
|
r.GET("/server-error", func(c *gin.Context) {
|
|
c.String(http.StatusServiceUnavailable, `{"error":"unavailable"}`)
|
|
})
|
|
r.GET("/no-content", func(c *gin.Context) {
|
|
c.Status(http.StatusNoContent)
|
|
})
|
|
r.GET("/not-modified", func(c *gin.Context) {
|
|
c.Status(http.StatusNotModified)
|
|
})
|
|
|
|
cases := []struct {
|
|
name string
|
|
path string
|
|
code int
|
|
body string
|
|
}{
|
|
{"429RateLimited", "/rate-limited", http.StatusTooManyRequests, `{"error":"too many requests"}`},
|
|
{"503ServiceUnavailable", "/server-error", http.StatusServiceUnavailable, `{"error":"unavailable"}`},
|
|
{"204NoContent", "/no-content", http.StatusNoContent, ""},
|
|
{"304NotModified", "/not-modified", http.StatusNotModified, ""},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
|
req.Header.Set("Accept-Encoding", prefs)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, tc.code, w.Code)
|
|
assert.Empty(t, w.Header().Get("Content-Encoding"),
|
|
"%s response must not advertise Content-Encoding", tc.name)
|
|
assert.Contains(t, w.Header().Get("Vary"), "Accept-Encoding",
|
|
"%s response should still carry Vary so caches stay correct", tc.name)
|
|
assert.Equal(t, tc.body, w.Body.String(),
|
|
"%s body must match the handler's raw bytes", tc.name)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewCompressMiddleware_HeadRequests(t *testing.T) {
|
|
// HEAD requests carry no body — Go's net/http discards body writes for
|
|
// HEAD on the wire — so the encoder must be skipped to save pool slots
|
|
// and CPU. But Vary: Accept-Encoding must still be set when the
|
|
// corresponding GET would be content-coding negotiated, so caches don't
|
|
// reuse the response for clients that asked for a different encoding
|
|
// (RFC 9110 §15.4.5: HEAD may legitimately omit content-determined
|
|
// fields like Content-Length, but Vary still belongs on the response).
|
|
for _, prefs := range []string{"gzip", "zstd", "zstd,gzip"} {
|
|
t.Run("Prefs="+strings.ReplaceAll(prefs, ",", "_"), func(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
conf := config.TestConfig()
|
|
conf.Options().HttpCompression = prefs
|
|
r := gin.New()
|
|
r.Use(NewCompressMiddleware(conf))
|
|
r.Any("/ok", func(c *gin.Context) {
|
|
c.String(http.StatusOK, "hello world")
|
|
})
|
|
r.Any(conf.BaseUri(config.ApiUri+"/dl/test"), func(c *gin.Context) {
|
|
c.String(http.StatusOK, "download")
|
|
})
|
|
|
|
t.Run("CompressiblePathSetsVaryButSkipsEncoder", func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodHead, "/ok", nil)
|
|
req.Header.Set("Accept-Encoding", "zstd, gzip")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
assert.Empty(t, w.Header().Get("Content-Encoding"),
|
|
"HEAD response must not advertise Content-Encoding (encoder is skipped)")
|
|
assert.Contains(t, w.Header().Get("Vary"), "Accept-Encoding",
|
|
"HEAD must still set Vary so caches know the GET varies by Accept-Encoding")
|
|
})
|
|
|
|
t.Run("ExcludedPathSetsNoVary", func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodHead, conf.BaseUri(config.ApiUri+"/dl/test"), nil)
|
|
req.Header.Set("Accept-Encoding", "zstd, gzip")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
assert.Empty(t, w.Header().Get("Content-Encoding"))
|
|
assert.Empty(t, w.Header().Get("Vary"),
|
|
"HEAD on a predicate-excluded path does not vary by Accept-Encoding")
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewCompressMiddleware_WeakensETagOnEncodedResponses(t *testing.T) {
|
|
// When the response is compressed, strong ETags must be rewritten to
|
|
// W/<value> so caches don't reuse a compressed body for a client that
|
|
// asked for a different content-coding (RFC 9110 §13.2.4). Weak ETags
|
|
// and missing ETags must be left untouched. Bypassed responses
|
|
// (4xx/5xx/204/304) keep their original ETag because the wire bytes
|
|
// match the strong validator.
|
|
cases := []struct {
|
|
name string
|
|
prefs string
|
|
accept string
|
|
status int
|
|
etagIn string
|
|
etagOut string
|
|
encoding string
|
|
}{
|
|
{"GzipWeakensStrongETag", "gzip", "gzip", http.StatusOK, `"abc123"`, `W/"abc123"`, "gzip"},
|
|
{"ZstdWeakensStrongETag", "zstd", "zstd", http.StatusOK, `"abc123"`, `W/"abc123"`, "zstd"},
|
|
{"PreservesAlreadyWeakETag", "gzip", "gzip", http.StatusOK, `W/"abc123"`, `W/"abc123"`, "gzip"},
|
|
{"DoesNothingWhenNoETag", "gzip", "gzip", http.StatusOK, "", "", "gzip"},
|
|
{"PreservesETagOnBypass4xx", "gzip", "gzip", http.StatusBadRequest, `"abc123"`, `"abc123"`, ""},
|
|
{"PreservesETagOnBypass5xx", "zstd", "zstd", http.StatusInternalServerError, `"abc123"`, `"abc123"`, ""},
|
|
{"DoesNothingWhenIdentityNegotiated", "zstd", "br", http.StatusOK, `"abc123"`, `"abc123"`, ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
conf := config.TestConfig()
|
|
conf.Options().HttpCompression = tc.prefs
|
|
r := gin.New()
|
|
r.Use(NewCompressMiddleware(conf))
|
|
r.GET("/ok", func(c *gin.Context) {
|
|
if tc.etagIn != "" {
|
|
c.Header("ETag", tc.etagIn)
|
|
}
|
|
c.String(tc.status, "payload")
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/ok", nil)
|
|
req.Header.Set("Accept-Encoding", tc.accept)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, tc.status, w.Code)
|
|
assert.Equal(t, tc.etagOut, w.Header().Get("ETag"),
|
|
"ETag header should match expected value after middleware processing")
|
|
assert.Equal(t, tc.encoding, w.Header().Get("Content-Encoding"),
|
|
"Content-Encoding should match expected encoding")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewCompressMiddleware_NoOpWhenDisabled(t *testing.T) {
|
|
t.Run("NilConfig", func(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
r := gin.New()
|
|
r.Use(NewCompressMiddleware(nil))
|
|
r.GET("/ok", func(c *gin.Context) { c.String(http.StatusOK, "hi") })
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/ok", nil)
|
|
req.Header.Set("Accept-Encoding", "gzip")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
assert.Empty(t, w.Header().Get("Content-Encoding"))
|
|
assert.Empty(t, w.Header().Get("Vary"))
|
|
assert.Equal(t, "hi", w.Body.String())
|
|
})
|
|
|
|
t.Run("NoneDisablesCompression", func(t *testing.T) {
|
|
r, _ := newCompressTestRouter(t, "none")
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/ok", nil)
|
|
req.Header.Set("Accept-Encoding", "gzip, zstd")
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
assert.Empty(t, w.Header().Get("Content-Encoding"))
|
|
assert.Empty(t, w.Header().Get("Vary"))
|
|
assert.Equal(t, "hello world", w.Body.String())
|
|
})
|
|
}
|