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>
148 lines
5.4 KiB
Go
148 lines
5.4 KiB
Go
package latest
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"sync"
|
|
"time"
|
|
|
|
"bookmarkmanager/backend/internal/store"
|
|
)
|
|
|
|
// acquireTimeout bounds one creation-time acquisition end to end: the series
|
|
// page plus the cover bytes. Nothing is waiting on it — the Reader's write has
|
|
// already returned — so this only stops a stalled Site from holding a
|
|
// goroutine and a connection open forever.
|
|
const acquireTimeout = 45 * time.Second
|
|
|
|
// Acquirer gives a Series its Latest Chapter and its Cover the moment the
|
|
// first Bookmark creates it, instead of leaving the Reader to wait out the
|
|
// poll queue — which is ordered by Reader count, so a Series with one Reader
|
|
// sits behind every popular one (ADR-0007).
|
|
//
|
|
// Both facts come from a single series-page fetch, which is also why no
|
|
// client-supplied cover hint is worth accepting: the page has to be fetched
|
|
// for the chapter signal regardless, so a hint would save no request while
|
|
// adding a client-controlled input to a server-side fetch.
|
|
//
|
|
// Every failure path is "log and move on". The Bookmark, its progress and its
|
|
// Latest Chapter are already committed; a Site that is down or a Cover that
|
|
// cannot be produced must not disturb any of them, and the Series is simply
|
|
// left blank until the poll's own cover pass (#61) fills it.
|
|
type Acquirer struct {
|
|
Store *store.Store
|
|
// Fetch retrieves the series page over plain TLS. Nil with a nil
|
|
// BrowserFetch disables acquisition entirely.
|
|
Fetch Fetcher
|
|
// BrowserFetch retrieves kagane and novelfull pages through the browser
|
|
// sidecar, the only thing that clears their Cloudflare challenge. The
|
|
// per-site fallback policy lives in fetcherFor. Nil leaves those Sites
|
|
// unacquired when no fallback applies.
|
|
BrowserFetch Fetcher
|
|
// Covers retrieves the cover bytes. Nil leaves the Cover blank and the
|
|
// chapter half working.
|
|
Covers CoverBytesFetcher
|
|
// BrowserCoverFetch retrieves browser-claimed cover bytes through the
|
|
// sidecar. Nil leaves those Covers blank; nothing falls back to a plain
|
|
// fetch, which would only ever retrieve a challenge page.
|
|
BrowserCoverFetch BrowserCoverFetcher
|
|
// Ctx cancels in-flight acquisitions at shutdown. A hook signature has
|
|
// nowhere to pass one, so it lives here; nil means context.Background.
|
|
Ctx context.Context
|
|
|
|
inflight sync.WaitGroup
|
|
}
|
|
|
|
// acquireSlots caps how many creation-time fetches run at once. A Reader whose
|
|
// userscript bulk-syncs creates many Series at once, and a burst of
|
|
// simultaneous requests from one server IP is the traffic shape most likely to
|
|
// move that IP's bot score — the same reason the poller staggers its batch.
|
|
var acquireSlots = make(chan struct{}, 2)
|
|
|
|
// Acquire starts one acquisition and returns immediately: a Reader's bookmark
|
|
// action may not block on a third-party Site's latency, nor fail with it. It
|
|
// is the store's OnSeriesCreated hook, so it only ever runs for a Series no
|
|
// Reader had bookmarked before.
|
|
func (a *Acquirer) Acquire(sr store.Series) {
|
|
a.inflight.Add(1)
|
|
go func() {
|
|
defer a.inflight.Done()
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("acquire %q: recovered from panic: %v", sr.Key(), r)
|
|
}
|
|
}()
|
|
parent := a.Ctx
|
|
if parent == nil {
|
|
parent = context.Background()
|
|
}
|
|
select {
|
|
case acquireSlots <- struct{}{}:
|
|
defer func() { <-acquireSlots }()
|
|
case <-parent.Done():
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(parent, acquireTimeout)
|
|
defer cancel()
|
|
a.acquire(ctx, sr)
|
|
}()
|
|
}
|
|
|
|
// Wait blocks until every started acquisition has finished. It exists for
|
|
// tests: an asynchronous side effect is otherwise unobservable without
|
|
// polling for it.
|
|
func (a *Acquirer) Wait() { a.inflight.Wait() }
|
|
|
|
func (a *Acquirer) acquire(ctx context.Context, sr store.Series) {
|
|
if a.Fetch == nil && a.BrowserFetch == nil {
|
|
return
|
|
}
|
|
// series_url arrives in a client-supplied PUT body, so the same gate the
|
|
// poller uses applies here — without it a token-holder chooses what the
|
|
// server fetches from its own network position.
|
|
if !fetchableSeriesURL(sr.Site, sr.SeriesURL) {
|
|
log.Printf("acquire %q: not fetchable: site=%q url=%q", sr.Key(), sr.Site, sr.SeriesURL)
|
|
return
|
|
}
|
|
|
|
f := fetcherFor(sr.Site, a.BrowserFetch, a.Fetch)
|
|
if f == nil {
|
|
log.Printf("acquire %q: no fetcher for site %q", sr.Key(), sr.Site)
|
|
return
|
|
}
|
|
body, status, err := f.Get(ctx, sr.SeriesURL)
|
|
if err != nil {
|
|
log.Printf("acquire %q: fetch %s: %v", sr.Key(), sr.SeriesURL, err)
|
|
return
|
|
}
|
|
if status != 200 {
|
|
log.Printf("acquire %q: fetch %s: status %d", sr.Key(), sr.SeriesURL, status)
|
|
return
|
|
}
|
|
|
|
// This page just served the same purpose a poll tick would have; without
|
|
// the stamp the row stays due and the poller refetches it immediately.
|
|
if err := a.Store.MarkLatestChecked(sr.Site, sr.SeriesID, time.Now().UnixMilli()); err != nil {
|
|
log.Printf("acquire %q: mark checked: %v", sr.Key(), err)
|
|
}
|
|
|
|
if latest, ok := latestChapterFrom(sr.Site, sr.SeriesURL, body); ok {
|
|
if err := a.Store.SetLatestChapter(sr.Site, sr.SeriesID, latest.Label, latest.Num); err != nil {
|
|
log.Printf("acquire %q: set latest chapter: %v", sr.Key(), err)
|
|
}
|
|
}
|
|
|
|
cover, ok := coverFrom(sr.Site, sr.SeriesURL, body)
|
|
if !ok {
|
|
return
|
|
}
|
|
bytes, contentType, err := fetchCoverBytes(ctx, cover, a.BrowserCoverFetch, a.Covers)
|
|
if err != nil {
|
|
log.Printf("acquire %q: fetch cover %s: %v", sr.Key(), cover, err)
|
|
return
|
|
}
|
|
if err := a.Store.SetSeriesCover(sr.Site, sr.SeriesID, cover, bytes, contentType); err != nil {
|
|
log.Printf("acquire %q: persist cover: %v", sr.Key(), err)
|
|
}
|
|
}
|