From 4d70677addc0c7245b4246d0b3e7746b38c500ad Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sat, 25 Jul 2026 22:27:03 +0700 Subject: [PATCH] docs: design for password-gated web UI served by the same backend 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 --- .../specs/2026-07-25-web-ui-design.md | 260 ++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-25-web-ui-design.md diff --git a/docs/superpowers/specs/2026-07-25-web-ui-design.md b/docs/superpowers/specs/2026-07-25-web-ui-design.md new file mode 100644 index 0000000..319fb85 --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-web-ui-design.md @@ -0,0 +1,260 @@ +# 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_events` table 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 `localStorage` cache; 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: + +```go +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: + +``` + "." base64url(HMAC-SHA256(, key)) +key = SHA256(API_TOKEN || "mangabm-web-session-v1") +``` + +Stateless: no session table, sessions survive restarts, and rotating +`API_TOKEN` invalidates every session at once. + +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 `NEW` badge appears when `latest_chapter_num` is present and greater than + `last_chapter_num`. +- **Continue** opens `last_chapter_url` in a new tab; it falls back to + `series_url` when 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_chapter` and `last_chapter_num` to the entered value, which does + move `updated_at` and 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-For` entry is the one keyed on. + +`web_test.go`: + +- `GET /` without a cookie returns `200` and the login page. +- `/ui/*` without a cookie returns `401`. +- `/ui/list` with a cookie returns the list fragment; `?tab=fav` returns only + favourites. +- Toggling favourite leaves `updated_at` unchanged. +- A chapter override changes `updated_at`. +- Deleting removes the row. +- With `WEB_PASSWORD` empty, `/` returns `404`. + +Templates are parsed once at startup so a broken template fails the process +immediately rather than the first request. + +## 7. Deployment + +- `.env` and `.env.example` gain `WEB_PASSWORD`. +- `docker-compose.prod.yml` gains a second Traefik router label for + `manga.violetcrown.my.id` pointing at the same service on port 8080. Both + routers share one container; no second service, no second certificate + resolver. +- A DNS `A`/`AAAA` record for `manga.violetcrown.my.id`. +- `DEPLOY.md` gains 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.