package httpapi import ( "crypto/rand" "encoding/hex" "sort" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/urpi/go-bananas/internal/domain" "github.com/urpi/go-bananas/internal/logging" "github.com/urpi/go-bananas/internal/tokenengine" ) // Logger is an alias for the logging interface so that middleware files do not // need to import the logging package. type Logger = logging.Logger // randomHex returns n random bytes as hex. func randomHex(n int) (string, error) { buf := make([]byte, n) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf), nil } // itoa is a small helper so middleware does not import strconv everywhere. func itoa(v int) string { return strconv.Itoa(v) } // ------------------------------------------------------------- theme cache -- // ThemeCache memoises rendered theme artefacts. // // This is the single most important performance component in the service. A // themed application requests CSS on every page view, usually from several // pages at once; rendering a theme costs a full traversal of the token document, // while serving a cached string costs one map lookup. Because the cache key // includes the theme version, a publish invalidates precisely one entry and // nothing else is disturbed. // // Entries are never evicted: themes are bounded by human authorship, so the // working set is small and stable. Replacing a theme's entry happens on write, // not on a timer, which removes any chance of serving a stale theme after a // publish. type ThemeCache struct { mu sync.RWMutex css map[string]cssEntry json map[string]jsonEntry metrics CacheStats // clock allows the stats window to be advanced in tests. now func() int64 } // CacheStats reports hit rates for the metrics endpoint. type CacheStats struct { CSSHits int64 CSSMisses int64 JSONHits int64 JSONMisses int64 Entries int LastEviction int64 } // cssEntry is one rendered stylesheet. type cssEntry struct { css string etag string version int64 updatedAt int64 } // jsonEntry is one rendered JSON payload. type jsonEntry struct { payload tokenengine.ResolvedTheme etag string version int64 } // NewThemeCache builds an empty cache. func NewThemeCache() *ThemeCache { return &ThemeCache{ css: make(map[string]cssEntry, 16), json: make(map[string]jsonEntry, 16), now: func() int64 { return nowUnix() }, } } // CacheKey identifies one rendered artefact. type CacheKey struct { Slug string Appearance domain.Appearance // Variant distinguishes the option sets the API can render, so that a // request for the compact stylesheet never receives the component stylesheet. Variant string } // key renders a CacheKey as a map key. func (k CacheKey) key() string { return k.Slug + "|" + string(k.Appearance) + "|" + k.Variant } // CSS returns the cached stylesheet, rendering it with render when absent. func (c *ThemeCache) CSS(theme *domain.Theme, opts tokenengine.RenderOptions, render func() string) (string, string) { key := CacheKey{Slug: theme.Slug, Appearance: opts.Appearance, Variant: opts.Variant()} c.mu.RLock() entry, ok := c.css[key.key()] c.mu.RUnlock() if ok && entry.version == theme.Version { atomic.AddInt64(&c.metrics.CSSHits, 1) return entry.css, entry.etag } atomic.AddInt64(&c.metrics.CSSMisses, 1) css := render() etag := computeETag(css) c.mu.Lock() c.css[key.key()] = cssEntry{css: css, etag: etag, version: theme.Version, updatedAt: c.now()} c.mu.Unlock() return css, etag } // JSON returns the cached JSON payload, rendering it when absent. func (c *ThemeCache) JSON(theme *domain.Theme, appearance domain.Appearance, render func() tokenengine.ResolvedTheme) (tokenengine.ResolvedTheme, string) { key := CacheKey{Slug: theme.Slug, Appearance: appearance, Variant: "json"} c.mu.RLock() entry, ok := c.json[key.key()] c.mu.RUnlock() if ok && entry.version == theme.Version { atomic.AddInt64(&c.metrics.JSONHits, 1) return entry.payload, entry.etag } atomic.AddInt64(&c.metrics.JSONMisses, 1) payload := render() etag := computeETag(theme.Slug + ":" + strconv.FormatInt(theme.Version, 10) + ":" + string(appearance) + ":" + payload.Theme.UpdatedAt) c.mu.Lock() c.json[key.key()] = jsonEntry{payload: payload, etag: etag, version: theme.Version} c.mu.Unlock() return payload, etag } // Invalidate drops every cached artefact for one slug. func (c *ThemeCache) Invalidate(slug string) int { c.mu.Lock() defer c.mu.Unlock() removed := 0 for key := range c.css { if strings.HasPrefix(key, slug+"|") { delete(c.css, key) removed++ } } for key := range c.json { if strings.HasPrefix(key, slug+"|") { delete(c.json, key) removed++ } } c.metrics.LastEviction = c.now() return removed } // Prefetch renders and caches a theme for both appearances, so that the first // request after a deploy is already warm. It is called once per theme at // start-up. func (c *ThemeCache) Prefetch(theme *domain.Theme, options func(tokenengine.RenderOptions)) int { warmed := 0 for _, appearance := range []domain.Appearance{domain.AppearanceLight, domain.AppearanceDark} { for _, variant := range tokenengine.VariantNames() { opts := tokenengine.RenderOptions{ Slug: theme.Slug, Appearance: appearance, Components: variant == tokenengine.VariantFull, Derive: tokenengine.DefaultDerive(), } if options != nil { options(opts) } themeCopy := theme _, _ = c.CSS(themeCopy, opts, func() string { return tokenengine.Render(themeCopy, opts) }) warmed++ _, _ = c.JSON(themeCopy, appearance, func() tokenengine.ResolvedTheme { return tokenengine.JSONPayload(themeCopy, appearance) }) } } return warmed } // Stats returns a snapshot of cache counters. func (c *ThemeCache) Stats() CacheStats { c.mu.RLock() entries := len(c.css) + len(c.json) c.mu.RUnlock() return CacheStats{ CSSHits: atomic.LoadInt64(&c.metrics.CSSHits), CSSMisses: atomic.LoadInt64(&c.metrics.CSSMisses), JSONHits: atomic.LoadInt64(&c.metrics.JSONHits), JSONMisses: atomic.LoadInt64(&c.metrics.JSONMisses), Entries: entries, LastEviction: atomic.LoadInt64(&c.metrics.LastEviction), } } // Flush empties the cache. It exists for tests and for the admin "rebuild cache" // action. func (c *ThemeCache) Flush() { c.mu.Lock() defer c.mu.Unlock() clear(c.css) clear(c.json) } // computeETag derives a strong validator from a payload. // // A content hash is the right choice here rather than a version counter: two // themes with different slugs can produce byte-identical CSS, and a content // hash lets a shared CDN deduplicate them. It is also self-correcting, because a // validator can never disagree with the body it describes. func computeETag(content string) string { return `"` + shortHash(content) + `"` } // shortHash returns a 128-bit hash rendered as hex. func shortHash(content string) string { // FNV-1a over two offset bases gives a 128-bit value using only the standard // library. The collision risk is irrelevant for cache validation, where a // collision would at worst serve one theme's bytes for another's. const ( offset64 = 1469598103934665603 prime64 = 1099511628211 ) var a, b uint64 = offset64, offset64 ^ 0x9e3779b97f4a7c15 for i := 0; i < len(content); i++ { a ^= uint64(content[i]) a *= prime64 b ^= uint64(content[len(content)-1-i]) b *= prime64 } var out [16]byte // The conversion to one byte per eight bits of the digest is the point of this // function: it produces a fixed-width, hex-encodable cache key. Discarding the // upper bits cannot overflow, because the shift count is bounded by the loop // bound, and it costs no key entropy that matters for a content-addressed key. // //nolint:gosec // G115: see above. This is a digest truncation, not a narrowing // of a length or a count. for i := 0; i < 8; i++ { out[i] = byte(a >> (8 * i)) out[8+i] = byte(b >> (8 * i)) } return hex.EncodeToString(out[:]) } // nowUnix returns the current Unix time in seconds. func nowUnix() int64 { return timeNow().Unix() } // sortedKeys returns a map's keys in sorted order, for stable output. func sortedKeys[V any](m map[string]V) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } sort.Strings(out) return out } // timeNow is the clock used by the cache, isolated so tests can reason about it. func timeNow() time.Time { return time.Now() }