Define Cover and record hosting its bytes (#47) #64
@@ -18,6 +18,13 @@ _Avoid_: manga, title, book, comic
|
|||||||
One third-party source a Series is published on. A Series on two Sites is two Series.
|
One third-party source a Series is published on. A Series on two Sites is two Series.
|
||||||
_Avoid_: source, host, provider, domain
|
_Avoid_: source, host, provider, domain
|
||||||
|
|
||||||
|
**Cover**:
|
||||||
|
The image that stands for a Series wherever it is listed. A fact about the Series like
|
||||||
|
its title — one Cover per Series, shared by every Reader, never per-Reader. Defined by
|
||||||
|
what a Reader's browser can display, not by where the Site keeps the picture: an address
|
||||||
|
no client can load is not a Cover, it is a missing one.
|
||||||
|
_Avoid_: thumbnail, poster, image URL, artwork
|
||||||
|
|
||||||
**Reader**:
|
**Reader**:
|
||||||
A person with their own Progress. Exactly one per set of credentials, so there is no
|
A person with their own Progress. Exactly one per set of credentials, so there is no
|
||||||
separate "account" concept to model — the credential belongs to the Reader.
|
separate "account" concept to model — the credential belongs to the Reader.
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# 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.
|
||||||
Reference in New Issue
Block a user