Add gated cover byte fetcher (#66)

## Summary

Adds a plain-TLS cover byte fetcher with a destination-class SSRF gate and wires public cover sources through the content-addressed filesystem store.

## Changes

- Resolve hostnames before connecting; refuse non-HTTPS, loopback, private, link-local, unique-local, CGNAT, credentials, and mixed public/private DNS answers.
- Re-check every redirect and resolve/classify again at dial time to close DNS rebinding.
- Reuse `maxBodyBytes`; reject oversized responses and non-image content types before persistence.
- Add generic `Store.GetCover`/`PutCover` source-URL storage while preserving the browser-backed kagane path.
- Keep cover prefetch failures isolated from chapter polling.
- Add observable tests for TLS, no-connection refusals, all refused address classes, redirect blocking, streaming body caps, non-image rejection, content-addressed persistence, DNS rebinding, and poller routing.

## Verification

- `go test -count=1 ./...`
- `go vet ./...`

Both pass. No test touches the live network.

Closes #57

Reviewed-on: #66
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #66.
This commit is contained in:
2026-08-10 00:45:55 +07:00
committed by sulthan
parent e8d1cba6c5
commit 9d6d3bde72
8 changed files with 559 additions and 25 deletions
+38 -11
View File
@@ -16,8 +16,8 @@ type Fetcher interface {
Get(ctx context.Context, url string) (body string, status int, err error)
}
// CoverFetcher retrieves one kagane cover through the browser-backed path.
type CoverFetcher interface {
// BrowserCoverFetcher retrieves one kagane cover through the browser-backed path.
type BrowserCoverFetcher interface {
Image(ctx context.Context, imageID string) (body []byte, contentType string, err error)
}
@@ -42,9 +42,11 @@ type Poller struct {
// cannot clear. Nil disables those sites entirely rather than falling back
// to Fetch, which would only ever retrieve a challenge page.
BrowserFetch Fetcher
// CoverFetch is optional: it prefetches kagane covers without changing the
// chapter poll's result when the browser or persistence fails.
CoverFetch CoverFetcher
// CoverFetch is optional; failures are logged and never affect the chapter poll.
CoverFetch BrowserCoverFetcher
// CoverBytesFetch is optional; it handles non-kagane sources through the same
// failure-isolated prefetch path.
CoverBytesFetch CoverBytesFetcher
Now func() time.Time // injected so tests can freeze it
Cooldown time.Duration
BrowserCooldown time.Duration
@@ -56,14 +58,39 @@ type Poller struct {
var browserBackedSites = []string{"kagane", "novelfull"}
func (p *Poller) prefetchCover(ctx context.Context, sr store.Series) {
if sr.Site != "kagane" || p.CoverFetch == nil {
if sr.Cover == "" {
return
}
imageID, ok := store.KaganeImageID(sr.Cover)
if !ok {
if sr.Site == "kagane" {
if p.CoverFetch == nil {
return
}
imageID, ok := store.KaganeImageID(sr.Cover)
if !ok {
return
}
_, _, found, err := p.Store.GetKaganeCover(imageID)
if err != nil {
log.Printf("latest poll %q: read cover: %v", sr.Key(), err)
return
}
if found {
return
}
body, contentType, err := p.CoverFetch.Image(ctx, imageID)
if err != nil {
log.Printf("latest poll %q: fetch cover: %v", sr.Key(), err)
return
}
if err := p.Store.PutKaganeCover(imageID, body, contentType); err != nil {
log.Printf("latest poll %q: persist cover: %v", sr.Key(), err)
}
return
}
_, _, found, err := p.Store.GetKaganeCover(imageID)
if p.CoverBytesFetch == nil {
return
}
_, _, found, err := p.Store.GetCover(sr.Cover)
if err != nil {
log.Printf("latest poll %q: read cover: %v", sr.Key(), err)
return
@@ -71,12 +98,12 @@ func (p *Poller) prefetchCover(ctx context.Context, sr store.Series) {
if found {
return
}
body, contentType, err := p.CoverFetch.Image(ctx, imageID)
body, contentType, err := p.CoverBytesFetch.Fetch(ctx, sr.Cover)
if err != nil {
log.Printf("latest poll %q: fetch cover: %v", sr.Key(), err)
return
}
if err := p.Store.PutKaganeCover(imageID, body, contentType); err != nil {
if err := p.Store.PutCover(sr.Cover, body, contentType); err != nil {
log.Printf("latest poll %q: persist cover: %v", sr.Key(), err)
}
}