Fix comix titles and covers, kagane volume chapters, and kagane cover rendering #37
Reference in New Issue
Block a user
Delete Branch "fix/comix-kagane-titles-and-covers"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fixes five reported symptoms across comix.to and kagane.to. Diagnosing them turned up two latent bugs underneath, both of which had to be fixed for the kagane cover work to function at all.
Reported symptoms and their causes
Comix - Read Comics online for freedocument.titleon client routing but never touches the server-renderedog:title. The adapter readog:title, so a cold load stored the homepage's title.og:titlestill holds whatever page loaded first.og:imageat all, socoverFromPage()had nothing to read.og:title. Volume-numbered series render"<Series> - Volume <v> Chapter <n>", which the suffix regex did not match, sochapterNumcame back null and nothing was recorded.SP Baby - Volume 1 Chapter 1cross-origin-resource-policy: same-origin. No<img>on the UI's origin can load one even from a browser holding the clearance cookie. Hot-linking cannot be made to work.What changed
Userscript. comix titles now come from
document.titlewith the chapter page's" - Ch.<n>"tail stripped, and the cover is theimgwhosealtmatches the cleaned title. comix fillsdocument.titlea beat after the URL changes — later than the nav watcher's 300 ms snapshot — so the watcher also re-detects when thedetect()signature changes, not only when the URL does. The kagane suffix regex takes an optionalVolume <v>segment. All three page shapes were captured live on 2026-08-08 and pinned as regression tests.Cover proxy.
Bookmark.CoverURL()rewrites a stored kaganeog:imageto/img/kagane/{id}; templates render.CoverURLinstead of.Cover. The endpoint is session-gated like every other UI route and fetches through the shared headless browser, which is same-origin with kagane and so satisfies both the challenge and the CORP header. Results are memoised in-process, so a cover costs one navigation per deployment lifetime. WithBROWSER_WS_URLunset the endpoint answers 404 rather than reaching for a nil fetcher — the same degrade-to-userscript behaviour the poller already has.The image id is matched against a UUID regex before it reaches the browser. That gate is load-bearing rather than tidiness: the cover is a stored client-supplied string, so an unvalidated one turns this endpoint into an SSRF primitive aimed at the deployment's own network.
ServeMuxpath-cleans a traversal into a redirect before the handler runs, but the handler does not depend on that, and a test pins it.Two latent bugs found underneath
BrowserFetcher.runnever let a challenge solve. It navigated, waited forbody, read once, and closed the tab — roughly half a second end to end. The Cloudflare interstitial has abodytoo, soWaitReadywas satisfied by the challenge page itself. This made the challenge unclearable rather than merely slow: an interstitial needs several seconds of a live page to solve itself and write clearance into the browser's shared cookie jar, so tearing the tab down first means every subsequent call is challenged exactly like the one before it.runnow holds one tab and re-reads until the caller's predicate reports an answer, bounded bychallengeTimeoutand the caller's own deadline. Exhausting the budget maps back to the 403 the poller already expects, keeping a challenged site distinct from a broken transport.chromedp/headless-shellcannot clear kagane's challenge at all. It is a stripped Chrome build and the tells are structural rather than a header:navigator.webdriveris true, the plugin list is empty, and the client hints are Chromium- rather than Chrome-branded. Overridingwebdriverthrough CDP was tried on its own and changed nothing.All measured 2026-08-08 from one IP against the same cover, so the comparisons are like for like:
chromedp/headless-shell:stablezenika/alpine-chromegoogle-chrome, default UA--headless=newadvertisesHeadlessChromegoogle-chrome, stock UA,TZ=UTCgoogle-chrome, stock UA, any non-UTCTZBoth remaining tells are load-bearing, and each was tested in isolation.
chrome/is a Debian image withgoogle-chrome-stable, a UA whose version is read back out of the binary at startup (a hardcoded one would drift out of step with theSec-CH-UAhints on the next Chrome update and become a fresh tell), and no--enable-automation.The timezone tell: UTC, not a country mismatch
The first pass concluded the zone had to match the egress IP's country. Re-measuring against the actual deployment case shows that was wrong, and the correction is in
1552dd1.The original inference read the host's
/etc/timezone(Asia/Bangkok) and assumed a Thai egress. It isn't — this host egresses from an Indonesian IP.Asia/Bangkokcleared not because it matched a country but because it simply isn't UTC, and the two share +07, which hid the distinction. Same container, same Indonesian IP:TZUTCAsia/JakartaAmerica/New_YorkAmerica/New_Yorkmatches neither the country nor the offset nor the hemisphere and clears just as fast. A UTC clock is itself the bot signal — Cloudflare scores it as the datacenter default — and any real zone satisfies the check.BROWSER_TZtherefore needs a plausible zone, not a geolocated one, and a deployment that changes region need not keep it in sync.One sharp edge remains: the usual
-v /etc/localtime:/etc/localtime:rodoes not work. Chrome resolves the zone through ICU, which takes the name from that path's symlink target and ignores the file's contents, so glibc reports the host zone while Chrome still reports UTC./etc/timezonecarries the name and is mounted instead.Chrome also binds its DevTools port to loopback and silently ignores
--remote-debugging-address, which is why headless-shell fronted it with socat. This image does the same, so it stays a drop-in: the compose service keeps theheadless-shellname and its pinned address, andBROWSER_WS_URLis unchanged.Verification
The smoke test ran against the exact compose configuration — built image, empty
BROWSER_TZ,/etc/timezonemounted, cold profile — hitting real kagane.to. It skips unlessSMOKE_BROWSER_WS_URLnames a sidecar, sogo test ./...stays hermetic and Docker-only.A red smoke run means the challenge is not clearing from that IP, which is a live, time-varying fact to re-check rather than necessarily a defect.
Security invariants
/img/kagane/{id}is session-gated byrequireSession, the same guard as every other UI route..CoverURLreturns a plain string and is not wrapped intemplate.HTML/URL.chrome/) built from Debian plus Google's own apt repo; no new Go modules.Deploying
Needs
docker compose build headless-shell.A UTC host must set
BROWSER_TZ, or kagane silently stops working. With it unset the sidecar falls back to the host's/etc/timezone; on a UTC server that yields UTC, which is the one value that never clears. Any real zone works —BROWSER_TZ=Asia/Jakartafor the current deployment..env.examplenow documents this; it previously did not mention the knob at all.Only the browser sidecar reads
BROWSER_TZ. The backend keeps its UTC clock, and stored timestamps are unix ms, so nothing else shifts.Deliberately not done
Retry/backoff around the cover proxy, and a panel-side cover fix. The panel renders no covers, and covers cache in-process after the first fetch. Worth adding if kagane starts rate-limiting.
Correction after review of the deployment case
1552dd1was added after the branch was first pushed: the deployment host runs UTC with an Indonesian egress IP, which prompted re-measuring the timezone claim and falsifying it. The earlier commits' reasoning is left intact rather than rebased away, so the diagnostic trail — including the wrong turn and what disproved it — stays readable.comix.to is an SPA whose client router rewrites document.title but never touches the server-rendered og:title. The adapter read og:title, so a bookmark taken after a cold load got the homepage's title ("Comix - Read Comics online for free") and one taken after an in-page hop got the previous series' title. Titles now come from document.title, with the chapter page's " - Ch.<n>" tail stripped. comix also serves no og:image at all, which is why every comix bookmark fell back to the monogram placeholder. The cover is now the img whose alt matches the cleaned title. Both fixes need the page to have finished its client-side route change, and comix fills document.title a beat after the URL changes - later than the nav watcher's 300ms snapshot. The watcher therefore also re-detects when the detect() signature changes, not only when the URL does. kagane reader URLs carry no chapter number, so it comes out of og:title. Volume-numbered series render "<Series> - Volume <v> Chapter <n>" with no episode name, a shape the suffix regex did not match. One unmatched title caused both reported symptoms: the volume tail stayed in the stored title ("SP Baby - Volume 1 Chapter 1"), and chapterNum came back null so no chapter was ever recorded for the series. The regex now takes an optional "Volume <v> " segment. All three page shapes were captured live on 2026-08-08 and are pinned as regression tests in userscript/test/logic.test.js.chromedp/headless-shell cannot clear kagane.to's managed challenge. It is a stripped Chrome build, and the tells are structural rather than a header: navigator.webdriver is true, the plugin list is empty, and the client hints are Chromium- rather than Chrome-branded. Overriding webdriver through CDP was tried on its own and changed nothing. Everything below was measured on 2026-08-08 from a single IP, against the same kagane cover, so the comparisons are like for like: chromedp/headless-shell:stable never cleared (90s) zenika/alpine-chrome never cleared - ships Chrome 124, old enough that Cloudflare refuses it and old enough to break chromedp's CDP structs google-chrome, default UA never cleared (60s) - --headless=new advertises "HeadlessChrome" google-chrome, stock UA, UTC never cleared (90s) google-chrome, stock UA, TZ set cleared in ~4s So both remaining tells are load-bearing, and each was tested in isolation. chrome/ is a Debian image with google-chrome-stable, a UA whose version is read back out of the binary at startup (a hardcoded one would drift out of step with the Sec-CH-UA hints on the next Chrome update and become a fresh tell), and no --enable-automation. The timezone matters because Cloudflare scores a browser whose clock zone disagrees with its egress IP's country as a proxy. Note that the usual `-v /etc/localtime:/etc/localtime:ro` does not work here: Chrome resolves the zone through ICU, which takes the name from that path's symlink target and ignores the file's contents, so glibc reports the host zone while Chrome still reports UTC. /etc/timezone carries the name and is mounted instead; BROWSER_TZ overrides it for a host whose clock is UTC in a country that is not. Chrome also binds its DevTools port to loopback and silently ignores --remote-debugging-address, which is why headless-shell fronted it with socat. This image does the same, so it stays a drop-in: the compose service keeps the headless-shell name and its pinned address, and BROWSER_WS_URL is unchanged. Deploying needs `docker compose build headless-shell`.kagane serves cover images from behind the same Cloudflare challenge as its pages and with cross-origin-resource-policy: same-origin. The second header is the decisive one: no <img> on the web UI's origin can load a kagane cover even from a browser that already holds the clearance cookie, verified 2026-08-08 by loading one from a foreign origin with and without a referrer. Hot-linking cannot be made to work, so every kagane series rendered the monogram placeholder. Bookmark.CoverURL rewrites a stored kagane og:image to /img/kagane/{id} and returns every other cover untouched; the templates render .CoverURL in place of .Cover. The endpoint is session-gated like every other UI route, and hands the id to the shared headless browser, whose fetch is same-origin with kagane and therefore satisfies both the challenge and the CORP header. Results are memoised in-process, so a cover costs one navigation per deployment lifetime. The id is matched against a UUID regex before it reaches the browser. That gate is load-bearing rather than tidiness: the cover is a stored client-supplied string, so an unvalidated one turns the endpoint into an SSRF primitive aimed at the deployment's own network. ServeMux path-cleans a traversal into a redirect before the handler runs, but the handler does not rely on that, and a test pins it. With BROWSER_WS_URL unset there is no browser and the endpoint answers 404 rather than reaching for a nil fetcher - the same degrade-to- userscript behaviour the poller already has for these sites.Added
a24fcf0— the API now stamps its logs in Asia/Jakarta too, so both services read on one clock.Cosmetic only, and it reaches nothing but Go's
logpackage. Re-checked before setting it: bookmark timestamps are unix ms (0001_bookmarks.sqlpins that — the userscripts sendDate.now()and the ordering rule compares the integers directly), the only two real time columns aretimestamptz, the poller works in durations, and there is no.Format(call in non-test backend code.No code or image change was needed:
distroless/staticalready carries the full tzdata (1247 entries), so Go resolves the name out of/usr/share/zoneinfo.The knob is
API_TZ, notTZ, because compose also reads the invoking shell's environment and a bareTZwould let an operator's exported zone silently become the container's. Defaults toAsia/Jakarta; setAPI_TZ=UTCfor the conventional server default.Verified against the built image at the same instant:
against
date -u16:24:33 / Jakarta 23:24:33.Note the two zones are set for unrelated reasons and need not agree:
BROWSER_TZmust be non-UTC or Cloudflare's challenge never clears, whereasAPI_TZis readability and could be anything.