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 -8
View File
@@ -55,7 +55,7 @@ func newWebHandler(store *Store, cfg Config) (*webHandler, error) {
return &webHandler{
store: store,
tmpl: tmpl,
key: sessionKey(cfg.Token),
key: sessionKey(cfg.Token, cfg.WebPassword),
password: cfg.WebPassword,
limiter: newLoginLimiter(),
}, nil
@@ -73,9 +73,10 @@ func (h *webHandler) register(mux *http.ServeMux) {
mux.HandleFunc("DELETE /ui/bookmarks/{key}", h.requireSession(h.uiDelete))
}
// staticHandler serves the embedded assets. The vendored htmx build and the
// stylesheet change only on deploy, so a long max-age is safe; a redeploy
// changes the binary and the browser revalidates on its own schedule.
// staticHandler serves the embedded assets. An hour, not longer: assets are
// not fingerprinted, and embed.FS reports a zero ModTime, so http.FileServer
// emits no Last-Modified or ETag and a client has no way to revalidate a
// cached copy after a deploy short of waiting out max-age.
func staticHandler() http.Handler {
sub, err := fs.Sub(staticFS, "static")
if err != nil {
@@ -253,9 +254,12 @@ func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) {
// uiChapter forces the read chapter to a value the user typed.
//
// It 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.
// 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.
func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) {
b, ok := h.loadForMutation(w, r)
if !ok {
@@ -272,9 +276,11 @@ func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) {
return
}
if num != b.LastChapterNum {
b.LastChapterURL = ""
}
b.LastChapter = raw
b.LastChapterNum = num
b.LastChapterURL = ""
b.UpdatedAt = time.Now().UnixMilli()
h.saveAndRenderCard(w, b)
}