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.
185 lines
5.9 KiB
Go
185 lines
5.9 KiB
Go
package latest
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"bookmarkmanager/backend/internal/store"
|
|
)
|
|
|
|
// CoverBytesFetcher retrieves one cover from its source URL. The caller owns
|
|
// persistence; this seam keeps network policy independent from the store.
|
|
type CoverBytesFetcher interface {
|
|
Fetch(ctx context.Context, sourceURL string) (body []byte, contentType string, err error)
|
|
}
|
|
|
|
// CoverResolver resolves a host before any connection is attempted. Tests
|
|
// inject it to exercise hostile DNS results without touching the live network.
|
|
type CoverResolver func(context.Context, string) ([]netip.Addr, error)
|
|
|
|
// TLSCoverFetcher retrieves image bytes with the standard HTTPS client. Unlike
|
|
// TLSFetcher, it does not need a browser fingerprint: cover hosts are public
|
|
// CDNs and the response is accepted only after the destination gate passes.
|
|
type TLSCoverFetcher struct {
|
|
client *http.Client
|
|
resolve CoverResolver
|
|
}
|
|
|
|
var _ CoverBytesFetcher = (*TLSCoverFetcher)(nil)
|
|
|
|
const coverRequestTimeout = 30 * time.Second
|
|
|
|
var carrierGradeNAT = netip.MustParsePrefix("100.64.0.0/10")
|
|
|
|
// NewCoverFetcher builds the production cover client with the real resolver.
|
|
func NewCoverFetcher() *TLSCoverFetcher {
|
|
return NewCoverFetcherWithResolver(nil)
|
|
}
|
|
|
|
// NewCoverFetcherWithResolver builds a cover client using resolve, or the real
|
|
// system resolver when resolve is nil.
|
|
func NewCoverFetcherWithResolver(resolve CoverResolver) *TLSCoverFetcher {
|
|
if resolve == nil {
|
|
resolve = defaultCoverResolver
|
|
}
|
|
return newCoverFetcher(newCoverHTTPClient(resolve), resolve)
|
|
}
|
|
|
|
func newCoverFetcher(client *http.Client, resolve CoverResolver) *TLSCoverFetcher {
|
|
f := &TLSCoverFetcher{client: client, resolve: resolve}
|
|
client.CheckRedirect = func(req *http.Request, _ []*http.Request) error {
|
|
if err := f.validateURL(req.Context(), req.URL); err != nil {
|
|
return fmt.Errorf("redirect destination: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
return f
|
|
}
|
|
|
|
func defaultCoverResolver(ctx context.Context, host string) ([]netip.Addr, error) {
|
|
return net.DefaultResolver.LookupNetIP(ctx, "ip", host)
|
|
}
|
|
|
|
func newCoverHTTPClient(resolve CoverResolver) *http.Client {
|
|
base, ok := http.DefaultTransport.(*http.Transport)
|
|
if !ok {
|
|
base = &http.Transport{}
|
|
}
|
|
transport := base.Clone()
|
|
// A proxy would make the dial target the proxy rather than the cover host,
|
|
// defeating destination classification. Cover fetching is direct by design.
|
|
transport.Proxy = nil
|
|
dialer := &net.Dialer{}
|
|
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
|
|
host, port, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("split cover address %q: %w", address, err)
|
|
}
|
|
addrs, err := resolveCoverHost(ctx, host, resolve)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, addr := range addrs {
|
|
if !publicCoverAddress(addr) {
|
|
return nil, fmt.Errorf("cover host resolves to refused address %s", addr)
|
|
}
|
|
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(addr.String(), port))
|
|
if err == nil {
|
|
return conn, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("cover host %q has no reachable address", host)
|
|
}
|
|
return &http.Client{Transport: transport, Timeout: coverRequestTimeout}
|
|
}
|
|
|
|
func (f *TLSCoverFetcher) Fetch(ctx context.Context, sourceURL string) ([]byte, string, error) {
|
|
u, err := url.Parse(sourceURL)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("parse cover URL: %w", err)
|
|
}
|
|
if err := f.validateURL(ctx, u); err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("build cover request: %w", err)
|
|
}
|
|
resp, err := f.client.Do(req)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("fetch cover: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, "", fmt.Errorf("fetch cover: status %d", resp.StatusCode)
|
|
}
|
|
raw, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("fetch cover: unsupported content type %q", resp.Header.Get("Content-Type"))
|
|
}
|
|
contentType, ok := store.CoverContentType(raw)
|
|
if !ok {
|
|
return nil, "", fmt.Errorf("fetch cover: unsupported content type %q", raw)
|
|
}
|
|
if resp.ContentLength > maxBodyBytes {
|
|
return nil, "", fmt.Errorf("fetch cover: response exceeds %d bytes", maxBodyBytes)
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes+1))
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("read cover: %w", err)
|
|
}
|
|
if len(body) > maxBodyBytes {
|
|
return nil, "", fmt.Errorf("fetch cover: response exceeds %d bytes", maxBodyBytes)
|
|
}
|
|
return body, contentType, nil
|
|
}
|
|
|
|
// This gate deliberately differs from fetchableSeriesURL: cover hosts are
|
|
// site-independent CDNs, so a Site host allowlist would reject valid covers.
|
|
func (f *TLSCoverFetcher) validateURL(ctx context.Context, u *url.URL) error {
|
|
if u == nil || u.Scheme != "https" || u.Host == "" || u.User != nil {
|
|
return errors.New("cover URL must use HTTPS without credentials")
|
|
}
|
|
host := u.Hostname()
|
|
if host == "" {
|
|
return errors.New("cover URL has no host")
|
|
}
|
|
addrs, err := resolveCoverHost(ctx, host, f.resolve)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve cover host %q: %w", host, err)
|
|
}
|
|
if len(addrs) == 0 {
|
|
return fmt.Errorf("resolve cover host %q: no addresses", host)
|
|
}
|
|
for _, addr := range addrs {
|
|
if !publicCoverAddress(addr) {
|
|
return fmt.Errorf("cover host %q resolves to refused address %s", host, addr)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resolveCoverHost(ctx context.Context, host string, resolve CoverResolver) ([]netip.Addr, error) {
|
|
if literal, err := netip.ParseAddr(host); err == nil {
|
|
return []netip.Addr{literal.Unmap()}, nil
|
|
}
|
|
return resolve(ctx, strings.TrimSuffix(host, "."))
|
|
}
|
|
|
|
func publicCoverAddress(addr netip.Addr) bool {
|
|
addr = addr.Unmap()
|
|
return addr.IsValid() && addr.IsGlobalUnicast() &&
|
|
!addr.IsLoopback() && !addr.IsPrivate() && !addr.IsLinkLocalUnicast() &&
|
|
!carrierGradeNAT.Contains(addr)
|
|
}
|