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
2026-07-24 16:23:24 +07:00
2026-07-24 16:23:24 +07:00

Manga Bookmark

Track manga read-progress on asurascans.com (a.k.a. asuracomic.net) and demonicscans.org from a phone (Bromite / mobile Chromium), synced to a self-hosted Go backend so bookmarks unify across both sites and all devices.

Two parts:

  • backend/ — tiny Go (net/http + pure-Go SQLite) sync service. 4 routes, static binary, distroless container.
  • userscript/manga-bookmark.user.js — single Bromite-compatible userscript (no GM_* APIs) that injects an on-page bookmark UI and syncs via fetch().
Bromite userscript (isolated world, Shadow DOM UI, localStorage cache)
    -- fetch() HTTPS -->  reverse proxy (TLS + CORS)  -->  Go net/http  -->  SQLite (volume)

1. Backend

Config (env)

Var Default Notes
API_TOKEN (required) Bearer token shared with the userscript.
ALLOWED_ORIGINS Asura + Demonic origins Comma-separated CORS allowlist.
DB_PATH /data/bookmarks.db SQLite file location.
PORT 8080 Plain HTTP; TLS terminated by the proxy.

Endpoints

Method Path Auth Description
GET /bookmarks Bearer All bookmarks (single-user).
PUT /bookmarks/{key} Bearer Upsert one series; returns the row as stored.
DELETE /bookmarks/{key} Bearer Remove one.
GET /healthz none 200 ok.

key is <site>:<series_id> — e.g. asura:trash-of-the-counts-family-f886a8af or demonic:Infinite-Level-Up-in-Murim. Sync is last-write-wins.

updated_at orders the bookmark list, so it moves only on real reading progress: the server applies its timestamp when the row is new or last_chapter_num changes, and otherwise keeps the stored one. Favouriting a series or recording a newly published chapter therefore leaves the order alone. Because the timestamp a client sends is only a candidate, PUT echoes the row as stored and clients adopt that rather than their own payload.

Develop / test

cd backend
go test ./...                          # unit + handler tests
CGO_ENABLED=0 go build                 # static binary

Run the stack

cp .env.example .env
# edit .env: set API_TOKEN (openssl rand -hex 32)

docker compose up -d --build           # binds 127.0.0.1:8080

Smoke test:

TOKEN=$(grep '^API_TOKEN=' .env | cut -d= -f2)
curl -s localhost:8080/healthz                                    # ok
curl -s localhost:8080/bookmarks                                  # 401
curl -s -H "Authorization: Bearer $TOKEN" localhost:8080/bookmarks # []
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"title":"Test","last_chapter":"Chapter 1","last_chapter_num":1}' \
  localhost:8080/bookmarks/asura:test-1
curl -s -i -X OPTIONS -H 'Origin: https://asurascans.com' \
  -H 'Access-Control-Request-Method: PUT' \
  localhost:8080/bookmarks/asura:test-1 | grep -i access-control   # 204 + CORS headers

Deploy behind your reverse proxy

Route https://manga-api.<domain> → the service on :8080 (TLS at the proxy).

  • Host proxy (nginx/Caddy on the host): the base compose already binds 127.0.0.1:8080; point the proxy proxy_pass http://127.0.0.1:8080;.

  • Docker proxy (Traefik/nginx in a container on its own network): use the override, which drops the published port and joins the shared network:

    docker network create proxy            # once, if it doesn't exist
    docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
    

    Set PROXY_NETWORK in .env if your network isn't named proxy.

Verify: https://manga-api.<domain>/healthz returns ok over valid TLS (no mixed-content), and an OPTIONS preflight from a real site origin returns the CORS headers.


2. Userscript

Configure

Edit the config block at the top of userscript/manga-bookmark.user.js:

const API_BASE = "https://manga-api.<domain>"; // no trailing slash
const API_TOKEN = "<same token as backend>";

The token lives in the userscript's isolated world — the manga sites' own JS cannot read it.

Install on Bromite (mobile)

