photoprism/internal/server/static_precompressed_test.go
Michael Mayer 9bbe7e27a0 Server: Serve precompressed static asset siblings #5552
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.
2026-05-03 13:43:59 +00:00

401 lines
16 KiB
Go

package server
import (
"bytes"
"compress/gzip"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"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"
)
// staticFixture writes a deterministic identity file plus precompressed
// gzip and zstd siblings into a fresh temp dir and returns the dir path,
// the relative file name, and the raw identity bytes for assertions.
func staticFixture(t *testing.T, name string, size int) (dir, filename string, identity []byte) {
t.Helper()
dir = t.TempDir()
alphabet := "0123456789abcdefghijklmnopqrstuvwxyz"
identity = bytes.Repeat([]byte(alphabet), (size/len(alphabet))+1)[:size]
require.NoError(t, os.WriteFile(filepath.Join(dir, name), identity, 0o600))
var gz bytes.Buffer
gw := gzip.NewWriter(&gz)
_, err := gw.Write(identity)
require.NoError(t, err)
require.NoError(t, gw.Close())
require.NoError(t, os.WriteFile(filepath.Join(dir, name+".gz"), gz.Bytes(), 0o600))
zenc, err := zstd.NewWriter(nil)
require.NoError(t, err)
zbytes := zenc.EncodeAll(identity, nil)
require.NoError(t, zenc.Close())
require.NoError(t, os.WriteFile(filepath.Join(dir, name+".zst"), zbytes, 0o600))
return dir, name, identity
}
// newPrecompressedTestRouter builds a router that mirrors the real
// production wiring: NewCompressMiddleware is registered globally and
// /static/*filepath is served by PrecompressedStatic. The runtime
// middleware should be a no-op for /static/* because the predicate now
// excludes those paths.
func newPrecompressedTestRouter(t *testing.T, prefs, dir string) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
conf := config.TestConfig()
conf.Options().HttpCompression = prefs
r := gin.New()
r.Use(NewCompressMiddleware(conf))
handler := PrecompressedStatic(conf, dir)
r.GET("/static/*filepath", handler)
r.HEAD("/static/*filepath", handler)
return r
}
func TestPrecompressedStatic_ServesZstdSibling(t *testing.T) {
dir, name, identity := staticFixture(t, "app.js", 4096)
r := newPrecompressedTestRouter(t, "zstd,gzip", dir)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/static/"+name, nil)
req.Header.Set("Accept-Encoding", "zstd, gzip")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "zstd", w.Header().Get("Content-Encoding"),
"zstd preference + zstd-capable client should pick the .zst sibling")
assert.Contains(t, w.Header().Get("Vary"), "Accept-Encoding",
"Vary belongs on every response that could vary by Accept-Encoding")
assert.Contains(t, w.Header().Get("Content-Type"), "javascript",
"Content-Type must reflect the identity filename, not the .zst suffix")
dec, err := zstd.NewReader(bytes.NewReader(w.Body.Bytes()))
require.NoError(t, err)
defer dec.Close()
out, err := io.ReadAll(dec)
require.NoError(t, err)
assert.Equal(t, identity, out, "zstd-decoded body must round-trip to the identity bytes")
}
func TestPrecompressedStatic_ServesGzipFallback(t *testing.T) {
dir, name, identity := staticFixture(t, "app.css", 4096)
r := newPrecompressedTestRouter(t, "zstd,gzip", dir)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/static/"+name, 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"),
"gzip-only clients fall back to the .gz sibling even when zstd is preferred")
assert.Contains(t, w.Header().Get("Vary"), "Accept-Encoding")
gr, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes()))
require.NoError(t, err)
defer gr.Close()
out, err := io.ReadAll(gr)
require.NoError(t, err)
assert.Equal(t, identity, out)
}
func TestPrecompressedStatic_ServesIdentityWhenNoEncodingMatches(t *testing.T) {
dir, name, identity := staticFixture(t, "app.js", 4096)
r := newPrecompressedTestRouter(t, "zstd,gzip", dir)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/static/"+name, nil)
// Client only accepts brotli, which we don't have a sibling for.
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"),
"identity fallback must not set Content-Encoding")
assert.Contains(t, w.Header().Get("Vary"), "Accept-Encoding",
"Vary still belongs on the response since a sibling existed for some encoding")
assert.Equal(t, identity, w.Body.Bytes(), "body must be the raw identity bytes")
}
func TestPrecompressedStatic_ServesIdentityWhenNoSiblings(t *testing.T) {
// File without precompressed siblings — happens during dev (watch mode)
// and for assets that don't compress meaningfully (skipped by the build
// script's MIN_RATIO threshold).
dir := t.TempDir()
identity := []byte(strings.Repeat("0123456789abcdefghijklmnopqrstuvwxyz", 16))
name := "raw.bin"
require.NoError(t, os.WriteFile(filepath.Join(dir, name), identity, 0o600))
r := newPrecompressedTestRouter(t, "zstd,gzip", dir)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/static/"+name, 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"),
"no sibling exists, so the response must be identity")
assert.Empty(t, w.Header().Get("Vary"),
"Vary must not be set when no precompressed sibling could ever have been served")
assert.Equal(t, identity, w.Body.Bytes())
}
func TestPrecompressedStatic_RangeRequestServesIdentity(t *testing.T) {
dir, name, identity := staticFixture(t, "app.js", 256)
r := newPrecompressedTestRouter(t, "zstd,gzip", dir)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/static/"+name, nil)
req.Header.Set("Range", "bytes=10-19")
req.Header.Set("Accept-Encoding", "zstd, gzip")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusPartialContent, w.Code,
"Range requests on existing siblings must still produce 206")
assert.Empty(t, w.Header().Get("Content-Encoding"),
"Range responses must serve the identity slice — byte offsets are over identity bytes")
assert.Contains(t, w.Header().Get("Vary"), "Accept-Encoding",
"Vary still belongs on Range responses so caches don't reuse the slice for non-Range requests")
assert.Equal(t, identity[10:20], w.Body.Bytes(),
"206 body must be the raw identity slice the client requested")
}
func TestPrecompressedStatic_RuntimeMiddlewareDoesNotDoubleEncode(t *testing.T) {
dir, name, identity := staticFixture(t, "app.js", 4096)
r := newPrecompressedTestRouter(t, "zstd,gzip", dir)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/static/"+name, nil)
req.Header.Set("Accept-Encoding", "zstd, gzip")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "zstd", w.Header().Get("Content-Encoding"),
"a single Content-Encoding header must be present — no double encoding")
assert.Len(t, w.Header().Values("Content-Encoding"), 1,
"Content-Encoding must not be wrapped twice when the runtime middleware is also active")
// Decode once and confirm the identity bytes pop out — if the runtime
// middleware had wrapped the body again, this single zstd decode would
// fail or yield encoded bytes instead of the original.
dec, err := zstd.NewReader(bytes.NewReader(w.Body.Bytes()))
require.NoError(t, err)
defer dec.Close()
out, err := io.ReadAll(dec)
require.NoError(t, err, "single zstd decode must succeed; double encoding would fail or return encoded bytes")
assert.Equal(t, identity, out, "single zstd decode must yield the identity bytes")
}
func TestPrecompressedStatic_NoneDisablesEncodedSelection(t *testing.T) {
dir, name, identity := staticFixture(t, "app.js", 4096)
r := newPrecompressedTestRouter(t, "none", dir)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/static/"+name, 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"),
"PHOTOPRISM_HTTP_COMPRESSION=none must disable precompressed-sibling selection")
assert.Empty(t, w.Header().Get("Vary"),
"with compression disabled the response never varies by Accept-Encoding — "+
"setting Vary would mislead shared caches into keeping redundant per-encoding entries")
assert.Equal(t, identity, w.Body.Bytes())
}
func TestPrecompressedStatic_PrecompressedResponseHasLastModified(t *testing.T) {
// http.ServeContent emits Last-Modified on every successful response
// from a non-zero modtime, which is what enables clients to revalidate
// later via If-Modified-Since. Confirm it survives the precompressed
// path so caches don't lose validators when zstd/gzip is selected.
dir, name, _ := staticFixture(t, "app.js", 4096)
mtime := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
require.NoError(t, os.Chtimes(filepath.Join(dir, name), mtime, mtime))
r := newPrecompressedTestRouter(t, "zstd,gzip", dir)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/static/"+name, nil)
req.Header.Set("Accept-Encoding", "zstd")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "zstd", w.Header().Get("Content-Encoding"))
assert.NotEmpty(t, w.Header().Get("Last-Modified"),
"precompressed responses must carry Last-Modified so clients can revalidate")
}
func TestPrecompressedStatic_PrecompressedReturns304OnIfModifiedSince(t *testing.T) {
// Conditional revalidation must work for precompressed siblings; before
// the http.ServeContent refactor servePrecompressed wrote 200 OK
// unconditionally, which would have broken cache freshness checks for
// every bundled JS/CSS asset in the wild.
dir, name, _ := staticFixture(t, "app.js", 4096)
mtime := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
require.NoError(t, os.Chtimes(filepath.Join(dir, name), mtime, mtime))
r := newPrecompressedTestRouter(t, "zstd,gzip", dir)
t.Run("ServerSideMatchReturns304", func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/static/"+name, nil)
req.Header.Set("Accept-Encoding", "zstd")
// If-Modified-Since equal to or after mtime — serve must 304.
req.Header.Set("If-Modified-Since", mtime.Add(time.Hour).UTC().Format(http.TimeFormat))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotModified, w.Code,
"a fresh If-Modified-Since must short-circuit to 304 even on the precompressed path")
assert.Empty(t, w.Body.Bytes(), "304 responses must not carry a body")
// http.ServeContent's writeNotModified strips Content-Encoding /
// Content-Length / Content-Type; assert that to lock in the contract.
assert.Empty(t, w.Header().Get("Content-Encoding"),
"304 must not advertise an encoding for an empty body")
assert.Empty(t, w.Header().Get("Content-Length"),
"304 must not advertise a body size for an empty body")
})
t.Run("StaleIfModifiedSinceStillReturns200", func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/static/"+name, nil)
req.Header.Set("Accept-Encoding", "zstd")
// If-Modified-Since strictly before mtime — serve must 200 with the
// encoded body, just like the non-conditional case.
req.Header.Set("If-Modified-Since", mtime.Add(-time.Hour).UTC().Format(http.TimeFormat))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code,
"a stale If-Modified-Since must not suppress the body")
assert.Equal(t, "zstd", w.Header().Get("Content-Encoding"))
assert.NotEmpty(t, w.Body.Bytes(),
"200 responses must carry the encoded body")
})
}
func TestPrecompressedStatic_ServiceWorkerKeepsJavaScriptMimeType(t *testing.T) {
// Browsers reject service workers served with a non-JavaScript
// Content-Type, so registering /static/build/sw.js must keep the .js
// MIME type when the encoded sibling is selected — otherwise the PWA
// would silently break on every refresh once the bundle is precompressed.
// This also covers the importScripts() shim (sw-scope-cleanup.js) and
// every workbox-*.js helper, since they share the same code path.
dir, name, _ := staticFixture(t, "sw.js", 4096)
r := newPrecompressedTestRouter(t, "zstd,gzip", dir)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/static/"+name, nil)
req.Header.Set("Accept-Encoding", "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("Content-Type"), "javascript",
"sw.js Content-Type must be derived from the identity filename, not the .zst suffix — "+
"otherwise the browser refuses to register the service worker")
}
func TestPrecompressedStatic_HeadRequest(t *testing.T) {
dir, name, identity := staticFixture(t, "app.js", 4096)
r := newPrecompressedTestRouter(t, "zstd,gzip", dir)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodHead, "/static/"+name, nil)
req.Header.Set("Accept-Encoding", "zstd")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "zstd", w.Header().Get("Content-Encoding"),
"HEAD must advertise the same encoding the GET would use")
assert.NotEmpty(t, w.Header().Get("Content-Length"),
"HEAD must report the precompressed sibling's size in Content-Length")
assert.Empty(t, w.Body.Bytes(),
"HEAD must not write a body even when a sibling exists")
_ = identity
}
func TestPrecompressedStatic_NotFound(t *testing.T) {
dir, _, _ := staticFixture(t, "app.js", 4096)
r := newPrecompressedTestRouter(t, "zstd,gzip", dir)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/static/missing.js", nil)
req.Header.Set("Accept-Encoding", "zstd")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code,
"unknown identity files must 404, not silently fall through to the sibling")
}
func TestPrecompressedStatic_RejectsPathTraversal(t *testing.T) {
// Sanity check that http.Dir.Open rejects "..". Without this guard,
// a crafted /static/..%2Fetc%2Fpasswd could leak files outside the
// static root.
dir, _, _ := staticFixture(t, "app.js", 4096)
// Place a sentinel file outside the static root.
outside := filepath.Join(filepath.Dir(dir), "secret.txt")
require.NoError(t, os.WriteFile(outside, []byte("super secret"), 0o600))
t.Cleanup(func() { _ = os.Remove(outside) })
r := newPrecompressedTestRouter(t, "zstd,gzip", dir)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/static/../secret.txt", nil)
r.ServeHTTP(w, req)
assert.NotEqual(t, http.StatusOK, w.Code,
"path traversal must not return a successful response")
assert.NotContains(t, w.Body.String(), "super secret",
"path traversal must not leak file contents from outside the static root")
}
func TestSiblingExists(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "yes.js"), []byte("x"), 0o600))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "subdir"), 0o750))
fs := http.Dir(dir)
t.Run("FileExists", func(t *testing.T) {
assert.True(t, siblingExists(fs, "yes.js"))
})
t.Run("FileMissing", func(t *testing.T) {
assert.False(t, siblingExists(fs, "no.js"))
})
t.Run("DirectoryNotConsideredFile", func(t *testing.T) {
// Directories are valid filesystem entries but not valid siblings.
assert.False(t, siblingExists(fs, "subdir"))
})
}
func TestContentTypeForName(t *testing.T) {
t.Run("Js", func(t *testing.T) {
assert.Contains(t, contentTypeForName("app.js"), "javascript")
})
t.Run("Css", func(t *testing.T) {
assert.Contains(t, contentTypeForName("app.css"), "css")
})
t.Run("UnknownExtensionReturnsEmpty", func(t *testing.T) {
assert.Equal(t, "", contentTypeForName("blob.xyzzy"))
})
t.Run("NoExtensionReturnsEmpty", func(t *testing.T) {
assert.Equal(t, "", contentTypeForName("README"))
})
}