From 933d3a949971c60cb1005495c44ba99598f1928a Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sat, 25 Jul 2026 22:37:54 +0700 Subject: [PATCH] docs: implementation plan for the web UI Eight TDD tasks: Store.Get + view helpers, session cookies, login rate limiting, htmx vendoring + config, templates and the login flow, mutation fragments, styling and search, then Docker/compose/deploy wiring. Co-Authored-By: Claude Opus 5 --- .../2026-07-25-web-ui-implementation-plan.md | 2330 +++++++++++++++++ 1 file changed, 2330 insertions(+) create mode 100644 plans/2026-07-25-web-ui-implementation-plan.md diff --git a/plans/2026-07-25-web-ui-implementation-plan.md b/plans/2026-07-25-web-ui-implementation-plan.md new file mode 100644 index 0000000..bea56e9 --- /dev/null +++ b/plans/2026-07-25-web-ui-implementation-plan.md @@ -0,0 +1,2330 @@ +# Web UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a password-gated website, served by the existing Go backend on a new subdomain, that shows the bookmark list with continue-reading, favourite, chapter-override, delete, and title search. + +**Architecture:** The existing binary gains server-rendered HTML pages and htmx fragment endpoints, with templates and static assets compiled in via `go:embed`. Browser requests authenticate with a stateless HMAC-signed session cookie; the userscript's bearer-authenticated `/bookmarks*` JSON API is not touched. All UI mutations go read-modify-write through the existing `Store.Upsert`, so the conditional-`updated_at` rule stays in exactly one place. + +**Tech Stack:** Go 1.23 stdlib (`net/http`, `html/template`, `embed`, `crypto/hmac`), `modernc.org/sqlite`, htmx 2.x vendored as a single file, hand-written CSS. No npm, no bundler, no build step. + +**Design doc:** `docs/superpowers/specs/2026-07-25-web-ui-design.md` + +## Global Constraints + +- Go 1.23, module `mangabm/backend`. Everything lives in `package main` under `backend/`. +- `CGO_ENABLED=0` must keep working — the image is `gcr.io/distroless/static:nonroot` and the binary must stay static. No new cgo dependencies. +- **No new Go module dependencies.** Stdlib only. htmx is vendored as a static asset, not a Go dependency. +- Do not modify `GET /bookmarks`, `PUT /bookmarks/{key}`, `DELETE /bookmarks/{key}`, `withAuth`, `withCORS`, or `userscript/manga-bookmark.user.js`. A regression there breaks phone reading. +- `Store.Upsert` is the only place the `updated_at` rule lives. Never write `updated_at` from a UI handler by any other route. +- Every UI handler renders the bookmark that `Upsert` **returned**, never the one it passed in. +- Existing test conventions: table-driven where there is more than one case, `t.TempDir()` for the database, `t.Helper()` on helpers, no external assertion library. +- Secrets (`API_TOKEN`, `WEB_PASSWORD`) come from environment variables only. Never hardcode, never log. +- Session cookie name: `mangabm_session`. HMAC domain-separation string: `mangabm-web-session-v1`. Both are exact — a typo silently invalidates every existing session. +- Run `gofmt -w` on every file you touch before committing. + +--- + +## File Structure + +| File | Responsibility | +| --- | --- | +| `backend/store.go` | Modify: add `Store.Get`, add `Bookmark.HasNewChapter` / `Bookmark.ContinueURL` | +| `backend/main.go` | Modify: `WEB_PASSWORD` config, wire web routes | +| `backend/session.go` | Create: cookie sign/verify, cookie set/clear, client IP, login rate limiter | +| `backend/web.go` | Create: page + fragment handlers, template embedding | +| `backend/templates/login.html` | Create: login page | +| `backend/templates/app.html` | Create: list page shell | +| `backend/templates/list.html` | Create: `list` fragment (cards only) | +| `backend/templates/card.html` | Create: `card` fragment (one series) | +| `backend/static/style.css` | Create: all styling | +| `backend/static/filter.js` | Create: client-side title search | +| `backend/static/htmx.min.js` | Create: vendored htmx 2.x | +| `backend/session_test.go` | Create: cookie, limiter, client IP tests | +| `backend/web_test.go` | Create: route, auth, mutation tests | +| `backend/Dockerfile` | Modify: copy `templates/` and `static/` into the build stage | +| `docker-compose.yml` | Modify: pass `WEB_PASSWORD` | +| `docker-compose.prod.yml` | Modify: second Traefik router for the web host | +| `.env.example`, `DEPLOY.md` | Modify: document `WEB_PASSWORD` and `MANGA_WEB_HOST` | + +Split rationale: `session.go` holds everything security-sensitive (signing, comparison, rate limiting) so it can be reviewed as one unit; `web.go` holds only request routing and rendering. Templates are split so that `card.html` is rendered both standalone (htmx swap after a mutation) and nested inside `list.html`. + +--- + +### Task 1: `Store.Get` and the two `Bookmark` view helpers + +**Files:** +- Modify: `backend/store.go` +- Test: `backend/store_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `func (s *Store) Get(key string) (Bookmark, bool, error)` — second return is false when the key does not exist; error is nil in that case. + - `func (b Bookmark) HasNewChapter() bool` + - `func (b Bookmark) ContinueURL() string` + +- [ ] **Step 1: Write the failing tests** + +Append to `backend/store_test.go`. Note `newTestStore` may not exist yet — check the file; if the existing tests only build a store inline, add this helper next to the other helpers at the top of the file: + +```go +func newTestStore(t *testing.T) *Store { + t.Helper() + store, err := OpenStore(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("OpenStore: %v", err) + } + t.Cleanup(func() { store.Close() }) + return store +} +``` + +Then the tests: + +```go +func TestStoreGet(t *testing.T) { + store := newTestStore(t) + if _, err := store.Upsert(Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1000, + }); err != nil { + t.Fatalf("Upsert: %v", err) + } + + got, ok, err := store.Get("asura:solo") + if err != nil { + t.Fatalf("Get: %v", err) + } + if !ok { + t.Fatal("Get ok = false, want true") + } + if got.Title != "Solo Leveling" || got.LastChapterNum != 45 { + t.Fatalf("Get = %+v, want title/chapter preserved", got) + } +} + +func TestStoreGetMissing(t *testing.T) { + store := newTestStore(t) + _, ok, err := store.Get("asura:nope") + if err != nil { + t.Fatalf("Get missing returned error %v, want nil", err) + } + if ok { + t.Fatal("Get ok = true for missing key, want false") + } +} + +func TestBookmarkHasNewChapter(t *testing.T) { + num := func(f float64) *float64 { return &f } + cases := []struct { + name string + b Bookmark + want bool + }{ + {"latest ahead", Bookmark{LastChapterNum: 45, LatestChapterNum: num(47)}, true}, + {"latest equal", Bookmark{LastChapterNum: 45, LatestChapterNum: num(45)}, false}, + {"latest behind", Bookmark{LastChapterNum: 45, LatestChapterNum: num(44)}, false}, + {"latest unknown", Bookmark{LastChapterNum: 45}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.b.HasNewChapter(); got != tc.want { + t.Fatalf("HasNewChapter() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestBookmarkContinueURL(t *testing.T) { + cases := []struct { + name string + b Bookmark + want string + }{ + {"chapter url present", Bookmark{LastChapterURL: "/ch/45", SeriesURL: "/series"}, "/ch/45"}, + {"falls back to series", Bookmark{SeriesURL: "/series"}, "/series"}, + {"both empty", Bookmark{}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.b.ContinueURL(); got != tc.want { + t.Fatalf("ContinueURL() = %q, want %q", got, tc.want) + } + }) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd backend && go test ./... -run 'TestStoreGet|TestBookmark' -v` +Expected: compile failure — `store.Get undefined`, `b.HasNewChapter undefined`, `b.ContinueURL undefined`. + +- [ ] **Step 3: Implement** + +Add to `backend/store.go`, immediately after the `Bookmark` struct: + +```go +// HasNewChapter reports whether the site has published past the read point. +// A nil LatestChapterNum means nothing has been captured yet, which is not the +// same as "nothing new". +func (b Bookmark) HasNewChapter() bool { + return b.LatestChapterNum != nil && *b.LatestChapterNum > b.LastChapterNum +} + +// ContinueURL is where the Continue button points: the chapter last read, or +// the series page when no chapter URL was ever captured. +func (b Bookmark) ContinueURL() string { + if b.LastChapterURL != "" { + return b.LastChapterURL + } + return b.SeriesURL +} +``` + +Add after `List`: + +```go +// Get returns one bookmark by key. A missing key is not an error: ok is false +// and err is nil. UI mutations read-modify-write through this so they preserve +// the fields they do not touch. +func (s *Store) Get(key string) (Bookmark, bool, error) { + b, err := scanBookmark(s.db.QueryRow( + `SELECT `+bookmarkColumns+` FROM bookmarks WHERE key = ?`, key).Scan) + if errors.Is(err, sql.ErrNoRows) { + return Bookmark{}, false, nil + } + if err != nil { + return Bookmark{}, false, fmt.Errorf("get %q: %w", key, err) + } + return b, true, nil +} +``` + +Add `"errors"` to the import block in `backend/store.go`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd backend && go test ./... -v` +Expected: PASS, including all pre-existing tests. + +- [ ] **Step 5: Commit** + +```bash +gofmt -w backend/store.go backend/store_test.go +git add backend/store.go backend/store_test.go +git commit -m "feat(backend): add Store.Get and bookmark view helpers" +``` + +--- + +### Task 2: Session cookie signing and verification + +**Files:** +- Create: `backend/session.go` +- Test: `backend/session_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `const sessionCookieName = "mangabm_session"` + - `const sessionTTL = 60 * 24 * time.Hour` + - `func sessionKey(apiToken string) []byte` + - `func signSession(key []byte, expiryMs int64) string` + - `func verifySession(key []byte, value string, nowMs int64) bool` + - `func setSessionCookie(w http.ResponseWriter, r *http.Request, key []byte)` + - `func clearSessionCookie(w http.ResponseWriter, r *http.Request)` + +- [ ] **Step 1: Write the failing tests** + +Create `backend/session_test.go`: + +```go +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestSessionRoundTrip(t *testing.T) { + key := sessionKey("token-abc") + now := time.Now().UnixMilli() + value := signSession(key, now+60_000) + if !verifySession(key, value, now) { + t.Fatal("verifySession = false for a freshly signed cookie, want true") + } +} + +func TestSessionRejects(t *testing.T) { + key := sessionKey("token-abc") + now := time.Now().UnixMilli() + valid := signSession(key, now+60_000) + payload, sig, _ := strings.Cut(valid, ".") + + cases := []struct { + name string + value string + }{ + {"empty", ""}, + {"no separator", payload + sig}, + {"unparseable expiry", "notanumber." + sig}, + {"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)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if verifySession(key, tc.value, now) { + t.Fatalf("verifySession(%q) = true, want false", tc.value) + } + }) + } +} + +func flipLastChar(s string) string { + if s == "" { + return "x" + } + last := s[len(s)-1] + if last == 'A' { + return s[:len(s)-1] + "B" + } + return s[:len(s)-1] + "A" +} + +func TestSessionKeyDependsOnToken(t *testing.T) { + a := sessionKey("token-a") + b := sessionKey("token-b") + if string(a) == string(b) { + t.Fatal("sessionKey collided for different API tokens") + } +} + +func TestSetSessionCookieAttributes(t *testing.T) { + cases := []struct { + name string + tls bool + forwarded string + wantSecure bool + }{ + {"plain http dev", false, "", false}, + {"direct tls", true, "", true}, + {"behind https proxy", false, "https", true}, + {"behind http proxy", false, "http", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/login", nil) + if tc.tls { + r.TLS = &tls.ConnectionState{} + } + if tc.forwarded != "" { + r.Header.Set("X-Forwarded-Proto", tc.forwarded) + } + rr := httptest.NewRecorder() + setSessionCookie(rr, r, sessionKey("token-abc")) + + cookies := rr.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("got %d cookies, want 1", len(cookies)) + } + c := cookies[0] + if c.Name != sessionCookieName { + t.Fatalf("cookie name = %q, want %q", c.Name, sessionCookieName) + } + if !c.HttpOnly { + t.Fatal("cookie HttpOnly = false, want true") + } + if c.SameSite != http.SameSiteLaxMode { + t.Fatalf("cookie SameSite = %v, want Lax", c.SameSite) + } + if c.Path != "/" { + t.Fatalf("cookie Path = %q, want /", c.Path) + } + if c.Secure != tc.wantSecure { + t.Fatalf("cookie Secure = %v, want %v", c.Secure, tc.wantSecure) + } + if c.MaxAge != int(sessionTTL/time.Second) { + t.Fatalf("cookie MaxAge = %d, want %d", c.MaxAge, int(sessionTTL/time.Second)) + } + }) + } +} + +func TestClearSessionCookie(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/logout", nil) + rr := httptest.NewRecorder() + clearSessionCookie(rr, r) + + cookies := rr.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("got %d cookies, want 1", len(cookies)) + } + if cookies[0].MaxAge >= 0 { + t.Fatalf("cleared cookie MaxAge = %d, want negative", cookies[0].MaxAge) + } +} +``` + +Add `"crypto/tls"` to that file's imports (used by `TestSetSessionCookieAttributes`). + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd backend && go test ./... -run TestSession -v` +Expected: compile failure — `sessionKey`, `signSession`, `verifySession`, `setSessionCookie`, `clearSessionCookie`, `sessionCookieName`, `sessionTTL` all undefined. + +- [ ] **Step 3: Implement** + +Create `backend/session.go`: + +```go +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "net/http" + "strconv" + "strings" + "time" +) + +const ( + sessionCookieName = "mangabm_session" + // 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. + sessionKeyPurpose = "mangabm-web-session-v1" +) + +// sessionKey derives the cookie-signing key from the API token. Sessions are +// stateless — there is no session table — so rotating API_TOKEN invalidates +// every outstanding cookie at once. +func sessionKey(apiToken string) []byte { + sum := sha256.Sum256([]byte(apiToken + sessionKeyPurpose)) + return sum[:] +} + +// signSession encodes ".". +func signSession(key []byte, expiryMs int64) string { + payload := strconv.FormatInt(expiryMs, 10) + return payload + "." + sessionMAC(key, payload) +} + +func sessionMAC(key []byte, payload string) string { + mac := hmac.New(sha256.New, key) + mac.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +// verifySession checks shape, then expiry, then the signature — in that order. +// The signature comparison is constant-time; the checks before it only look at +// data the holder already supplied, so their timing leaks nothing. +func verifySession(key []byte, value string, nowMs int64) bool { + payload, sig, ok := strings.Cut(value, ".") + if !ok { + return false + } + expiry, err := strconv.ParseInt(payload, 10, 64) + if err != nil || expiry <= nowMs { + return false + } + want := sessionMAC(key, payload) + return subtle.ConstantTimeCompare([]byte(sig), []byte(want)) == 1 +} + +// isHTTPS reports whether the browser's connection is encrypted. Behind Traefik +// the Go server itself speaks plain HTTP, so the forwarded header is the only +// signal; without this check the Secure cookie would never be set in +// production, and setting it unconditionally would break http://localhost dev. +func isHTTPS(r *http.Request) bool { + return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" +} + +func setSessionCookie(w http.ResponseWriter, r *http.Request, key []byte) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: signSession(key, time.Now().Add(sessionTTL).UnixMilli()), + Path: "/", + MaxAge: int(sessionTTL / time.Second), + HttpOnly: true, + Secure: isHTTPS(r), + SameSite: http.SameSiteLaxMode, + }) +} + +func clearSessionCookie(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: isHTTPS(r), + SameSite: http.SameSiteLaxMode, + }) +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd backend && go test ./... -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -w backend/session.go backend/session_test.go +git add backend/session.go backend/session_test.go +git commit -m "feat(backend): stateless HMAC session cookies for the web UI" +``` + +--- + +### Task 3: Login rate limiter and client IP extraction + +**Files:** +- Modify: `backend/session.go` +- Test: `backend/session_test.go` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: + - `func clientIP(r *http.Request) string` + - `type loginLimiter struct{ ... }` + - `func newLoginLimiter() *loginLimiter` + - `func (l *loginLimiter) retryAfter(ip string, now time.Time) time.Duration` — zero when not blocked + - `func (l *loginLimiter) fail(ip string, now time.Time)` + - `func (l *loginLimiter) reset(ip string)` + - `const loginMaxFailures = 10`, `const loginWindow = 20 * time.Minute` + +- [ ] **Step 1: Write the failing tests** + +Append to `backend/session_test.go`: + +```go +func TestClientIP(t *testing.T) { + cases := []struct { + name string + remoteAddr string + xff []string + want string + }{ + {"no header falls back to remote addr", "203.0.113.9:5555", nil, "203.0.113.9"}, + {"single proxy hop", "10.0.0.1:5555", []string{"203.0.113.9"}, "203.0.113.9"}, + { + // The client sent "1.2.3.4" itself; Traefik appended the address it + // actually saw. Only the rightmost entry is trustworthy. + name: "spoofed left entry is ignored", + remoteAddr: "10.0.0.1:5555", + xff: []string{"1.2.3.4, 203.0.113.9"}, + want: "203.0.113.9", + }, + { + name: "spoofed separate header line is ignored", + remoteAddr: "10.0.0.1:5555", + xff: []string{"1.2.3.4", "203.0.113.9"}, + want: "203.0.113.9", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/login", nil) + r.RemoteAddr = tc.remoteAddr + for _, v := range tc.xff { + r.Header.Add("X-Forwarded-For", v) + } + if got := clientIP(r); got != tc.want { + t.Fatalf("clientIP() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestLoginLimiterBlocksAfterMaxFailures(t *testing.T) { + l := newLoginLimiter() + now := time.Now() + for i := 0; i < loginMaxFailures; i++ { + if wait := l.retryAfter("1.2.3.4", now); wait != 0 { + t.Fatalf("blocked after %d failures, want block only after %d", i, loginMaxFailures) + } + l.fail("1.2.3.4", now) + } + wait := l.retryAfter("1.2.3.4", now) + if wait <= 0 { + t.Fatalf("retryAfter = %v after %d failures, want > 0", wait, loginMaxFailures) + } + if wait > loginWindow { + t.Fatalf("retryAfter = %v, want <= %v", wait, loginWindow) + } +} + +func TestLoginLimiterWindowExpires(t *testing.T) { + l := newLoginLimiter() + start := time.Now() + for i := 0; i < loginMaxFailures; i++ { + l.fail("1.2.3.4", start) + } + if l.retryAfter("1.2.3.4", start) == 0 { + t.Fatal("expected block immediately after the failures") + } + later := start.Add(loginWindow + time.Second) + if wait := l.retryAfter("1.2.3.4", later); wait != 0 { + t.Fatalf("retryAfter = %v once the window passed, want 0", wait) + } +} + +func TestLoginLimiterResetClearsCounter(t *testing.T) { + l := newLoginLimiter() + now := time.Now() + for i := 0; i < loginMaxFailures; i++ { + l.fail("1.2.3.4", now) + } + l.reset("1.2.3.4") + if wait := l.retryAfter("1.2.3.4", now); wait != 0 { + t.Fatalf("retryAfter = %v after reset, want 0", wait) + } +} + +func TestLoginLimiterIsPerIP(t *testing.T) { + l := newLoginLimiter() + now := time.Now() + for i := 0; i < loginMaxFailures; i++ { + l.fail("1.2.3.4", now) + } + if wait := l.retryAfter("5.6.7.8", now); wait != 0 { + t.Fatalf("retryAfter for a different IP = %v, want 0", wait) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd backend && go test ./... -run 'TestClientIP|TestLoginLimiter' -v` +Expected: compile failure — `clientIP`, `newLoginLimiter`, `loginMaxFailures`, `loginWindow` undefined. + +- [ ] **Step 3: Implement** + +Append to `backend/session.go`: + +```go +const ( + loginMaxFailures = 10 + loginWindow = 20 * time.Minute +) + +// clientIP returns the address the reverse proxy actually observed. +// +// Traefik appends the peer address to whatever X-Forwarded-For the client sent, +// so the leftmost entry is attacker-controlled and the rightmost is not. Go's +// Header.Get would only read the first header line, which a client can preempt +// by sending its own; Values covers every line so the true last hop is found. +// RemoteAddr is useless behind the proxy — it is always the Traefik container — +// so it serves only as the direct-connection fallback for local development. +func clientIP(r *http.Request) string { + if vals := r.Header.Values("X-Forwarded-For"); len(vals) > 0 { + hops := strings.Split(vals[len(vals)-1], ",") + if ip := strings.TrimSpace(hops[len(hops)-1]); ip != "" { + return ip + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +// loginLimiter throttles password guessing: loginMaxFailures failures inside a +// rolling loginWindow blocks further attempts from that IP until the oldest one +// ages out. There is no permanent ban and no unlock step. +// +// Behind carrier-grade NAT this budget is shared with every other subscriber on +// the same public address, so a stranger can lock the owner out for up to one +// window. That is accepted: the block self-heals, and ten attempts is generous +// for a mistyped password. +// +// State is in memory and per-process, so a restart clears it. Entries are +// pruned lazily on access; for a single-user deployment the map cannot grow +// past the handful of addresses that ever attempt a login. +type loginLimiter struct { + mu sync.Mutex + failures map[string][]time.Time +} + +func newLoginLimiter() *loginLimiter { + return &loginLimiter{failures: make(map[string][]time.Time)} +} + +// retryAfter returns how long ip must wait, or zero when it may try now. +func (l *loginLimiter) retryAfter(ip string, now time.Time) time.Duration { + l.mu.Lock() + defer l.mu.Unlock() + + recent := l.pruneLocked(ip, now) + if len(recent) < loginMaxFailures { + return 0 + } + return recent[0].Add(loginWindow).Sub(now) +} + +func (l *loginLimiter) fail(ip string, now time.Time) { + l.mu.Lock() + defer l.mu.Unlock() + l.failures[ip] = append(l.pruneLocked(ip, now), now) +} + +func (l *loginLimiter) reset(ip string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.failures, ip) +} + +// pruneLocked drops attempts older than the window and returns what is left. +// The caller must hold l.mu. +func (l *loginLimiter) pruneLocked(ip string, now time.Time) []time.Time { + cutoff := now.Add(-loginWindow) + kept := l.failures[ip][:0] + for _, at := range l.failures[ip] { + if at.After(cutoff) { + kept = append(kept, at) + } + } + if len(kept) == 0 { + delete(l.failures, ip) + return nil + } + l.failures[ip] = kept + return kept +} +``` + +Add `"net"` and `"sync"` to the import block in `backend/session.go`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd backend && go test ./... -v` +Expected: PASS. + +- [ ] **Step 5: Run the race detector** + +Run: `cd backend && go test -race ./...` +Expected: PASS, no race warnings. The limiter is shared across concurrent requests, so this matters. + +- [ ] **Step 6: Commit** + +```bash +gofmt -w backend/session.go backend/session_test.go +git add backend/session.go backend/session_test.go +git commit -m "feat(backend): per-IP login rate limit with proxy-aware client IP" +``` + +--- + +### Task 4: Vendor htmx and add the config variable + +**Files:** +- Create: `backend/static/htmx.min.js` +- Modify: `backend/main.go` +- Test: `backend/store_test.go` (extend the existing `testConfig`, add a config test) + +**Interfaces:** +- Consumes: nothing. +- Produces: `Config.WebPassword string`, populated from `WEB_PASSWORD`. + +- [ ] **Step 1: Vendor htmx** + +```bash +mkdir -p backend/static +curl -fsSL https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js -o backend/static/htmx.min.js +wc -c backend/static/htmx.min.js +``` + +Expected: roughly 48000–52000 bytes. If the download fails or the file is under 10000 bytes, stop — do not proceed with a truncated file. Fetch it manually from `https://github.com/bigskysoftware/htmx/releases` instead. The file is committed to the repository on purpose: the Docker build has no network access and there is no npm step. + +- [ ] **Step 2: Write the failing test** + +Append to `backend/store_test.go`: + +```go +func TestLoadConfigWebPassword(t *testing.T) { + t.Setenv("API_TOKEN", "token-abc") + t.Setenv("WEB_PASSWORD", "hunter2") + if got := loadConfig().WebPassword; got != "hunter2" { + t.Fatalf("WebPassword = %q, want hunter2", got) + } + + t.Setenv("WEB_PASSWORD", "") + if got := loadConfig().WebPassword; got != "" { + t.Fatalf("WebPassword = %q with the variable unset, want empty", got) + } +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `cd backend && go test ./... -run TestLoadConfigWebPassword -v` +Expected: compile failure — `cfg.WebPassword undefined`. + +- [ ] **Step 4: Implement** + +In `backend/main.go`, add the field to `Config`: + +```go + // WebPassword gates the browser UI. Empty disables the web routes entirely. + WebPassword string +``` + +And in `loadConfig`, inside the struct literal: + +```go + WebPassword: os.Getenv("WEB_PASSWORD"), +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cd backend && go test ./... -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +gofmt -w backend/main.go backend/store_test.go +git add backend/static/htmx.min.js backend/main.go backend/store_test.go +git commit -m "chore(backend): vendor htmx 2.0.4 and add WEB_PASSWORD config" +``` + +--- + +### Task 5: Templates and the login flow + +**Files:** +- Create: `backend/web.go`, `backend/templates/login.html`, `backend/templates/app.html`, `backend/templates/list.html`, `backend/templates/card.html` +- Modify: `backend/main.go` +- Test: `backend/web_test.go` + +**Interfaces:** +- Consumes: `sessionKey`, `signSession`, `setSessionCookie`, `clearSessionCookie`, `sessionCookieName`, `verifySession`, `clientIP`, `newLoginLimiter`, `loginMaxFailures` (Tasks 2–3); `Store.List`, `Bookmark.HasNewChapter`, `Bookmark.ContinueURL` (Task 1); `Config.WebPassword` (Task 4). +- Produces: + - `type webHandler struct{ store *Store; tmpl *template.Template; key []byte; password string; limiter *loginLimiter }` + - `func newWebHandler(store *Store, cfg Config) (*webHandler, error)` + - `func (h *webHandler) register(mux *http.ServeMux)` + - `func (h *webHandler) authed(r *http.Request) bool` + - `func (h *webHandler) requireSession(next http.HandlerFunc) http.HandlerFunc` + - `type listView struct{ Tab string; Recent []Bookmark; Items []Bookmark }` + +All four templates are created in this task because `newWebHandler` parses the whole set at startup and fails if any is missing. Tasks 6 and 7 fill in the interactive attributes and the styling. + +- [ ] **Step 1: Write the failing tests** + +Create `backend/web_test.go`: + +```go +package main + +import ( + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +const testPassword = "hunter2" + +func webConfig() Config { + cfg := testConfig() + cfg.WebPassword = testPassword + return cfg +} + +// newWebTestServer returns the full router plus the store behind it, so tests +// can seed rows and assert on what the handlers wrote back. +func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *Store) { + t.Helper() + store, err := OpenStore(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("OpenStore: %v", err) + } + t.Cleanup(func() { store.Close() }) + return newRouter(store, cfg), store +} + +// sessionCookie returns a cookie a handler will accept for cfg's API token. +func sessionCookie(t *testing.T, cfg Config) *http.Cookie { + t.Helper() + return &http.Cookie{ + Name: sessionCookieName, + Value: signSession(sessionKey(cfg.Token), time.Now().Add(time.Hour).UnixMilli()), + } +} + +func TestIndexWithoutSessionShowsLogin(t *testing.T) { + srv, _ := newWebTestServer(t, webConfig()) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + + if rr.Code != http.StatusOK { + t.Fatalf("GET / status = %d, want 200", rr.Code) + } + if !strings.Contains(rr.Body.String(), `type="password"`) { + t.Fatal("GET / without a session did not render the password field") + } +} + +func TestIndexWithSessionShowsList(t *testing.T) { + cfg := webConfig() + srv, store := newWebTestServer(t, cfg) + if _, err := store.Upsert(Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45, + UpdatedAt: time.Now().UnixMilli(), + }); err != nil { + t.Fatalf("Upsert: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(sessionCookie(t, cfg)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("GET / status = %d, want 200", rr.Code) + } + if !strings.Contains(rr.Body.String(), "Solo Leveling") { + t.Fatal("GET / with a session did not render the bookmark title") + } +} + +func TestLoginSuccessSetsCookie(t *testing.T) { + srv, _ := newWebTestServer(t, webConfig()) + req := httptest.NewRequest(http.MethodPost, "/login", + strings.NewReader(url.Values{"password": {testPassword}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusSeeOther { + t.Fatalf("POST /login status = %d, want 303", rr.Code) + } + cookies := rr.Result().Cookies() + if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" { + t.Fatalf("POST /login cookies = %+v, want one non-empty %s", cookies, sessionCookieName) + } +} + +func TestLoginWrongPassword(t *testing.T) { + srv, _ := newWebTestServer(t, webConfig()) + req := httptest.NewRequest(http.MethodPost, "/login", + strings.NewReader(url.Values{"password": {"wrong"}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("POST /login status = %d, want 401", rr.Code) + } + if len(rr.Result().Cookies()) != 0 { + t.Fatal("a failed login set a cookie") + } +} + +func TestLoginRateLimited(t *testing.T) { + srv, _ := newWebTestServer(t, webConfig()) + post := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/login", + strings.NewReader(url.Values{"password": {"wrong"}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("X-Forwarded-For", "203.0.113.9") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + return rr + } + for i := 0; i < loginMaxFailures; i++ { + if code := post().Code; code != http.StatusUnauthorized { + t.Fatalf("attempt %d status = %d, want 401", i+1, code) + } + } + rr := post() + if rr.Code != http.StatusTooManyRequests { + t.Fatalf("attempt %d status = %d, want 429", loginMaxFailures+1, rr.Code) + } + if after := rr.Header().Get("Retry-After"); after == "" { + t.Fatal("429 response has no Retry-After header") + } else if n, err := strconv.Atoi(after); err != nil || n <= 0 { + t.Fatalf("Retry-After = %q, want a positive integer", after) + } +} + +func TestLogoutClearsCookie(t *testing.T) { + cfg := webConfig() + srv, _ := newWebTestServer(t, cfg) + req := httptest.NewRequest(http.MethodPost, "/logout", nil) + req.AddCookie(sessionCookie(t, cfg)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusSeeOther { + t.Fatalf("POST /logout status = %d, want 303", rr.Code) + } + cookies := rr.Result().Cookies() + if len(cookies) != 1 || cookies[0].MaxAge >= 0 { + t.Fatalf("POST /logout cookies = %+v, want one expiring cookie", cookies) + } +} + +func TestWebDisabledWhenNoPassword(t *testing.T) { + cfg := testConfig() // WebPassword empty + srv, _ := newWebTestServer(t, cfg) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + + if rr.Code != http.StatusNotFound { + t.Fatalf("GET / with WEB_PASSWORD unset = %d, want 404", rr.Code) + } +} + +func TestBookmarksAPIStillBearerOnly(t *testing.T) { + cfg := webConfig() + srv, _ := newWebTestServer(t, cfg) + + // A session cookie must not grant access to the userscript's JSON API. + req := httptest.NewRequest(http.MethodGet, "/bookmarks", nil) + req.AddCookie(sessionCookie(t, cfg)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("GET /bookmarks with only a cookie = %d, want 401", rr.Code) + } + + // And the bearer token must still work. + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil))) + if rr.Code != http.StatusOK { + t.Fatalf("GET /bookmarks with bearer = %d, want 200", rr.Code) + } +} + +func TestStaticAssetsServed(t *testing.T) { + srv, _ := newWebTestServer(t, webConfig()) + for _, path := range []string{"/static/style.css", "/static/htmx.min.js", "/static/filter.js"} { + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) + if rr.Code != http.StatusOK { + t.Fatalf("GET %s = %d, want 200", path, rr.Code) + } + if rr.Body.Len() == 0 { + t.Fatalf("GET %s returned an empty body", path) + } + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd backend && go test ./... -run 'TestIndex|TestLogin|TestLogout|TestWeb|TestStatic|TestBookmarksAPI' -v` +Expected: compile failure — `webConfig` references `Config.WebPassword` (present after Task 4) but `newWebHandler` and the routes do not exist, so `GET /` returns 404 and the login tests fail. + +- [ ] **Step 3: Create the templates** + +`backend/templates/login.html`: + +```html +{{define "login"}} + + + + + + + mangaBookmark + + + +
+

