Files
mangaBookmark/docs/superpowers/specs/2026-07-25-web-ui-design.md
T
sulthan 4d70677add 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 <noreply@anthropic.com>
2026-07-25 22:27:03 +07:00

261 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```
<expiry_unix_ms> "." base64url(HMAC-SHA256(<expiry_unix_ms>, 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.