Adds a password-gated browser UI for the bookmark list, served by the same Go
binary and container as the userscript API.
## What
- `GET /` — list page, or the login page when there is no session (200, no redirect).
- `POST /login`, `POST /logout` — stateless HMAC session cookie, 60-day Max-Age.
- `GET /ui/list?tab=all|fav`, `POST /ui/bookmarks/{key}/favorite`,
`POST /ui/bookmarks/{key}/chapter`, `DELETE /ui/bookmarks/{key}` — htmx fragments.
- `GET /static/*` — embedded `style.css`, `htmx.min.js`, `filter.js`.
Mobile-first dark CSS, 2–3 column grid at ≥900px, "Continue reading" strip of the
five most recent series, NEW badge, client-side title search, no build step.
## Stack
Go `html/template` + htmx 2.0.4 (vendored, 50 KB) + plain CSS. No npm, no bundler.
Templates and assets are `go:embed`-ed, so `CGO_ENABLED=0` and the distroless
image still hold.
## Auth
`WEB_PASSWORD` gates the UI; unset means the web routes are never registered and
`/` returns 404. Session cookie is `HttpOnly`, `SameSite=Lax`, `Secure` when the
request is HTTPS. The signing key derives from `API_TOKEN` + `WEB_PASSWORD`, so
rotating either logs every browser out. Login is rate-limited to 10 failures per
20 minutes per client IP, keyed on the **rightmost** `X-Forwarded-For` entry
(Traefik appends the observed peer, so the leftmost is client-spoofable). CGNAT
lockout is a known, accepted limitation — the window self-heals.
## Invariants preserved
- A session cookie never authenticates `/bookmarks*`. That API stays JSON +
bearer token, unchanged, as does the userscript.
- `Store.Upsert` is byte-for-byte unmodified. Every UI write goes
read-modify-write through the new `Store.Get`, so the conditional-`updated_at`
rule (favouriting must not reorder the list, a chapter override must) lives in
exactly one function.
## Deployment
`docker-compose.prod.yml` gains a second Traefik router on `MANGA_WEB_HOST`
pointing at the same service — one container, one certificate resolver, no second
service. Both `MANGA_API_HOST` and `MANGA_WEB_HOST` are required (`:?`), with no
example fallback in `.env.example`: a placeholder there would make Traefik
silently publish the UI on a domain you do not own. Needs a DNS A/AAAA record for
`manga.<domain>`. See `DEPLOY.md` §1b.
## Docs
- Design: `docs/superpowers/specs/2026-07-25-web-ui-design.md`
- Plan: `plans/2026-07-25-web-ui-implementation-plan.md`
## Verification
`gofmt` clean, `go vet`, `go test -race ./...`, `CGO_ENABLED=0 go build`, a real
`docker build` + curl smoke test, and a Playwright pass covering login
reject/accept, favourite-without-reorder, chapter edit, delete-with-confirm,
search, tab switch + back button, 390px with no horizontal overflow, and zero JS
console errors.
Reviewed-on: #1
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
11 KiB
Web UI Design — browser-accessible bookmark list
Date: 2026-07-25
Branch: feat/web-ui
Status: approved
1. Problem
The bookmark list is reachable only from inside the userscript, which means it exists only on pages of asurascans.com and demonicscans.org. There is no way to open the list on its own — from a desktop, from a phone home screen, or when neither manga site is loaded.
This adds a website, served by the existing Go backend, that renders the same list with the same actions.
2. Scope
In scope:
- Password-gated website showing all bookmarks, ordered by
updated_at DESC. - All / Favourites tabs.
- A "Continue reading" strip of the five most recent series.
- Per-series actions: continue reading, toggle favourite, manually override the read chapter, delete.
- Client-side title search.
Out of scope:
- A chapter-level reading-event log. The list order already answers "what did I
read last". A
reading_eventstable is a separate future spec. - Any change to
GET /bookmarks,PUT /bookmarks/{key},DELETE /bookmarks/{key}, or to the userscript. Those stay exactly as they are, so the website cannot regress phone reading. - Offline support. The userscript keeps its
localStoragecache; the website is server-rendered and requires connectivity.
3. Architecture
One binary, one container, one SQLite file. The website is added to the running service rather than deployed alongside it.
Bromite userscript ──bearer──> /bookmarks* ─┐
├─> Store ──> SQLite
Browser (phone/desktop) ──cookie──> / , /ui/*┘
New files under backend/:
| File | Purpose |
|---|---|
web.go |
Page and HTML-fragment handlers |
session.go |
Cookie signing/verification, login rate limit |
templates/*.html |
go:embed-ed templates |
static/* |
go:embed-ed style.css, htmx.min.js, filter.js |
Templates and static assets are embedded, so the image stays a single static
binary on distroless and CGO_ENABLED=0 still holds.
3.1 Routes
| Route | Auth | Response |
|---|---|---|
GET / |
session | List page; login page when no valid session |
POST /login |
none | Sets cookie, 303 to / |
POST /logout |
session | Clears cookie, 303 to / |
GET /static/{path...} |
none | Embedded asset, long-lived cache header |
GET /ui/list?tab=all|fav |
session | List fragment |
POST /ui/bookmarks/{key}/favorite |
session | Re-rendered card |
POST /ui/bookmarks/{key}/chapter |
session | Re-rendered card |
DELETE /ui/bookmarks/{key} |
session | 200 with empty body |
GET / returns the login page with status 200 rather than redirecting to a
separate login URL. One page, no redirect loop to reason about.
/ui/* returns HTML fragments, not JSON, and is authenticated by cookie. It is
kept separate from /bookmarks* deliberately: that API is JSON, authenticated
by bearer token, and consumed by the userscript. Sharing one route for two
representations and two auth schemes would couple the website's needs to the
userscript's contract.
Middleware layering is unchanged at the top: withCORS stays outermost.
/bookmarks* keeps withAuth (bearer). / and /ui/* are wrapped in a new
withSession. Web routes are same-origin, so CORS is a no-op for them.
3.2 Store change
Store gains one method:
func (s *Store) Get(key string) (Bookmark, bool, error)
Every UI mutation is read-modify-write: load the row, change the single field,
call the existing Upsert, then render the row Upsert returns. This reuses
the conditional-updated_at rule rather than reimplementing it — favouriting
does not reorder the list, a chapter override does. Rendering the returned row
(not the request payload) is the same contract PUT /bookmarks/{key} already
follows.
Not adding Get and instead patching columns directly would duplicate the
updated_at decision in a second place. That rule has already caused one bug;
it lives in exactly one function.
4. Session authentication
4.1 Configuration
New environment variable WEB_PASSWORD. When it is empty the web routes are not
registered at all and / returns 404. Fail-closed: a deployment that forgets
the variable exposes nothing.
The password is stored in plaintext in .env, alongside API_TOKEN. This is a
single-user deployment with no user table, and anyone who can read .env
already holds the API token, so hashing it protects nothing that is not already
lost. .env is gitignored and the repository is private and self-hosted.
4.2 Cookie
Name mangabm_session. Value:
<expiry_unix_ms> "." base64url(HMAC-SHA256(<expiry_unix_ms>, key))
key = SHA256(API_TOKEN || 0x00 || WEB_PASSWORD || "mangabm-web-session-v1")
Stateless: no session table, sessions survive restarts, and rotating either
API_TOKEN or WEB_PASSWORD invalidates every session at once. Both secrets
are bound in so that changing the password actually logs existing browsers out;
the 0x00 separates the two variable-length secrets so no pair of different
inputs can concatenate to the same string.
Attributes: HttpOnly, SameSite=Lax, Path=/, Max-Age 60 days so the phone
stays logged in across long gaps. Secure is set when r.TLS != nil or
X-Forwarded-Proto: https, and omitted otherwise so http://localhost
development can still log in.
Verification order is fixed: split on ., parse the expiry, reject if it is in
the past, and only then subtle.ConstantTimeCompare the HMAC. Comparing before
validating the shape leaks structure through error timing.
The password comparison at login is also constant-time.
4.3 CSRF
All mutations are POST or DELETE and carry a SameSite=Lax cookie, which a
cross-site form post does not send. No separate CSRF token.
4.4 Login rate limit
In-memory, no persistence. Ten failed attempts within a rolling 20-minute window
for one client IP return 429 with a Retry-After header. Entries expire on
their own; there is no permanent ban and no unlock step. A successful login
clears that IP's counter.
Client IP is the rightmost entry of X-Forwarded-For. Traefik appends the
peer address it observed to whatever the client sent, so the leftmost entry is
attacker-controlled and the rightmost is not. r.RemoteAddr is unusable here —
behind Traefik it is always the proxy's container address, which would turn a
per-IP limit into a global one.
Known and accepted limitation: behind carrier-grade NAT the limit is shared with every other subscriber on the same public address, so a stranger exhausting the budget can lock the owner out for up to 20 minutes. The window self-heals and ten attempts is generous for a mistyped password, so this is preferred over removing the limit.
5. Interface
Mobile-first. Dark by default, honouring prefers-color-scheme. Tap targets at
least 44px. At viewports 900px and wider the card list becomes a 2–3 column
grid.
5.1 Login page
A centered card with a single password field (type="password",
autocomplete="current-password") and a submit button. Failed attempts render
an inline error. A rate-limited attempt renders how long to wait.
5.2 List page
┌──────────────────────────┐
│ mangaBookmark [logout]│
│ [ search… ] │
│ ( All ) ( Favourites ) │
├──────────────────────────┤
│ Continue reading │
│ [card][card][card] → │
├──────────────────────────┤
│ ┌────┬───────────────┐ │
│ │cvr │ Title ASURA│ │
│ │ │ Ch 45 · NEW 47│ │
│ │ │ [Continue]★✎🗑│ │
│ └────┴───────────────┘ │
└──────────────────────────┘
- The main list is ordered
updated_at DESC. That ordering is the reading history; no separate history view exists. - "Continue reading" shows the top five of the same ordering in a horizontally scrolling strip.
- A
NEWbadge appears whenlatest_chapter_numis present and greater thanlast_chapter_num. - Continue opens
last_chapter_urlin a new tab; it falls back toseries_urlwhen no chapter URL is stored. - The favourite control is an htmx
POST; the swapped-in card shows the new state. The list does not reorder. - The chapter override expands an inline number input on the card. Submitting
forces
last_chapterandlast_chapter_numto the entered value, which does moveupdated_atand therefore does reorder the list. - Delete asks for confirmation, then htmx removes the card from the DOM.
- Search filters cards by title in the browser with roughly fifteen lines of JavaScript. No request is made.
- The empty list renders a short message pointing at the userscript.
5.3 Tabs
Switching tabs issues GET /ui/list?tab=… and swaps the list container,
pushing the URL so the back button works. Favourites is the same list filtered
to favorite = true, in the same order.
6. Testing
session_test.go:
- A signed cookie round-trips and verifies.
- An expired cookie is rejected.
- A cookie with a tampered HMAC is rejected.
- A cookie with a tampered expiry is rejected.
- A correct password logs in; a wrong one does not.
- Ten failures trip the limiter; the eleventh attempt returns
429. - A successful login clears the counter.
- The rightmost
X-Forwarded-Forentry is the one keyed on.
web_test.go:
GET /without a cookie returns200and the login page./ui/*without a cookie returns401./ui/listwith a cookie returns the list fragment;?tab=favreturns only favourites.- Toggling favourite leaves
updated_atunchanged. - A chapter override changes
updated_at. - Deleting removes the row.
- With
WEB_PASSWORDempty,/returns404.
Templates are parsed once at startup so a broken template fails the process immediately rather than the first request.
7. Deployment
.envand.env.examplegainWEB_PASSWORD.docker-compose.prod.ymlgains a second Traefik router label formanga.violetcrown.my.idpointing at the same service on port 8080. Both routers share one container; no second service, no second certificate resolver.- A DNS
A/AAAArecord formanga.violetcrown.my.id. DEPLOY.mdgains a section covering the DNS record, the new variable, and generating a password.
ALLOWED_ORIGINS is untouched. The website is same-origin and never triggers
CORS; only the userscript's cross-origin calls do.