Fix comix titles and covers, kagane volume chapters, and kagane cover rendering #37

Merged
sulthan merged 6 commits from fix/comix-kagane-titles-and-covers into main 2026-08-08 23:27:33 +07:00
Owner

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

# Symptom Cause
1 comix bookmark titled Comix - Read Comics online for free comix is an SPA that rewrites document.title on client routing but never touches the server-rendered og:title. The adapter read og:title, so a cold load stored the homepage's title.
2 next comix bookmark gets the previous series' title Same cause. After an in-page hop, og:title still holds whatever page loaded first.
3 comix cover shows the placeholder comix serves no og:image at all, so coverFromPage() had nothing to read.
4 kagane chapter never appears in the bookmark list Reader URLs carry no chapter number, so it is parsed out of og:title. Volume-numbered series render "<Series> - Volume <v> Chapter <n>", which the suffix regex did not match, so chapterNum came back null and nothing was recorded.
5 kagane title includes the chapter, e.g. SP Baby - Volume 1 Chapter 1 Same unmatched regex — the tail was never stripped. One fix covers 4 and 5.
6 kagane cover blocked in the web UI kagane serves covers behind its Cloudflare challenge and with cross-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.title with the chapter page's " - Ch.<n>" tail stripped, and the cover is the img whose alt matches the cleaned title. comix fills document.title a beat after the URL changes — later than the nav watcher's 300 ms snapshot — so the watcher also re-detects when the detect() signature changes, not only when the URL does. The kagane suffix regex takes an optional Volume <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 kagane og:image to /img/kagane/{id}; templates render .CoverURL instead 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. With BROWSER_WS_URL unset 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. ServeMux path-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.run never let a challenge solve. It navigated, waited for body, read once, and closed the tab — roughly half a second end to end. The Cloudflare interstitial has a body too, so WaitReady was 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. run now holds one tab and re-reads until the caller's predicate reports an answer, bounded by challengeTimeout and 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-shell cannot clear kagane's challenge at all. 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.

All measured 2026-08-08 from one IP against the same cover, so the comparisons are like for like:

Browser Result
chromedp/headless-shell:stable never cleared (90 s)
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 (60 s) — --headless=new advertises HeadlessChrome
google-chrome, stock UA, TZ=UTC never cleared (90 s)
google-chrome, stock UA, any non-UTC TZ cleared in ~4 s

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 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/Bangkok cleared 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:

TZ Result
UTC never cleared (60 s, twice)
Asia/Jakarta cleared in 4 s
America/New_York cleared in 4 s

America/New_York matches 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_TZ therefore 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:ro does 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/timezone carries 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 the headless-shell name and its pinned address, and BROWSER_WS_URL is unchanged.

Verification

go test ./...        all packages ok
node --test          37 + 12 pass, 0 fail

SMOKE_BROWSER_WS_URL=... go test -run TestSmokeKagane ./internal/latest
  TestSmokeKaganeImage  PASS (5.29s)  fetched 56710 bytes of image/webp
  TestSmokeKaganeGet    PASS (1.17s)  status=200, real chapter-list JSON

