cce3d61799
The web proxy for kagane covers is dead: 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. Remove the second way to reach a Cover: - Bookmark.CoverURL() and the templates' use of it: templates render the wire value (.Cover) and nothing else. - GET /img/kagane/{id}, web.CoverFetcher, coverIDRe: the route and its identifier validation are gone, and with them web/cover.go. - store.KaganeImageID, GetKaganeCover, PutKaganeCover, kaganeCoverSourceURL: the proxy's persistence. - Bookmark.CoverSource: dead once CoverURL is gone. Acquisition keeps the browser where kagane genuinely needs it, but the Site name leaves the routing: kaganeImageURLRe lives in browser.go with the rest of the per-Site knowledge, browserCoverURL claims the URLs the sidecar alone can fetch, and fetchCoverBytes routes by URL shape with no Site argument. No plain-TLS fallback for a claimed URL — that would only retrieve a challenge page. Cover tests: kagane route tests removed, the general-route guarantees they pinned kept and re-pinned — unstored and traversal-shaped addresses serve nothing (TestPublicCoverRejectsUnknownAddress), non-image content types are never echoed back (TestPublicCoverNeverEchoesNonImage + TestCoverStoreAcceptsAnySourceURL). Store content-addressing and reopen-persistence tests rewritten against PutCover/GetCover. No Site name remains in a cover code path outside the acquisition module; go test ./... green.
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)
|
|
}
|
|
}
|