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>
116 lines
3.9 KiB
Go
116 lines
3.9 KiB
Go
package userscript
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const testToken = "s3cret-token"
|
|
|
|
// sampleScript is a stand-in for the real userscript: a metadata block with a
|
|
// @version line, plus a body that must survive the rewrite untouched.
|
|
const sampleScript = `// ==UserScript==
|
|
// @name Manga Bookmark Sync
|
|
// @version 1.5.0
|
|
// @match https://asurascans.com/*
|
|
// ==/UserScript==
|
|
(function () { "use strict"; })();
|
|
`
|
|
|
|
// writeScript drops a userscript in a temp dir with a known mtime and returns
|
|
// its path plus the version string the handler is expected to stamp.
|
|
func writeScript(t *testing.T, body string) (path, wantVersion string) {
|
|
t.Helper()
|
|
path = filepath.Join(t.TempDir(), "manga-bookmark.user.js")
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatalf("write script: %v", err)
|
|
}
|
|
mod := time.Date(2026, 7, 28, 16, 42, 0, 0, time.UTC)
|
|
if err := os.Chtimes(path, mod, mod); err != nil {
|
|
t.Fatalf("chtimes: %v", err)
|
|
}
|
|
return path, "2026.07.28.1642"
|
|
}
|
|
|
|
// newTestMux registers Handler the same way main.go's router does, without
|
|
// pulling in the store or the rest of the app.
|
|
func newTestMux(token, path string) http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", Handler(token, path))
|
|
return mux
|
|
}
|
|
|
|
func getScript(t *testing.T, srv http.Handler, token string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/u/"+token+"/manga-bookmark.user.js", nil))
|
|
return rr
|
|
}
|
|
|
|
func TestUserscriptServedWithStampedVersion(t *testing.T) {
|
|
path, wantVersion := writeScript(t, sampleScript)
|
|
rr := getScript(t, newTestMux(testToken, path), testToken)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
|
|
t.Errorf("Content-Type = %q, want text/javascript", ct)
|
|
}
|
|
if cc := rr.Header().Get("Cache-Control"); cc != "no-cache" {
|
|
t.Errorf("Cache-Control = %q, want no-cache", cc)
|
|
}
|
|
body := rr.Body.String()
|
|
if !strings.Contains(body, "// @version "+wantVersion) {
|
|
t.Errorf("body has no stamped version %q:\n%s", wantVersion, body)
|
|
}
|
|
if strings.Contains(body, "1.5.0") {
|
|
t.Errorf("body still carries the file's own version:\n%s", body)
|
|
}
|
|
// Everything outside the @version line is served verbatim.
|
|
if !strings.Contains(body, `(function () { "use strict"; })();`) {
|
|
t.Errorf("body was altered beyond the version line:\n%s", body)
|
|
}
|
|
if !strings.Contains(body, "// @name Manga Bookmark Sync") {
|
|
t.Errorf("metadata block was altered:\n%s", body)
|
|
}
|
|
}
|
|
|
|
// The empty-token case ("/u//manga-bookmark.user.js") is covered at the
|
|
// router level (see backend's guardEmptyUserscriptToken): ServeMux 307s it to
|
|
// "/u/manga-bookmark.user.js" before this handler's own token check ever runs.
|
|
func TestUserscriptWrongTokenIs404(t *testing.T) {
|
|
path, _ := writeScript(t, sampleScript)
|
|
srv := newTestMux(testToken, path)
|
|
for _, tok := range []string{"wrong", testToken + "x", testToken[:3]} {
|
|
if got := getScript(t, srv, tok).Code; got != http.StatusNotFound {
|
|
t.Errorf("token %q: status = %d, want 404", tok, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUserscriptMissingFileIs404(t *testing.T) {
|
|
srv := newTestMux(testToken, filepath.Join(t.TempDir(), "absent.user.js"))
|
|
if got := getScript(t, srv, testToken).Code; got != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", got)
|
|
}
|
|
}
|
|
|
|
func TestUserscriptWithoutVersionLineServedUnmodified(t *testing.T) {
|
|
const noVersion = "// ==UserScript==\n// @name x\n// ==/UserScript==\nconsole.log(1);\n"
|
|
path, _ := writeScript(t, noVersion)
|
|
rr := getScript(t, newTestMux(testToken, path), testToken)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
if rr.Body.String() != noVersion {
|
|
t.Fatalf("body = %q, want it unmodified", rr.Body.String())
|
|
}
|
|
}
|