78234f3c19
Fixes #62
Browser-backed Sites join the Cover pipeline: kagane and novelfull Series now get their Covers at creation, through the same acquisition path as every other Site, instead of waiting for a poll pass.
## What changed
`latest.Acquirer` (creation-time acquisition, fired by the first Bookmark of a Series) previously skipped kagane and novelfull entirely — their pages only yield a Cloudflare challenge to the TLS client, so the request was spent for nothing. It now routes them like the poller does, with the two Sites split exactly as the issue demands:
- **kagane** — page fetched through the browser sidecar, cover URL extracted from the API JSON, bytes fetched through the browser sidecar (the only path that clears the challenge) into the content-addressed store. With no `BROWSER_WS_URL` configured, acquisition is skipped entirely and nothing falls back to a plain fetch.
- **novelfull** — page fetched through the browser sidecar, cover URL extracted from the HTML, bytes fetched over plain TLS through the ordinary gated fetcher (its image paths answer 200 with `access-control-allow-origin: *`, measured 2026-08-09). With no browser configured, the page fetch falls back to the TLS client — novelfull's challenge is a live time-varying fact (AGENTS.md), so when the page body answers, the Cover still lands; when it is challenged, nothing happens.
The byte-routing rule (kagane → browser, every other Site → TLS) is now one shared function (`latest.fetchCoverBytes`) used by both the Poller and the Acquirer, so the two cannot drift apart.
## Acceptance criteria
- [x] kagane cover bytes are fetched through the browser sidecar and stored in the content-addressed store — `TestAcquireKaganeCoverThroughBrowser`
- [x] novelfull cover URLs are extracted from the browser-fetched HTML, and its bytes are fetched over plain TLS — `TestAcquireNovelfullCoverOverPlainTLS`
- [x] With no browser sidecar configured, kagane Covers are absent and nothing falls back to a plain fetch — `TestAcquireKaganeSkippedWithoutBrowser`
- [x] With no browser sidecar configured, novelfull Covers still work if its page body is available — `TestAcquireNovelfullCoverWithoutBrowser`
- [x] Manually verified on-device: a kagane Series shows its Cover in the panel, not a broken-image glyph — being run by a separate manual-verification agent against a mocked scenario (no prod data); not part of this PR
- [x] `go test ./...` is green, with live-network checks gated behind `SMOKE_BROWSER_WS_URL` like the existing kagane image smoke test — new `TestSmokeAcquireKaganeCover` proves the end-to-end acquire path against the real browser when the env var is set
## Verification
- `go test ./...` green across all packages
- New unit tests exercise every routing decision with fakes — no network in the default suite
- Smoke test gated behind `SMOKE_BROWSER_WS_URL`, skipped by default
## Post-review changes (a66491a)
- **One routing rule for pages too** — `fetcherFor` is now a shared function used by both the Poller and the Acquirer; novelfull falls back to the plain-TLS fetcher in *both* when no browser is configured, so pre-existing (client-scraped) novelfull rows get healed by the poll as well, not just Series created after this change (`TestNovelfullUsesTLSWhenNoBrowserFetcher`).
- **Byte-level no-fallback proof** — `TestAcquireKaganeBytesNeverFallBackToPlainTLS` pins that kagane cover bytes never route to the TLS fetcher even when the page came through a browser.
- **Acquirer wired independent of the TLS client** — if `NewTLSFetcher` fails, kagane/novelfull acquisition still works via the sidecar (`main.go`).
- AGENTS.md (root + backend) updated for the novelfull plain-TLS fallback.
Reviewed-on: #72
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
208 lines
6.9 KiB
Go
208 lines
6.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)
|
|
}
|
|
|
|
// fetchCoverBytes routes a cover's byte retrieval by Site: only kagane needs
|
|
// the browser for image bytes — its covers answer a plain fetch with a
|
|
// challenge and `cross-origin-resource-policy: same-origin` — while every
|
|
// other Site's CDN answers plain TLS. Missing fetchers degrade to an error the
|
|
// caller logs, never a fallback onto a path that cannot succeed. One routing
|
|
// rule for the poll and the acquirer, so the two cannot drift apart.
|
|
func fetchCoverBytes(ctx context.Context, site, cover string, browser BrowserCoverFetcher, tls CoverBytesFetcher) ([]byte, string, error) {
|
|
if site == "kagane" {
|
|
if browser == nil {
|
|
return nil, "", errors.New("no cover fetcher")
|
|
}
|
|
imageID, ok := store.KaganeImageID(cover)
|
|
if !ok {
|
|
return nil, "", errors.New("invalid kagane cover URL")
|
|
}
|
|
return browser.Image(ctx, imageID)
|
|
}
|
|
if tls == nil {
|
|
return nil, "", errors.New("no cover fetcher")
|
|
}
|
|
return tls.Fetch(ctx, cover)
|
|
}
|
|
|
|
// 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)
|
|
}
|