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.
401 lines
16 KiB
Go
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"))
|
|
})
|
|
}
|