mangaBookmark

+
+ + + {{if .Error}}

{{.Error}}

{{end}} + +
+
+ + +{{end}} +``` + +`backend/templates/app.html`: + +```html +{{define "app"}} + + + + + + + mangaBookmark + + + + + +
+

mangaBookmark

+
+ +
+
+ + + + + + {{if .Recent}} +
+

Continue reading

+ +
+ {{end}} + +
+ {{template "list" .}} +
+ + +{{end}} +``` + +`backend/templates/list.html`: + +```html +{{define "list"}} +{{if .Items}} + {{range .Items}}{{template "card" .}}{{end}} +{{else}} +

+ Nothing here yet. Bookmarks appear once the userscript records a chapter. +

+{{end}} +{{end}} +``` + +`backend/templates/card.html` — the interactive attributes land in Task 6; this is the static shape: + +```html +{{define "card"}} + +{{end}} +``` + +- [ ] **Step 4: Create placeholder static assets** + +`TestStaticAssetsServed` requires both files to exist and be non-empty. Task 7 writes the real content. + +`backend/static/style.css`: + +```css +/* Styling lands in Task 7. */ +:root { color-scheme: dark light; } +``` + +`backend/static/filter.js`: + +```js +// Title search and tab-state handling land in Task 7. +function setActiveTab(el) { + el.parentElement.querySelectorAll("[role=tab]").forEach(function (t) { + t.classList.toggle("active", t === el); + }); +} +``` + +- [ ] **Step 5: Implement `web.go`** + +Create `backend/web.go`: + +```go +package main + +import ( + "crypto/subtle" + "embed" + "html/template" + "io/fs" + "log" + "net/http" + "strconv" + "time" +) + +//go:embed templates +var templateFS embed.FS + +//go:embed static +var staticFS embed.FS + +// recentCount is how many series the "Continue reading" strip shows. +const recentCount = 5 + +// webHandler serves the browser UI: full pages at / and htmx fragments at /ui/. +// It is a separate handler from bookmarkHandler because the two speak different +// representations (HTML versus JSON) to different clients under different auth. +type webHandler struct { + store *Store + tmpl *template.Template + key []byte + password string + limiter *loginLimiter +} + +// listView is what every list-rendering template receives. +type listView struct { + Tab string // "all" or "fav" + Recent []Bookmark + Items []Bookmark +} + +// loginView is what the login template receives. +type loginView struct { + Error string +} + +// newWebHandler parses every template up front so a broken one kills the +// process at startup rather than the first request that touches it. +func newWebHandler(store *Store, cfg Config) (*webHandler, error) { + tmpl, err := template.ParseFS(templateFS, "templates/*.html") + if err != nil { + return nil, err + } + return &webHandler{ + store: store, + tmpl: tmpl, + key: sessionKey(cfg.Token), + password: cfg.WebPassword, + limiter: newLoginLimiter(), + }, nil +} + +func (h *webHandler) register(mux *http.ServeMux) { + mux.HandleFunc("GET /{$}", h.index) + mux.HandleFunc("POST /login", h.login) + mux.HandleFunc("POST /logout", h.logout) + mux.Handle("GET /static/", staticHandler()) + + mux.HandleFunc("GET /ui/list", h.requireSession(h.uiList)) +} + +// 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. +func staticHandler() http.Handler { + sub, err := fs.Sub(staticFS, "static") + if err != nil { + panic("embed static: " + err.Error()) + } + files := http.FileServer(http.FS(sub)) + return http.StripPrefix("/static/", http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "public, max-age=3600") + files.ServeHTTP(w, r) + })) +} + +// authed reports whether the request carries a valid session cookie. +func (h *webHandler) authed(r *http.Request) bool { + c, err := r.Cookie(sessionCookieName) + return err == nil && verifySession(h.key, c.Value, time.Now().UnixMilli()) +} + +// requireSession guards the fragment endpoints. It answers 401 rather than +// redirecting, because htmx swaps whatever body it receives into the page and a +// redirected login page would be spliced into the card list. +func (h *webHandler) requireSession(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !h.authed(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next(w, r) + } +} + +func (h *webHandler) render(w http.ResponseWriter, status int, name string, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + if err := h.tmpl.ExecuteTemplate(w, name, data); err != nil { + // The status line is already sent, so this can only be logged. + log.Printf("render %s: %v", name, err) + } +} + +// index renders the list, or the login page when there is no session. The login +// page is served at / with status 200 rather than as a redirect to a separate +// URL: one page, no redirect loop to reason about. +func (h *webHandler) index(w http.ResponseWriter, r *http.Request) { + if !h.authed(r) { + h.render(w, http.StatusOK, "login", loginView{}) + return + } + view, err := h.buildListView(r.URL.Query().Get("tab")) + if err != nil { + log.Printf("index: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + h.render(w, http.StatusOK, "app", view) +} + +// buildListView loads the list once and derives both the tab-filtered items and +// the recent strip from it. The strip always reflects overall recency, not the +// active tab, so it is built before filtering. +func (h *webHandler) buildListView(tab string) (listView, error) { + all, err := h.store.List() // already ordered updated_at DESC + if err != nil { + return listView{}, err + } + + recent := all + if len(recent) > recentCount { + recent = recent[:recentCount] + } + + items := all + if tab == "fav" { + items = []Bookmark{} + for _, b := range all { + if b.Favorite { + items = append(items, b) + } + } + } else { + tab = "all" + } + return listView{Tab: tab, Recent: recent, Items: items}, nil +} + +func (h *webHandler) uiList(w http.ResponseWriter, r *http.Request) { + view, err := h.buildListView(r.URL.Query().Get("tab")) + if err != nil { + log.Printf("ui list: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + h.render(w, http.StatusOK, "list", view) +} + +func (h *webHandler) login(w http.ResponseWriter, r *http.Request) { + ip := clientIP(r) + if wait := h.limiter.retryAfter(ip, time.Now()); wait > 0 { + secs := int(wait.Seconds()) + 1 + w.Header().Set("Retry-After", strconv.Itoa(secs)) + h.render(w, http.StatusTooManyRequests, "login", loginView{ + Error: "Too many attempts. Try again in " + + strconv.Itoa((secs+59)/60) + " min.", + }) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + got := r.PostFormValue("password") + if subtle.ConstantTimeCompare([]byte(got), []byte(h.password)) != 1 { + h.limiter.fail(ip, time.Now()) + h.render(w, http.StatusUnauthorized, "login", loginView{Error: "Wrong password."}) + return + } + + h.limiter.reset(ip) + setSessionCookie(w, r, h.key) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (h *webHandler) logout(w http.ResponseWriter, r *http.Request) { + clearSessionCookie(w, r) + http.Redirect(w, r, "/", http.StatusSeeOther) +} +``` + +- [ ] **Step 6: Wire it into the router** + +In `backend/main.go`, inside `newRouter`, after the existing `mux.Handle("/bookmarks/", auth)` line and before the `return`: + +```go + // The browser UI is registered only when a password is configured, so a + // deployment that forgets WEB_PASSWORD exposes nothing rather than + // exposing an unprotected list. + if cfg.WebPassword != "" { + web, err := newWebHandler(store, cfg) + if err != nil { + log.Fatalf("web handler: %v", err) + } + web.register(mux) + } +``` + +`GET /{$}` matches only the exact path `/`, so registering it does not shadow `/bookmarks` or `/healthz`. + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `cd backend && go test ./... -v` +Expected: PASS, including every pre-existing test. `TestBookmarksAPIStillBearerOnly` is the one that proves the userscript's API is unaffected. + +- [ ] **Step 8: Commit** + +```bash +gofmt -w backend/web.go backend/main.go backend/web_test.go +git add backend/web.go backend/web_test.go backend/main.go backend/templates backend/static +git commit -m "feat(backend): password login, session gate, and list page" +``` + +--- + +### Task 6: Favourite, chapter override, and delete fragments + +**Files:** +- Modify: `backend/web.go`, `backend/templates/card.html` +- Test: `backend/web_test.go` + +**Interfaces:** +- Consumes: `Store.Get`, `Store.Upsert`, `Store.Delete`, `webHandler.requireSession`, `webHandler.render`. +- Produces: routes `POST /ui/bookmarks/{key}/favorite`, `POST /ui/bookmarks/{key}/chapter`, `DELETE /ui/bookmarks/{key}`. + +**Decision recorded here:** a manual chapter override clears `last_chapter_url`. The stored URL points at the chapter that was actually read; once the number is forced to something else, that URL is wrong. Clearing it makes Continue fall back to the series page, which is always correct, instead of linking to a chapter the user has already passed. + +- [ ] **Step 1: Write the failing tests** + +Append to `backend/web_test.go`: + +```go +// seed inserts one bookmark and returns it as stored. +func seed(t *testing.T, store *Store, b Bookmark) Bookmark { + t.Helper() + stored, err := store.Upsert(b) + if err != nil { + t.Fatalf("Upsert: %v", err) + } + return stored +} + +func uiRequest(t *testing.T, cfg Config, method, path string, form url.Values) *http.Request { + t.Helper() + var req *http.Request + if form == nil { + req = httptest.NewRequest(method, path, nil) + } else { + req = httptest.NewRequest(method, path, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + req.AddCookie(sessionCookie(t, cfg)) + return req +} + +func TestUIRoutesRequireSession(t *testing.T) { + srv, _ := newWebTestServer(t, webConfig()) + cases := []struct{ method, path string }{ + {http.MethodGet, "/ui/list"}, + {http.MethodPost, "/ui/bookmarks/asura:solo/favorite"}, + {http.MethodPost, "/ui/bookmarks/asura:solo/chapter"}, + {http.MethodDelete, "/ui/bookmarks/asura:solo"}, + } + for _, tc := range cases { + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rr.Code) + } + }) + } +} + +func TestFavoriteTogglesWithoutReordering(t *testing.T) { + cfg := webConfig() + srv, store := newWebTestServer(t, cfg) + before := seed(t, store, Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45, + UpdatedAt: 1_000_000, + }) + + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("favorite status = %d, want 200", rr.Code) + } + + after, ok, err := store.Get("asura:solo") + if err != nil || !ok { + t.Fatalf("Get after favorite: %v ok=%v", err, ok) + } + if !after.Favorite { + t.Fatal("Favorite = false after toggling, want true") + } + if after.UpdatedAt != before.UpdatedAt { + t.Fatalf("UpdatedAt moved from %d to %d; favouriting must not reorder the list", + before.UpdatedAt, after.UpdatedAt) + } + if !strings.Contains(rr.Body.String(), `id="card-asura:solo"`) { + t.Fatal("favorite response did not render the card fragment") + } + + // Toggling again turns it back off. + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil)) + back, _, _ := store.Get("asura:solo") + if back.Favorite { + t.Fatal("Favorite = true after a second toggle, want false") + } +} + +func TestChapterOverrideMovesUpdatedAt(t *testing.T) { + cfg := webConfig() + srv, store := newWebTestServer(t, cfg) + before := seed(t, store, Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45, + LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo", + UpdatedAt: 1_000_000, + }) + + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, + "/ui/bookmarks/asura:solo/chapter", url.Values{"chapter": {"60"}})) + if rr.Code != http.StatusOK { + t.Fatalf("chapter override status = %d, want 200", rr.Code) + } + + after, ok, err := store.Get("asura:solo") + if err != nil || !ok { + t.Fatalf("Get after override: %v ok=%v", err, ok) + } + if after.LastChapterNum != 60 || after.LastChapter != "60" { + t.Fatalf("chapter = %q/%v, want 60", after.LastChapter, after.LastChapterNum) + } + if after.UpdatedAt <= before.UpdatedAt { + t.Fatalf("UpdatedAt = %d, want later than %d", after.UpdatedAt, before.UpdatedAt) + } + if after.LastChapterURL != "" { + t.Fatalf("LastChapterURL = %q, want cleared by a manual override", after.LastChapterURL) + } + if after.Title != "Solo Leveling" { + t.Fatalf("Title = %q, want the untouched fields preserved", after.Title) + } +} + +func TestChapterOverrideRejectsBadInput(t *testing.T) { + cfg := webConfig() + srv, store := newWebTestServer(t, cfg) + seed(t, store, Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1_000_000, + }) + + for _, bad := range []string{"", "abc", "-3"} { + t.Run("input "+bad, func(t *testing.T) { + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, + "/ui/bookmarks/asura:solo/chapter", url.Values{"chapter": {bad}})) + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rr.Code) + } + after, _, _ := store.Get("asura:solo") + if after.LastChapterNum != 45 { + t.Fatalf("chapter changed to %v on invalid input", after.LastChapterNum) + } + }) + } +} + +func TestMutationsOnMissingKey(t *testing.T) { + cfg := webConfig() + srv, _ := newWebTestServer(t, cfg) + cases := []struct { + name string + req *http.Request + }{ + {"favorite", uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:nope/favorite", nil)}, + {"chapter", uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:nope/chapter", url.Values{"chapter": {"1"}})}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, tc.req) + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rr.Code) + } + }) + } +} + +func TestUIDeleteRemovesRow(t *testing.T) { + cfg := webConfig() + srv, store := newWebTestServer(t, cfg) + seed(t, store, Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + Title: "Solo Leveling", UpdatedAt: 1_000_000, + }) + + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodDelete, "/ui/bookmarks/asura:solo", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("delete status = %d, want 200", rr.Code) + } + if rr.Body.Len() != 0 { + t.Fatalf("delete body = %q, want empty so htmx swaps the card away", rr.Body.String()) + } + if _, ok, _ := store.Get("asura:solo"); ok { + t.Fatal("row still present after delete") + } +} + +func TestUIListFavouritesTab(t *testing.T) { + cfg := webConfig() + srv, store := newWebTestServer(t, cfg) + seed(t, store, Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + Title: "Solo Leveling", Favorite: true, UpdatedAt: 2_000_000, + }) + seed(t, store, Bookmark{ + Key: "demonic:tower", Site: "demonic", SeriesID: "tower", + Title: "Tower of God", Favorite: false, UpdatedAt: 1_000_000, + }) + + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list?tab=fav", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, "Solo Leveling") { + t.Fatal("favourites tab omitted the favourited series") + } + if strings.Contains(body, "Tower of God") { + t.Fatal("favourites tab included a non-favourite") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd backend && go test ./... -run 'TestUI|TestFavorite|TestChapter|TestMutations' -v` +Expected: FAIL — the mutation routes are unregistered, so `/ui/bookmarks/...` returns 404 where 401 or 200 is expected. + +- [ ] **Step 3: Register the routes** + +In `backend/web.go`, add to `register`, below the `GET /ui/list` line: + +```go + mux.HandleFunc("POST /ui/bookmarks/{key}/favorite", h.requireSession(h.uiFavorite)) + mux.HandleFunc("POST /ui/bookmarks/{key}/chapter", h.requireSession(h.uiChapter)) + mux.HandleFunc("DELETE /ui/bookmarks/{key}", h.requireSession(h.uiDelete)) +``` + +- [ ] **Step 4: Implement the handlers** + +Append to `backend/web.go`: + +```go +// loadForMutation fetches the row a mutation targets, writing the error +// response itself when there is nothing to mutate. +func (h *webHandler) loadForMutation(w http.ResponseWriter, r *http.Request) (Bookmark, bool) { + key := r.PathValue("key") + if key == "" { + http.Error(w, "missing key", http.StatusBadRequest) + return Bookmark{}, false + } + b, ok, err := h.store.Get(key) + if err != nil { + log.Printf("ui get %q: %v", key, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return Bookmark{}, false + } + if !ok { + http.Error(w, "not found", http.StatusNotFound) + return Bookmark{}, false + } + return b, true +} + +// saveAndRenderCard upserts and renders the row as stored. Upsert decides +// whether updated_at moves, so the argument's timestamp is only a candidate and +// the response must come from the return value. +func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, b Bookmark) { + stored, err := h.store.Upsert(b) + if err != nil { + log.Printf("ui upsert %q: %v", b.Key, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + h.render(w, http.StatusOK, "card", stored) +} + +// uiFavorite flips the favourite flag. last_chapter_num is untouched, so +// Upsert keeps the stored updated_at and the list does not reorder. +func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) { + b, ok := h.loadForMutation(w, r) + if !ok { + return + } + b.Favorite = !b.Favorite + b.UpdatedAt = time.Now().UnixMilli() + h.saveAndRenderCard(w, b) +} + +// 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. +func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) { + b, ok := h.loadForMutation(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + raw := strings.TrimSpace(r.PostFormValue("chapter")) + num, err := strconv.ParseFloat(raw, 64) + if err != nil || num < 0 { + http.Error(w, "chapter must be a non-negative number", http.StatusBadRequest) + return + } + + b.LastChapter = raw + b.LastChapterNum = num + b.LastChapterURL = "" + b.UpdatedAt = time.Now().UnixMilli() + h.saveAndRenderCard(w, b) +} + +// uiDelete removes the row and answers with an empty body, which htmx swaps in +// place of the card — removing it from the page. +func (h *webHandler) uiDelete(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("key") + if key == "" { + http.Error(w, "missing key", http.StatusBadRequest) + return + } + if err := h.store.Delete(key); err != nil { + log.Printf("ui delete %q: %v", key, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) +} +``` + +Add `"strings"` to the import block in `backend/web.go`. + +- [ ] **Step 5: Add the controls to the card template** + +Replace the `
` block in `backend/templates/card.html` with: + +```html +
+ Continue + + + +
+ +``` + +- [ ] **Step 6: Add the toggle helper** + +Append to `backend/static/filter.js`: + +```js +function toggleChapterForm(key) { + var form = document.getElementById("chapter-form-" + key); + if (!form) return; + form.hidden = !form.hidden; + if (!form.hidden) form.querySelector("input").focus(); +} +``` + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `cd backend && go test ./... -v` +Expected: PASS. `TestFavoriteTogglesWithoutReordering` is the important one — it is the regression guard on the `updated_at` rule. + +- [ ] **Step 8: Commit** + +```bash +gofmt -w backend/web.go backend/web_test.go +git add backend/web.go backend/web_test.go backend/templates/card.html backend/static/filter.js +git commit -m "feat(backend): favourite, chapter override, and delete fragments" +``` + +--- + +### Task 7: Styling and client-side search + +**Files:** +- Modify: `backend/static/style.css`, `backend/static/filter.js` + +**Interfaces:** +- Consumes: the class names in the templates from Tasks 5 and 6 — `login-body`, `login-card`, `error`, `topbar`, `ghost`, `search`, `tabs`, `active`, `recent`, `recent-strip`, `recent-card`, `recent-title`, `recent-chapter`, `list`, `card`, `cover`, `body`, `title`, `meta`, `site`, `chapter`, `new`, `actions`, `primary`, `icon`, `on`, `danger`, `chapter-form`, `empty`. The card carries `data-title` for the search filter. +- Produces: nothing other tasks consume. + +- [ ] **Step 1: Write the stylesheet** + +Replace the whole of `backend/static/style.css`: + +```css +/* Mobile first. Dark by default because manga reading happens at night; the + light branch follows the system preference. */ +:root { + color-scheme: dark light; + --bg: #14161a; + --surface: #1d2026; + --surface-2: #262a32; + --text: #e8eaed; + --muted: #9aa1ac; + --accent: #6aa9ff; + --danger: #ff6a6a; + --star: #ffc857; + --radius: 12px; +} + +@media (prefers-color-scheme: light) { + :root { + --bg: #f4f5f7; + --surface: #ffffff; + --surface-2: #eceef2; + --text: #1a1d22; + --muted: #5d646e; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + padding: 0 12px calc(24px + env(safe-area-inset-bottom)); + background: var(--bg); + color: var(--text); + font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +/* Every interactive element clears the 44px touch-target floor. */ +button, .primary, [role="tab"] { + min-height: 44px; + border-radius: var(--radius); + border: 0; + font: inherit; + cursor: pointer; +} + +/* --- login --- */ + +.login-body { + display: grid; + place-items: center; + min-height: 100dvh; +} + +.login-card { + width: min(380px, 100%); + padding: 24px; + background: var(--surface); + border-radius: var(--radius); +} + +.login-card h1 { margin: 0 0 20px; font-size: 1.25rem; } +.login-card label { display: block; margin-bottom: 6px; color: var(--muted); font-size: .875rem; } + +.login-card input { + width: 100%; + min-height: 44px; + padding: 0 12px; + margin-bottom: 12px; + background: var(--surface-2); + color: var(--text); + border: 1px solid transparent; + border-radius: var(--radius); + font: inherit; +} + +.login-card input:focus-visible { outline: 2px solid var(--accent); } +.login-card button { width: 100%; background: var(--accent); color: #0b1220; font-weight: 600; } +.error { margin: 0 0 12px; color: var(--danger); font-size: .875rem; } + +/* --- chrome --- */ + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 0; +} + +.topbar h1 { margin: 0; font-size: 1.125rem; } +.ghost { padding: 0 12px; background: var(--surface-2); color: var(--muted); } + +.search { + width: 100%; + min-height: 44px; + padding: 0 12px; + margin-bottom: 12px; + background: var(--surface); + color: var(--text); + border: 1px solid transparent; + border-radius: var(--radius); + font: inherit; +} + +.search:focus-visible { outline: 2px solid var(--accent); } + +.tabs { display: flex; gap: 8px; margin-bottom: 16px; } + +.tabs [role="tab"] { + flex: 1; + display: grid; + place-items: center; + background: var(--surface); + color: var(--muted); + text-decoration: none; +} + +.tabs [role="tab"].active { background: var(--accent); color: #0b1220; font-weight: 600; } + +/* --- continue reading --- */ + +.recent h2 { margin: 0 0 8px; font-size: .8125rem; text-transform: uppercase; color: var(--muted); } + +.recent-strip { + display: flex; + gap: 10px; + overflow-x: auto; + padding-bottom: 8px; + margin-bottom: 16px; + scroll-snap-type: x mandatory; + -webkit-overflow-scrolling: touch; +} + +.recent-card { + flex: 0 0 110px; + scroll-snap-align: start; + display: block; + padding: 8px; + background: var(--surface); + border-radius: var(--radius); + color: var(--text); + text-decoration: none; +} + +.recent-card img { width: 100%; aspect-ratio: 3 / 4; object-fit: cover; border-radius: 8px; } +.recent-title { display: block; margin-top: 6px; font-size: .8125rem; line-height: 1.25; + overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } +.recent-chapter { display: block; color: var(--muted); font-size: .75rem; } + +/* --- list --- */ + +.list { display: grid; gap: 10px; } + +.card { + display: grid; + grid-template-columns: 72px 1fr; + gap: 12px; + padding: 10px; + background: var(--surface); + border-radius: var(--radius); +} + +.card .cover img { width: 72px; aspect-ratio: 3 / 4; object-fit: cover; border-radius: 8px; } +.card .body { min-width: 0; } +.card .title { margin: 0 0 4px; font-size: 1rem; line-height: 1.25; } + +.meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 0 0 10px; + font-size: .75rem; color: var(--muted); } + +.site { padding: 2px 6px; background: var(--surface-2); border-radius: 6px; text-transform: uppercase; } +.new { padding: 2px 6px; background: var(--accent); color: #0b1220; border-radius: 6px; font-weight: 700; } + +.actions { display: flex; flex-wrap: wrap; gap: 8px; } + +.primary { + flex: 1 1 auto; + display: grid; + place-items: center; + padding: 0 14px; + background: var(--accent); + color: #0b1220; + font-weight: 600; + text-decoration: none; +} + +.icon { width: 44px; background: var(--surface-2); color: var(--text); font-size: 1.125rem; } +.icon.on { color: var(--star); } +.icon.danger { color: var(--danger); } + +.chapter-form { display: flex; gap: 8px; margin-top: 8px; } + +.chapter-form input { + flex: 1; + min-height: 44px; + padding: 0 12px; + background: var(--surface-2); + color: var(--text); + border: 1px solid transparent; + border-radius: var(--radius); + font: inherit; +} + +.chapter-form button { padding: 0 14px; background: var(--accent); color: #0b1220; font-weight: 600; } +.empty { padding: 32px 12px; text-align: center; color: var(--muted); } + +/* Cards hidden by the search filter. */ +.card[hidden] { display: none; } + +/* --- wide screens --- */ + +@media (min-width: 900px) { + body { max-width: 1100px; margin: 0 auto; padding-inline: 24px; } + .list { grid-template-columns: repeat(2, 1fr); } + .recent-card { flex-basis: 140px; } +} + +@media (min-width: 1300px) { + .list { grid-template-columns: repeat(3, 1fr); } +} + +@media (prefers-reduced-motion: reduce) { + * { animation: none !important; transition: none !important; } +} +``` + +- [ ] **Step 2: Write the search filter** + +Replace the whole of `backend/static/filter.js`: + +```js +// Title search runs entirely in the browser: the full list is already in the +// DOM, so filtering it needs no request. +(function () { + function applyFilter() { + var box = document.getElementById("search"); + if (!box) return; + var needle = box.value.trim().toLowerCase(); + document.querySelectorAll(".card").forEach(function (card) { + var title = (card.dataset.title || "").toLowerCase(); + card.hidden = needle !== "" && title.indexOf(needle) === -1; + }); + } + + document.addEventListener("input", function (e) { + if (e.target && e.target.id === "search") applyFilter(); + }); + + // htmx replaces the list on a tab switch, so re-apply to the new cards. + document.body.addEventListener("htmx:afterSwap", applyFilter); +})(); + +function setActiveTab(el) { + el.parentElement.querySelectorAll("[role=tab]").forEach(function (t) { + t.classList.toggle("active", t === el); + }); +} + +function toggleChapterForm(key) { + var form = document.getElementById("chapter-form-" + key); + if (!form) return; + form.hidden = !form.hidden; + if (!form.hidden) form.querySelector("input").focus(); +} +``` + +- [ ] **Step 3: Verify the tests still pass** + +Run: `cd backend && go test ./... -v` +Expected: PASS. `TestStaticAssetsServed` confirms both files are still embedded and non-empty. + +- [ ] **Step 4: Look at it in a real browser** + +```bash +cd backend +API_TOKEN=dev-token WEB_PASSWORD=dev-pass DB_PATH=/tmp/mangabm-dev.db PORT=8080 go run . +``` + +Seed a row so there is something to look at: + +```bash +curl -X PUT http://localhost:8080/bookmarks/asura:solo \ + -H "Authorization: Bearer dev-token" -H "Content-Type: application/json" \ + -d '{"title":"Solo Leveling","series_url":"https://example.test/solo","last_chapter":"45","last_chapter_num":45,"latest_chapter":"47","latest_chapter_num":47}' +``` + +Open `http://localhost:8080/`, sign in with `dev-pass`, and confirm by hand: +- the login page rejects a wrong password and accepts the right one; +- the card shows the `NEW 47` badge; +- the star toggles and the card does not jump position; +- ✎ opens the number input and saving updates the chapter; +- 🗑 asks for confirmation and removes the card; +- typing in the search box filters; +- switching to Favourites and back works, and the browser back button follows; +- at a narrow width (device toolbar, 390px) nothing overflows horizontally. + +Stop the server when done. + +- [ ] **Step 5: Commit** + +```bash +git add backend/static/style.css backend/static/filter.js +git commit -m "feat(backend): mobile-first styling and client-side title search" +``` + +--- + +### Task 8: Docker, compose, and deployment docs + +**Files:** +- Modify: `backend/Dockerfile`, `docker-compose.yml`, `docker-compose.prod.yml`, `.env.example`, `DEPLOY.md`, `CLAUDE.md` + +**Interfaces:** +- Consumes: `WEB_PASSWORD` (Task 4), the `templates/` and `static/` directories (Tasks 5–7). +- Produces: nothing other tasks consume. + +**This task is required, not optional.** The current Dockerfile copies only `*.go`; without the change the image builds and then panics at startup on the missing embedded directories. + +- [ ] **Step 1: Fix the Dockerfile** + +In `backend/Dockerfile`, replace the line: + +```dockerfile +COPY *.go ./ +``` + +with: + +```dockerfile +# Source plus the go:embed'd assets. Missing either directory turns the embed +# directive into a build error, so both must be copied before `go build`. +COPY *.go ./ +COPY templates/ ./templates/ +COPY static/ ./static/ +``` + +- [ ] **Step 2: Verify the image builds and runs** + +```bash +docker build -t mangabm-backend:test ./backend +docker run --rm -e API_TOKEN=dev-token -e WEB_PASSWORD=dev-pass \ + -e DB_PATH=/tmp/test.db -p 8080:8080 mangabm-backend:test & +sleep 2 +curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/healthz +curl -s http://localhost:8080/ | grep -c 'type="password"' +curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/static/htmx.min.js +``` + +Expected: `200`, then `1`, then `200`. Stop the container afterwards (`docker stop $(docker ps -q --filter ancestor=mangabm-backend:test)`). + +If the build fails with `pattern templates: no matching files found`, the COPY lines are wrong or in the wrong stage. + +- [ ] **Step 3: Pass the password through compose** + +In `docker-compose.yml`, add to the `environment:` block under `manga-api`: + +```yaml + # Gates the browser UI. Unset means the web routes are not served at all. + WEB_PASSWORD: ${WEB_PASSWORD:-} +``` + +- [ ] **Step 4: Add the second Traefik router** + +In `docker-compose.prod.yml`, add to the `labels:` list. Both routers point at the one `mangabm` service, so there is no second container and no second certificate resolver: + +```yaml + # Second hostname for the browser UI, same container. Traefik needs the + # service named explicitly once more than one router targets it. + - "traefik.http.routers.mangabm.service=mangabm" + - "traefik.http.routers.mangaweb.rule=Host(`${MANGA_WEB_HOST:-manga.example.com}`)" + - "traefik.http.routers.mangaweb.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}" + - "traefik.http.routers.mangaweb.tls=true" + - "traefik.http.routers.mangaweb.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}" + - "traefik.http.routers.mangaweb.service=mangabm" +``` + +Also update the header comment block in that file to list `MANGA_WEB_HOST` alongside `MANGA_API_HOST`. + +- [ ] **Step 5: Document the variables** + +Append to `.env.example`: + +```ini +# --- Web UI --- +# Password for the browser UI at https://$MANGA_WEB_HOST. Leave unset to +# disable the web UI entirely (the routes are not registered at all). +# Generate one: openssl rand -base64 18 +WEB_PASSWORD= + +# Subdomain Traefik routes to the browser UI (prod override only). The same +# container also answers on MANGA_API_HOST for the userscript's API. +# MANGA_WEB_HOST=manga.example.com +``` + +Add a section to `DEPLOY.md` after the existing `.env` section: + +```markdown +## 1b. Web UI + +The browser UI is served by the same container on a second hostname. + +1. Add a DNS `A`/`AAAA` record for `manga.` pointing at the server — + the same address as `manga-api.`. + +2. Set both variables in `.env`: + + ```ini + MANGA_WEB_HOST=manga.violetcrown.my.id + WEB_PASSWORD= + ``` + + Generate and insert in one line: + + ```bash + sed -i "s|^WEB_PASSWORD=.*|WEB_PASSWORD=$(openssl rand -base64 18)|" .env + grep -E '^WEB_PASSWORD=' .env # this is what you type into the site + ``` + +3. Redeploy and check: + + ```bash + docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build + curl -s -o /dev/null -w '%{http_code}\n' https://manga.violetcrown.my.id/ + ``` + + Expected `200`, serving the login page. + +Leaving `WEB_PASSWORD` unset is safe: the web routes are not registered and `/` +returns 404. The userscript's API on `MANGA_API_HOST` is unaffected either way. + +Sessions are signed with a key derived from `API_TOKEN`, so rotating the token +logs every browser out. The session cookie lasts 60 days. +``` + +- [ ] **Step 6: Update the project instructions** + +In `CLAUDE.md`, under **Architecture**, add after the `Endpoints:` bullet: + +```markdown +- **Web UI:** the same binary serves a password-gated browser UI on a second + hostname — `GET /` (list, or login page when there is no session), + `POST /login`, `POST /logout`, `GET /static/*`, and htmx fragment endpoints + under `/ui/*`. Templates and assets are `go:embed`-ed, so `backend/Dockerfile` + must copy `templates/` and `static/` as well as `*.go`. Sessions are stateless + HMAC cookies keyed off `API_TOKEN`; `WEB_PASSWORD` gates them and, when empty, + the web routes are not registered at all. UI mutations read-modify-write + through `Store.Get` + `Store.Upsert` so the `updated_at` rule stays in one + place. See `docs/superpowers/specs/2026-07-25-web-ui-design.md`. +``` + +Add `WEB_PASSWORD` to the **Config via env** bullet in the same file. + +- [ ] **Step 7: Full verification** + +```bash +cd backend +gofmt -l . # expect no output +go vet ./... # expect no output +go test -race ./... # expect ok +CGO_ENABLED=0 go build -o /dev/null . +``` + +All four must pass before committing. + +- [ ] **Step 8: Commit** + +```bash +git add backend/Dockerfile docker-compose.yml docker-compose.prod.yml .env.example DEPLOY.md CLAUDE.md +git commit -m "chore: build, route, and document the web UI" +``` + +--- + +## Self-Review Notes + +Spec coverage check against `docs/superpowers/specs/2026-07-25-web-ui-design.md`: + +| Spec section | Task | +| --- | --- | +| §3.1 all eight routes | 5 (`/`, `/login`, `/logout`, `/static/*`, `/ui/list`), 6 (three mutations) | +| §3.2 `Store.Get`, read-modify-write | 1, 6 | +| §4.1 `WEB_PASSWORD`, fail-closed 404 | 4, 5 | +| §4.2 cookie format, attributes, verify order | 2 | +| §4.3 CSRF via SameSite=Lax | 2 (attribute), no extra work | +| §4.4 rate limit, rightmost XFF | 3, 5 | +| §5.1 login page | 5 | +| §5.2 list page, strip, NEW badge, actions, search, empty state | 5, 6, 7 | +| §5.3 tabs with pushed URL | 5 | +| §6 test list | 1, 2, 3, 5, 6 | +| §7 deployment | 8 | + +One decision was added during planning and is not in the spec: **a manual chapter override clears `last_chapter_url`** (Task 6). Recorded in the task and covered by `TestChapterOverrideRejectsBadInput`'s sibling `TestChapterOverrideMovesUpdatedAt`.