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>
120 lines
3.5 KiB
Go
120 lines
3.5 KiB
Go
package httpmw
|
|
|
|
import (
|
|
"compress/gzip"
|
|
"crypto/subtle"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
const bearerPrefix = "Bearer "
|
|
|
|
// Auth guards a handler with a constant-time bearer-token check.
|
|
func Auth(token string, next http.Handler) http.Handler {
|
|
want := []byte(token)
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
h := r.Header.Get("Authorization")
|
|
if !strings.HasPrefix(h, bearerPrefix) {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
got := []byte(strings.TrimPrefix(h, bearerPrefix))
|
|
if subtle.ConstantTimeCompare(got, want) != 1 {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// gzipWriter compresses the body and drops Content-Length, which no longer
|
|
// describes what goes on the wire. WriteHeader is where the decision is made:
|
|
// only then is Content-Type known.
|
|
type gzipWriter struct {
|
|
http.ResponseWriter
|
|
gz *gzip.Writer
|
|
decided bool
|
|
}
|
|
|
|
// compressible covers what this server actually serves in bulk: HTML, CSS, JS
|
|
// and JSON. Fonts are woff2, which is already compressed — gzipping them costs
|
|
// CPU to add bytes.
|
|
func compressible(contentType string) bool {
|
|
ct, _, _ := strings.Cut(contentType, ";")
|
|
switch strings.TrimSpace(ct) {
|
|
case "text/html", "text/css", "text/javascript", "application/javascript",
|
|
"application/json", "text/plain":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (w *gzipWriter) WriteHeader(status int) {
|
|
if !w.decided {
|
|
w.decided = true
|
|
if compressible(w.Header().Get("Content-Type")) {
|
|
w.Header().Set("Content-Encoding", "gzip")
|
|
w.Header().Del("Content-Length")
|
|
w.gz = gzip.NewWriter(w.ResponseWriter)
|
|
}
|
|
}
|
|
w.ResponseWriter.WriteHeader(status)
|
|
}
|
|
|
|
func (w *gzipWriter) Write(b []byte) (int, error) {
|
|
if !w.decided {
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
if w.gz != nil {
|
|
return w.gz.Write(b)
|
|
}
|
|
return w.ResponseWriter.Write(b)
|
|
}
|
|
|
|
// Gzip compresses text responses for clients that ask. The templates,
|
|
// stylesheet and htmx together are ~120 KB uncompressed and roughly a quarter
|
|
// of that gzipped, which is the difference between a fast and a slow first load
|
|
// on mobile data.
|
|
func Gzip(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
w.Header().Add("Vary", "Accept-Encoding")
|
|
gw := &gzipWriter{ResponseWriter: w}
|
|
defer func() {
|
|
if gw.gz != nil {
|
|
gw.gz.Close()
|
|
}
|
|
}()
|
|
next.ServeHTTP(gw, r)
|
|
})
|
|
}
|
|
|
|
// CORS reflects the request Origin only when it is in allowed, answers
|
|
// preflight OPTIONS with 204, and passes everything else through. It wraps the
|
|
// auth middleware so preflight (which carries no Authorization header) is never
|
|
// rejected by auth.
|
|
func CORS(allowed []string, next http.Handler) http.Handler {
|
|
set := make(map[string]struct{}, len(allowed))
|
|
for _, o := range allowed {
|
|
set[o] = struct{}{}
|
|
}
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
origin := r.Header.Get("Origin")
|
|
if _, ok := set[origin]; ok && origin != "" {
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
w.Header().Add("Vary", "Origin")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET,PUT,DELETE,OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Authorization,Content-Type")
|
|
w.Header().Set("Access-Control-Max-Age", "86400")
|
|
}
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|