navidrome/server/imghttp/headers.go
Deluan 6bb3e98e4c fix(artwork): give full-size disc art a real ETag
Regression from 04d5a556. Keying the resize cache on identity meant the
disc response no longer carried a content hash, and the full-size branch
set no ETag either — so WriteImageHeaders fell back to the empty hash
and emitted `ETag: ""` for every full-size disc image. Since ifNoneMatch
compares the unquoted value, a client echoing that back matched, and got
a 304 even after the image was replaced.

The full-size branch now carries the same identity validator the sized
branch uses, so it moves when the folder's images change.

WriteImageHeaders is hardened against the class as well: an empty
validator is no validator, so it is neither emitted nor matched.

Reported by Codex on #5847.
2026-07-25 15:06:51 -04:00

73 lines
2.5 KiB
Go

// Package imghttp holds the shared HTTP caching contract for artwork responses, so the
// subsonic, public, and jellyfin image handlers apply identical headers without importing
// each other.
package imghttp
import (
"net/http"
"strings"
"github.com/navidrome/navidrome/core/artwork"
)
// WriteImageHeaders applies the artwork caching contract and reports whether a 304 was written
// (in which case the caller must not write a body). requestedHash is the hash the client asserted
// (id suffix / JWT payload / jellyfin tag param), or "" when the request carried no hash.
func WriteImageHeaders(w http.ResponseWriter, r *http.Request, img *artwork.Image, requestedHash string) (wrote304 bool) {
h := w.Header()
// Placeholders are transient stand-ins for not-yet-resolved art: never cached, no validators.
if img.Placeholder {
h.Set("Cache-Control", "no-store")
return false
}
// The validator identifies the served representation (resized/re-encoded bytes version it via
// ETag), so a CoverArtQuality/EnableWebPEncoding change invalidates a revalidating client's
// cache. Falls back to the pixel hash for full-size originals (bytes == the hash).
etag := img.ETag
if etag == "" {
etag = img.Hash
}
// An empty validator is not one: emitting it would hand every such response the same ETag,
// and matching it would 304 a client that echoed it back even after the bytes changed.
if etag != "" {
h.Set("ETag", `"`+etag+`"`)
}
if !img.LastUpdated.IsZero() {
h.Set("Last-Modified", img.LastUpdated.UTC().Format(http.TimeFormat))
}
// Immutable only when the client asked for the exact current pixel hash; bare/legacy/mismatched
// requests get cheap ETag revalidation instead, which fixes stale art after re-resolution.
if requestedHash != "" && requestedHash == img.Hash {
h.Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
h.Set("Cache-Control", "public, no-cache")
}
if etag != "" && ifNoneMatch(r.Header.Get("If-None-Match"), etag) {
w.WriteHeader(http.StatusNotModified)
return true
}
return false
}
// ifNoneMatch reports whether the If-None-Match header asserts the given hash, using weak
// comparison (RFC 9110): "*" matches any current representation and W/ prefixes are ignored.
func ifNoneMatch(header, hash string) bool {
header = strings.TrimSpace(header)
if header == "" {
return false
}
if header == "*" {
return true
}
for _, tag := range strings.Split(header, ",") {
tag = strings.TrimSpace(tag)
tag = strings.TrimPrefix(tag, "W/")
if strings.Trim(tag, `"`) == hash {
return true
}
}
return false
}