From edae491161f1ee4d863d47428eeb06a3b89c52cb Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sun, 26 Jul 2026 03:27:31 +0700 Subject: [PATCH] fix: keep MANGA_WEB_HOST unset in .env.example and make no-op saves inert Re-review of the fix wave found the .env.example edit defeated the fix it belonged to: shipping MANGA_WEB_HOST=manga.example.com re-supplied the value that ${MANGA_WEB_HOST:?} exists to reject, so a fresh `cp .env.example .env` started fine and Traefik published the UI router on a domain the operator does not own. Left commented, matching MANGA_API_HOST; DEPLOY.md 1 now lists it among the required variables. Also: - uiChapter leaves last_chapter too, not only last_chapter_url, when the submitted number is unchanged. It used to rewrite the display string ("45.0" to "45") behind a frozen updated_at. - Design spec 4.2 documents the two-secret key derivation. - Corrected the pruneLocked aliasing rationale and the stale sessionKeyPurpose comment. Co-Authored-By: Claude Opus 5 --- .env.example | 10 ++++++---- DEPLOY.md | 5 ++++- backend/session.go | 9 +++++---- backend/web.go | 19 +++++++++++-------- backend/web_test.go | 12 +++++++++--- .../specs/2026-07-25-web-ui-design.md | 9 ++++++--- 6 files changed, 41 insertions(+), 23 deletions(-) diff --git a/.env.example b/.env.example index c4057b1..119c07f 100644 --- a/.env.example +++ b/.env.example @@ -23,7 +23,9 @@ ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicsca # Generate one: openssl rand -base64 18 WEB_PASSWORD= -# Subdomain Traefik routes to the browser UI (required by the prod override -# whenever the web UI is enabled). The same container also answers on -# MANGA_API_HOST for the userscript's API. -MANGA_WEB_HOST=manga.example.com +# Subdomain Traefik routes to the browser UI (required by the prod override, +# whether or not WEB_PASSWORD is set). Left commented on purpose: an example +# value here would be a silent wrong-hostname fallback, and Traefik would +# publish the UI router on a domain you do not own. The same container also +# answers on MANGA_API_HOST for the userscript's API. +# MANGA_WEB_HOST=manga.example.com diff --git a/DEPLOY.md b/DEPLOY.md index 5f87122..98fbe78 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -38,8 +38,11 @@ API_TOKEN= # CORS allowlist — leave as-is unless a site changes hostname. ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicscans.org -# Required for the Traefik override. +# Required for the Traefik override. Both have no fallback — compose refuses +# to start without them. MANGA_WEB_HOST is required even if you never set +# WEB_PASSWORD; see 1b. MANGA_API_HOST=manga-api.violetcrown.my.id +MANGA_WEB_HOST=manga.violetcrown.my.id # Only if your Traefik setup differs from these defaults: # PROXY_NETWORK=proxy diff --git a/backend/session.go b/backend/session.go index 9224320..34d63c8 100644 --- a/backend/session.go +++ b/backend/session.go @@ -18,7 +18,8 @@ const ( // 60 days: long enough that a phone stays logged in between reading spells. sessionTTL = 60 * 24 * time.Hour // Domain separation, so the session key can never collide with any other - // use of API_TOKEN. Changing this string logs everyone out. + // use of the secrets it is derived from. Changing this string logs + // everyone out. sessionKeyPurpose = "mangabm-web-session-v1" ) @@ -169,9 +170,9 @@ func (l *loginLimiter) reset(ip string) { func (l *loginLimiter) pruneLocked(ip string, now time.Time) []time.Time { cutoff := now.Add(-loginWindow) // In-place filter: kept reuses the backing array of the slice being - // ranged over. The range expression captures the slice header once at - // the start, so the append cursor (kept) can never outrun the read - // cursor (the range index) — safe to alias. + // ranged over. Safe to alias because append writes at index len(kept), + // which is always <= the range index i, and element i is read before + // that write — the write cursor can never overtake the read cursor. kept := l.failures[ip][:0] for _, at := range l.failures[ip] { if at.After(cutoff) { diff --git a/backend/web.go b/backend/web.go index 1b1bd97..d712a42 100644 --- a/backend/web.go +++ b/backend/web.go @@ -254,12 +254,15 @@ func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) { // uiChapter forces the read chapter to a value the user typed. // -// When that value actually changes the number, it also clears last_chapter_url: -// that URL points at the chapter actually read, and once the number is forced -// elsewhere it would send the reader backwards. ContinueURL then falls back to -// the series page, which is always right. Resubmitting the same number — the -// form is pre-filled, so a bare tap of Save is an easy accidental submit — -// leaves last_chapter_url untouched instead of destroying it for no reason. +// Writing the number also clears last_chapter_url: that URL points at the +// chapter actually read, and once the number is forced elsewhere it would send +// the reader backwards. ContinueURL then falls back to the series page, which +// is always right. +// +// A submit that does not change the number touches nothing. The form is +// pre-filled, so a bare tap of Save is an easy accidental submit; it must not +// destroy last_chapter_url, nor rewrite the last_chapter display string ("45.0" +// to "45") behind a frozen updated_at. func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) { b, ok := h.loadForMutation(w, r) if !ok { @@ -278,9 +281,9 @@ func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) { if num != b.LastChapterNum { b.LastChapterURL = "" + b.LastChapter = raw + b.LastChapterNum = num } - b.LastChapter = raw - b.LastChapterNum = num b.UpdatedAt = time.Now().UnixMilli() h.saveAndRenderCard(w, b) } diff --git a/backend/web_test.go b/backend/web_test.go index e811ab6..c4e81ce 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -357,14 +357,16 @@ func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) { srv, store := newWebTestServer(t, cfg) before := seed(t, store, Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", - Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45, + Title: "Solo Leveling", LastChapter: "45.0", LastChapterNum: 45, LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo", UpdatedAt: 1_000_000, }) // The chapter form is pre-filled with the current value, so tapping Save - // without editing resubmits the unchanged number. That must be a no-op: - // it must not silently clear last_chapter_url or move updated_at. + // without editing resubmits the unchanged number. That must be a no-op: it + // must not clear last_chapter_url, rewrite the last_chapter display string, + // or move updated_at. The seed stores "45.0" against 45 so the display + // string differs from what the form submits back. rr := httptest.NewRecorder() srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/chapter", url.Values{"chapter": {"45"}})) @@ -380,6 +382,10 @@ func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) { t.Fatalf("LastChapterURL = %q, want preserved %q on a no-op save", after.LastChapterURL, before.LastChapterURL) } + if after.LastChapter != before.LastChapter { + t.Fatalf("LastChapter = %q, want preserved %q on a no-op save", + after.LastChapter, before.LastChapter) + } if after.UpdatedAt != before.UpdatedAt { t.Fatalf("UpdatedAt = %d, want unchanged %d on a no-op save", after.UpdatedAt, before.UpdatedAt) diff --git a/docs/superpowers/specs/2026-07-25-web-ui-design.md b/docs/superpowers/specs/2026-07-25-web-ui-design.md index 319fb85..e82cd9d 100644 --- a/docs/superpowers/specs/2026-07-25-web-ui-design.md +++ b/docs/superpowers/specs/2026-07-25-web-ui-design.md @@ -122,11 +122,14 @@ Name `mangabm_session`. Value: ``` "." base64url(HMAC-SHA256(, key)) -key = SHA256(API_TOKEN || "mangabm-web-session-v1") +key = SHA256(API_TOKEN || 0x00 || WEB_PASSWORD || "mangabm-web-session-v1") ``` -Stateless: no session table, sessions survive restarts, and rotating -`API_TOKEN` invalidates every session at once. +Stateless: no session table, sessions survive restarts, and rotating either +`API_TOKEN` or `WEB_PASSWORD` invalidates every session at once. Both secrets +are bound in so that changing the password actually logs existing browsers out; +the `0x00` separates the two variable-length secrets so no pair of different +inputs can concatenate to the same string. Attributes: `HttpOnly`, `SameSite=Lax`, `Path=/`, `Max-Age` 60 days so the phone stays logged in across long gaps. `Secure` is set when `r.TLS != nil` or