Files
mangaBookmark/docs/superpowers/specs/2026-07-25-web-ui-design.md
T
sulthan ebc7a546c5 feat: password-gated web UI on the same backend (#1)
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>
2026-07-26 03:59:58 +07:00

264 lines
11 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 || 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 `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.