92eba07da7
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. Reviewed-on: #68 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
90 lines
3.0 KiB
Go
90 lines
3.0 KiB
Go
package web
|
|
|
|
import (
|
|
"bookmarkmanager/backend/internal/store"
|
|
"context"
|
|
"log"
|
|
"net/http"
|
|
"regexp"
|
|
"time"
|
|
)
|
|
|
|
// CoverFetcher retrieves one kagane cover by image id. Satisfied by
|
|
// latest.BrowserFetcher. It is nil when BROWSER_WS_URL is unset; uncached
|
|
// covers are then unavailable, while covers already stored by the backend
|
|
// remain available without a browser.
|
|
type CoverFetcher interface {
|
|
Image(ctx context.Context, imageID string) (body []byte, contentType string, err error)
|
|
}
|
|
|
|
// coverIDRe matches the request path segment that becomes part of an outbound
|
|
// URL. The proxy is session-gated, but the id still reaches a headless browser,
|
|
// so it is validated at the boundary rather than passed through.
|
|
var coverIDRe = regexp.MustCompile(`^[0-9a-f-]{36}$`)
|
|
|
|
// coverTimeout bounds one proxied cover. Shorter than the fetcher's own
|
|
// challenge budget on purpose: a browser page is waiting on this, and a cover
|
|
// that has not arrived by now is better left as a broken slot than as a request
|
|
// holding a connection open.
|
|
const coverTimeout = 20 * time.Second
|
|
|
|
// kaganeCover serves a kagane cover from the backend's own origin.
|
|
//
|
|
// kagane answers image requests with a Cloudflare challenge and
|
|
// `cross-origin-resource-policy: same-origin`, so the web UI cannot render one
|
|
// directly under any combination of referrer policy or crossorigin attribute
|
|
// (verified 2026-08-08). Fetching it through the headless browser that already
|
|
// clears the challenge, and re-serving it here, is what puts the bytes on an
|
|
// origin the page may load from.
|
|
func (h *Handler) kaganeCover(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if !coverIDRe.MatchString(id) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
body, contentType, ok, err := h.store.GetKaganeCover(id)
|
|
if err != nil {
|
|
log.Printf("read kagane cover %s: %v", id, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if ok {
|
|
writeCover(w, body, contentType)
|
|
return
|
|
}
|
|
if h.covers == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), coverTimeout)
|
|
defer cancel()
|
|
body, contentType, err = h.covers.Image(ctx, id)
|
|
if err != nil {
|
|
log.Printf("kagane cover %s: %v", id, err)
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
canonical, ok := store.CoverContentType(contentType)
|
|
if !ok {
|
|
log.Printf("kagane cover %s: unexpected content type %q", id, contentType)
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
contentType = canonical
|
|
if err := h.store.PutKaganeCover(id, body, contentType); err != nil {
|
|
log.Printf("persist kagane cover %s: %v", id, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeCover(w, body, contentType)
|
|
}
|
|
|
|
// writeCover sends the bytes with a long cache life: an image id names one
|
|
// immutable rendering, so a client that has it never needs to ask again.
|
|
func writeCover(w http.ResponseWriter, body []byte, contentType string) {
|
|
w.Header().Set("Content-Type", contentType)
|
|
w.Header().Set("Cache-Control", "private, max-age=604800, immutable")
|
|
w.Write(body)
|
|
}
|