feat: password-gated web UI on the same backend #1
Reference in New Issue
Block a user
Delete Branch "feat/web-ui"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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/*— embeddedstyle.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, soCGO_ENABLED=0and the distrolessimage still hold.
Auth
WEB_PASSWORDgates the UI; unset means the web routes are never registered and/returns 404. Session cookie isHttpOnly,SameSite=Lax,Securewhen therequest is HTTPS. The signing key derives from
API_TOKEN+WEB_PASSWORD, sorotating either logs every browser out. Login is rate-limited to 10 failures per
20 minutes per client IP, keyed on the rightmost
X-Forwarded-Forentry(Traefik appends the observed peer, so the leftmost is client-spoofable). CGNAT
lockout is a known, accepted limitation — the window self-heals.
Invariants preserved
/bookmarks*. That API stays JSON +bearer token, unchanged, as does the userscript.
Store.Upsertis byte-for-byte unmodified. Every UI write goesread-modify-write through the new
Store.Get, so the conditional-updated_atrule (favouriting must not reorder the list, a chapter override must) lives in
exactly one function.
Deployment
docker-compose.prod.ymlgains a second Traefik router onMANGA_WEB_HOSTpointing at the same service — one container, one certificate resolver, no second
service. Both
MANGA_API_HOSTandMANGA_WEB_HOSTare required (:?), with noexample fallback in
.env.example: a placeholder there would make Traefiksilently publish the UI on a domain you do not own. Needs a DNS A/AAAA record for
manga.<domain>. SeeDEPLOY.md§1b.Docs
docs/superpowers/specs/2026-07-25-web-ui-design.mdplans/2026-07-25-web-ui-implementation-plan.mdVerification
gofmtclean,go vet,go test -race ./...,CGO_ENABLED=0 go build, a realdocker build+ curl smoke test, and a Playwright pass covering loginreject/accept, favourite-without-reorder, chapter edit, delete-with-confirm,
search, tab switch + back button, 390px with no horizontal overflow, and zero JS
console errors.
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>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>