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
+82 -2
View File
@@ -6,6 +6,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
@@ -106,7 +107,7 @@ func TestKaganeCoverServesPersistedBytesAfterRestart(t *testing.T) {
DiscordID: "cover-owner",
TokenHash: sha256.Sum256([]byte("cover-owner-token")),
}
first, err := store.Open(url, owner, coverDir)
first, err := store.Open(url, owner, coverDir, testCoverBaseURL)
if err != nil {
t.Fatalf("Open: %v", err)
}
@@ -118,7 +119,7 @@ func TestKaganeCoverServesPersistedBytesAfterRestart(t *testing.T) {
t.Fatalf("close first store: %v", err)
}
second, err := store.Open(url, owner, coverDir)
second, err := store.Open(url, owner, coverDir, testCoverBaseURL)
if err != nil {
t.Fatalf("reopen: %v", err)
}
@@ -224,3 +225,82 @@ func TestKaganeCoverWithoutFetcher(t *testing.T) {
t.Fatalf("status = %d, want 404", rr.Code)
}
}
// The acquired Cover is served from this deployment's own origin, to any
// browser rendering a third-party page — no session, no credential (ADR-0007).
func TestPublicCoverServesStoredBytesUnauthenticated(t *testing.T) {
const sourceURL = "https://cdn.asurascans.com/covers/solo.webp"
srv, st := newWebTestServer(t, testConfig())
if err := st.PutCover(sourceURL, []byte("\x00webp-bytes"), "image/webp"); err != nil {
t.Fatalf("PutCover: %v", err)
}
// The wire URL is what a client actually requests, so the path under test
// is taken from it rather than rebuilt by hand.
wire := st.CoverWireURL(store.CoverAddress(sourceURL))
path, ok := strings.CutPrefix(wire, testCoverBaseURL)
if !ok {
t.Fatalf("wire URL %q is not on the public origin %q", wire, testCoverBaseURL)
}
rr := getCover(t, srv, path, nil)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 without any credential", rr.Code)
}
if got := rr.Body.String(); got != "\x00webp-bytes" {
t.Fatalf("body = %q, want the stored bytes", got)
}
if got := rr.Header().Get("Content-Type"); got != "image/webp" {
t.Fatalf("Content-Type = %q, want the stored one", got)
}
// Content-addressed bytes never change, so a client that has them must
// never need to ask again.
if got := rr.Header().Get("Cache-Control"); !strings.Contains(got, "immutable") {
t.Fatalf("Cache-Control = %q, want an immutable cache directive", got)
}
}
func TestPublicCoverRejectsUnknownAddress(t *testing.T) {
srv, _ := newWebTestServer(t, testConfig())
cases := map[string]string{
"unknown": "/covers/" + store.CoverAddress("https://cdn.example/never-stored.jpg"),
"malformed": "/covers/not-an-address",
"traversal": "/covers/../../etc/passwd",
"empty": "/covers/",
}
for name, path := range cases {
t.Run(name, func(t *testing.T) {
if rr := getCover(t, srv, path, nil); rr.Code == http.StatusOK {
t.Fatalf("%s: status = 200, want anything but a served body", path)
}
})
}
}
// The whole point of acquiring bytes is that the UI shows them: the card's
// <img> must carry the public address, not a third-party URL and not a
// placeholder.
func TestListRendersAcquiredCover(t *testing.T) {
const sourceURL = "https://static.comix.to/039d/i/1/34/6a6742bf15736@280.jpg"
srv, st := newWebTestServer(t, testConfig())
if _, err := st.Upsert(st.OwnerID(), store.Bookmark{
Key: "comix:n8we", Site: "comix", SeriesID: "n8we", Title: "Dungeons and Crayons",
SeriesURL: "https://comix.to/title/n8we", UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed: %v", err)
}
if err := st.SetSeriesCover("comix", "n8we", sourceURL, []byte("\xff\xd8jpeg"), "image/jpeg"); err != nil {
t.Fatalf("SetSeriesCover: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/ui/list", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
want := `src="` + testCoverBaseURL + "/covers/" + store.CoverAddress(sourceURL) + `"`
if !strings.Contains(rr.Body.String(), want) {
t.Fatalf("rendered list does not contain %s", want)
}
}