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>
227 lines
7.6 KiB
Go
227 lines
7.6 KiB
Go
package latest
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"testing"
|
|
)
|
|
|
|
func TestCoverFetcherFetchesPublicHTTPSImage(t *testing.T) {
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.TLS == nil {
|
|
t.Fatal("cover request was not made over TLS")
|
|
}
|
|
w.Header().Set("Content-Type", "image/jpeg")
|
|
io.WriteString(w, "cover-bytes")
|
|
}))
|
|
defer server.Close()
|
|
|
|
transport := server.Client().Transport.(*http.Transport).Clone()
|
|
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // test server certificate
|
|
transport.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) {
|
|
return (&net.Dialer{}).DialContext(ctx, network, server.Listener.Addr().String())
|
|
}
|
|
client := &http.Client{Transport: transport}
|
|
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://cdn.example/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)
|
|
}
|
|
}
|
|
|
|
func TestNewCoverFetcherRechecksResolverBeforeConnection(t *testing.T) {
|
|
var requests int
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
requests++
|
|
}))
|
|
defer server.Close()
|
|
_, port, err := net.SplitHostPort(server.Listener.Addr().String())
|
|
if err != nil {
|
|
t.Fatalf("server address: %v", err)
|
|
}
|
|
|
|
resolves := 0
|
|
fetcher := NewCoverFetcherWithResolver(func(context.Context, string) ([]netip.Addr, error) {
|
|
resolves++
|
|
if resolves == 1 {
|
|
return []netip.Addr{netip.MustParseAddr("198.51.100.10")}, nil
|
|
}
|
|
return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil
|
|
})
|
|
_, _, err = fetcher.Fetch(context.Background(), "https://cdn.example:"+port+"/cover.jpg")
|
|
if err == nil {
|
|
t.Fatal("Fetch accepted a destination that became private")
|
|
}
|
|
if resolves != 2 {
|
|
t.Fatalf("resolver calls = %d, want preflight and dial checks", resolves)
|
|
}
|
|
if requests != 0 {
|
|
t.Fatalf("requests = %d, want 0", requests)
|
|
}
|
|
}
|
|
|
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
|
|
|
func coverResponse(status int, contentType, location string, body []byte) *http.Response {
|
|
header := make(http.Header)
|
|
if contentType != "" {
|
|
header.Set("Content-Type", contentType)
|
|
}
|
|
if location != "" {
|
|
header.Set("Location", location)
|
|
}
|
|
return &http.Response{
|
|
StatusCode: status,
|
|
Status: http.StatusText(status),
|
|
Header: header,
|
|
Body: io.NopCloser(bytes.NewReader(body)),
|
|
ContentLength: int64(len(body)),
|
|
}
|
|
}
|
|
|
|
func TestCoverFetcherRefusesUnsafeDestinationsBeforeRequest(t *testing.T) {
|
|
var calls int
|
|
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
|
calls++
|
|
return coverResponse(http.StatusOK, "image/jpeg", "", []byte("must not reach network")), nil
|
|
})}
|
|
resolve := func(_ context.Context, host string) ([]netip.Addr, error) {
|
|
switch host {
|
|
case "loopback.example":
|
|
return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil
|
|
case "private.example":
|
|
return []netip.Addr{netip.MustParseAddr("10.0.0.1")}, nil
|
|
case "linklocal.example":
|
|
return []netip.Addr{netip.MustParseAddr("169.254.1.1")}, nil
|
|
case "unique-local.example":
|
|
return []netip.Addr{netip.MustParseAddr("fc00::1")}, nil
|
|
case "cgnat.example":
|
|
return []netip.Addr{netip.MustParseAddr("100.64.0.1")}, nil
|
|
default:
|
|
return []netip.Addr{netip.MustParseAddr("198.51.100.10")}, nil
|
|
}
|
|
}
|
|
fetcher := newCoverFetcher(client, resolve)
|
|
|
|
tests := []string{
|
|
"http://public.example/cover.jpg",
|
|
"https://127.0.0.1/cover.jpg",
|
|
"https://10.0.0.1/cover.jpg",
|
|
"https://169.254.1.1/cover.jpg",
|
|
"https://[fc00::1]/cover.jpg",
|
|
"https://100.64.0.1/cover.jpg",
|
|
"https://loopback.example/cover.jpg",
|
|
"https://private.example/cover.jpg",
|
|
"https://linklocal.example/cover.jpg",
|
|
"https://unique-local.example/cover.jpg",
|
|
"https://cgnat.example/cover.jpg",
|
|
}
|
|
for _, sourceURL := range tests {
|
|
t.Run(sourceURL, func(t *testing.T) {
|
|
calls = 0
|
|
if _, _, err := fetcher.Fetch(context.Background(), sourceURL); err == nil {
|
|
t.Fatal("Fetch accepted refused destination")
|
|
}
|
|
if calls != 0 {
|
|
t.Fatalf("network calls = %d, want 0", calls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCoverFetcherStopsRedirectIntoPrivateAddress(t *testing.T) {
|
|
var calls int
|
|
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
calls++
|
|
if req.URL.Hostname() != "cdn.example" {
|
|
t.Fatalf("redirect reached %s", req.URL)
|
|
}
|
|
return coverResponse(http.StatusFound, "", "https://internal.example/cover.jpg", nil), nil
|
|
})}
|
|
fetcher := newCoverFetcher(client, func(_ context.Context, host string) ([]netip.Addr, error) {
|
|
if host == "internal.example" {
|
|
return []netip.Addr{netip.MustParseAddr("192.168.1.1")}, nil
|
|
}
|
|
return []netip.Addr{netip.MustParseAddr("198.51.100.10")}, nil
|
|
})
|
|
|
|
if _, _, err := fetcher.Fetch(context.Background(), "https://cdn.example/cover.jpg"); err == nil {
|
|
t.Fatal("Fetch followed redirect into private address")
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("network calls = %d, want only public first hop", calls)
|
|
}
|
|
}
|
|
|
|
func TestCoverFetcherRejectsOversizedBody(t *testing.T) {
|
|
var calls int
|
|
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
|
calls++
|
|
response := coverResponse(http.StatusOK, "image/webp", "", bytes.Repeat([]byte("x"), maxBodyBytes+1))
|
|
response.ContentLength = -1
|
|
return response, nil
|
|
})}
|
|
fetcher := newCoverFetcher(client, func(context.Context, string) ([]netip.Addr, error) {
|
|
return []netip.Addr{netip.MustParseAddr("198.51.100.10")}, nil
|
|
})
|
|
|
|
if _, _, err := fetcher.Fetch(context.Background(), "https://cdn.example/large.webp"); err == nil {
|
|
t.Fatal("Fetch accepted oversized body")
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("network calls = %d, want 1", calls)
|
|
}
|
|
}
|
|
|
|
func TestCoverFetcherRejectsNonImage(t *testing.T) {
|
|
var calls int
|
|
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
|
calls++
|
|
return coverResponse(http.StatusOK, "text/html", "", []byte("challenge")), nil
|
|
})}
|
|
fetcher := newCoverFetcher(client, func(context.Context, string) ([]netip.Addr, error) {
|
|
return []netip.Addr{netip.MustParseAddr("198.51.100.10")}, nil
|
|
})
|
|
|
|
if _, _, err := fetcher.Fetch(context.Background(), "https://cdn.example/challenge"); err == nil {
|
|
t.Fatal("Fetch accepted non-image response")
|
|
}
|
|
if calls != 1 {
|
|
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)
|
|
}
|
|
}
|