180ee78b1f
Tracks read progress on comix.to and kagane.to alongside asura and demonic, in both the userscript and the backend. Implements `docs/superpowers/plans/2026-08-03-comix-kagane-support.md`. ## Userscript - `comix` adapter — `/title/<id>-<slug>`; only the id prefix is identity (the slug follows the title). No `og:image`, so the cover is matched by `alt`. - `kagane` adapter — reader URLs are uuids with no chapter number, so it comes out of `og:title`; anchor scanning is structurally impossible, replaced by `latestChapterFromApi` against kagane's same-origin JSON API. - `seriesId` threaded through `latestChapterFromAnchors` so comix can scope its scan to its own series and a recommendation strip cannot win the maximum. - `@match` for both hosts, panel chips, v1.6.0. ## Backend - `latestChapterFrom` cases: comix parses the SSR JSON state blob (`latestChapterUrl`, scoped to the series id); kagane parses API JSON (`chapter_no`). - Poller allowlist extended; `Poller.BrowserFetch` with `fetcherFor(site)` routes kagane to a browser fetcher. Nil means kagane is not polled at all — never a fallback to the TLS fetcher, which would only ever retrieve a challenge page. - `BrowserFetcher`: chromedp against a `headless-shell` sidecar. kagane sits behind a Cloudflare JS challenge that no TLS fingerprint clears, and the request is made inside the page rather than by replaying `cf_clearance`. - `BROWSER_WS_URL` wiring, sidecar in both compose files (no `ports:`, dedicated non-external network), Dockerfile on `golang:1.26-alpine` — chromedp requires go 1.26. - Web UI `--comix` / `--kagane` tokens in both colour branches. ## Notes for review - `series_url` is client-supplied and a headless browser is a strong SSRF primitive, so kagane's host is pinned twice: in `fetchableSeriesURL` and again in `kaganeAPIURL`. - Three chained defects found during verification made the browser path dead under Compose (sidecar flag collision, Chrome's Host-header DNS-rebinding check, the wrong chromedp option). Fixed; the compose comments record the wrong configurations too, so they don't get "simplified" back. - `ALLOWED_ORIGINS` now includes both new origins. Without it every write from comix/kagane silently fails CORS preflight, parks in the retry queue, and drops at the cap. ## Verification 221 backend tests, 32 userscript tests, static `CGO_ENABLED=0` build, both compose configs. Two gaps, both real: 1. The userscript on live pages via Violentmonkey needs a human browser profile — not run. Check: comix series page (title/cover, no chapter), comix chapter page (records the number; an *older* chapter must not regress it), comix SPA navigation without reload, kagane series page (og:image cover), kagane reader (number from `og:title`), both chips opening the right sites. 2. The kagane browser path has not completed end-to-end anywhere. Dial/navigate/fetch is confirmed, but Cloudflare 403'd headless-shell's Chrome on every attempt from the dev sandbox, and comix's poll-through-Docker was blocked by that environment's TLS interception. Both environment-dependent rather than branch defects — the first real deploy is the actual verification. Reviewed-on: #13 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
246 lines
7.0 KiB
Go
246 lines
7.0 KiB
Go
package main
|
|
|
|
import (
|
|
"compress/gzip"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"mangabm/backend/internal/store"
|
|
)
|
|
|
|
func TestLoadLatestPollDefaults(t *testing.T) {
|
|
for _, k := range []string{
|
|
"LATEST_CHAPTER_POLL_ENABLED", "LATEST_CHAPTER_POLL_COOLDOWN",
|
|
"LATEST_CHAPTER_POLL_INTERVAL", "LATEST_CHAPTER_POLL_STAGGER",
|
|
"LATEST_CHAPTER_POLL_BATCH",
|
|
} {
|
|
t.Setenv(k, "")
|
|
}
|
|
|
|
got := loadLatestPoll()
|
|
want := LatestPoll{
|
|
Enabled: true,
|
|
Cooldown: time.Hour,
|
|
Interval: 10 * time.Minute,
|
|
Stagger: 20 * time.Second,
|
|
Batch: 14,
|
|
}
|
|
if got != want {
|
|
t.Fatalf("loadLatestPoll() = %+v, want %+v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestLoadLatestPollEnabledParsing(t *testing.T) {
|
|
tests := []struct {
|
|
raw string
|
|
want bool
|
|
}{
|
|
{"", true}, {"1", true}, {"true", true}, {"TRUE", true}, {"yes", true}, {"on", true},
|
|
{"0", false}, {"false", false}, {"False", false}, {"no", false}, {"off", false},
|
|
{"maybe", true}, // unparseable falls back to the default
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run("raw="+tt.raw, func(t *testing.T) {
|
|
t.Setenv("LATEST_CHAPTER_POLL_ENABLED", tt.raw)
|
|
if got := loadLatestPoll().Enabled; got != tt.want {
|
|
t.Fatalf("Enabled = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadLatestPollClampsAndFallsBack(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
env map[string]string
|
|
wantFrom func(LatestPoll) any
|
|
want any
|
|
}{
|
|
{
|
|
name: "cooldown below the floor is clamped up",
|
|
env: map[string]string{"LATEST_CHAPTER_POLL_COOLDOWN": "1m"},
|
|
wantFrom: func(p LatestPoll) any { return p.Cooldown },
|
|
want: 15 * time.Minute,
|
|
},
|
|
{
|
|
name: "cooldown at the floor is kept",
|
|
env: map[string]string{"LATEST_CHAPTER_POLL_COOLDOWN": "15m"},
|
|
wantFrom: func(p LatestPoll) any { return p.Cooldown },
|
|
want: 15 * time.Minute,
|
|
},
|
|
{
|
|
name: "a valid override is honoured",
|
|
env: map[string]string{"LATEST_CHAPTER_POLL_INTERVAL": "5m"},
|
|
wantFrom: func(p LatestPoll) any { return p.Interval },
|
|
want: 5 * time.Minute,
|
|
},
|
|
{
|
|
name: "an unparseable duration falls back",
|
|
env: map[string]string{"LATEST_CHAPTER_POLL_INTERVAL": "ten minutes"},
|
|
wantFrom: func(p LatestPoll) any { return p.Interval },
|
|
want: 10 * time.Minute,
|
|
},
|
|
{
|
|
name: "a zero duration falls back",
|
|
env: map[string]string{"LATEST_CHAPTER_POLL_STAGGER": "0s"},
|
|
wantFrom: func(p LatestPoll) any { return p.Stagger },
|
|
want: 20 * time.Second,
|
|
},
|
|
{
|
|
name: "a valid batch is honoured",
|
|
env: map[string]string{"LATEST_CHAPTER_POLL_BATCH": "30"},
|
|
wantFrom: func(p LatestPoll) any { return p.Batch },
|
|
want: 30,
|
|
},
|
|
{
|
|
name: "a negative batch falls back",
|
|
env: map[string]string{"LATEST_CHAPTER_POLL_BATCH": "-5"},
|
|
wantFrom: func(p LatestPoll) any { return p.Batch },
|
|
want: 14,
|
|
},
|
|
{
|
|
name: "a non-numeric batch falls back",
|
|
env: map[string]string{"LATEST_CHAPTER_POLL_BATCH": "lots"},
|
|
wantFrom: func(p LatestPoll) any { return p.Batch },
|
|
want: 14,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
for k, v := range tt.env {
|
|
t.Setenv(k, v)
|
|
}
|
|
if got := tt.wantFrom(loadLatestPoll()); got != tt.want {
|
|
t.Fatalf("got %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPutStatusValidation(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
status string
|
|
want int
|
|
}{
|
|
{"empty is no opinion", "", http.StatusOK},
|
|
{"reading", "reading", http.StatusOK},
|
|
{"archived", "archived", http.StatusOK},
|
|
{"finished is web-only", "finished", http.StatusBadRequest},
|
|
{"garbage", "dropped", http.StatusBadRequest},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
body := fmt.Sprintf(`{"title":"Solo","status":%q}`, tc.status)
|
|
req := auth(httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo",
|
|
strings.NewReader(body)))
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != tc.want {
|
|
t.Fatalf("status = %d, want %d (body %s)", rr.Code, tc.want, rr.Body.String())
|
|
}
|
|
if tc.want != http.StatusOK {
|
|
return
|
|
}
|
|
var got store.Bookmark
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
want := tc.status
|
|
if want == "" {
|
|
want = "reading"
|
|
}
|
|
if got.Status != want {
|
|
t.Fatalf("stored status = %q, want %q", got.Status, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A PUT that omits the status field entirely (what a userscript build
|
|
// predating the column sends) is the actual preserve path — the "" case
|
|
// above only exercises the fresh-INSERT default and never touches an
|
|
// existing bucket. This must both keep the archived bucket and still apply
|
|
// the chapter progress carried in the same request.
|
|
func TestPutOmittedStatusPreservesArchivedAndAppliesProgress(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
|
|
seed := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo",
|
|
strings.NewReader(`{"title":"Solo","status":"archived"}`))
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, auth(seed))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("seed status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo",
|
|
strings.NewReader(`{"title":"Solo","last_chapter":"12","last_chapter_num":12}`))
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, auth(req))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var got store.Bookmark
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if got.Status != "archived" {
|
|
t.Fatalf("stored status = %q, want %q", got.Status, "archived")
|
|
}
|
|
if got.LastChapterNum != 12 {
|
|
t.Fatalf("stored last_chapter_num = %v, want 12", got.LastChapterNum)
|
|
}
|
|
}
|
|
|
|
func TestGzipCompressesTextNotFonts(t *testing.T) {
|
|
srv, _ := newWebTestServer(t, webConfig())
|
|
|
|
cases := []struct {
|
|
path string
|
|
want bool
|
|
}{
|
|
{"/static/style.css", true},
|
|
{"/static/filter.js", true},
|
|
{"/static/htmx.min.js", true},
|
|
{"/static/fonts/dm-sans-var-latin.woff2", false},
|
|
}
|
|
for _, tc := range cases {
|
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
|
req.Header.Set("Accept-Encoding", "gzip")
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("GET %s = %d, want 200", tc.path, rr.Code)
|
|
}
|
|
got := rr.Header().Get("Content-Encoding") == "gzip"
|
|
if got != tc.want {
|
|
t.Errorf("GET %s Content-Encoding gzip = %v, want %v", tc.path, got, tc.want)
|
|
}
|
|
if got {
|
|
zr, err := gzip.NewReader(rr.Body)
|
|
if err != nil {
|
|
t.Fatalf("GET %s: body is not gzip: %v", tc.path, err)
|
|
}
|
|
if _, err := io.ReadAll(zr); err != nil {
|
|
t.Fatalf("GET %s: gzip body did not decode: %v", tc.path, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A client that does not ask still gets plain bytes.
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/static/style.css", nil))
|
|
if enc := rr.Header().Get("Content-Encoding"); enc != "" {
|
|
t.Errorf("Content-Encoding without Accept-Encoding = %q, want empty", enc)
|
|
}
|
|
}
|