64c27fe896
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.
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)
|
|
}
|
|
}
|