feat: password-gated web UI on the same backend #1

Merged
sulthan merged 14 commits from feat/web-ui into main 2026-07-26 03:59:59 +07:00
Owner

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.

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.
sulthan added 14 commits 2026-07-26 03:32:42 +07:00
Adds a browser-accessible bookmark list on a new subdomain, served by the
existing Go binary via go:embed'd templates and htmx. Cookie sessions
(HMAC-keyed off API_TOKEN, 60-day) gate /ui/*; the bearer-authenticated
/bookmarks* API and the userscript are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Adds sessionKey/signSession/verifySession primitives and
setSessionCookie/clearSessionCookie helpers in a new backend/session.go.
Sessions are derived from API_TOKEN via HMAC-SHA256 with domain
separation (sessionKeyPurpose), so there is no session table and
rotating the token invalidates every outstanding cookie at once.
No routes or handlers yet — that's task 5.
Adds clientIP() (reads the rightmost X-Forwarded-For hop via
Header.Values, since Traefik appends the peer address it actually
observed and the leftmost entries are client-controlled) and
loginLimiter, an in-memory per-IP counter that blocks after
loginMaxFailures within loginWindow. No routes wire these up yet —
that lands in Task 5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the three UI mutation endpoints (favourite toggle, manual chapter
override, delete) plus the card controls that call them via htmx.
Each mutation is a read-modify-write through Store.Get/Upsert so
Upsert alone decides whether updated_at moves — favouriting must not
reorder the list, only real reading progress should.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hx-target="#card-<key>" is an invalid CSS selector for any key containing
a colon (every real bookmark key is "<site>:<series_id>"), so htmx threw
before swapping and the favourite/delete/chapter-override controls were
dead in the browser. Switch to the attribute-selector form
[id='card-<key>'], which querySelectorAll accepts regardless of the id's
characters.

Also close a validation gap in uiChapter: strconv.ParseFloat accepts
"NaN"/"Infinity"/"-Inf" with err == nil, and every comparison against NaN
is false, so num < 0 let both through to last_chapter_num and permanently
broke HasNewChapter. Reject non-finite values explicitly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Also fixes a CSS specificity bug found during manual browser testing:
.chapter-form { display: flex } has the same specificity as the browser's
built-in [hidden] { display: none } rule and wins by cascade order, so the
per-card chapter-edit form stayed visible even with the hidden attribute
set. Added .chapter-form[hidden] { display: none } alongside the existing
.card[hidden] override.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fix backend/Dockerfile to COPY templates/ and static/ (the go:embed
assets from Tasks 5-7) alongside *.go, plus backend/.dockerignore which
was silently excluding both directories from the build context — the
Dockerfile fix alone still failed the build. Wire WEB_PASSWORD through
docker-compose.yml, add a second Traefik router (mangaweb) plus explicit
service labels on both routers in docker-compose.prod.yml, and document
the new variables and deploy steps in .env.example, DEPLOY.md, and
CLAUDE.md.
Final-review fix wave over the web UI branch.

- sessionKey now derives from API_TOKEN and WEB_PASSWORD with a \x00
  separator, so rotating the password logs every browser out too.
- uiChapter only clears last_chapter_url when the number actually
  changes. The form is pre-filled, so a bare tap of Save resubmits the
  same value; that used to destroy the chapter URL silently while
  updated_at stayed put, degrading Continue to the series index page.
- MANGA_WEB_HOST is now required by the prod override rather than
  falling back to manga.example.com, matching MANGA_API_HOST.
- Comment fixes: static cache rationale, pruneLocked aliasing
  invariant, and the stale "3 routes" line in CLAUDE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re-review of the fix wave found the .env.example edit defeated the fix
it belonged to: shipping MANGA_WEB_HOST=manga.example.com re-supplied
the value that ${MANGA_WEB_HOST:?} exists to reject, so a fresh
`cp .env.example .env` started fine and Traefik published the UI router
on a domain the operator does not own. Left commented, matching
MANGA_API_HOST; DEPLOY.md 1 now lists it among the required variables.

Also:
- uiChapter leaves last_chapter too, not only last_chapter_url, when the
  submitted number is unchanged. It used to rewrite the display string
  ("45.0" to "45") behind a frozen updated_at.
- Design spec 4.2 documents the two-secret key derivation.
- Corrected the pruneLocked aliasing rationale and the stale
  sessionKeyPurpose comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sulthan merged commit ebc7a546c5 into main 2026-07-26 03:59:59 +07:00
sulthan deleted branch feat/web-ui 2026-07-27 12:00:38 +07:00
Sign in to join this conversation.