# 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.