diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 53115d3..06ecf7f 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -136,12 +136,12 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN 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 - (verified 2026-08-08). `Bookmark.CoverURL` rewrites a stored kagane - `og:image` to `/img/kagane/{id}`, served by `internal/web/cover.go` through - `latest.BrowserFetcher.Image` and memoised in-process. 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. + `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. - **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 49cc4a4..86a658f 100644 --- a/backend/cover_test.go +++ b/backend/cover_test.go @@ -2,11 +2,15 @@ package main import ( "context" + "crypto/sha256" "errors" "net/http" "net/http/httptest" "sync/atomic" "testing" + + "bookmarkmanager/backend/internal/pgtest" + "bookmarkmanager/backend/internal/store" ) // fakeCovers stands in for the headless browser. It counts calls so the test @@ -95,6 +99,41 @@ func TestKaganeCoverServesStoredBytesWithoutBrowser(t *testing.T) { } } +func TestKaganeCoverServesPersistedBytesAfterRestart(t *testing.T) { + url := pgtest.URL(t) + owner := store.Owner{ + DiscordID: "cover-owner", + TokenHash: sha256.Sum256([]byte("cover-owner-token")), + } + first, err := store.Open(url, owner) + 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) + 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"} @@ -147,9 +186,11 @@ func TestKaganeCoverRejectsBadInput(t *testing.T) { if rr.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", rr.Code) } - if _, _, ok, err := st.GetKaganeCover(testCoverID); err != nil { + _, _, ok, err := st.GetKaganeCover(tc.id) + if err != nil { t.Fatalf("GetKaganeCover after rejection: %v", err) - } else if ok { + } + if ok { t.Fatal("rejected cover was persisted") } }) diff --git a/backend/internal/web/cover.go b/backend/internal/web/cover.go index e33cf41..079ec3b 100644 --- a/backend/internal/web/cover.go +++ b/backend/internal/web/cover.go @@ -9,9 +9,9 @@ import ( ) // 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. +// 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) } @@ -52,11 +52,13 @@ func (h *Handler) kaganeCover(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - if body, contentType, ok, err := h.store.GetKaganeCover(id); err != nil { + 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 - } else if ok { + } + if ok { writeCover(w, body, contentType) return } @@ -67,7 +69,7 @@ func (h *Handler) kaganeCover(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), coverTimeout) defer cancel() - body, contentType, err := h.covers.Image(ctx, id) + body, contentType, err = h.covers.Image(ctx, id) if err != nil { log.Printf("kagane cover %s: %v", id, err) http.NotFound(w, r)