diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index 1781651..1833adc 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -119,7 +119,9 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
browser-fetched page and the bytes go over plain TLS. With no browser
configured, kagane Covers are simply absent; novelfull still gets one — at
creation and on the poll — when its page body happens to answer a plain
- request (the challenge is a live time-varying fact).
+ request (the challenge is a live time-varying fact). The old kagane-only
+ serving path (`/img/kagane/{id}`, template rewrite, `CoverFetcher`) is gone
+ (issue #63): the one public route serves every Site.
- **`updated_at` drives list order, so moves only on real reading progress:** server apply its timestamp when row new or `last_chapter_num` changes, else keep stored value — favouriting series or recording newly published chapter must not reorder list. `PUT` therefore returns row **as stored**, clients must adopt that response rather than own payload. See `plans/2026-07-25-bookmark-list-favorites-design.md` §4.
- **Lifecycle buckets:** `status` on each bookmark is `reading` | `archived` |
`finished`, orthogonal to `favorite`. Archived and finished appear only in
@@ -156,21 +158,21 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
Reader's credential at serve time).
`BROWSER_WS_URL` (CDP endpoint of the browser, which runs on a **separate
machine** and is reached over the tailnet — ADR-0006, `chrome/docker-compose.yml`.
- Used by the poller for kagane and novelfull *and* by the web UI's kagane
- cover proxy; unset — the default — disables browser polling and serves 404
- for covers not already stored, leaving those sites to the userscript alone.
- Must be a tailnet IP, never a hostname: Chrome's DevTools handler 500s
+ Used by the poller for kagane and novelfull page fetches and by the cover
+ pipeline for kagane's image bytes (the browser is the only route that clears
+ the challenge kagane serves its covers behind); unset — the default —
+ disables browser polling and leaves kagane Covers blank until stored bytes
+ exist. Must be a tailnet IP, never a hostname: Chrome's DevTools handler 500s
`/json/version` for any Host that isn't an IP or `localhost`).
-- **kagane covers are proxied, not hot-linked:** kagane serves cover images
- behind the same challenge as its pages and with
- `cross-origin-resource-policy: same-origin`, so no `
` on the web UI's
- origin can load one — not even from a browser holding the clearance cookie
- `og:image` to `/img/kagane/{id}`. `internal/web/cover.go` reads the persistent
- `covers` table first, then fetches a miss through `latest.BrowserFetcher.Image`.
- The templates render `.CoverURL`, never `.Cover`. The id is matched against a
- UUID regex before it reaches the browser: the stored value is client-supplied,
- so an unchecked one is an SSRF primitive pointed at the deployment's own
- network.
+- **No per-Site cover path (issue #63):** every Cover — all six Sites — is
+ served by the one public `GET /covers/{addr}` route from content-addressed
+ bytes. There is no proxy, no per-Site rewrite, no second place that decides
+ a Cover's renderable address: the wire `cover` is it. The only place a Site
+ name still appears in cover code is the acquisition module, where kagane's
+ image URLs are claimed by `latest.BrowserFetcher` (`browserCoverURL`) because
+ they answer a plain fetch with a challenge and
+ `cross-origin-resource-policy: same-origin`; every other Site's CDN answers
+ plain TLS. Templates render `.Cover` — the wire value — never anything else.
- **Web UI also owns:** session-gated `GET /install/{manga,novel}-bookmark.user.js`
(renders the bindmounted script with the acting Reader's derived credential
substituted in — the credential never appears in page markup, the address
diff --git a/backend/cover_test.go b/backend/cover_test.go
index 660b845..535851b 100644
--- a/backend/cover_test.go
+++ b/backend/cover_test.go
@@ -1,40 +1,14 @@
package main
import (
- "context"
- "crypto/sha256"
- "errors"
"net/http"
"net/http/httptest"
"strings"
- "sync/atomic"
"testing"
- "bookmarkmanager/backend/internal/pgtest"
"bookmarkmanager/backend/internal/store"
)
-// fakeCovers stands in for the headless browser. It counts calls so the test
-// can prove the store spares the browser after the first navigation.
-type fakeCovers struct {
- body []byte
- contentType string
- err error
- calls atomic.Int32
- lastID atomic.Value
-}
-
-func (f *fakeCovers) Image(_ context.Context, imageID string) ([]byte, string, error) {
- f.calls.Add(1)
- f.lastID.Store(imageID)
- if f.err != nil {
- return nil, "", f.err
- }
- return f.body, f.contentType, nil
-}
-
-const testCoverID = "019fe11a-84c3-7fc3-a84b-88787374b617"
-
func getCover(t *testing.T, srv http.Handler, path string, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
@@ -46,186 +20,6 @@ func getCover(t *testing.T, srv http.Handler, path string, cookie *http.Cookie)
return rr
}
-// kagane serves its covers behind a Cloudflare challenge and with
-// cross-origin-resource-policy: same-origin, so the UI can only show one by
-// re-serving the bytes from its own origin.
-func TestKaganeCoverPersistsAndReusesStoredBytes(t *testing.T) {
- cf := &fakeCovers{body: []byte("\x00webp-bytes"), contentType: "image/webp"}
- cfg := testConfig()
- cfg.Covers = cf
- srv, st := newWebTestServer(t, cfg)
- cookie := sessionCookie(t, st)
-
- rr := getCover(t, srv, "/img/kagane/"+testCoverID, cookie)
- if rr.Code != http.StatusOK {
- t.Fatalf("first request: status = %d, want 200", rr.Code)
- }
- if got := rr.Body.String(); got != string(cf.body) {
- t.Fatalf("first request: body = %q, want %q", got, cf.body)
- }
-
- // A new Handler has no process-local state from the first request. The same
- // store must still answer without navigating the browser again.
- srv = newRouter(st, cfg)
- rr = getCover(t, srv, "/img/kagane/"+testCoverID, cookie)
- if rr.Code != http.StatusOK {
- t.Fatalf("stored request: status = %d, want 200", rr.Code)
- }
- if got := rr.Body.String(); got != string(cf.body) {
- t.Fatalf("stored request: body = %q, want %q", got, cf.body)
- }
- if got := cf.calls.Load(); got != 1 {
- t.Fatalf("fetcher called %d times, want 1 — stored bytes must survive a new handler", got)
- }
- if got := cf.lastID.Load(); got != testCoverID {
- t.Fatalf("fetched image id = %v, want %s", got, testCoverID)
- }
-}
-
-func TestKaganeCoverServesStoredBytesWithoutBrowser(t *testing.T) {
- cf := &fakeCovers{err: errors.New("browser must not be called")}
- cfg := testConfig()
- cfg.Covers = cf
- srv, st := newWebTestServer(t, cfg)
- if err := st.PutKaganeCover(testCoverID, []byte("already-stored"), "image/png"); err != nil {
- t.Fatalf("PutKaganeCover: %v", err)
- }
-
- rr := getCover(t, srv, "/img/kagane/"+testCoverID, sessionCookie(t, st))
- if rr.Code != http.StatusOK || rr.Body.String() != "already-stored" {
- t.Fatalf("stored request = (%d, %q), want (200, already-stored)", rr.Code, rr.Body.String())
- }
- if got := cf.calls.Load(); got != 0 {
- t.Fatalf("fetcher called %d times for a stored cover, want 0", got)
- }
-}
-
-func TestKaganeCoverServesPersistedBytesAfterRestart(t *testing.T) {
- url := pgtest.URL(t)
- coverDir := t.TempDir()
- owner := store.Owner{
- DiscordID: "cover-owner",
- TokenHash: sha256.Sum256([]byte("cover-owner-token")),
- }
- first, err := store.Open(url, owner, coverDir, testCoverBaseURL)
- if err != nil {
- t.Fatalf("Open: %v", err)
- }
- if err := first.PutKaganeCover(testCoverID, []byte("survives-restart"), "image/jpeg"); err != nil {
- first.Close()
- t.Fatalf("PutKaganeCover: %v", err)
- }
- if err := first.Close(); err != nil {
- t.Fatalf("close first store: %v", err)
- }
-
- second, err := store.Open(url, owner, coverDir, testCoverBaseURL)
- if err != nil {
- t.Fatalf("reopen: %v", err)
- }
- defer second.Close()
- cf := &fakeCovers{err: errors.New("browser must not be called after restart")}
- cfg := testConfig()
- cfg.Covers = cf
- rr := getCover(t, newRouter(second, cfg), "/img/kagane/"+testCoverID, sessionCookie(t, second))
- if rr.Code != http.StatusOK || rr.Body.String() != "survives-restart" {
- t.Fatalf("restarted request = (%d, %q), want (200, survives-restart)", rr.Code, rr.Body.String())
- }
- if got := cf.calls.Load(); got != 0 {
- t.Fatalf("fetcher called %d times after restart, want 0", got)
- }
-}
-
-// The proxy reaches a headless browser, so it is not open to the internet.
-func TestKaganeCoverRequiresSession(t *testing.T) {
- cf := &fakeCovers{body: []byte("x"), contentType: "image/webp"}
- cfg := testConfig()
- cfg.Covers = cf
- srv, _ := newWebTestServer(t, cfg)
-
- rr := getCover(t, srv, "/img/kagane/"+testCoverID, nil)
- if rr.Code != http.StatusUnauthorized {
- t.Fatalf("status = %d, want 401", rr.Code)
- }
- if got := cf.calls.Load(); got != 0 {
- t.Fatalf("fetcher called %d times for an unauthenticated request, want 0", got)
- }
-}
-
-func TestKaganeCoverRejectsBadInput(t *testing.T) {
- cases := []struct {
- name string
- id string
- fetch *fakeCovers
- }{
- {
- "an id that is not a uuid never reaches the browser",
- "solo-leveling",
- &fakeCovers{body: []byte("x"), contentType: "image/webp"},
- },
- {
- "a uuid-shaped id with a trailing segment is rejected whole",
- testCoverID + "x",
- &fakeCovers{body: []byte("x"), contentType: "image/webp"},
- },
- {
- "a challenged fetch is a missing cover",
- testCoverID,
- &fakeCovers{err: errors.New("challenge held")},
- },
- {
- "a content type outside the image set is not echoed back",
- testCoverID,
- &fakeCovers{body: []byte("