feat(cover): acquire a Series Cover at creation (#59)

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.
This commit is contained in:
2026-08-10 02:31:13 +07:00
parent b6b88bde8a
commit 64c27fe896
18 changed files with 971 additions and 115 deletions
@@ -204,3 +204,23 @@ func TestCoverFetcherRejectsNonImage(t *testing.T) {
t.Fatalf("network calls = %d, want 1", calls)
}
}
// comix labels its covers "image/jpg", which is not a registered type but is
// what the Site actually answers with; the bytes are stored under the real
// name so one image cannot land under two spellings.
func TestCoverFetcherCanonicalisesJpgAlias(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return coverResponse(http.StatusOK, "image/jpg", "", []byte("cover-bytes")), nil
})}
fetcher := newCoverFetcher(client, func(context.Context, string) ([]netip.Addr, error) {
return []netip.Addr{netip.MustParseAddr("198.51.100.10")}, nil
})
body, contentType, err := fetcher.Fetch(context.Background(), "https://static.comix.to/cover.jpg")
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if string(body) != "cover-bytes" || contentType != "image/jpeg" {
t.Fatalf("Fetch = (%q, %q), want (cover-bytes, image/jpeg)", body, contentType)
}
}