933d3a9499
Eight TDD tasks: Store.Get + view helpers, session cookies, login rate limiting, htmx vendoring + config, templates and the login flow, mutation fragments, styling and search, then Docker/compose/deploy wiring. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2331 lines
73 KiB
Markdown
2331 lines
73 KiB
Markdown
# 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`.
|