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 @@
- {{if .CoverURL}} + {{if .Cover}} {{else}}{{end}} {{if .HasNewChapter}} {{else if .Favorite}}{{end}} diff --git a/backend/internal/web/web.go b/backend/internal/web/web.go index 4dd0470..4c3c753 100644 --- a/backend/internal/web/web.go +++ b/backend/internal/web/web.go @@ -50,9 +50,6 @@ type Handler struct { // httpClient is the plain stdlib client that talks to Discord. It is not // an injected interface: tests point APIBase at a stub server instead. httpClient *http.Client - // covers proxies kagane cover images, which no browser can load directly. - // Nil disables the endpoint — see CoverFetcher. - covers CoverFetcher } // listView is what every list-rendering template receives. @@ -114,7 +111,7 @@ type loginView struct { // New parses every template up front so a broken one kills the process at // startup rather than the first request that touches it. -func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string, covers CoverFetcher) (*Handler, error) { +func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string) (*Handler, error) { tmpl, err := template.ParseFS(templateFS, "templates/*.html") if err != nil { return nil, err @@ -129,7 +126,6 @@ func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, nove states: newOAuthStates(), limiter: session.NewLoginLimiter(), httpClient: &http.Client{Timeout: discordTimeout}, - covers: covers, }, nil } @@ -146,10 +142,6 @@ func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("POST /ui/bookmarks/{key}/chapter", h.requireSession(h.uiChapter)) mux.HandleFunc("DELETE /ui/bookmarks/{key}", h.requireSession(h.uiDelete)) - // Session-gated like every other UI route: the deployment proxies kagane's - // images for its own Readers, not for the internet. - mux.HandleFunc("GET /img/kagane/{id}", h.requireSession(h.kaganeCover)) - // Install endpoints render the script directly under the session: the // credential travels inside the served bytes, never in the address bar or // the page markup. Updates after install use the credential-bearing /u/ diff --git a/backend/main.go b/backend/main.go index 7c47ed7..2a8f3c7 100644 --- a/backend/main.go +++ b/backend/main.go @@ -57,10 +57,6 @@ type Config struct { NovelUserscriptPath string // LatestPoll configures the background latest-chapter fetcher. LatestPoll LatestPoll - // Covers proxies kagane cover images for the web UI. Not from the - // environment: it is the shared headless browser, wired in main once it - // connects, and nil in every test router. - Covers web.CoverFetcher } // LatestPoll configures the background latest-chapter poller. @@ -235,7 +231,7 @@ func newRouter(s *store.Store, cfg Config) http.Handler { // The browser UI is always registered; signing in is Discord OAuth, so // there is no password to forget and no gate to leave unset. wh, err := web.New(s, cfg.Discord, []byte(cfg.TokenKey), - cfg.UserscriptPath, cfg.NovelUserscriptPath, cfg.Covers) + cfg.UserscriptPath, cfg.NovelUserscriptPath) if err != nil { log.Fatalf("web handler: %v", err) } @@ -307,9 +303,9 @@ func main() { // chapters on its own. // // One headless browser serves both consumers that need a Cloudflare - // challenge cleared: the poller's kagane/novelfull fetches and the web - // UI's kagane cover proxy. Optional — unset leaves both degraded to what - // they were before the sidecar existed. + // challenge cleared: the poller's kagane/novelfull page fetches and + // kagane's cover bytes. Optional — unset leaves kagane unpolled and its + // Covers blank until the bytes exist. var browser latest.Fetcher pollCtx, stopPoll := context.WithCancel(context.Background()) defer stopPoll() @@ -319,7 +315,6 @@ func main() { log.Printf("browser fetcher disabled: %v", err) } else { browser = bf - cfg.Covers = bf context.AfterFunc(pollCtx, bf.Close) log.Printf("browser fetcher at %s", ws) }