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

10 KiB
Raw Blame History

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:

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.

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.