Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
287 lines
8.4 KiB
Go
287 lines
8.4 KiB
Go
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() }
|