A newly bookmarked Series acquires its Cover at creation (#59) #68

Merged
sulthan merged 1 commits from feat/59-cover-at-creation into main 2026-08-10 04:07:54 +07:00
Owner

Closes #59.

Part of spec #55, and the ticket that fixes the reported bug #47. Architecture: docs/adr/0007-backend-hosts-cover-bytes.md. Does not close #47 or #55.

What changed

A Reader bookmarks a Series nobody holds yet — the exact case in #47 — and within seconds the list shows its artwork instead of a broken image. The first Bookmark to create a Series fires Store.OnSeriesCreated after commit, and the new latest.Acquirer turns that into one series-page fetch that yields both the Latest Chapter and the cover URL. The bytes go through the gated cover fetcher from #57 and are stored content-addressed through #56, so the wire carries an absolute URL on this deployment's own origin — never a third-party address, and never one that 404s.

Store

  • Migration 0009_series_cover_address.sql adds series.cover_address. The two facts are now split: series.cover is the third-party source address the bytes came from (the acquisition path's dedupe key), series.cover_address is the SHA-256 they are stored under. An empty cover_address is precisely what "no Cover yet" means, which is the distinction both the API and the UI depend on.
  • SetSeriesCover writes the address only after the bytes are on disk, so the wire can never name an object that is not there.
  • CoverWireURL builds PUBLIC_BASE_URL + /covers/<sha256> for every scanned row, and returns "" for a blank address.
  • The cover columns are gone from Upsert's INSERT and its DO UPDATE. A client-supplied cover cannot reach the shared Series row on any path, not just the creation path.
  • Open now rejects a base URL that is not an absolute http(s) origin: PUBLIC_BASE_URL=bookmarks.example.com would otherwise start cleanly and emit addresses no browser can load.

Acquisition

  • internal/latest/acquire.go: one fetch, gated by the poller's own fetchableSeriesURL (a series_url arrives in a client-supplied PUT body, so without the gate a token-holder chooses what the server fetches from its own network position).
  • Asynchronous and log-and-drop. The Bookmark, its progress and its Latest Chapter are already committed; a Site that is down or a cover that cannot be produced disturbs none of them.
  • Bounded by a two-slot semaphore. A bulk sync creating N Series would otherwise fire N simultaneous requests from one IP — the traffic shape the poller's stagger exists to avoid.
  • Cancelled at shutdown (shares the poller's context) and stamps latest_checked_at, so the poller does not refetch the same page a tick later.
  • Browser-backed Sites (kagane, novelfull) are deliberately skipped: their pages only yield a Cloudflare challenge to the TLS client, so the request would be spent for nothing. They arrive in #62.

Wire and route

  • GET /covers/{address} serves the bytes publicly and uncredentialed with Cache-Control: public, max-age=604800, immutable. The address is gated by a ^[0-9a-f]{64}$ pattern and cross-checked against a pure function of itself before any filesystem read, so no request shaped like a traversal reaches disk.
  • PUT /bookmarks/{key} still accepts a cover field and discards it, permanently. Rejecting it would break every installed userscript the moment this deploys, and ADR-0004's compatibility argument depends on those scripts continuing to work. The decode site says so in place of a TODO nobody intends to keep.
  • store.CoverContentType canonicalises comix's non-standard image/jpg to image/jpeg, so one image cannot land under two spellings. This one was found by the live smoke test, not by reading.

Config

PUBLIC_BASE_URL is new and required (cover URLs must go out absolute — the userscript renders them on third-party origins, where a relative path resolves against the Site). Documented in .env.example, docker-compose.yml (:? so compose fails too), DEPLOY.md and backend/AGENTS.md.

Acceptance criteria

All twelve of #59's criteria are met; the checklist on the issue is ticked with the evidence.

Verification

  • go test ./... green (Docker-backed Postgres suite).
  • Live smoke against a real backend + Postgres: bookmarking comix:n8we-dungeons-and-crayons produced "cover": "http://127.0.0.1:8099/covers/8ce74d80…" and "latest_chapter": "Chapter 81" within seconds of the PUT; curl on that address returned 200, Content-Type: image/jpeg, Cache-Control: public, max-age=604800, immutable, and a 280x420 JPEG. That run is what surfaced the image/jpg content type.
  • Mutation-checked the asynchrony test: removing the go from Acquire turns TestAcquireDoesNotBlockTheWrite red.

Reviewed

Both axes of /code-review were run against this diff before commit. Their findings that were actionable here are folded in: the concurrency bound, the shutdown tie, the PUBLIC_BASE_URL validation, the missing latest_checked_at stamp, and a test that could not fail.

Known sequencing

A kagane/novelfull Series created between this deploy and #62 has no cover source at all: the acquisition skips those Sites and Upsert no longer persists the userscript-scraped address. This is #59's stated boundary rather than a defect, but it is a user-visible gap on two Sites and should order #62 accordingly.

Closes #59. Part of spec #55, and the ticket that fixes the reported bug #47. Architecture: `docs/adr/0007-backend-hosts-cover-bytes.md`. Does not close #47 or #55. ## What changed A Reader bookmarks a Series nobody holds yet — the exact case in #47 — and within seconds the list shows its artwork instead of a broken image. The first Bookmark to create a Series fires `Store.OnSeriesCreated` after commit, and the new `latest.Acquirer` turns that into **one** series-page fetch that yields both the Latest Chapter and the cover URL. The bytes go through the gated cover fetcher from #57 and are stored content-addressed through #56, so the wire carries an absolute URL on this deployment's own origin — never a third-party address, and never one that 404s. ### Store - Migration `0009_series_cover_address.sql` adds `series.cover_address`. The two facts are now split: `series.cover` is the third-party source address the bytes came from (the acquisition path's dedupe key), `series.cover_address` is the SHA-256 they are stored under. An empty `cover_address` is precisely what "no Cover yet" means, which is the distinction both the API and the UI depend on. - `SetSeriesCover` writes the address only after the bytes are on disk, so the wire can never name an object that is not there. - `CoverWireURL` builds `PUBLIC_BASE_URL + /covers/<sha256>` for every scanned row, and returns `""` for a blank address. - The cover columns are gone from `Upsert`'s `INSERT` and its `DO UPDATE`. A client-supplied cover cannot reach the shared Series row on any path, not just the creation path. - `Open` now rejects a base URL that is not an absolute `http(s)` origin: `PUBLIC_BASE_URL=bookmarks.example.com` would otherwise start cleanly and emit addresses no browser can load. ### Acquisition - `internal/latest/acquire.go`: one fetch, gated by the poller's own `fetchableSeriesURL` (a `series_url` arrives in a client-supplied PUT body, so without the gate a token-holder chooses what the server fetches from its own network position). - Asynchronous and log-and-drop. The Bookmark, its progress and its Latest Chapter are already committed; a Site that is down or a cover that cannot be produced disturbs none of them. - Bounded by a two-slot semaphore. A bulk sync creating N Series would otherwise fire N simultaneous requests from one IP — the traffic shape the poller's stagger exists to avoid. - Cancelled at shutdown (shares the poller's context) and stamps `latest_checked_at`, so the poller does not refetch the same page a tick later. - Browser-backed Sites (kagane, novelfull) are deliberately skipped: their pages only yield a Cloudflare challenge to the TLS client, so the request would be spent for nothing. They arrive in #62. ### Wire and route - `GET /covers/{address}` serves the bytes publicly and uncredentialed with `Cache-Control: public, max-age=604800, immutable`. The address is gated by a `^[0-9a-f]{64}$` pattern and cross-checked against a pure function of itself before any filesystem read, so no request shaped like a traversal reaches disk. - `PUT /bookmarks/{key}` still accepts a `cover` field and discards it, permanently. Rejecting it would break every installed userscript the moment this deploys, and ADR-0004's compatibility argument depends on those scripts continuing to work. The decode site says so in place of a TODO nobody intends to keep. - `store.CoverContentType` canonicalises comix's non-standard `image/jpg` to `image/jpeg`, so one image cannot land under two spellings. This one was found by the live smoke test, not by reading. ### Config `PUBLIC_BASE_URL` is new and required (cover URLs must go out absolute — the userscript renders them on third-party origins, where a relative path resolves against the Site). Documented in `.env.example`, `docker-compose.yml` (`:?` so compose fails too), `DEPLOY.md` and `backend/AGENTS.md`. ## Acceptance criteria All twelve of #59's criteria are met; the checklist on the issue is ticked with the evidence. ## Verification - `go test ./...` green (Docker-backed Postgres suite). - Live smoke against a real backend + Postgres: bookmarking `comix:n8we-dungeons-and-crayons` produced `"cover": "http://127.0.0.1:8099/covers/8ce74d80…"` and `"latest_chapter": "Chapter 81"` within seconds of the PUT; `curl` on that address returned `200`, `Content-Type: image/jpeg`, `Cache-Control: public, max-age=604800, immutable`, and a 280x420 JPEG. That run is what surfaced the `image/jpg` content type. - Mutation-checked the asynchrony test: removing the `go` from `Acquire` turns `TestAcquireDoesNotBlockTheWrite` red. ## Reviewed Both axes of `/code-review` were run against this diff before commit. Their findings that were actionable here are folded in: the concurrency bound, the shutdown tie, the `PUBLIC_BASE_URL` validation, the missing `latest_checked_at` stamp, and a test that could not fail. ## Known sequencing A kagane/novelfull Series created between this deploy and #62 has no cover source at all: the acquisition skips those Sites and `Upsert` no longer persists the userscript-scraped address. This is #59's stated boundary rather than a defect, but it is a user-visible gap on two Sites and should order #62 accordingly.
sulthan added 1 commit 2026-08-10 02:31:59 +07:00
A Reader who bookmarks a Series nobody holds yet no longer waits out the
poll queue for its artwork: the first Bookmark to create a Series fires
Store.OnSeriesCreated, and latest.Acquirer turns that into a single
series-page fetch yielding both the Latest Chapter and the cover URL. The
bytes are fetched through the gated cover fetcher and stored
content-addressed, so the wire carries an absolute URL on this
deployment's own origin (ADR-0007) - never a third-party address and
never one that 404s.

- series.cover_address (migration 0009) splits the third-party source
  address the bytes came from (series.cover) from the content address
  they are stored under. A blank cover_address is what "no Cover yet"
  means, so the wire field is empty until real bytes exist.
- GET /covers/{address} serves the bytes publicly and uncredentialed,
  immutable-cached; the address is gated by a 64-hex pattern and
  cross-checked against a pure function of itself before any filesystem
  read.
- Client-sent cover values are decoded and discarded permanently: the
  cover columns are absent from Upsert's INSERT and its DO UPDATE, so no
  request value can reach the shared Series row (extends ADR-0003's
  "ignored after creation" to "ignored always", keeps ADR-0004's flat
  wire so installed userscripts keep working).
- Acquisition is asynchronous and log-and-drop: the Reader's write
  neither blocks on nor fails with a third-party Site. It is bounded by
  a two-slot semaphore, cancelled at shutdown, and stamps
  latest_checked_at so the poller does not refetch the same page a tick
  later.
- store.CoverContentType canonicalises comix's non-standard "image/jpg"
  to "image/jpeg", so one image cannot land under two spellings.
- PUBLIC_BASE_URL is a new required setting; Open rejects anything that
  is not an absolute http(s) origin, since a bare hostname would start
  cleanly and emit addresses no browser can load.

Verified against a live backend: bookmarking a comix series produced a
280x420 JPEG served from /covers/<sha256> with the immutable cache
header, and the web UI card renders that address.
sulthan merged commit 92eba07da7 into main 2026-08-10 04:07:54 +07:00
Sign in to join this conversation.