Fix comix titles and covers, kagane volume chapters, and kagane cover rendering #37
@@ -2,7 +2,9 @@ package latest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -22,6 +24,12 @@ const challengeTimeout = 45 * time.Second
|
|||||||
|
|
||||||
var kaganeSeriesRe = regexp.MustCompile(`^/series/([0-9a-f-]{36})/?$`)
|
var kaganeSeriesRe = regexp.MustCompile(`^/series/([0-9a-f-]{36})/?$`)
|
||||||
|
|
||||||
|
// kaganeImageIDRe pins the only path segment Image interpolates into an
|
||||||
|
// outbound URL. The id arrives from a stored cover URL, which a client
|
||||||
|
// supplied, so it is matched rather than trusted: a headless browser is a
|
||||||
|
// strong SSRF primitive.
|
||||||
|
var kaganeImageIDRe = regexp.MustCompile(`^[0-9a-f-]{36}$`)
|
||||||
|
|
||||||
// BrowserFetcher retrieves pages through a remote headless Chrome over the
|
// BrowserFetcher retrieves pages through a remote headless Chrome over the
|
||||||
// DevTools Protocol.
|
// DevTools Protocol.
|
||||||
//
|
//
|
||||||
@@ -91,23 +99,6 @@ func (f *BrowserFetcher) Get(ctx context.Context, seriesURL string) (string, int
|
|||||||
return "", 0, fmt.Errorf("not a fetchable browser series url: %q", seriesURL)
|
return "", 0, fmt.Errorf("not a fetchable browser series url: %q", seriesURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, challengeTimeout)
|
|
||||||
defer cancel()
|
|
||||||
// A fresh tab per fetch, closed on return, so one wedged page cannot
|
|
||||||
// poison later polls.
|
|
||||||
tabCtx, cancelTab := chromedp.NewContext(f.allocCtx)
|
|
||||||
defer cancelTab()
|
|
||||||
// Bind the caller's deadline to the tab.
|
|
||||||
tabCtx, cancelDeadline := context.WithCancel(tabCtx)
|
|
||||||
defer cancelDeadline()
|
|
||||||
go func() {
|
|
||||||
<-ctx.Done()
|
|
||||||
cancelDeadline()
|
|
||||||
}()
|
|
||||||
|
|
||||||
var body string
|
var body string
|
||||||
// kagane's chapter list is only in its JSON API, which must be called from
|
// kagane's chapter list is only in its JSON API, which must be called from
|
||||||
// inside the page so the request carries the clearance cookie. novelfull
|
// inside the page so the request carries the clearance cookie. novelfull
|
||||||
@@ -123,24 +114,134 @@ func (f *BrowserFetcher) Get(ctx context.Context, seriesURL string) (string, int
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := chromedp.Run(tabCtx,
|
// novelfull's payload is the DOM itself, and the interstitial has a DOM
|
||||||
chromedp.Navigate(seriesURL),
|
// too, so "we have an answer" has to exclude it explicitly. kagane's
|
||||||
// The challenge reloads the page itself when it passes; waiting for the
|
// in-page fetch just fails while challenged, which is already the signal.
|
||||||
// site's own root element is what tells us we are through it.
|
done := func() bool { return body != "" && (isKagane || !isInterstitial(body)) }
|
||||||
chromedp.WaitReady("body", chromedp.ByQuery),
|
if err := f.run(ctx, seriesURL, read, done); err != nil {
|
||||||
read,
|
// Challenge never cleared, or the API refused. Indistinguishable from
|
||||||
)
|
// here and handled identically by the caller.
|
||||||
if err != nil {
|
if errors.Is(err, errChallengeHeld) {
|
||||||
|
return "", 403, nil
|
||||||
|
}
|
||||||
return "", 0, fmt.Errorf("browser fetch %q: %w", seriesURL, err)
|
return "", 0, fmt.Errorf("browser fetch %q: %w", seriesURL, err)
|
||||||
}
|
}
|
||||||
if body == "" {
|
|
||||||
// Challenge still up, or the API refused. Indistinguishable from here
|
|
||||||
// and handled identically by the caller.
|
|
||||||
return "", 403, nil
|
|
||||||
}
|
|
||||||
return body, 200, nil
|
return body, 200, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Image retrieves one kagane cover as raw bytes and its content type.
|
||||||
|
//
|
||||||
|
// It exists because kagane serves covers behind the same challenge as its
|
||||||
|
// pages *and* with `cross-origin-resource-policy: same-origin`, so an <img> on
|
||||||
|
// the web UI's origin cannot load one even from a browser that already holds
|
||||||
|
// the clearance cookie (verified 2026-08-08). Proxying is the only route.
|
||||||
|
//
|
||||||
|
// The image URL is navigated to rather than fetched from some other kagane
|
||||||
|
// page: the challenge only runs on a top-level navigation, and once it clears
|
||||||
|
// the document *is* the image, so a same-origin fetch of location.href reads
|
||||||
|
// it straight back out of the cache.
|
||||||
|
//
|
||||||
|
// The challenge is not solved by the first read: WaitReady("body") is satisfied
|
||||||
|
// by the interstitial too. run holds the tab open until the in-page fetch
|
||||||
|
// succeeds, which is what gives the challenge script the seconds it needs.
|
||||||
|
func (f *BrowserFetcher) Image(ctx context.Context, imageID string) ([]byte, string, error) {
|
||||||
|
if !kaganeImageIDRe.MatchString(imageID) {
|
||||||
|
return nil, "", fmt.Errorf("not a kagane image id: %q", imageID)
|
||||||
|
}
|
||||||
|
var dataURL string
|
||||||
|
err := f.run(ctx, "https://kagane.to/api/v2/image/"+imageID+"/compressed",
|
||||||
|
chromedp.Evaluate(`fetch(location.href).then(r => r.ok
|
||||||
|
? r.blob().then(b => new Promise(res => {
|
||||||
|
const fr = new FileReader();
|
||||||
|
fr.onload = () => res(fr.result);
|
||||||
|
fr.readAsDataURL(b);
|
||||||
|
}))
|
||||||
|
: "")`, &dataURL, awaitPromise),
|
||||||
|
func() bool { return dataURL != "" })
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("browser image %s: %w", imageID, err)
|
||||||
|
}
|
||||||
|
// "data:image/webp;base64,<payload>".
|
||||||
|
head, payload, ok := strings.Cut(dataURL, ";base64,")
|
||||||
|
if !ok {
|
||||||
|
return nil, "", fmt.Errorf("browser image %s: not a data url", imageID)
|
||||||
|
}
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("browser image %s: %w", imageID, err)
|
||||||
|
}
|
||||||
|
return raw, strings.TrimPrefix(head, "data:"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// errChallengeHeld reports that the budget ran out with the interstitial still
|
||||||
|
// up. Distinct from a transport failure: it means "this site said no", which
|
||||||
|
// the poller answers with a 403 and its ordinary cooldown.
|
||||||
|
var errChallengeHeld = errors.New("challenge held")
|
||||||
|
|
||||||
|
// challengePollInterval paces re-reads while a challenge solves itself.
|
||||||
|
const challengePollInterval = 2 * time.Second
|
||||||
|
|
||||||
|
// isInterstitial reports whether html is Cloudflare's challenge page rather
|
||||||
|
// than the site's own. Matched on the challenge runtime's script path, which is
|
||||||
|
// stable across the interstitial's wording and locale — the visible "Just a
|
||||||
|
// moment..." title is neither.
|
||||||
|
func isInterstitial(html string) bool {
|
||||||
|
return strings.Contains(html, "/cdn-cgi/challenge-platform/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// run navigates to target and re-reads until done reports an answer, bounded by
|
||||||
|
// challengeTimeout and by the caller's own deadline, in a tab that is closed on
|
||||||
|
// return so one wedged page cannot poison later calls.
|
||||||
|
//
|
||||||
|
// Holding the tab open across re-reads is the whole point. A Cloudflare
|
||||||
|
// interstitial needs several seconds of a live page to solve itself and write
|
||||||
|
// clearance into the browser's shared cookie jar; reading once and closing the
|
||||||
|
// tab — which is what this did before 2026-08-08 — never gives it that window,
|
||||||
|
// so every fetch lands on the interstitial and the clearance that would have
|
||||||
|
// unblocked all the later ones is never obtained.
|
||||||
|
func (f *BrowserFetcher) run(ctx context.Context, target string, read chromedp.Action, done func() bool) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, challengeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
tabCtx, cancelTab := chromedp.NewContext(f.allocCtx)
|
||||||
|
defer cancelTab()
|
||||||
|
// Bind the caller's deadline to the tab.
|
||||||
|
tabCtx, cancelDeadline := context.WithCancel(tabCtx)
|
||||||
|
defer cancelDeadline()
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
cancelDeadline()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := chromedp.Run(tabCtx,
|
||||||
|
chromedp.Navigate(target),
|
||||||
|
chromedp.WaitReady("body", chromedp.ByQuery),
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for {
|
||||||
|
// The challenge reloads the page when it passes, which tears down the
|
||||||
|
// execution context mid-read. That is a retry, not a failure.
|
||||||
|
if err := chromedp.Run(tabCtx, read); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
} else if done() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if lastErr != nil {
|
||||||
|
return fmt.Errorf("%w (last read: %v)", errChallengeHeld, lastErr)
|
||||||
|
}
|
||||||
|
return errChallengeHeld
|
||||||
|
case <-time.After(challengePollInterval):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// kaganeAPIURL maps a stored series_url to the JSON endpoint carrying its
|
// kaganeAPIURL maps a stored series_url to the JSON endpoint carrying its
|
||||||
// chapter list. Returning false for anything else is a second line of defence
|
// chapter list. Returning false for anything else is a second line of defence
|
||||||
// behind fetchableSeriesURL: a headless browser is a strong SSRF primitive and
|
// behind fetchableSeriesURL: a headless browser is a strong SSRF primitive and
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package latest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSmokeKaganeImage is the live proof that the cover proxy's fetch actually
|
||||||
|
// clears Cloudflare and returns image bytes. It needs a real headless Chrome
|
||||||
|
// with outbound network, so it runs only when SMOKE_BROWSER_WS_URL is set:
|
||||||
|
//
|
||||||
|
// docker run --rm --shm-size=1gb -p 19222:9222 chromedp/headless-shell:stable
|
||||||
|
// SMOKE_BROWSER_WS_URL=ws://127.0.0.1:19222 go test -run TestSmokeKaganeImage ./internal/latest
|
||||||
|
func TestSmokeKaganeImage(t *testing.T) {
|
||||||
|
ws := os.Getenv("SMOKE_BROWSER_WS_URL")
|
||||||
|
if ws == "" {
|
||||||
|
t.Skip("SMOKE_BROWSER_WS_URL unset")
|
||||||
|
}
|
||||||
|
const imageID = "019fe11a-84c3-7fc3-a84b-88787374b617" // SP Baby's cover
|
||||||
|
|
||||||
|
// The same URL through a plain client is what the web UI's <img> gets.
|
||||||
|
// Asserting on it keeps the test honest about why the browser is needed.
|
||||||
|
req, err := http.NewRequest(http.MethodGet,
|
||||||
|
"https://kagane.to/api/v2/image/"+imageID+"/compressed", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res, err := (&http.Client{Timeout: 15 * time.Second}).Do(req); err == nil {
|
||||||
|
res.Body.Close()
|
||||||
|
if res.StatusCode == http.StatusOK {
|
||||||
|
t.Log("note: kagane answered a plain request 200 — the challenge is not up right now")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := NewBrowserFetcher(ws)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewBrowserFetcher: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
body, contentType, err := f.Image(ctx, imageID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Image: %v", err)
|
||||||
|
}
|
||||||
|
if len(body) < 1000 {
|
||||||
|
t.Fatalf("body is %d bytes, want a real image", len(body))
|
||||||
|
}
|
||||||
|
if contentType != "image/webp" {
|
||||||
|
t.Fatalf("content type = %q, want image/webp", contentType)
|
||||||
|
}
|
||||||
|
// WebP files start with "RIFF....WEBP".
|
||||||
|
if string(body[:4]) != "RIFF" || string(body[8:12]) != "WEBP" {
|
||||||
|
t.Fatalf("body is not a WebP: % x", body[:12])
|
||||||
|
}
|
||||||
|
t.Logf("fetched %d bytes of %s", len(body), contentType)
|
||||||
|
|
||||||
|
if _, _, err := f.Image(ctx, "not-a-uuid"); err == nil {
|
||||||
|
t.Fatal("Image accepted a non-uuid id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control for the test above: the poller's own kagane path, same sidecar. If
|
||||||
|
// this fails too, the sidecar is not clearing the challenge at all and the
|
||||||
|
// image result says nothing about Image itself.
|
||||||
|
func TestSmokeKaganeGet(t *testing.T) {
|
||||||
|
ws := os.Getenv("SMOKE_BROWSER_WS_URL")
|
||||||
|
if ws == "" {
|
||||||
|
t.Skip("SMOKE_BROWSER_WS_URL unset")
|
||||||
|
}
|
||||||
|
f, err := NewBrowserFetcher(ws)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewBrowserFetcher: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
body, status, err := f.Get(ctx, "https://kagane.to/series/019fe11a-8670-7cf3-8343-0b02057d3787")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("status=%d bytes=%d head=%.80q", status, len(body), body)
|
||||||
|
if status != 200 {
|
||||||
|
t.Fatalf("status = %d, want 200 — the sidecar is not clearing the challenge", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user