Files
mangaBookmark/plans/2026-07-25-web-ui-implementation-plan.md
T
sulthan ebc7a546c5 feat: password-gated web UI on the same backend (#1)
Adds a password-gated browser UI for the bookmark list, served by the same Go
binary and container as the userscript API.

## What

- `GET /` — list page, or the login page when there is no session (200, no redirect).
- `POST /login`, `POST /logout` — stateless HMAC session cookie, 60-day Max-Age.
- `GET /ui/list?tab=all|fav`, `POST /ui/bookmarks/{key}/favorite`,
  `POST /ui/bookmarks/{key}/chapter`, `DELETE /ui/bookmarks/{key}` — htmx fragments.
- `GET /static/*` — embedded `style.css`, `htmx.min.js`, `filter.js`.

Mobile-first dark CSS, 2–3 column grid at ≥900px, "Continue reading" strip of the
five most recent series, NEW badge, client-side title search, no build step.

## Stack

Go `html/template` + htmx 2.0.4 (vendored, 50 KB) + plain CSS. No npm, no bundler.
Templates and assets are `go:embed`-ed, so `CGO_ENABLED=0` and the distroless
image still hold.

## Auth

`WEB_PASSWORD` gates the UI; unset means the web routes are never registered and
`/` returns 404. Session cookie is `HttpOnly`, `SameSite=Lax`, `Secure` when the
request is HTTPS. The signing key derives from `API_TOKEN` + `WEB_PASSWORD`, so
rotating either logs every browser out. Login is rate-limited to 10 failures per
20 minutes per client IP, keyed on the **rightmost** `X-Forwarded-For` entry
(Traefik appends the observed peer, so the leftmost is client-spoofable). CGNAT
lockout is a known, accepted limitation — the window self-heals.

## Invariants preserved

- A session cookie never authenticates `/bookmarks*`. That API stays JSON +
  bearer token, unchanged, as does the userscript.
- `Store.Upsert` is byte-for-byte unmodified. Every UI write goes
  read-modify-write through the new `Store.Get`, so the conditional-`updated_at`
  rule (favouriting must not reorder the list, a chapter override must) lives in
  exactly one function.

## Deployment

`docker-compose.prod.yml` gains a second Traefik router on `MANGA_WEB_HOST`
pointing at the same service — one container, one certificate resolver, no second
service. Both `MANGA_API_HOST` and `MANGA_WEB_HOST` are required (`:?`), with no
example fallback in `.env.example`: a placeholder there would make Traefik
silently publish the UI on a domain you do not own. Needs a DNS A/AAAA record for
`manga.<domain>`. See `DEPLOY.md` §1b.

## Docs

- Design: `docs/superpowers/specs/2026-07-25-web-ui-design.md`
- Plan: `plans/2026-07-25-web-ui-implementation-plan.md`

## Verification

`gofmt` clean, `go vet`, `go test -race ./...`, `CGO_ENABLED=0 go build`, a real
`docker build` + curl smoke test, and a Playwright pass covering login
reject/accept, favourite-without-reorder, chapter edit, delete-with-confirm,
search, tab switch + back button, 390px with no horizontal overflow, and zero JS
console errors.

Reviewed-on: #1
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-07-26 03:59:58 +07:00

73 KiB
Raw Blame History

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:

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:

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:

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

// 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
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"

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:

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:

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 "<expiryMs>.<base64url HMAC(expiryMs)>".
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
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:

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:

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
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

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:

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:

	// WebPassword gates the browser UI. Empty disables the web routes entirely.
	WebPassword string

And in loadConfig, inside the struct literal:

		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
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:

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:

{{define "login"}}
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <meta name="color-scheme" content="dark light">
  <title>mangaBookmark</title>
  <link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
  <main class="login-card">
    <h1>mangaBookmark</h1>
    <form method="post" action="/login">
      <label for="password">Password</label>
      <input id="password" name="password" type="password"
             autocomplete="current-password" autofocus required>
      {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
      <button type="submit">Sign in</button>
    </form>
  </main>
</body>
</html>
{{end}}

backend/templates/app.html:

{{define "app"}}
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <meta name="color-scheme" content="dark light">
  <title>mangaBookmark</title>
  <link rel="stylesheet" href="/static/style.css">
  <script src="/static/htmx.min.js" defer></script>
  <script src="/static/filter.js" defer></script>
</head>
<body>
  <header class="topbar">
    <h1>mangaBookmark</h1>
    <form method="post" action="/logout">
      <button type="submit" class="ghost">Log out</button>
    </form>
  </header>

  <input id="search" class="search" type="search" placeholder="Search titles…"
         autocomplete="off" aria-label="Search titles">

  <nav class="tabs" role="tablist">
    <a role="tab" href="/?tab=all" class="{{if eq .Tab "all"}}active{{end}}"
       hx-get="/ui/list?tab=all" hx-target="#list" hx-swap="innerHTML"
       hx-push-url="/?tab=all" hx-on::after-request="setActiveTab(this)">All</a>
    <a role="tab" href="/?tab=fav" class="{{if eq .Tab "fav"}}active{{end}}"
       hx-get="/ui/list?tab=fav" hx-target="#list" hx-swap="innerHTML"
       hx-push-url="/?tab=fav" hx-on::after-request="setActiveTab(this)">Favourites</a>
  </nav>

  {{if .Recent}}
  <section class="recent">
    <h2>Continue reading</h2>
    <div class="recent-strip">
      {{range .Recent}}
      <a class="recent-card" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer">
        {{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">{{end}}
        <span class="recent-title">{{.Title}}</span>
        <span class="recent-chapter">Ch {{.LastChapter}}</span>
      </a>
      {{end}}
    </div>
  </section>
  {{end}}

  <main id="list" class="list">
    {{template "list" .}}
  </main>
</body>
</html>
{{end}}

backend/templates/list.html:

{{define "list"}}
{{if .Items}}
  {{range .Items}}{{template "card" .}}{{end}}
{{else}}
  <p class="empty">
    Nothing here yet. Bookmarks appear once the userscript records a chapter.
  </p>
{{end}}
{{end}}

backend/templates/card.html — the interactive attributes land in Task 6; this is the static shape:

{{define "card"}}
<article class="card" id="card-{{.Key}}" data-title="{{.Title}}">
  <a class="cover" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer">
    {{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">{{end}}
  </a>
  <div class="body">
    <h3 class="title">{{.Title}}</h3>
    <p class="meta">
      <span class="site site-{{.Site}}">{{.Site}}</span>
      <span class="chapter">Ch {{.LastChapter}}</span>
      {{if .HasNewChapter}}<span class="new">NEW {{.LatestChapter}}</span>{{end}}
    </p>
    <div class="actions">
      <a class="primary" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer">Continue</a>
    </div>
  </div>
</article>
{{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:

/* Styling lands in Task 7. */
:root { color-scheme: dark light; }

backend/static/filter.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:

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:

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

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

	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:

// 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 <div class="actions"> block in backend/templates/card.html with:

    <div class="actions">
      <a class="primary" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer">Continue</a>
      <button class="icon {{if .Favorite}}on{{end}}"
              title="Favourite" aria-label="Toggle favourite"
              hx-post="/ui/bookmarks/{{.Key}}/favorite"
              hx-target="#card-{{.Key}}" hx-swap="outerHTML">
        {{if .Favorite}}★{{else}}☆{{end}}
      </button>
      <button class="icon" title="Set chapter" aria-label="Set chapter"
              onclick="toggleChapterForm('{{.Key}}')">✎</button>
      <button class="icon danger" title="Remove" aria-label="Remove"
              hx-delete="/ui/bookmarks/{{.Key}}"
              hx-target="#card-{{.Key}}" hx-swap="outerHTML"
              hx-confirm="Remove {{.Title}} from the list?">🗑</button>
    </div>
    <form class="chapter-form" id="chapter-form-{{.Key}}" hidden
          hx-post="/ui/bookmarks/{{.Key}}/chapter"
          hx-target="#card-{{.Key}}" hx-swap="outerHTML">
      <input name="chapter" type="number" step="0.1" min="0"
             value="{{.LastChapterNum}}" aria-label="Chapter number" required>
      <button type="submit">Save</button>
    </form>
  • Step 6: Add the toggle helper

Append to backend/static/filter.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
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"

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:

/* 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:

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

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
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:

COPY *.go ./

with:

# 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
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:

      # 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:

      # 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:

# --- 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:

## 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.<yourdomain>` pointing at the server —
   the same address as `manga-api.<yourdomain>`.

2. Set both variables in `.env`:

   ```ini
   MANGA_WEB_HOST=manga.violetcrown.my.id
   WEB_PASSWORD=<paste output of: openssl rand -base64 18>

Generate and insert in one line:

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
  1. Redeploy and check:

    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
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
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.