4d70677add
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>
261 lines
10 KiB
Markdown
261 lines
10 KiB
Markdown
# 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.
|