# ADR-0007: The backend hosts every Site's Cover bytes Date: 2026-08-09 Status: accepted ## Decision A Cover is the image a Reader's browser can display for a Series. Where the Site keeps the picture is the backend's problem, not the client's: the backend fetches the bytes, stores them, and serves them from its own origin. No client ever renders a third-party URL, and no client ever supplies one. Concretely: - **Acquisition is server-side.** The Cover is extracted from the same series-page fetch that already yields Latest Chapter. It runs once at Series creation rather than waiting for the poll queue, so a newly bookmarked Series has both facts in seconds instead of up to a queue's depth. The poll fills a blank Cover and never overwrites a non-blank one. - **Bytes live on a filesystem volume**, content-addressed by the SHA-256 of the source URL, sharded `${COVER_DIR}/ab/cd/`. The database holds the path and content type, not the bytes. - **One public route** serves them. No session, no credential. - **The wire carries an absolute URL** built from a configured public base, and carries `""` until the bytes exist. ## Why a future reader will find this surprising Four of the six Sites let anyone hot-link their covers — `static.comix.to` even answers `access-control-allow-origin: *`. Hosting copies looks like work we were not obliged to do. We were obliged. kagane serves covers with `cross-origin-resource-policy: same-origin` behind a JavaScript challenge (measured 2026-08-08), so no `` outside kagane.to can load one under any combination of referrer policy and `crossorigin` attribute. The first fix for that was a kagane-only proxy applied in the web templates — and it produced issue #47, because the JSON API kept emitting the raw kagane URL and the userscript rendered it into a broken-image glyph. A per-Site exception that only one of two clients knows about is not a fix; it is a bug with a delay on it. Uniformity is the property being bought: every client renders every Cover the same way, and a Site changing its CORP header or its CDN cannot break a client again. ## Considered options **Per-Site exceptions, proxying only what must be proxied.** Cheapest, and what we had. Rejected: it is what produced #47, and it requires every current and future client to know which Sites are special. **A host allowlist for the outbound fetch**, mirroring `fetchableSeriesURL`. Rejected in favour of destination-class control — see below. **Cover bytes in Postgres `bytea`**, extending the existing `covers` table. Rejected: covers are immutable blobs served straight to browsers, which is what a filesystem is for. The cost is real and accepted — durability is now two things to back up instead of one, against ADR-0001's grain. **Per-Reader Cover overrides.** Rejected, consistent with ADR-0003's rejection of per-Reader title overrides. A Cover is a fact about the Series. ## Two deliberate relaxations **Destination control is deny-class, not an allowlist.** The outbound fetch requires `https`, resolves DNS first and refuses loopback, private, link-local and CGNAT addresses, re-checks on every redirect hop, and caps body size and content type. It does *not* pin a host set, which is what `fetchableSeriesURL` does for `series_url`. Cover hosts are CDNs that move: `demonicscans.org` serves its covers from `readermc.org`, a host with no visible relationship to the Site. An allowlist would silently stop producing Covers the day a Site switched CDN, and the failure would look like this bug. The resolved-IP check is the load-bearing part; without it, an attacker-controlled page need only publish a DNS name pointing at `127.0.0.1`. **The cover route is public, where the kagane proxy was session-gated.** An `` in the userscript panel cannot send a bearer token, and it cannot be given one: the panel's shadow root is `mode: "open"`, so the host page's own JavaScript can read any `src` we set. A credential in an image URL is a credential handed to a third-party site. The route serves public artwork from public Sites and its path reveals nothing about which Reader holds what. The residual cost is that we can be hot-linked by others. ## Consequences - The poll's cover prefetch, today guarded by `sr.Site != "kagane"`, applies to every Site in both Libraries. Nothing about Covers is conditioned on `kind`. - Only kagane still needs the CDP browser for its bytes. The other five Sites fetch over plain TLS — including novelfull, whose HTML answers `cf-mitigated: challenge` while its image paths answer 200 with `access-control-allow-origin: *` (measured 2026-08-09). - Client-side cover scraping is deleted from both userscripts. It could not help: a scraped URL has no render path left, and it is absent exactly when a Series is created — neither comix nor lightnovelworld exposes a cover on a chapter page, which is where a Reader bookmarks mid-read. - `PUT /bookmarks/{key}` still accepts a `cover` field and ignores it. This extends ADR-0003's "ignored after creation" to "ignored always", and keeps the flat wire contract ADR-0004 requires so installed scripts keep working. The field is therefore permanently inert rather than pending removal, and says so at the decode site. - A Cover that fails to load falls back to the placeholder in both clients. The broken-image glyph reported in #47 is not a state we render. - The existing kagane `covers` rows are dropped rather than migrated; that path re-fetches on demand already. - Two Sites deserve a note for whoever writes the extractor: asura's `.webp` cover URL answers `Content-Type: image/jpeg`, so trust the header; demonic's `og:image` carries a raw unencoded space and must be percent-encoded before fetching.