diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index 1781651..e7f116d 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 extraction module (`latest`), where
+ kagane's image URLs are claimed by `browserOnlyCoverURL` — 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..9c3c93f 100644
--- a/backend/cover_test.go
+++ b/backend/cover_test.go
@@ -1,40 +1,15 @@
package main
import (
- "context"
- "crypto/sha256"
- "errors"
+ "database/sql"
"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 +21,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("`)
+// kaganeImageURLRe matches the canonical compressed image route kagane's API
+// publishes — the only cover URL form the extractor emits and the browser
+// fetcher accepts. The URL is matched in full (scheme, host, id shape) rather
+// than trusted: the value a fetcher is pointed at may have been client-
+// supplied, and a headless browser is a strong SSRF primitive.
+var kaganeImageURLRe = regexp.MustCompile(`^https://kagane\.to/api/v2/image/([0-9a-f-]{36})/compressed$`)
+
+// browserOnlyCoverURL reports whether the browser sidecar is the only fetcher
+// for cover bytes at imageURL. kagane's image route answers a plain fetch with
+// a challenge and `cross-origin-resource-policy: same-origin`, so a TLS fetch
+// would only ever retrieve a challenge page and must not be attempted
+// (ADR-0007). This is the byte-fetch router's per-Site knowledge; it lives in
+// the extraction module, which owns kagane's URL shapes.
+func browserOnlyCoverURL(imageURL string) bool {
+ return kaganeImageURLRe.MatchString(imageURL)
+}
+
// kagane's browser-fetched series response publishes cover image IDs under
// series_covers. The API's canonical compressed image route is the only URL
// form accepted by the store and browser fetcher; no rendition is guessed.
@@ -178,8 +195,12 @@ func kaganeCoverURL(body string) string {
return ""
}
for _, cover := range response.SeriesCovers {
- if kaganeImageIDRe.MatchString(cover.ImageID) {
- return "https://kagane.to/api/v2/image/" + cover.ImageID + "/compressed"
+ // Validate the assembled URL against the same regex the browser
+ // fetcher enforces, so the extractor can never emit an address the
+ // fetch would refuse.
+ imageURL := "https://kagane.to/api/v2/image/" + cover.ImageID + "/compressed"
+ if kaganeImageURLRe.MatchString(imageURL) {
+ return imageURL
}
}
return ""
diff --git a/backend/internal/latest/smoke_image_test.go b/backend/internal/latest/smoke_image_test.go
index e52f421..45e17c8 100644
--- a/backend/internal/latest/smoke_image_test.go
+++ b/backend/internal/latest/smoke_image_test.go
@@ -10,9 +10,10 @@ import (
"bookmarkmanager/backend/internal/store"
)
-// TestSmokeKaganeImage is the live proof that the cover proxy's fetch actually
-// clears Cloudflare and returns image bytes. It needs the real browser unit
-// with outbound network, so it runs only when SMOKE_BROWSER_WS_URL is set:
+// TestSmokeKaganeImage is the live proof that the acquisition path's browser
+// fetch actually clears Cloudflare and returns image bytes. It needs the real
+// browser unit with outbound network, so it runs only when SMOKE_BROWSER_WS_URL
+// is set:
//
// cd chrome && BROWSER_BIND_ADDR=127.0.0.1 docker compose up -d --build
// SMOKE_BROWSER_WS_URL=ws://127.0.0.1:9222 go test -run TestSmokeKaganeImage ./internal/latest
@@ -24,12 +25,11 @@ func TestSmokeKaganeImage(t *testing.T) {
if ws == "" {
t.Skip("SMOKE_BROWSER_WS_URL unset")
}
- const imageID = "019fe11a-84c3-7fc3-a84b-88787374b617" // SP Baby's cover
+ const imageURL = "https://kagane.to/api/v2/image/019fe11a-84c3-7fc3-a84b-88787374b617/compressed" // SP Baby's cover
- // The same URL through a plain client is what the web UI's
gets.
+ // The same URL through a plain client is what any other fetcher would get.
// Asserting on it keeps the test honest about why the browser is needed.
- req, err := http.NewRequest(http.MethodGet,
- "https://kagane.to/api/v2/image/"+imageID+"/compressed", nil)
+ req, err := http.NewRequest(http.MethodGet, imageURL, nil)
if err != nil {
t.Fatal(err)
}
@@ -48,7 +48,7 @@ func TestSmokeKaganeImage(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
- body, contentType, err := f.Image(ctx, imageID)
+ body, contentType, err := f.Image(ctx, imageURL)
if err != nil {
t.Fatalf("Image: %v", err)
}
@@ -64,8 +64,10 @@ func TestSmokeKaganeImage(t *testing.T) {
}
t.Logf("fetched %d bytes of %s", len(body), contentType)
- if _, _, err := f.Image(ctx, "not-a-uuid"); err == nil {
- t.Fatal("Image accepted a non-uuid id")
+ // The browser module claims only the cover URL shape it can clear a
+ // challenge for; anything else must be refused before any navigation.
+ if _, _, err := f.Image(ctx, "https://cdn.example/cover.jpg"); err == nil {
+ t.Fatal("Image accepted a cover URL the browser module does not claim")
}
}
diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go
index 1237fe1..8cb1264 100644
--- a/backend/internal/store/store.go
+++ b/backend/internal/store/store.go
@@ -40,10 +40,6 @@ type Bookmark struct {
// address and never an address that 404s (ADR-0007). A client may still
// send this field and it is discarded on the way in; see Upsert.
Cover string `json:"cover"`
- // CoverSource is the third-party address the bytes were fetched from. It
- // stays off the wire: it is the acquisition path's dedupe key, and no
- // client is ever asked to render one.
- CoverSource string `json:"-"`
LastChapter string `json:"last_chapter"`
LastChapterNum float64 `json:"last_chapter_num"`
LastChapterURL string `json:"last_chapter_url"`
@@ -155,20 +151,6 @@ func (b Bookmark) Initial() string {
return "?"
}
-// kaganeCoverRe matches the cover URL kagane's og:image carries, which is what
-// the userscript stores for that site.
-var kaganeCoverRe = regexp.MustCompile(`^https://kagane\.to/api/v2/image/([0-9a-f-]{36})/compressed$`)
-
-// KaganeImageID extracts the validated image id from the cover URL recorded by
-// the userscript.
-func KaganeImageID(cover string) (string, bool) {
- m := kaganeCoverRe.FindStringSubmatch(cover)
- if m == nil {
- return "", false
- }
- return m[1], true
-}
-
// CoverContentType canonicalises a fetched response's media type and reports
// whether the bytes are safe to store and serve. comix answers "image/jpg",
// which no standard lists but browsers accept; it is stored as the real name
@@ -184,17 +166,6 @@ func CoverContentType(contentType string) (string, bool) {
}
}
-// CoverURL is the src the web UI puts in an
. Cover already is an address
-// on this origin, so for every site but kagane it is used as-is. kagane's
-// bytes still arrive through the browser-backed proxy, which is keyed by image
-// id rather than by content address until #62 moves it onto the same path.
-func (b Bookmark) CoverURL() string {
- if imageID, ok := KaganeImageID(b.CoverSource); ok {
- return "/img/kagane/" + imageID
- }
- return b.Cover
-}
-
// Library buckets. A bookmark is in exactly one. This cannot be derived from
// Site: asurascans serves manga and novels from the same /comics/ path, so the
// userscript that recorded the page is the only party that knows which.
@@ -217,7 +188,7 @@ var migrations embed.FS
// compile-time constant; every request value is bound as a parameter. The
// series-owned fields are joined in from the series table, in scanBookmark
// order, so the flat Bookmark reads back whole despite the split (ADR-0004).
-const bookmarkColumns = `b.site, b.series_id, s.title, s.series_url, s.cover, s.cover_address,
+const bookmarkColumns = `b.site, b.series_id, s.title, s.series_url, s.cover_address,
b.last_chapter, b.last_chapter_num, b.last_chapter_url,
b.favorite, s.latest_chapter, s.latest_chapter_num, b.updated_at, b.status, s.kind`
@@ -562,7 +533,7 @@ func (s *Store) scanBookmark(scan func(...any) error) (Bookmark, error) {
latestChapterNum sql.NullFloat64
)
if err := scan(
- &b.Site, &b.SeriesID, &b.Title, &b.SeriesURL, &b.CoverSource, &coverAddress,
+ &b.Site, &b.SeriesID, &b.Title, &b.SeriesURL, &coverAddress,
&b.LastChapter, &b.LastChapterNum, &b.LastChapterURL,
&b.Favorite, &b.LatestChapter, &latestChapterNum, &b.UpdatedAt, &b.Status, &b.Kind,
); err != nil {
@@ -617,10 +588,6 @@ func coverRelativePath(address string) string {
return address[:2] + "/" + address[2:4] + "/" + address
}
-func kaganeCoverSourceURL(imageID string) string {
- return "https://kagane.to/api/v2/image/" + imageID + "/compressed"
-}
-
func (s *Store) getCover(sourceURL string) ([]byte, string, bool, error) {
return s.getCoverByAddress(coverSourceAddress(sourceURL))
}
@@ -703,18 +670,6 @@ func (s *Store) PutCover(sourceURL string, body []byte, contentType string) erro
return s.putCover(sourceURL, body, contentType)
}
-// GetKaganeCover returns one persisted cover. Missing covers are reported with
-// ok=false rather than as an error so the web handler can fetch them once.
-func (s *Store) GetKaganeCover(imageID string) ([]byte, string, bool, error) {
- return s.getCover(kaganeCoverSourceURL(imageID))
-}
-
-// PutKaganeCover persists one fetched cover. The source URL's content address
-// makes each stored object immutable, so later writes for that URL are ignored.
-func (s *Store) PutKaganeCover(imageID string, body []byte, contentType string) error {
- return s.putCover(kaganeCoverSourceURL(imageID), body, contentType)
-}
-
// CoverAddress is the content address bytes fetched from sourceURL are stored
// under. It is a pure function of the URL, so the acquisition path can name a
// Cover before it has the bytes.
diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go
index 1dde1a4..e273288 100644
--- a/backend/internal/store/store_test.go
+++ b/backend/internal/store/store_test.go
@@ -550,45 +550,6 @@ func TestDisplayChapter(t *testing.T) {
}
}
-// CoverURL reads the source address for the kagane branch and the wire value
-// otherwise, so both are set the way scanBookmark sets them.
-func TestCoverURL(t *testing.T) {
- cases := []struct {
- name string
- coverSource string
- cover string
- want string
- }{
- {
- "kagane routes through the proxy",
- "https://kagane.to/api/v2/image/019fe11a-84c3-7fc3-a84b-88787374b617/compressed",
- "https://bookmarks.test/covers/" + CoverAddress("kagane"),
- "/img/kagane/019fe11a-84c3-7fc3-a84b-88787374b617",
- },
- {
- "another site is served from our own origin",
- "https://gg.asuracomic.net/storage/media/1/conversions/cover.webp",
- "https://bookmarks.test/covers/" + CoverAddress("asura"),
- "https://bookmarks.test/covers/" + CoverAddress("asura"),
- },
- {
- "a lookalike host is not rewritten",
- "https://evil.example/api/v2/image/019fe11a-84c3-7fc3-a84b-88787374b617/compressed",
- "https://bookmarks.test/covers/" + CoverAddress("evil"),
- "https://bookmarks.test/covers/" + CoverAddress("evil"),
- },
- {"no cover stays empty", "", "", ""},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- b := Bookmark{CoverSource: tc.coverSource, Cover: tc.cover}
- if got := b.CoverURL(); got != tc.want {
- t.Errorf("CoverURL() = %q, want %q", got, tc.want)
- }
- })
- }
-}
-
func TestUpsertKindDefaultsToManga(t *testing.T) {
store := newTestStore(t)
got, err := store.Upsert(store.OwnerID(), Bookmark{
@@ -1397,7 +1358,7 @@ func TestTwoReadersShareOneSeriesWithIndependentProgress(t *testing.T) {
t.Fatalf("due after one Reader left = %+v, want the series still polled", due)
}
}
-func TestKaganeCoverPersistsAcrossReopen(t *testing.T) {
+func TestCoverPersistsAcrossReopen(t *testing.T) {
url := pgtest.URL(t)
coverDir := t.TempDir()
first, err := Open(url, testOwner, coverDir, testCoverBaseURL)
@@ -1405,8 +1366,9 @@ func TestKaganeCoverPersistsAcrossReopen(t *testing.T) {
t.Fatalf("Open: %v", err)
}
body := []byte("stored-cover")
- if err := first.PutKaganeCover("019fe11a-84c3-7fc3-a84b-88787374b617", body, "image/webp"); err != nil {
- t.Fatalf("PutKaganeCover: %v", err)
+ const sourceURL = "https://cdn.example/covers/series.jpg"
+ if err := first.PutCover(sourceURL, body, "image/webp"); err != nil {
+ t.Fatalf("PutCover: %v", err)
}
if err := first.Close(); err != nil {
t.Fatalf("close first store: %v", err)
@@ -1417,9 +1379,9 @@ func TestKaganeCoverPersistsAcrossReopen(t *testing.T) {
t.Fatalf("reopen: %v", err)
}
defer second.Close()
- got, contentType, ok, err := second.GetKaganeCover("019fe11a-84c3-7fc3-a84b-88787374b617")
+ got, contentType, ok, err := second.GetCover(sourceURL)
if err != nil {
- t.Fatalf("GetKaganeCover: %v", err)
+ t.Fatalf("GetCover: %v", err)
}
if !ok || !bytes.Equal(got, body) || contentType != "image/webp" {
t.Fatalf("stored cover = (%q, %q, %v), want (%q, image/webp, true)", got, contentType, ok, body)
@@ -1443,7 +1405,7 @@ func TestOpenRequiresAbsoluteCoverBaseURL(t *testing.T) {
}
}
-func TestKaganeCoverIsContentAddressedOnFilesystem(t *testing.T) {
+func TestCoverIsContentAddressedOnFilesystem(t *testing.T) {
url := pgtest.URL(t)
coverDir := t.TempDir()
first, err := Open(url, testOwner, coverDir, testCoverBaseURL)
@@ -1451,14 +1413,13 @@ func TestKaganeCoverIsContentAddressedOnFilesystem(t *testing.T) {
t.Fatalf("Open: %v", err)
}
body := []byte("stored-cover")
- const imageID = "019fe11a-84c3-7fc3-a84b-88787374b617"
- if err := first.PutKaganeCover(imageID, body, "image/webp"); err != nil {
+ const sourceURL = "https://cdn.example/covers/series.jpg"
+ if err := first.PutCover(sourceURL, body, "image/webp"); err != nil {
first.Close()
- t.Fatalf("PutKaganeCover: %v", err)
+ t.Fatalf("PutCover: %v", err)
}
defer first.Close()
- sourceURL := "https://kagane.to/api/v2/image/" + imageID + "/compressed"
addressBytes := sha256.Sum256([]byte(sourceURL))
address := hex.EncodeToString(addressBytes[:])
wantPath := filepath.Join(address[:2], address[2:4], address)
diff --git a/backend/internal/web/cover.go b/backend/internal/web/cover.go
deleted file mode 100644
index 03e0c04..0000000
--- a/backend/internal/web/cover.go
+++ /dev/null
@@ -1,89 +0,0 @@
-package web
-
-import (
- "bookmarkmanager/backend/internal/store"
- "context"
- "log"
- "net/http"
- "regexp"
- "time"
-)
-
-// CoverFetcher retrieves one kagane cover by image id. Satisfied by
-// latest.BrowserFetcher. It is nil when BROWSER_WS_URL is unset; uncached
-// covers are then unavailable, while covers already stored by the backend
-// remain available without a browser.
-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}$`)
-
-// 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
-
-// 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.
-func (h *Handler) kaganeCover(w http.ResponseWriter, r *http.Request) {
- id := r.PathValue("id")
- if !coverIDRe.MatchString(id) {
- http.NotFound(w, r)
- return
- }
- body, contentType, ok, err := h.store.GetKaganeCover(id)
- if err != nil {
- log.Printf("read kagane cover %s: %v", id, err)
- http.Error(w, "internal error", http.StatusInternalServerError)
- return
- }
- if ok {
- writeCover(w, body, contentType)
- return
- }
- if h.covers == nil {
- http.NotFound(w, r)
- 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
- }
- canonical, ok := store.CoverContentType(contentType)
- if !ok {
- log.Printf("kagane cover %s: unexpected content type %q", id, contentType)
- http.NotFound(w, r)
- return
- }
- contentType = canonical
- if err := h.store.PutKaganeCover(id, body, contentType); err != nil {
- log.Printf("persist kagane cover %s: %v", id, err)
- http.Error(w, "internal error", http.StatusInternalServerError)
- return
- }
- writeCover(w, body, contentType)
-}
-
-// 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, body []byte, contentType string) {
- w.Header().Set("Content-Type", contentType)
- w.Header().Set("Cache-Control", "private, max-age=604800, immutable")
- w.Write(body)
-}
diff --git a/backend/internal/web/templates/card.html b/backend/internal/web/templates/card.html
index 3098125..4b79804 100644
--- a/backend/internal/web/templates/card.html
+++ b/backend/internal/web/templates/card.html
@@ -6,7 +6,7 @@