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

2331 lines
73 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 "<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**
```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"}}
<!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`:
```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`:
```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:
```html
{{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`:
```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 `<div class="actions">` block in `backend/templates/card.html` with:
```html
<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`:
```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.<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:
```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`.