The smoke test ran against the exact compose configuration — built image, empty BROWSER_TZ, /etc/timezone mounted, cold profile — hitting real kagane.to. It skips unless SMOKE_BROWSER_WS_URL names a sidecar, so go 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

  • Auth unchanged. /img/kagane/{id} is session-gated by requireSession, the same guard as every other UI route.
  • Outbound fetch gated: the id is UUID-validated before it reaches the browser, keeping the existing rule that a client-supplied string never selects a fetch target unchecked.
  • No new secrets, no new logging of credentials, no change to CORS, sessions, or crypto.
  • Templates still escape everything; .CoverURL returns a plain string and is not wrapped in template.HTML/URL.
  • One new dependency-free image (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/Jakarta for the current deployment. .env.example now 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

1552dd1 was 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.

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 | # | Symptom | Cause | |---|---------|-------| | 1 | comix bookmark titled `Comix - Read Comics online for free` | comix is an SPA that rewrites `document.title` on client routing but never touches the server-rendered `og:title`. The adapter read `og:title`, so a cold load stored the homepage's title. | | 2 | next comix bookmark gets the *previous* series' title | Same cause. After an in-page hop, `og:title` still holds whatever page loaded first. | | 3 | comix cover shows the placeholder | comix serves no `og:image` at all, so `coverFromPage()` had nothing to read. | | 4 | kagane chapter never appears in the bookmark list | Reader URLs carry no chapter number, so it is parsed out of `og:title`. Volume-numbered series render `"<Series> - Volume <v> Chapter <n>"`, which the suffix regex did not match, so `chapterNum` came back null and nothing was recorded. | | 5 | kagane title includes the chapter, e.g. `SP Baby - Volume 1 Chapter 1` | Same unmatched regex — the tail was never stripped. One fix covers 4 and 5. | | 6 | kagane cover blocked in the web UI | kagane serves covers behind its Cloudflare challenge **and** with `cross-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.title` with the chapter page's `" - Ch.<n>"` tail stripped, and the cover is the `img` whose `alt` matches the cleaned title. comix fills `document.title` a beat *after* the URL changes — later than the nav watcher's 300 ms snapshot — so the watcher also re-detects when the `detect()` signature changes, not only when the URL does. The kagane suffix regex takes an optional `Volume <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 kagane `og:image` to `/img/kagane/{id}`; templates render `.CoverURL` instead 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. With `BROWSER_WS_URL` unset 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. `ServeMux` path-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.run` never let a challenge solve.** It navigated, waited for `body`, read once, and closed the tab — roughly half a second end to end. The Cloudflare interstitial has a `body` too, so `WaitReady` was 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. `run` now holds one tab and re-reads until the caller's predicate reports an answer, bounded by `challengeTimeout` and 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-shell` cannot clear kagane's challenge at all.** 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. All measured 2026-08-08 from one IP against the same cover, so the comparisons are like for like: | Browser | Result | |---------|--------| | `chromedp/headless-shell:stable` | never cleared (90 s) | | `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 (60 s) — `--headless=new` advertises `HeadlessChrome` | | `google-chrome`, stock UA, `TZ=UTC` | never cleared (90 s) | | `google-chrome`, stock UA, any non-UTC `TZ` | **cleared in ~4 s** | 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 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/Bangkok` cleared 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: | `TZ` | Result | |------|--------| | `UTC` | never cleared (60 s, **twice**) | | `Asia/Jakarta` | cleared in 4 s | | `America/New_York` | cleared in 4 s | `America/New_York` matches 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_TZ` therefore 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:ro` does **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/timezone` carries 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 the `headless-shell` name and its pinned address, and `BROWSER_WS_URL` is unchanged. ## Verification ``` go test ./... all packages ok node --test 37 + 12 pass, 0 fail SMOKE_BROWSER_WS_URL=... go test -run TestSmokeKagane ./internal/latest TestSmokeKaganeImage PASS (5.29s) fetched 56710 bytes of image/webp TestSmokeKaganeGet PASS (1.17s) status=200, real chapter-list JSON ``` The smoke test ran against the exact compose configuration — built image, empty `BROWSER_TZ`, `/etc/timezone` mounted, cold profile — hitting real kagane.to. It skips unless `SMOKE_BROWSER_WS_URL` names a sidecar, so `go 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 - Auth unchanged. `/img/kagane/{id}` is session-gated by `requireSession`, the same guard as every other UI route. - Outbound fetch gated: the id is UUID-validated before it reaches the browser, keeping the existing rule that a client-supplied string never selects a fetch target unchecked. - No new secrets, no new logging of credentials, no change to CORS, sessions, or crypto. - Templates still escape everything; `.CoverURL` returns a plain string and is not wrapped in `template.HTML`/`URL`. - One new dependency-free image (`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/Jakarta` for the current deployment. `.env.example` now 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 `1552dd1` was 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.
sulthan added 4 commits 2026-08-08 23:04:55 +07:00
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.
BrowserFetcher.run navigated, waited for "body", read once, and closed
the tab - about half a second end to end. The Cloudflare interstitial has
a body too, so WaitReady was satisfied by the challenge page itself, and
the read that followed was of the interstitial rather than the site.

That 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; tearing the tab
down first means the clearance that would have unblocked every later
fetch is never obtained, so each call is challenged exactly like the one
before it.

run now holds one tab and re-reads until the caller's predicate reports
an answer, bounded by challengeTimeout and by the caller's own deadline.
Each caller supplies the predicate that fits its payload: kagane's
in-page fetch simply returns nothing while challenged, whereas
novelfull's payload is the DOM, and the interstitial has a DOM as well,
so that one excludes the challenge markup explicitly.

Exhausting the budget is now reported as errChallengeHeld and mapped back
to the 403 the poller already expects, keeping a challenged site distinct
from a broken transport.

Image loses its own retry loop, which run now subsumes.

Measured against a real kagane cover from a cold browser profile: no
image at all before, 4.9s to a 56710-byte image/webp after. The live
proof is TestSmokeKagane* in smoke_image_test.go, which skips unless
SMOKE_BROWSER_WS_URL names a sidecar, so `go test ./...` stays hermetic.
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.
sulthan added 1 commit 2026-08-08 23:16:51 +07:00
The previous commit documented the clock-zone requirement as "the zone
must match the egress IP's country". Re-measuring against the deployment
case shows that is wrong.

The original inference came from reading the host's /etc/timezone
(Asia/Bangkok) and assuming the egress IP was Thai. It is not: this host
egresses from an Indonesian IP. Asia/Bangkok cleared the challenge not
because it matched a country but because it is simply not UTC, and the
two share +07, which hid the distinction.

Measured 2026-08-08, identical container, one Indonesian egress IP:

  TZ=UTC               never cleared (60s, twice)
  TZ=Asia/Jakarta      cleared in 4s
  TZ=America/New_York  cleared in 4s

America/New_York matches neither the country nor the offset nor the
hemisphere, and clears just as fast. So a UTC clock is itself the bot
signal - Cloudflare scores it as the datacenter default - and any real
zone satisfies the check.

This makes the knob considerably less fragile than documented: BROWSER_TZ
needs a plausible zone, not a geolocated one, and a deployment that moves
region does not have to keep it in sync. Comments in chrome/entrypoint.sh
and docker-compose.yml, the hard constraint in AGENTS.md, and the PR
description are corrected accordingly.

BROWSER_TZ is also documented in .env.example for the first time, which
is the file an operator actually copies - the setting decides whether
kagane works at all, and a UTC server (the common case) is exactly the
one that fails with it unset.

Re-verified against the shipped image with TZ=Asia/Jakarta:
TestSmokeKaganeImage PASS (5.00s, 56710 bytes of image/webp),
TestSmokeKaganeGet PASS (1.24s, status 200).
sulthan added 1 commit 2026-08-08 23:25:29 +07:00
The browser sidecar needs a non-UTC zone to clear Cloudflare's challenge,
which left the two services logging on different clocks - the API in UTC,
the sidecar in local time. Correlating a poll failure against anything
else then means doing arithmetic at the worst possible moment.

This is cosmetic and nothing else in the service has a zone. Bookmark
timestamps are unix milliseconds (0001_bookmarks.sql pins that: the
userscripts send Date.now() and the ordering rule compares the integers
directly), the only two real time columns are timestamptz, the poller
works in durations, and nothing anywhere formats a wall clock - there is
no .Format( call in non-test backend code. So the zone can only reach
Go's log package, which stamps lines in local time.

No code change and no image change: distroless/static already carries the
full tzdata (1247 entries, Asia/Jakarta among them), so Go resolves the
name straight out of /usr/share/zoneinfo.

Named API_TZ rather than TZ because compose also reads the invoking
shell's environment, and a bare TZ would let an operator's exported zone
silently become the container's.

Verified against the built image, same instant:

  no TZ            2026/08/08 16:24:30 open store: ...
  TZ=Asia/Jakarta  2026/08/08 23:24:30 open store: ...

matching `date -u` 16:24:33 and Jakarta 23:24:33.
Author
Owner

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 log package. Re-checked before setting it: bookmark timestamps are unix ms (0001_bookmarks.sql pins that — the userscripts send Date.now() and the ordering rule compares the integers directly), the only two real time columns are timestamptz, the poller works in durations, and there is no .Format( call in non-test backend code.

No code or image change was needed: distroless/static already carries the full tzdata (1247 entries), so Go resolves the name out of /usr/share/zoneinfo.

The knob is API_TZ, not TZ, because compose also reads the invoking shell's environment and a bare TZ would let an operator's exported zone silently become the container's. Defaults to Asia/Jakarta; set API_TZ=UTC for the conventional server default.

Verified against the built image at the same instant:

no TZ            2026/08/08 16:24:30 open store: ...
TZ=Asia/Jakarta  2026/08/08 23:24:30 open store: ...

against date -u 16:24:33 / Jakarta 23:24:33.

Note the two zones are set for unrelated reasons and need not agree: BROWSER_TZ must be non-UTC or Cloudflare's challenge never clears, whereas API_TZ is readability and could be anything.

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 `log` package. Re-checked before setting it: bookmark timestamps are unix ms (`0001_bookmarks.sql` pins that — the userscripts send `Date.now()` and the ordering rule compares the integers directly), the only two real time columns are `timestamptz`, the poller works in durations, and there is no `.Format(` call in non-test backend code. No code or image change was needed: `distroless/static` already carries the full tzdata (1247 entries), so Go resolves the name out of `/usr/share/zoneinfo`. The knob is `API_TZ`, not `TZ`, because compose also reads the invoking shell's environment and a bare `TZ` would let an operator's exported zone silently become the container's. Defaults to `Asia/Jakarta`; set `API_TZ=UTC` for the conventional server default. Verified against the built image at the same instant: ``` no TZ 2026/08/08 16:24:30 open store: ... TZ=Asia/Jakarta 2026/08/08 23:24:30 open store: ... ``` against `date -u` 16:24:33 / Jakarta 23:24:33. Note the two zones are set for unrelated reasons and need not agree: `BROWSER_TZ` must be non-UTC or Cloudflare's challenge never clears, whereas `API_TZ` is readability and could be anything.
sulthan merged commit 741b23322b into main 2026-08-08 23:27:33 +07:00
Sign in to join this conversation.