package web import ( "context" "log" "net/http" "regexp" "sync" "time" ) // CoverFetcher retrieves one kagane cover by image id. Satisfied by // latest.BrowserFetcher, and nil when BROWSER_WS_URL is unset — which leaves // kagane covers exactly as unavailable as they were before this endpoint // existed, rather than hanging a request on a fetcher that cannot run. type CoverFetcher interface { Image(ctx context.Context, imageID string) (body []byte, contentType string, err error) } // coverIDRe matches the request path segment that becomes part of an outbound // URL. The proxy is session-gated, but the id still reaches a headless browser, // so it is validated at the boundary rather than passed through. var coverIDRe = regexp.MustCompile(`^[0-9a-f-]{36}$`) // coverTypes is the set of content types the proxy will echo back. A response // header sourced from a third party is not repeated verbatim: anything outside // this set is treated as "not a cover". var coverTypes = map[string]bool{ "image/webp": true, "image/jpeg": true, "image/png": true, "image/avif": true, "image/gif": true, } // coverTimeout bounds one proxied cover. Shorter than the fetcher's own // challenge budget on purpose: a browser page is waiting on this, and a cover // that has not arrived by now is better left as a broken slot than as a request // holding a connection open. const coverTimeout = 20 * time.Second // coverCacheMax caps the in-memory cover cache. Covers are immutable per image // id and a library holds tens of series, so this is a ceiling that is never // reached in practice; reaching it clears the map rather than evicting by age. // // ponytail: flush-on-full, not LRU. Swap it for an LRU if a library ever grows // past this and the flush starts costing refetches. const coverCacheMax = 500 type cachedCover struct { body []byte contentType string } type coverCache struct { mu sync.Mutex m map[string]cachedCover } func (c *coverCache) get(id string) (cachedCover, bool) { c.mu.Lock() defer c.mu.Unlock() v, ok := c.m[id] return v, ok } func (c *coverCache) put(id string, v cachedCover) { c.mu.Lock() defer c.mu.Unlock() if c.m == nil || len(c.m) >= coverCacheMax { c.m = make(map[string]cachedCover, coverCacheMax) } c.m[id] = v } // kaganeCover serves a kagane cover from the backend's own origin. // // kagane answers image requests with a Cloudflare challenge and // `cross-origin-resource-policy: same-origin`, so the web UI cannot render one // directly under any combination of referrer policy or crossorigin attribute // (verified 2026-08-08). Fetching it through the headless browser that already // clears the challenge, and re-serving it here, is what puts the bytes on an // origin the page may load from. // // ponytail: covers are fetched on first view, one browser navigation at a time // behind the fetcher's mutex, so a first load of a large kagane library // trickles in over a few seconds. The cache makes it a one-off. Prefetching // during the poll cycle is the upgrade if that ever grates. func (h *Handler) kaganeCover(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if !coverIDRe.MatchString(id) { http.NotFound(w, r) return } if h.covers == nil { http.NotFound(w, r) return } if v, ok := h.coverCache.get(id); ok { writeCover(w, v) return } ctx, cancel := context.WithTimeout(r.Context(), coverTimeout) defer cancel() body, contentType, err := h.covers.Image(ctx, id) if err != nil { log.Printf("kagane cover %s: %v", id, err) http.NotFound(w, r) return } if !coverTypes[contentType] { log.Printf("kagane cover %s: unexpected content type %q", id, contentType) http.NotFound(w, r) return } v := cachedCover{body: body, contentType: contentType} h.coverCache.put(id, v) writeCover(w, v) } // writeCover sends the bytes with a long cache life: an image id names one // immutable rendering, so a client that has it never needs to ask again. func writeCover(w http.ResponseWriter, v cachedCover) { w.Header().Set("Content-Type", v.contentType) w.Header().Set("Cache-Control", "private, max-age=604800, immutable") w.Write(v.body) }