Files
mangaBookmark/docs/adr/0007-backend-hosts-cover-bytes.md
T
sulthan f7130e7cfa Define Cover and record hosting its bytes (#47)
Cover was named in the Series entry but never defined, and the gap is
what #47 is: nothing said whether a Cover is an address on a Site or an
image a Reader's browser can actually display. Kagane proved they are
not the same thing.

The ADR records the decision that follows — the backend fetches, stores
and serves every Site's cover bytes — along with the alternatives that
were rejected and the two knowing relaxations: destination-class control
instead of the host allowlist fetchableSeriesURL sets as precedent, and
a public cover route where the kagane proxy is session-gated.
2026-08-09 23:16:56 +07:00

5.7 KiB

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/<sha256>. 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 <img> 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 <img> 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.