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>
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user