7c7d597019
Closes #63 Deletes the second way to reach a Cover. Since #62, every Site's cover bytes land in the content-addressed store at creation or on the poll, and the one public route serves them all — nothing needs the kagane proxy anymore. ## What went - **Template-level rewrite:** `Bookmark.CoverURL()` and both templates' use of it. Cards and chrome now render `.Cover` — the wire value — and nothing else. `Bookmark.CoverSource` was dead once `CoverURL` went, so it and its `bookmarkColumns` entry are gone too. - **Kagane-only cover route and its identifier validation:** `GET /img/kagane/{id}`, `web.CoverFetcher`, `coverIDRe`, and the whole `internal/web/cover.go`. - **The proxy's persistence:** `store.KaganeImageID`, `GetKaganeCover`, `PutKaganeCover`, `kaganeCoverSourceURL`, `kaganeCoverRe`. - **The kagane-shaped branch in the byte-fetch routing:** `fetchCoverBytes` no longer takes a `site` argument and no longer names a Site. The URL shape kagane's API publishes is claimed by the browser module itself — `kaganeImageURLRe` + `browserCoverURL` live in `latest/browser.go` with the rest of the per-Site knowledge — and `BrowserFetcher.Image` is now URL-driven (it validates the URL it will navigate to, same SSRF discipline as before). The no-plain-TLS-fallback rule for a claimed URL is preserved: a claimed address with no browser is an error, never a challenge-page fetch. ## What stayed (deliberately) - `BrowserFetcher.Image` and the browser-backed acquisition path: kagane genuinely serves cover bytes behind the challenge + `cross-origin-resource-policy: same-origin`, so the sidecar remains the only fetcher for them — it just routes by URL claim now instead of by Site name. - `fetcherFor`'s per-Site page routing (kagane/novelfull page fetches) — that is the page path, not a cover path. ## Acceptance criteria - [x] Template-level kagane cover rewrite gone - [x] Kagane-only cover route and its identifier validation gone - [x] Tests removed/rewritten against the general route, guarantees kept: unstored + traversal-shaped addresses serve nothing (`TestPublicCoverRejectsUnknownAddress`), non-image content types never echoed (`TestPublicCoverNeverEchoesNonImage` — new; the store-side gate was already pinned by `TestCoverStoreAcceptsAnySourceURL`). Store reopen-persistence and filesystem content-addressing tests rewritten against `PutCover`/`GetCover`, no guarantee lost. - [x] No Site name in a cover code path outside the acquisition module (`grep kagane backend`: store/web/templates/api are clean; remaining hits are `latest/browser.go` + `latest/sites.go`, tests, docs) - [x] Web UI and panel render Covers for all six Sites (templates render the wire address; panel renders `b.cover` — untouched, it never had a kagane path) - [x] `go test ./...` green ## Verification - `go vet ./...` clean - `go test ./...` — all packages pass (root 16.9s, latest 12.7s, store 12.7s, web 0.004s) - `CGO_ENABLED=0 go build` produces the static binary - Cover-path tests run verbosely: `TestPublicCoverServesStoredBytesUnauthenticated`, `TestPublicCoverRejectsUnknownAddress` (unknown/malformed/traversal/empty), `TestPublicCoverNeverEchoesNonImage`, `TestListRendersAcquiredCover`, `TestAcquireKaganeCoverThroughBrowser`, `TestRunOncePrefetchesKaganeCover`, `TestRunOnceRoutesNonKaganeCoverToPublicFetcher` all pass; the three `SMOKE_*` tests skip without the browser sidecar, as designed Live browser verification of the "web UI and panel render Covers for all six Sites" criterion is being run separately with Playwright against real Site pages and a locally mocked backend. Reviewed-on: #73 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
205 lines
6.8 KiB
Go
205 lines
6.8 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 URL shape, not by Site
|
|
// name: the browser fetcher's module claims the addresses only it can fetch
|
|
// (kagane's image route answers a plain fetch with a challenge and
|
|
// `cross-origin-resource-policy: same-origin`), and everything else goes over
|
|
// 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, cover string, browser BrowserCoverFetcher, tls CoverBytesFetcher) ([]byte, string, error) {
|
|
if browserOnlyCoverURL(cover) {
|
|
if browser == nil {
|
|
return nil, "", errors.New("no cover fetcher")
|
|
}
|
|
return browser.Image(ctx, cover)
|
|
}
|
|
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)
|
|
}
|