fix(backend): bind session key to both secrets and guard no-op chapter saves

Final-review fix wave over the web UI branch.

- sessionKey now derives from API_TOKEN and WEB_PASSWORD with a \x00
  separator, so rotating the password logs every browser out too.
- uiChapter only clears last_chapter_url when the number actually
  changes. The form is pre-filled, so a bare tap of Save resubmits the
  same value; that used to destroy the chapter URL silently while
  updated_at stayed put, degrading Continue to the series index page.
- MANGA_WEB_HOST is now required by the prod override rather than
  falling back to manga.example.com, matching MANGA_API_HOST.
- Comment fixes: static cache rationale, pruneLocked aliasing
  invariant, and the stale "3 routes" line in CLAUDE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 03:18:00 +07:00
parent aac00ec01c
commit 22bf68f12f
8 changed files with 88 additions and 28 deletions
+14 -6
View File
@@ -10,7 +10,7 @@ import (
)
func TestSessionRoundTrip(t *testing.T) {
key := sessionKey("token-abc")
key := sessionKey("token-abc", "pw-abc")
now := time.Now().UnixMilli()
value := signSession(key, now+60_000)
if !verifySession(key, value, now) {
@@ -19,7 +19,7 @@ func TestSessionRoundTrip(t *testing.T) {
}
func TestSessionRejects(t *testing.T) {
key := sessionKey("token-abc")
key := sessionKey("token-abc", "pw-abc")
now := time.Now().UnixMilli()
valid := signSession(key, now+60_000)
payload, sig, _ := strings.Cut(valid, ".")
@@ -34,7 +34,7 @@ func TestSessionRejects(t *testing.T) {
{"expired", signSession(key, now-1)},
{"tampered signature", payload + "." + flipLastChar(sig)},
{"tampered expiry", "99999999999999." + sig},
{"signed with another key", signSession(sessionKey("other-token"), now+60_000)},
{"signed with another key", signSession(sessionKey("other-token", "pw-abc"), now+60_000)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -57,13 +57,21 @@ func flipLastChar(s string) string {
}
func TestSessionKeyDependsOnToken(t *testing.T) {
a := sessionKey("token-a")
b := sessionKey("token-b")
a := sessionKey("token-a", "pw-abc")
b := sessionKey("token-b", "pw-abc")
if string(a) == string(b) {
t.Fatal("sessionKey collided for different API tokens")
}
}
func TestSessionKeyDependsOnWebPassword(t *testing.T) {
a := sessionKey("token-abc", "pw-a")
b := sessionKey("token-abc", "pw-b")
if string(a) == string(b) {
t.Fatal("sessionKey collided for different web passwords with the same API token")
}
}
func TestSetSessionCookieAttributes(t *testing.T) {
cases := []struct {
name string
@@ -86,7 +94,7 @@ func TestSetSessionCookieAttributes(t *testing.T) {
r.Header.Set("X-Forwarded-Proto", tc.forwarded)
}
rr := httptest.NewRecorder()
setSessionCookie(rr, r, sessionKey("token-abc"))
setSessionCookie(rr, r, sessionKey("token-abc", "pw-abc"))
cookies := rr.Result().Cookies()
if len(cookies) != 1 {