78234f3c19
Fixes #62
Browser-backed Sites join the Cover pipeline: kagane and novelfull Series now get their Covers at creation, through the same acquisition path as every other Site, instead of waiting for a poll pass.
## What changed
`latest.Acquirer` (creation-time acquisition, fired by the first Bookmark of a Series) previously skipped kagane and novelfull entirely — their pages only yield a Cloudflare challenge to the TLS client, so the request was spent for nothing. It now routes them like the poller does, with the two Sites split exactly as the issue demands:
- **kagane** — page fetched through the browser sidecar, cover URL extracted from the API JSON, bytes fetched through the browser sidecar (the only path that clears the challenge) into the content-addressed store. With no `BROWSER_WS_URL` configured, acquisition is skipped entirely and nothing falls back to a plain fetch.
- **novelfull** — page fetched through the browser sidecar, cover URL extracted from the HTML, bytes fetched over plain TLS through the ordinary gated fetcher (its image paths answer 200 with `access-control-allow-origin: *`, measured 2026-08-09). With no browser configured, the page fetch falls back to the TLS client — novelfull's challenge is a live time-varying fact (AGENTS.md), so when the page body answers, the Cover still lands; when it is challenged, nothing happens.
The byte-routing rule (kagane → browser, every other Site → TLS) is now one shared function (`latest.fetchCoverBytes`) used by both the Poller and the Acquirer, so the two cannot drift apart.
## Acceptance criteria
- [x] kagane cover bytes are fetched through the browser sidecar and stored in the content-addressed store — `TestAcquireKaganeCoverThroughBrowser`
- [x] novelfull cover URLs are extracted from the browser-fetched HTML, and its bytes are fetched over plain TLS — `TestAcquireNovelfullCoverOverPlainTLS`
- [x] With no browser sidecar configured, kagane Covers are absent and nothing falls back to a plain fetch — `TestAcquireKaganeSkippedWithoutBrowser`
- [x] With no browser sidecar configured, novelfull Covers still work if its page body is available — `TestAcquireNovelfullCoverWithoutBrowser`
- [x] Manually verified on-device: a kagane Series shows its Cover in the panel, not a broken-image glyph — being run by a separate manual-verification agent against a mocked scenario (no prod data); not part of this PR
- [x] `go test ./...` is green, with live-network checks gated behind `SMOKE_BROWSER_WS_URL` like the existing kagane image smoke test — new `TestSmokeAcquireKaganeCover` proves the end-to-end acquire path against the real browser when the env var is set
## Verification
- `go test ./...` green across all packages
- New unit tests exercise every routing decision with fakes — no network in the default suite
- Smoke test gated behind `SMOKE_BROWSER_WS_URL`, skipped by default
## Post-review changes (a66491a)
- **One routing rule for pages too** — `fetcherFor` is now a shared function used by both the Poller and the Acquirer; novelfull falls back to the plain-TLS fetcher in *both* when no browser is configured, so pre-existing (client-scraped) novelfull rows get healed by the poll as well, not just Series created after this change (`TestNovelfullUsesTLSWhenNoBrowserFetcher`).
- **Byte-level no-fallback proof** — `TestAcquireKaganeBytesNeverFallBackToPlainTLS` pins that kagane cover bytes never route to the TLS fetcher even when the page came through a browser.
- **Acquirer wired independent of the TLS client** — if `NewTLSFetcher` fails, kagane/novelfull acquisition still works via the sidecar (`main.go`).
- AGENTS.md (root + backend) updated for the novelfull plain-TLS fallback.
Reviewed-on: #72
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
14 KiB
14 KiB
Guidance for OpenCode (and Claude Code) working under backend/. See root AGENTS.md for the project-wide architecture diagram, hard constraints, and design system.
- Backend (
backend/): stdlibnet/http(handful routes, no framework) + Postgres overjackc/pgx/v5(pure Go,CGO_ENABLED=0-> static binary -> distroless/scratch image). Reverse proxy terminates TLS; Go service listens plain:8080. Single binary, split into packages underbackend/internal/:store(Bookmark type, Postgres persistence, migration runner),latest(background poller, site parsers, TLS fetcher),session(cookie signing, login rate limiter),httpmw(Auth/Gzip/CORS middleware),api(JSON bookmark handlers),userscript(userscript-serving handler),web(browser UI handler +templates/+static/,go:embed-ed).backend/main.gois the composition root — the only place that wires packages together intonewRouter. Root-level*_test.gohold integration tests that exercise the full router; unit tests for a package live beside it underinternal/. - Schema is migration-owned.
internal/store/migrations/*.sqlisgo:embed-ed and applied on every start bystore.migrate: one numbered file per change, one transaction each, versions recorded inschema_migrations. Files are append-only — editing an applied one changes nothing on a database that already ran it. No column probing, no data-fixup migrations: both were SQLite-era machinery and are gone. - Tests need Docker.
internal/pgteststarts onepostgres:17-alpinecontainer per test binary (TestMain->pgtest.Main) and hands each test its own database (pgtest.URL(t)). A package whose tests touch the store must have thatTestMain. - Reader-owned store, four tables.
readersis keyed by Discord user ID and carries the SHA-256 of the Reader's userscript credential plus atoken_epoch(issue #24). Credentials are derived, never stored:token.Token(TOKEN_KEY, discord_id, epoch)(HMAC,internal/token), and only its SHA-256 sits inreaders.token_sha256, so install URLs can be rebuilt after any restart while a database leak yields nothing but hashes. The seed creates the owner row at startup; its epoch-0 hash is refreshed on every start only while the row has never been rotated, so a restart can never resurrect a rotated-away credential. Every other row is created by that Reader's own first login (Store.EnsureReader, idempotent ondiscord_id, and it never rewrites an existing row's hash). Rotation isStore.RotateToken(epoch bump + hash rewrite in one transaction), driven by the web UI.serieskeyed(site, series_id)(asura|demonic|comix|kagane|novelfull|lightnovelworld) owns the shared facts — title, cover, canonical URL,kind(manga|novel), Latest Chapter,latest_checked_at— andbookmarksholds only what differs between readers: progress, favourite, lifecycle bucket,updated_at. A bookmark is keyed(reader_id, site, series_id)— no surrogate id; the wirekeyis derived assite:series_idon read — and every store read/write is scoped to the reader it names. Auth resolves the acting Reader from the presented credential (httpmw.Auth) and nothing else — there is no unauthenticated-by-Reader route and no global token; the reader id travels in the request context. Sync last-write-wins; the wire format stays flat (ADR-0004).Store.Upsertdecomposes one flat body across two tables and enforces the ownership rule: clienttitle/series_url/coverare written only when the series row is new (ADR-0003). - Endpoints:
GET /bookmarks,PUT /bookmarks/{key}(upsert; seeupdated_atrule below),DELETE /bookmarks/{key},GET /healthz(no auth). - Web UI: same binary serve the browser UI on a second
hostname —
GET /(list, or login page when no session),GET /auth/discord+GET /auth/discord/callback(Discord OAuth, ADR-0002),POST /logout,GET /static/*, htmx fragment endpoints under/ui/*. Templates + assetsgo:embed-ed underbackend/internal/web/, sobackend/Dockerfilemust copy the wholeinternal/tree, not just*.go. Sessions are rows in thesessionstable: the cookie carries only an opaque id, looked up (and expiry- checked) on every request, and deleting the row revokes the session. Guild membership is registration (issue #27):discordCallbackgates on membership (andDISCORD_REQUIRED_ROLEwhen set) and then callsStore.EnsureReader, so a refusal creates nothing and a returning Reader reuses their row. The owner is the only Reader with administrative reach:POST /readers/{id}/revoke(404 for anyone else) drops that Reader's sessions, and thereaderspanel renders only on the owner's page. A Reader with no bookmarks at all seeslistView.Fresh, whose empty state offers both install links instead of describing a filter. UI mutations read-modify-write throughStore.Get+Store.Upsertsoupdated_atrule stays one place. Seedocs/superpowers/specs/2026-07-25-web-ui-design.md. Design-tool caveat: templates link/static/style.cssroot-absolutely (correct — served from/), but impeccable detector resolves stylesheet href withpath.resolve(fileDir, href), drops directory on leading/and silently skip file. Relative href don't help either: template's directory isn't its served path. Sodetect.mjs backend/internal/web/templatesreports false clean — always passbackend/internal/web/statictoo. One finding there,overused-fonton "Instrument Serif", deliberate identity choice, not debt. - Every action that moves series out of list is confirm-gated.
Archive, finish, remove each open own
.confirm-rowdisclosure (toggleConfirmRow(key, kind)infilter.js,kind∈archive|finish|remove); restore fire instantly since it's the reversal. Remove's row wear ember wash, two reversible ones wear.calmgrey.--emberstay reserved for new-chapter signal: busy bar and inline error use--mute. - Latest-chapter poller: ticker goroutine in same binary re-check
each bookmarked series' newest published chapter from backend's own
network access, so
latest_chapterstay fresh when user not browsing. Second, parallel signal — userscript keep ownmaybeCaptureLatestOnSeriesPage/backgroundRefreshLatestlogic unchanged. Two independent clocks: per-series cooldown (series.latest_checked_at, enforced byStore.DueForLatestCheck's WHERE clause) and wake interval. The poller walks Series, not Bookmarks — a series referenced by several bookmarks is fetched once per cycle, and the due queue ordersreader_count DESC, latest_checked_at ASC(ADR-0003). Series row stamped before fetch so broken series wait out full cooldown instead of retrying every tick; found chapter written straight to the series row viaStore.SetLatestChapter, so a bookmark'supdated_at— and the list order — is never touched. Fetches usebogdanfinn/tls-clientwith Chrome profile as defence in depth against fingerprint-based blocking; any failure log and skip. kagane and novelfull sit behind Cloudflare JavaScript challenges the TLS client can't clear, so they are fetched over CDP viaBROWSER_WS_URL; kagane is simply not polled when that's unset, while novelfull falls back to a plain-TLS attempt — its challenge is a live time-varying fact, and its cover bytes never need the browser. Seedocs/superpowers/specs/2026-07-26-server-latest-chapter-polling-design.md. The poller's series write is a single-column UPDATE (Store.SetLatestChapter), not a read-modify-write of the whole bookmark: it cannot revert read progress or moveupdated_at, so the old stale-re-read race is gone with the Get+Upsert flow. - Covers are acquired at creation, then served from our own origin
(ADR-0007): the first Bookmark of a Series fires
Store.OnSeriesCreated, whichlatest.Acquirerturns into one series-page fetch yielding both the Latest Chapter and the cover URL; the bytes then go throughlatest.CoverBytesFetcherintoStore.SetSeriesCover. It runs in a goroutine — the Reader's PUT must neither block on a Site nor fail with one — and every failure is logged and dropped, leaving the Bookmark intact. The wire'scoveris the absolutePUBLIC_BASE_URL + /covers/{sha256}once bytes exist and""before, never an address that 404s.GET /covers/{addr}is public and uncredentialed: the userscript renders it on a Site's origin, where no cookie or token of ours travels. A client-sentcoveris decoded and discarded, permanently (ADR-0004 compatibility). Browser-backed Sites join the same pipeline (issue #62): kagane pages and cover bytes go through the browser sidecar (nothing falls back to a plain fetch, which would only retrieve a challenge page), while novelfull needs the browser only for its HTML — the cover URL comes out of the 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). updated_atdrives list order, so moves only on real reading progress: server apply its timestamp when row new orlast_chapter_numchanges, else keep stored value — favouriting series or recording newly published chapter must not reorder list.PUTtherefore returns row as stored, clients must adopt that response rather than own payload. Seeplans/2026-07-25-bookmark-list-favorites-design.md§4.- Lifecycle buckets:
statuson each bookmark isreading|archived|finished, orthogonal tofavorite. Archived and finished appear only in own tab — not in All, Updated, Favourites, or recent strip. Poller keeps checking archived series and skip finished ones.finishedsettable only from web UI;PUT /bookmarks/{key}reject it with 400. Empty incoming status means "keep stored one" — resolved on theVALUESside ofStore.Upsert, not conflict clause, sinceexcluded.*is post-evaluation row and default applied there would wipe bucket on every PUT from client that predates column. Seedocs/superpowers/specs/2026-07-27-status-buckets-design.md. - Config via env:
TOKEN_KEY(derives every Reader's userscript credential; required),OWNER_DISCORD_ID(seeds the owner Reader — the administrator and the owner of every pre-registration bookmark; required),ALLOWED_ORIGINS(comma list),DATABASE_URL(Postgres connection URL, required — no default),COVER_DIR(required filesystem volume for content-addressed Cover bytes),PUBLIC_BASE_URL(required origin this deployment answers on, trailing slash trimmed; every Cover URL on the wire is built from it, absolute because the userscript renders on a Site's origin — ADR-0007),PORT(default8080),DISCORD_CLIENT_ID/_CLIENT_SECRET/_GUILD_ID/_REDIRECT_URI(required; Discord OAuth for the browser UI),DISCORD_REQUIRED_ROLE(optional role gate, empty by default),DISCORD_API_BASE(defaulthttps://discord.com/api/v10),LATEST_CHAPTER_POLL_ENABLED/_COOLDOWN/_BROWSER_COOLDOWN/_INTERVAL/_BATCH/_STAGGER(background latest-chapter poller; defaults on,1hplain-TLS cooldown,6hbrowser cooldown,10m/14/20s; both cooldowns have a15mfloor).USERSCRIPT_PATHandNOVEL_USERSCRIPT_PATH(files served at/u/{token}/manga-bookmark.user.jsand/u/{token}/novel-bookmark.user.js, defaults/userscript/manga-bookmark.user.jsand/userscript/novel-bookmark.user.js, both supplied by bindmount; the__API_TOKEN__placeholder inside them is substituted with the requesting 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/json/versionfor any Host that isn't an IP orlocalhost). - 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<img>on the web UI's origin can load one — not even from a browser holding the clearance cookieog:imageto/img/kagane/{id}.internal/web/cover.goreads the persistentcoverstable first, then fetches a miss throughlatest.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 bar, or a redirect;?download=1addsContent-Disposition: attachmentfor mobile Violentmonkey, which ignores a.user.jsnavigation) andPOST /rotate-token(atomic epoch bump + hash rewrite; invalidates every installed copy, so the panel warns to reinstall on all devices). Owner-onlyPOST /readers/{id}/revoke(drops one Reader's session rows and re-renders thereaderspanel; 404 for any non-owner) is the only route that reaches across Readers.