Bromite runs Chromium's native userscript engine (no Tampermonkey needed):

  1. Bromite → Settings → User scripts → enable user scripts (allow the permission prompt).
  2. Save the configured manga-bookmark.user.js to the device (or open its raw URL). Bromite detects the .user.js and offers to install it.
  3. Confirm the install; the @match list covers both sites.
  4. Open a series on either site — a 📑 button appears bottom-right.

Exact menu wording varies by Bromite build; if "User scripts" is absent, update Bromite or use a build with userscript support.

Desktop iteration (optional)

The script is GM_*-free, so it also runs in Tampermonkey/Violentmonkey on desktop for faster testing — install the same file unchanged.

Use

  • Bookmark: on a series or chapter page, open the panel → + Bookmark this.
  • Auto-progress: opening a chapter of a bookmarked series records it when the chapter number ≥ the stored one (re-reading older chapters never regresses progress; unparseable numbers set the current chapter).
  • Manual override: panel → Edit on any row forces a specific chapter.
  • Continue: jumps to the last-read chapter (or the series page).
  • Latest chapter: rows read Read: … · Latest: … once the newest published chapter is known and it is ahead of your progress. See below for how that is found.
  • Favourites: the ☆ on any row toggles it; the ★ Favourites tab narrows the list. Favourited series still appear under All. The flag syncs, so it follows you across devices; the chosen tab does not persist.
  • Bookmarks made on Asura appear when the panel is opened on Demonic, and vice versa — the backend is the shared store.

Neither favouriting nor learning a new chapter reorders the list — only reading progress does.

Offline / backend down: changes are cached in localStorage and retried on the next successful load (last-write-wins).

How "latest chapter" is found

Only a series page lists every chapter (a reader page links just its neighbours), and the backend cannot fetch either site — Cloudflare blocks server-side requests, and neither site offers an API or feed to poll. So the userscript does the looking, from your own browser session:

  • Opening a bookmarked series page records its newest chapter directly.
  • Otherwise it fetches series pages in the background — same-origin only, so browsing Asura refreshes Asura bookmarks and Demonic refreshes Demonic. One series per navigation, and at most one check per series every 4 hours (LATEST_CHECK_BATCH / LATEST_CHECK_THROTTLE_MS). Failures are silent and simply retried after the window.

Freshness is tracked per device in localStorage under mangabm:lastchecked and is deliberately not synced, since each device checks on its own.

This means a bookmark is as current as its last check — not the moment a chapter drops. Nothing can be instant here: neither site offers push, feeds, or an API.


Adapter reference (verified live 2026-07-24)

The site adapters key everything off URL regex, with title/cover from og:title / og:image. Confirmed against live pages via Playwright:

Site Series URL Chapter URL series_id
Asura (asurascans.com) /comics/<slug-hash> /comics/<slug-hash>/chapter/<n> <slug-hash>
Demonic (demonicscans.org) /manga/<slug> /title/<slug>/chapter/<n>/<page> (chaptered.php?manga=<id>&chapter=<n> 301s here) <slug>

Notes:

  • asuracomic.net deep links are dead (re-checked 2026-07-25). They 301 to the asurascans.com root, discarding the path, at the edge — before the userscript gets a document — so nothing client-side can rescue them. Reach series through asurascans.com. The host stays matched in case the redirect starts preserving paths again.
  • Asura og:title carries a Chapter N - Read Online \| Asura Scans suffix that the adapter strips; Demonic chapter og:title is <Title> Chapter N.
  • Demonic's <slug> is identical on /manga/… and the canonical /title/… reader, so a bookmark set from the series page and the auto-update from the reader resolve to the same key.
  • Asura showed no Next.js markers on the live site, so navigation uses a framework-agnostic watcher (history patch + polling) rather than a Next-only hook — works for client-routed and full-reload sites alike.

If either site changes its URL shape, update the regex in the matching adapter in userscript/manga-bookmark.user.js and re-verify.

S
Description
No description provided
Readme 13 MiB
Languages
Go 75.9%
JavaScript 15.1%
CSS 4.6%
HTML 3.6%
Shell 0.5%
Other 0.3%