Delete the kagane-specific cover path (#63)

The web proxy for kagane covers is dead: since #62 every Site's cover
bytes land in the content-addressed store at creation or on the poll,
and the one public route serves them all. Remove the second way to
reach a Cover:

- Bookmark.CoverURL() and the templates' use of it: templates render
  the wire value (.Cover) and nothing else.
- GET /img/kagane/{id}, web.CoverFetcher, coverIDRe: the route and its
  identifier validation are gone, and with them web/cover.go.
- store.KaganeImageID, GetKaganeCover, PutKaganeCover,
  kaganeCoverSourceURL: the proxy's persistence.
- Bookmark.CoverSource: dead once CoverURL is gone.

Acquisition keeps the browser where kagane genuinely needs it, but the
Site name leaves the routing: kaganeImageURLRe lives in browser.go with
the rest of the per-Site knowledge, browserCoverURL claims the URLs the
sidecar alone can fetch, and fetchCoverBytes routes by URL shape with
no Site argument. No plain-TLS fallback for a claimed URL — that would
only retrieve a challenge page.

Cover tests: kagane route tests removed, the general-route guarantees
they pinned kept and re-pinned — unstored and traversal-shaped
addresses serve nothing (TestPublicCoverRejectsUnknownAddress), non-image
content types are never echoed back (TestPublicCoverNeverEchoesNonImage
+ TestCoverStoreAcceptsAnySourceURL). Store content-addressing and
reopen-persistence tests rewritten against PutCover/GetCover.

No Site name remains in a cover code path outside the acquisition
module; go test ./... green.
This commit is contained in:
2026-08-10 11:24:42 +07:00
parent 78234f3c19
commit cce3d61799
16 changed files with 127 additions and 480 deletions
+17 -15
View File
@@ -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 browser-fetched page and the bytes go over plain TLS. With no browser
configured, kagane Covers are simply absent; novelfull still gets one — at 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 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. - **`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` | - **Lifecycle buckets:** `status` on each bookmark is `reading` | `archived` |
`finished`, orthogonal to `favorite`. Archived and finished appear only in `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). Reader's credential at serve time).
`BROWSER_WS_URL` (CDP endpoint of the browser, which runs on a **separate `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`. 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 Used by the poller for kagane and novelfull page fetches and by the cover
cover proxy; unset — the default — disables browser polling and serves 404 pipeline for kagane's image bytes (the browser is the only route that clears
for covers not already stored, leaving those sites to the userscript alone. the challenge kagane serves its covers behind); unset — the default —
Must be a tailnet IP, never a hostname: Chrome's DevTools handler 500s 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`). `/json/version` for any Host that isn't an IP or `localhost`).
- **kagane covers are proxied, not hot-linked:** kagane serves cover images - **No per-Site cover path (issue #63):** every Cover — all six Sites — is
behind the same challenge as its pages and with served by the one public `GET /covers/{addr}` route from content-addressed
`cross-origin-resource-policy: same-origin`, so no `<img>` on the web UI's bytes. There is no proxy, no per-Site rewrite, no second place that decides
origin can load one — not even from a browser holding the clearance cookie a Cover's renderable address: the wire `cover` is it. The only place a Site
`og:image` to `/img/kagane/{id}`. `internal/web/cover.go` reads the persistent name still appears in cover code is the acquisition module, where kagane's
`covers` table first, then fetches a miss through `latest.BrowserFetcher.Image`. image URLs are claimed by `latest.BrowserFetcher` (`browserCoverURL`) because
The templates render `.CoverURL`, never `.Cover`. The id is matched against a they answer a plain fetch with a challenge and
UUID regex before it reaches the browser: the stored value is client-supplied, `cross-origin-resource-policy: same-origin`; every other Site's CDN answers
so an unchecked one is an SSRF primitive pointed at the deployment's own plain TLS. Templates render `.Cover` — the wire value — never anything else.
network.
- **Web UI also owns:** session-gated `GET /install/{manga,novel}-bookmark.user.js` - **Web UI also owns:** session-gated `GET /install/{manga,novel}-bookmark.user.js`
(renders the bindmounted script with the acting Reader's derived credential (renders the bindmounted script with the acting Reader's derived credential
substituted in — the credential never appears in page markup, the address substituted in — the credential never appears in page markup, the address
+17 -206
View File
@@ -1,40 +1,14 @@
package main package main
import ( import (
"context"
"crypto/sha256"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"bookmarkmanager/backend/internal/pgtest"
"bookmarkmanager/backend/internal/store" "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 { func getCover(t *testing.T, srv http.Handler, path string, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper() t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil) 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 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("<script>"), contentType: "text/html"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := testConfig()
cfg.Covers = tc.fetch
srv, st := newWebTestServer(t, cfg)
rr := getCover(t, srv, "/img/kagane/"+tc.id, sessionCookie(t, st))
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code)
}
_, _, ok, err := st.GetKaganeCover(tc.id)
if err != nil {
t.Fatalf("GetKaganeCover after rejection: %v", err)
}
if ok {
t.Fatal("rejected cover was persisted")
}
})
}
}
// ServeMux path-cleans a traversal into a redirect before the handler runs, so
// the guarantee to pin down is that no request shaped like one ever gets bytes.
func TestKaganeCoverTraversalServesNothing(t *testing.T) {
cf := &fakeCovers{body: []byte("secret"), contentType: "image/webp"}
cfg := testConfig()
cfg.Covers = cf
srv, st := newWebTestServer(t, cfg)
rr := getCover(t, srv, "/img/kagane/../../etc/passwd", sessionCookie(t, st))
if rr.Code == http.StatusOK {
t.Fatalf("status = 200, want anything but a served body")
}
if got := cf.calls.Load(); got != 0 {
t.Fatalf("fetcher called %d times for a traversal, want 0", got)
}
}
// Without BROWSER_WS_URL there is no fetcher, and the endpoint must answer
// rather than reach for a nil one.
func TestKaganeCoverWithoutFetcher(t *testing.T) {
srv, st := newWebTestServer(t, testConfig())
rr := getCover(t, srv, "/img/kagane/"+testCoverID, sessionCookie(t, st))
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code)
}
}
// The acquired Cover is served from this deployment's own origin, to any // The acquired Cover is served from this deployment's own origin, to any
// browser rendering a third-party page — no session, no credential (ADR-0007). // browser rendering a third-party page — no session, no credential (ADR-0007).
func TestPublicCoverServesStoredBytesUnauthenticated(t *testing.T) { func TestPublicCoverServesStoredBytesUnauthenticated(t *testing.T) {
@@ -276,6 +70,23 @@ func TestPublicCoverRejectsUnknownAddress(t *testing.T) {
} }
} }
// A content type outside the image set is never echoed back. The old kagane
// proxy could fetch text/html from a challenged fetch and had to refuse it;
// the general route's only input is what the store accepted, and the store
// refuses to record anything that is not an image, so the address that would
// name one is a miss, not a served body.
func TestPublicCoverNeverEchoesNonImage(t *testing.T) {
const sourceURL = "https://cdn.example/cover"
srv, st := newWebTestServer(t, testConfig())
if err := st.PutCover(sourceURL, []byte("<script>"), "text/html"); err == nil {
t.Fatal("PutCover accepted a non-image content type")
}
rr := getCover(t, srv, "/covers/"+store.CoverAddress(sourceURL), nil)
if rr.Code == http.StatusOK {
t.Fatalf("status = 200, want nothing served for a cover the store refused")
}
}
// The whole point of acquiring bytes is that the UI shows them: the card's // The whole point of acquiring bytes is that the UI shows them: the card's
// <img> must carry the public address, not a third-party URL and not a // <img> must carry the public address, not a third-party URL and not a
// placeholder. // placeholder.
+3 -3
View File
@@ -42,8 +42,8 @@ type Acquirer struct {
// Covers retrieves the cover bytes. Nil leaves the Cover blank and the // Covers retrieves the cover bytes. Nil leaves the Cover blank and the
// chapter half working. // chapter half working.
Covers CoverBytesFetcher Covers CoverBytesFetcher
// BrowserCoverFetch retrieves kagane cover bytes through the browser // BrowserCoverFetch retrieves browser-claimed cover bytes through the
// sidecar. Nil leaves kagane Covers blank; nothing falls back to a plain // sidecar. Nil leaves those Covers blank; nothing falls back to a plain
// fetch, which would only ever retrieve a challenge page. // fetch, which would only ever retrieve a challenge page.
BrowserCoverFetch BrowserCoverFetcher BrowserCoverFetch BrowserCoverFetcher
// Ctx cancels in-flight acquisitions at shutdown. A hook signature has // Ctx cancels in-flight acquisitions at shutdown. A hook signature has
@@ -136,7 +136,7 @@ func (a *Acquirer) acquire(ctx context.Context, sr store.Series) {
if !ok { if !ok {
return return
} }
bytes, contentType, err := fetchCoverBytes(ctx, sr.Site, cover, a.BrowserCoverFetch, a.Covers) bytes, contentType, err := fetchCoverBytes(ctx, cover, a.BrowserCoverFetch, a.Covers)
if err != nil { if err != nil {
log.Printf("acquire %q: fetch cover %s: %v", sr.Key(), cover, err) log.Printf("acquire %q: fetch cover %s: %v", sr.Key(), cover, err)
return return
+2 -2
View File
@@ -312,8 +312,8 @@ func TestAcquireKaganeCoverThroughBrowser(t *testing.T) {
if got := covers.callCount(); got != 1 { if got := covers.callCount(); got != 1 {
t.Fatalf("browser cover fetches = %d, want 1", got) t.Fatalf("browser cover fetches = %d, want 1", got)
} }
if got := covers.calls[0]; got != kaganeImageID { if got := covers.calls[0]; got != kaganeCoverSrc {
t.Fatalf("browser cover fetched image id %q, want %q", got, kaganeImageID) t.Fatalf("browser cover fetched URL %q, want %q", got, kaganeCoverSrc)
} }
got := readBookmark(t, s, kaganeKey) got := readBookmark(t, s, kaganeKey)
if want := testCoverBaseURL + "/covers/" + store.CoverAddress(kaganeCoverSrc); got.Cover != want { if want := testCoverBaseURL + "/covers/" + store.CoverAddress(kaganeCoverSrc); got.Cover != want {
+30 -12
View File
@@ -24,12 +24,26 @@ const challengeTimeout = 45 * time.Second
var kaganeSeriesRe = regexp.MustCompile(`^/series/([0-9a-f-]{36})/?$`) var kaganeSeriesRe = regexp.MustCompile(`^/series/([0-9a-f-]{36})/?$`)
// kaganeImageIDRe pins the only path segment Image interpolates into an // kaganeImageIDRe pins the only image id the kagane extractor accepts. It
// outbound URL. The id arrives from a stored cover URL, which a client // arrives from the browser-fetched API body, so it is matched rather than
// supplied, so it is matched rather than trusted: a headless browser is a // trusted.
// strong SSRF primitive.
var kaganeImageIDRe = regexp.MustCompile(`^[0-9a-f-]{36}$`) var kaganeImageIDRe = regexp.MustCompile(`^[0-9a-f-]{36}$`)
// kaganeImageURLRe matches the only cover URL Image fetches: the canonical
// compressed image route kagane's API publishes. It doubles as the byte-fetch
// router's claim check (fetchCoverBytes) — an address of this shape answers a
// plain fetch with a challenge, so the browser is the only route for it.
var kaganeImageURLRe = regexp.MustCompile(`^https://kagane\.to/api/v2/image/([0-9a-f-]{36})/compressed$`)
// browserCoverURL reports whether the browser sidecar is the only fetcher for
// cover bytes at imageURL. kagane serves them behind the same challenge as its
// pages, so a plain TLS fetch would only ever retrieve a challenge page and
// must not be attempted (ADR-0007). Per-Site knowledge, kept in the browser
// module with the rest of it.
func browserCoverURL(imageURL string) bool {
return kaganeImageURLRe.MatchString(imageURL)
}
// BrowserFetcher retrieves pages through a remote headless Chrome over the // BrowserFetcher retrieves pages through a remote headless Chrome over the
// DevTools Protocol. // DevTools Protocol.
// //
@@ -134,12 +148,14 @@ func (f *BrowserFetcher) Get(ctx context.Context, seriesURL string) (string, int
return body, 200, nil return body, 200, nil
} }
// Image retrieves one kagane cover as raw bytes and its content type. // Image retrieves one cover's bytes through the browser sidecar, and its
// content type.
// //
// It exists because kagane serves covers behind the same challenge as its // It exists because kagane serves covers behind the same challenge as its
// pages *and* with `cross-origin-resource-policy: same-origin`, so an <img> on // pages *and* with `cross-origin-resource-policy: same-origin`, so the bytes
// the web UI's origin cannot load one even from a browser that already holds // are only reachable from inside a browser that already holds the clearance
// the clearance cookie (verified 2026-08-08). Proxying is the only route. // cookie (verified 2026-08-08). Acquisition through the sidecar is the only
// route.
// //
// The image URL is navigated to rather than fetched from some other kagane // The image URL is navigated to rather than fetched from some other kagane
// page: the challenge only runs on a top-level navigation, and once it clears // page: the challenge only runs on a top-level navigation, and once it clears
@@ -149,12 +165,14 @@ func (f *BrowserFetcher) Get(ctx context.Context, seriesURL string) (string, int
// The challenge is not solved by the first read: WaitReady("body") is satisfied // The challenge is not solved by the first read: WaitReady("body") is satisfied
// by the interstitial too. run holds the tab open until the in-page fetch // by the interstitial too. run holds the tab open until the in-page fetch
// succeeds, which is what gives the challenge script the seconds it needs. // succeeds, which is what gives the challenge script the seconds it needs.
func (f *BrowserFetcher) Image(ctx context.Context, imageID string) ([]byte, string, error) { func (f *BrowserFetcher) Image(ctx context.Context, imageURL string) ([]byte, string, error) {
if !kaganeImageIDRe.MatchString(imageID) { m := kaganeImageURLRe.FindStringSubmatch(imageURL)
return nil, "", fmt.Errorf("not a kagane image id: %q", imageID) if m == nil {
return nil, "", fmt.Errorf("not a browser-fetchable cover url: %q", imageURL)
} }
imageID := m[1]
var dataURL string var dataURL string
err := f.run(ctx, "https://kagane.to/api/v2/image/"+imageID+"/compressed", err := f.run(ctx, imageURL,
chromedp.Evaluate(`fetch(location.href).then(r => r.ok chromedp.Evaluate(`fetch(location.href).then(r => r.ok
? r.blob().then(b => new Promise(res => { ? r.blob().then(b => new Promise(res => {
const fr = new FileReader(); const fr = new FileReader();
+10 -13
View File
@@ -22,22 +22,19 @@ type CoverBytesFetcher interface {
Fetch(ctx context.Context, sourceURL string) (body []byte, contentType string, err error) Fetch(ctx context.Context, sourceURL string) (body []byte, contentType string, err error)
} }
// fetchCoverBytes routes a cover's byte retrieval by Site: only kagane needs // fetchCoverBytes routes a cover's byte retrieval by URL shape, not by Site
// the browser for image bytes — its covers answer a plain fetch with a // name: the browser fetcher's module claims the addresses only it can fetch
// challenge and `cross-origin-resource-policy: same-origin` — while every // (kagane's image route answers a plain fetch with a challenge and
// other Site's CDN answers plain TLS. Missing fetchers degrade to an error the // `cross-origin-resource-policy: same-origin`), and everything else goes over
// caller logs, never a fallback onto a path that cannot succeed. One routing // plain TLS. Missing fetchers degrade to an error the caller logs, never a
// rule for the poll and the acquirer, so the two cannot drift apart. // fallback onto a path that cannot succeed. One routing rule for the poll and
func fetchCoverBytes(ctx context.Context, site, cover string, browser BrowserCoverFetcher, tls CoverBytesFetcher) ([]byte, string, error) { // the acquirer, so the two cannot drift apart.
if site == "kagane" { func fetchCoverBytes(ctx context.Context, cover string, browser BrowserCoverFetcher, tls CoverBytesFetcher) ([]byte, string, error) {
if browserCoverURL(cover) {
if browser == nil { if browser == nil {
return nil, "", errors.New("no cover fetcher") return nil, "", errors.New("no cover fetcher")
} }
imageID, ok := store.KaganeImageID(cover) return browser.Image(ctx, cover)
if !ok {
return nil, "", errors.New("invalid kagane cover URL")
}
return browser.Image(ctx, imageID)
} }
if tls == nil { if tls == nil {
return nil, "", errors.New("no cover fetcher") return nil, "", errors.New("no cover fetcher")
+11 -8
View File
@@ -16,9 +16,11 @@ type Fetcher interface {
Get(ctx context.Context, url string) (body string, status int, err error) Get(ctx context.Context, url string) (body string, status int, err error)
} }
// BrowserCoverFetcher retrieves one kagane cover through the browser-backed path. // BrowserCoverFetcher retrieves one cover's bytes through the browser-backed
// path — the only route that clears the challenge kagane's image URLs answer
// a plain fetch with. Satisfied by BrowserFetcher.
type BrowserCoverFetcher interface { type BrowserCoverFetcher interface {
Image(ctx context.Context, imageID string) (body []byte, contentType string, err error) Image(ctx context.Context, imageURL string) (body []byte, contentType string, err error)
} }
// Poller re-checks each bookmarked series' newest published chapter on a // Poller re-checks each bookmarked series' newest published chapter on a
@@ -44,8 +46,8 @@ type Poller struct {
BrowserFetch Fetcher BrowserFetch Fetcher
// CoverFetch is optional; failures are logged and never affect the chapter poll. // CoverFetch is optional; failures are logged and never affect the chapter poll.
CoverFetch BrowserCoverFetcher CoverFetch BrowserCoverFetcher
// CoverBytesFetch is optional; it handles non-kagane sources through the same // CoverBytesFetch is optional; it handles plain-TLS sources through the
// failure-isolated prefetch path. // same failure-isolated prefetch path.
CoverBytesFetch CoverBytesFetcher CoverBytesFetch CoverBytesFetcher
Now func() time.Time // injected so tests can freeze it Now func() time.Time // injected so tests can freeze it
Cooldown time.Duration Cooldown time.Duration
@@ -81,9 +83,10 @@ func (p *Poller) fillBlankCover(ctx context.Context, sr store.Series, body strin
// prefetchCover heals Series that already carry a third-party source URL but // prefetchCover heals Series that already carry a third-party source URL but
// no stored address — the state left by client-supplied covers before // no stored address — the state left by client-supplied covers before
// acquisition moved server-side. Every Site takes the same path; only the // acquisition moved server-side. Every Site takes the same path; fetchCoverBytes
// byte fetcher differs (kagane needs the browser). New blanks have no source // routes by URL shape, so browser-claimed URLs still need the sidecar. New
// URL and go through fillBlankCover from the series page instead. // blanks have no source URL and go through fillBlankCover from the series page
// instead.
func (p *Poller) prefetchCover(ctx context.Context, sr store.Series) { func (p *Poller) prefetchCover(ctx context.Context, sr store.Series) {
if sr.Cover == "" || sr.CoverAddress != "" { if sr.Cover == "" || sr.CoverAddress != "" {
return return
@@ -106,7 +109,7 @@ func (p *Poller) prefetchCover(ctx context.Context, sr store.Series) {
// failure is logged against the Series and swallowed so the chapter poll // failure is logged against the Series and swallowed so the chapter poll
// cannot see it. // cannot see it.
func (p *Poller) storeCover(ctx context.Context, sr store.Series, sourceURL string) { func (p *Poller) storeCover(ctx context.Context, sr store.Series, sourceURL string) {
bytes, contentType, err := fetchCoverBytes(ctx, sr.Site, sourceURL, p.CoverFetch, p.CoverBytesFetch) bytes, contentType, err := fetchCoverBytes(ctx, sourceURL, p.CoverFetch, p.CoverBytesFetch)
if err != nil { if err != nil {
log.Printf("latest poll %q: fetch cover %s: %v", sr.Key(), sourceURL, err) log.Printf("latest poll %q: fetch cover %s: %v", sr.Key(), sourceURL, err)
return return
+6 -6
View File
@@ -116,9 +116,9 @@ type fakeCoverFetcher struct {
err error err error
} }
func (f *fakeCoverFetcher) Image(_ context.Context, imageID string) ([]byte, string, error) { func (f *fakeCoverFetcher) Image(_ context.Context, imageURL string) ([]byte, string, error) {
f.mu.Lock() f.mu.Lock()
f.calls = append(f.calls, imageID) f.calls = append(f.calls, imageURL)
f.mu.Unlock() f.mu.Unlock()
if f.err != nil { if f.err != nil {
return nil, "", f.err return nil, "", f.err
@@ -778,9 +778,9 @@ func TestRunOncePrefetchesKaganeCover(t *testing.T) {
} }
p.runOnce(context.Background()) p.runOnce(context.Background())
body, contentType, ok, err := s.GetKaganeCover("019f84bc-9ba0-7ed9-86f5-8b905ec7c28b") body, contentType, ok, err := s.CoverByAddress(store.CoverAddress(coverURL))
if err != nil || !ok { if err != nil || !ok {
t.Fatalf("GetKaganeCover: %v found=%v", err, ok) t.Fatalf("CoverByAddress: %v found=%v", err, ok)
} }
if string(body) != "cover-bytes" || contentType != "image/webp" { if string(body) != "cover-bytes" || contentType != "image/webp" {
t.Fatalf("stored cover = (%q, %q), want (cover-bytes, image/webp)", body, contentType) t.Fatalf("stored cover = (%q, %q), want (cover-bytes, image/webp)", body, contentType)
@@ -877,7 +877,7 @@ func TestRunOnceRejectsInvalidKaganeCover(t *testing.T) {
} }
p.runOnce(context.Background()) p.runOnce(context.Background())
if _, _, found, err := s.GetKaganeCover("019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"); err != nil || found { if _, _, found, err := s.CoverByAddress(store.CoverAddress(coverURL)); err != nil || found {
t.Fatalf("invalid cover persisted = %v, err %v; want missing", found, err) t.Fatalf("invalid cover persisted = %v, err %v; want missing", found, err)
} }
} }
@@ -902,7 +902,7 @@ func TestRunOnceWithoutCoverFetcherStillPollsKagane(t *testing.T) {
} }
p.runOnce(context.Background()) p.runOnce(context.Background())
if _, _, found, err := s.GetKaganeCover("019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"); err != nil || found { if _, _, found, err := s.CoverByAddress(store.CoverAddress(coverURL)); err != nil || found {
t.Fatalf("cover after nil CoverFetch = found %v, err %v; want missing", found, err) t.Fatalf("cover after nil CoverFetch = found %v, err %v; want missing", found, err)
} }
} }
+12 -10
View File
@@ -10,9 +10,10 @@ import (
"bookmarkmanager/backend/internal/store" "bookmarkmanager/backend/internal/store"
) )
// TestSmokeKaganeImage is the live proof that the cover proxy's fetch actually // TestSmokeKaganeImage is the live proof that the acquisition path's browser
// clears Cloudflare and returns image bytes. It needs the real browser unit // fetch actually clears Cloudflare and returns image bytes. It needs the real
// with outbound network, so it runs only when SMOKE_BROWSER_WS_URL is set: // 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 // 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 // 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 == "" { if ws == "" {
t.Skip("SMOKE_BROWSER_WS_URL unset") 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 <img> 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. // Asserting on it keeps the test honest about why the browser is needed.
req, err := http.NewRequest(http.MethodGet, req, err := http.NewRequest(http.MethodGet, imageURL, nil)
"https://kagane.to/api/v2/image/"+imageID+"/compressed", nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -48,7 +48,7 @@ func TestSmokeKaganeImage(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel() defer cancel()
body, contentType, err := f.Image(ctx, imageID) body, contentType, err := f.Image(ctx, imageURL)
if err != nil { if err != nil {
t.Fatalf("Image: %v", err) t.Fatalf("Image: %v", err)
} }
@@ -64,8 +64,10 @@ func TestSmokeKaganeImage(t *testing.T) {
} }
t.Logf("fetched %d bytes of %s", len(body), contentType) t.Logf("fetched %d bytes of %s", len(body), contentType)
if _, _, err := f.Image(ctx, "not-a-uuid"); err == nil { // The browser module claims only the cover URL shape it can clear a
t.Fatal("Image accepted a non-uuid id") // 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")
} }
} }
+2 -47
View File
@@ -40,10 +40,6 @@ type Bookmark struct {
// address and never an address that 404s (ADR-0007). A client may still // 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. // send this field and it is discarded on the way in; see Upsert.
Cover string `json:"cover"` 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"` LastChapter string `json:"last_chapter"`
LastChapterNum float64 `json:"last_chapter_num"` LastChapterNum float64 `json:"last_chapter_num"`
LastChapterURL string `json:"last_chapter_url"` LastChapterURL string `json:"last_chapter_url"`
@@ -155,20 +151,6 @@ func (b Bookmark) Initial() string {
return "?" 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 // CoverContentType canonicalises a fetched response's media type and reports
// whether the bytes are safe to store and serve. comix answers "image/jpg", // 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 // 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 <img>. 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 // 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 // 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. // 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 // compile-time constant; every request value is bound as a parameter. The
// series-owned fields are joined in from the series table, in scanBookmark // 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). // 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.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` 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 latestChapterNum sql.NullFloat64
) )
if err := scan( 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.LastChapter, &b.LastChapterNum, &b.LastChapterURL,
&b.Favorite, &b.LatestChapter, &latestChapterNum, &b.UpdatedAt, &b.Status, &b.Kind, &b.Favorite, &b.LatestChapter, &latestChapterNum, &b.UpdatedAt, &b.Status, &b.Kind,
); err != nil { ); err != nil {
@@ -617,10 +588,6 @@ func coverRelativePath(address string) string {
return address[:2] + "/" + address[2:4] + "/" + address 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) { func (s *Store) getCover(sourceURL string) ([]byte, string, bool, error) {
return s.getCoverByAddress(coverSourceAddress(sourceURL)) 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) 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 // 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 // under. It is a pure function of the URL, so the acquisition path can name a
// Cover before it has the bytes. // Cover before it has the bytes.
+10 -49
View File
@@ -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) { func TestUpsertKindDefaultsToManga(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
got, err := store.Upsert(store.OwnerID(), Bookmark{ 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) 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) url := pgtest.URL(t)
coverDir := t.TempDir() coverDir := t.TempDir()
first, err := Open(url, testOwner, coverDir, testCoverBaseURL) first, err := Open(url, testOwner, coverDir, testCoverBaseURL)
@@ -1405,8 +1366,9 @@ func TestKaganeCoverPersistsAcrossReopen(t *testing.T) {
t.Fatalf("Open: %v", err) t.Fatalf("Open: %v", err)
} }
body := []byte("stored-cover") body := []byte("stored-cover")
if err := first.PutKaganeCover("019fe11a-84c3-7fc3-a84b-88787374b617", body, "image/webp"); err != nil { const sourceURL = "https://cdn.example/covers/series.jpg"
t.Fatalf("PutKaganeCover: %v", err) if err := first.PutCover(sourceURL, body, "image/webp"); err != nil {
t.Fatalf("PutCover: %v", err)
} }
if err := first.Close(); err != nil { if err := first.Close(); err != nil {
t.Fatalf("close first store: %v", err) t.Fatalf("close first store: %v", err)
@@ -1417,9 +1379,9 @@ func TestKaganeCoverPersistsAcrossReopen(t *testing.T) {
t.Fatalf("reopen: %v", err) t.Fatalf("reopen: %v", err)
} }
defer second.Close() defer second.Close()
got, contentType, ok, err := second.GetKaganeCover("019fe11a-84c3-7fc3-a84b-88787374b617") got, contentType, ok, err := second.GetCover(sourceURL)
if err != nil { if err != nil {
t.Fatalf("GetKaganeCover: %v", err) t.Fatalf("GetCover: %v", err)
} }
if !ok || !bytes.Equal(got, body) || contentType != "image/webp" { 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) 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) url := pgtest.URL(t)
coverDir := t.TempDir() coverDir := t.TempDir()
first, err := Open(url, testOwner, coverDir, testCoverBaseURL) first, err := Open(url, testOwner, coverDir, testCoverBaseURL)
@@ -1451,14 +1413,13 @@ func TestKaganeCoverIsContentAddressedOnFilesystem(t *testing.T) {
t.Fatalf("Open: %v", err) t.Fatalf("Open: %v", err)
} }
body := []byte("stored-cover") body := []byte("stored-cover")
const imageID = "019fe11a-84c3-7fc3-a84b-88787374b617" const sourceURL = "https://cdn.example/covers/series.jpg"
if err := first.PutKaganeCover(imageID, body, "image/webp"); err != nil { if err := first.PutCover(sourceURL, body, "image/webp"); err != nil {
first.Close() first.Close()
t.Fatalf("PutKaganeCover: %v", err) t.Fatalf("PutCover: %v", err)
} }
defer first.Close() defer first.Close()
sourceURL := "https://kagane.to/api/v2/image/" + imageID + "/compressed"
addressBytes := sha256.Sum256([]byte(sourceURL)) addressBytes := sha256.Sum256([]byte(sourceURL))
address := hex.EncodeToString(addressBytes[:]) address := hex.EncodeToString(addressBytes[:])
wantPath := filepath.Join(address[:2], address[2:4], address) wantPath := filepath.Join(address[:2], address[2:4], address)
-89
View File
@@ -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)
}
+1 -1
View File
@@ -6,7 +6,7 @@
<div class="row"> <div class="row">
<a class="cover" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer" <a class="cover" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer"
tabindex="-1" aria-hidden="true"> tabindex="-1" aria-hidden="true">
{{if .CoverURL}}<img src="{{.CoverURL}}" alt="" loading="lazy"> {{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
{{/* aria-hidden on the cover link is not enough — Chromium still exposes {{/* aria-hidden on the cover link is not enough — Chromium still exposes
the letter because the link is programmatically focusable — so the the letter because the link is programmatically focusable — so the
monogram carries its own, same as the recent strip's. */}} monogram carries its own, same as the recent strip's. */}}
+1 -1
View File
@@ -14,7 +14,7 @@
<a class="recent-card {{if .HasNewChapter}}is-new{{end}}" href="{{.ContinueURL}}" <a class="recent-card {{if .HasNewChapter}}is-new{{end}}" href="{{.ContinueURL}}"
target="_blank" rel="noopener noreferrer"> target="_blank" rel="noopener noreferrer">
<span class="recent-cover"> <span class="recent-cover">
{{if .CoverURL}}<img src="{{.CoverURL}}" alt="" loading="lazy"> {{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
{{else}}<span class="monogram" aria-hidden="true">{{.Initial}}</span>{{end}} {{else}}<span class="monogram" aria-hidden="true">{{.Initial}}</span>{{end}}
{{if .HasNewChapter}}<span class="foot-rule"></span> {{if .HasNewChapter}}<span class="foot-rule"></span>
{{else if .Favorite}}<span class="foot-rule brass"></span>{{end}} {{else if .Favorite}}<span class="foot-rule brass"></span>{{end}}
+1 -9
View File
@@ -50,9 +50,6 @@ type Handler struct {
// httpClient is the plain stdlib client that talks to Discord. It is not // httpClient is the plain stdlib client that talks to Discord. It is not
// an injected interface: tests point APIBase at a stub server instead. // an injected interface: tests point APIBase at a stub server instead.
httpClient *http.Client 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. // 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 // New parses every template up front so a broken one kills the process at
// startup rather than the first request that touches it. // 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") tmpl, err := template.ParseFS(templateFS, "templates/*.html")
if err != nil { if err != nil {
return nil, err return nil, err
@@ -129,7 +126,6 @@ func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, nove
states: newOAuthStates(), states: newOAuthStates(),
limiter: session.NewLoginLimiter(), limiter: session.NewLoginLimiter(),
httpClient: &http.Client{Timeout: discordTimeout}, httpClient: &http.Client{Timeout: discordTimeout},
covers: covers,
}, nil }, 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("POST /ui/bookmarks/{key}/chapter", h.requireSession(h.uiChapter))
mux.HandleFunc("DELETE /ui/bookmarks/{key}", h.requireSession(h.uiDelete)) 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 // Install endpoints render the script directly under the session: the
// credential travels inside the served bytes, never in the address bar or // credential travels inside the served bytes, never in the address bar or
// the page markup. Updates after install use the credential-bearing /u/ // the page markup. Updates after install use the credential-bearing /u/
+4 -9
View File
@@ -57,10 +57,6 @@ type Config struct {
NovelUserscriptPath string NovelUserscriptPath string
// LatestPoll configures the background latest-chapter fetcher. // LatestPoll configures the background latest-chapter fetcher.
LatestPoll LatestPoll 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. // 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 // The browser UI is always registered; signing in is Discord OAuth, so
// there is no password to forget and no gate to leave unset. // there is no password to forget and no gate to leave unset.
wh, err := web.New(s, cfg.Discord, []byte(cfg.TokenKey), wh, err := web.New(s, cfg.Discord, []byte(cfg.TokenKey),
cfg.UserscriptPath, cfg.NovelUserscriptPath, cfg.Covers) cfg.UserscriptPath, cfg.NovelUserscriptPath)
if err != nil { if err != nil {
log.Fatalf("web handler: %v", err) log.Fatalf("web handler: %v", err)
} }
@@ -307,9 +303,9 @@ func main() {
// chapters on its own. // chapters on its own.
// //
// One headless browser serves both consumers that need a Cloudflare // One headless browser serves both consumers that need a Cloudflare
// challenge cleared: the poller's kagane/novelfull fetches and the web // challenge cleared: the poller's kagane/novelfull page fetches and
// UI's kagane cover proxy. Optional — unset leaves both degraded to what // kagane's cover bytes. Optional — unset leaves kagane unpolled and its
// they were before the sidecar existed. // Covers blank until the bytes exist.
var browser latest.Fetcher var browser latest.Fetcher
pollCtx, stopPoll := context.WithCancel(context.Background()) pollCtx, stopPoll := context.WithCancel(context.Background())
defer stopPoll() defer stopPoll()
@@ -319,7 +315,6 @@ func main() {
log.Printf("browser fetcher disabled: %v", err) log.Printf("browser fetcher disabled: %v", err)
} else { } else {
browser = bf browser = bf
cfg.Covers = bf
context.AfterFunc(pollCtx, bf.Close) context.AfterFunc(pollCtx, bf.Close)
log.Printf("browser fetcher at %s", ws) log.Printf("browser fetcher at %s", ws)
} }