feat(backend): per-Reader userscript credential with UI install and rotation (#24)

Each Reader's userscript credential is derived from TOKEN_KEY, their
Discord id and a token epoch (HMAC-SHA256, hex); only its SHA-256 sits in
readers.token_sha256, so install URLs survive restarts while a database
leak yields nothing but hashes. One credential authenticates the script
download path and the API bearer header.

- internal/token: derivation + hashing; migration 0006 adds token_epoch
- seed refreshes the owner's epoch-0 hash only before first rotation
- httpmw.Auth resolves the acting Reader from the credential hash and
  stashes it in the request context; the retired API_TOKEN resolves to
  the owner until API_TOKEN_GRACE_UNTIL, logged per use, on both the
  bearer and script-download paths
- userscript handler renders the bindmounted file with the resolved
  Reader's credential substituted for __API_TOKEN__; a legacy-path
  request during grace serves the derived credential, so devices
  self-migrate on their next update poll
- web UI: Userscripts panel with session-gated install endpoints that
  render the script directly (credential never in markup, address bar
  or a redirect) and confirm-gated rotation; atomic epoch bump + hash
  rewrite in the store
- both userscripts carry __API_TOKEN__ placeholders; the committed
  global-token literal is removed (rotating at deploy retires it for
  real — it survives in git history)
- env: TOKEN_KEY required, API_TOKEN/API_TOKEN_GRACE_UNTIL retire the
  legacy credential; docs and compose updated
This commit is contained in:
2026-08-08 09:34:52 +07:00
parent bcc6b45515
commit 8f752ed86b
25 changed files with 1149 additions and 234 deletions
+13 -2
View File
@@ -1,8 +1,19 @@
# Copy to .env and fill in. Never commit the real .env. # Copy to .env and fill in. Never commit the real .env.
# Long random secret shared with the userscript's API_TOKEN. Generate one: # Secret every Reader's userscript credential is derived from (issue #24):
# the backend rebuilds install URLs from it, and only SHA-256 hashes of the
# credentials ever touch the database. Generate one:
# openssl rand -hex 32 # openssl rand -hex 32
API_TOKEN=changeme-generate-a-long-random-token TOKEN_KEY=changeme-generate-a-long-random-token
# Retired global credential, kept only during the cutover window so
# already-installed scripts keep working. Remove both it and
# API_TOKEN_GRACE_UNTIL once the window has passed and every device has
# reinstalled through the web UI.
# API_TOKEN=
# Moment the retired credential stops resolving to the owner (YYYY-MM-DD or
# RFC3339). Enforced in code on every request; unset means it is already dead.
# API_TOKEN_GRACE_UNTIL=2026-08-22
# The owner's Discord user ID — the one Reader every bookmark belongs to # The owner's Discord user ID — the one Reader every bookmark belongs to
# (seeded at startup). Discord snowflake, e.g. 1046923170000000000. # (seeded at startup). Discord snowflake, e.g. 1046923170000000000.
+4 -4
View File
@@ -70,7 +70,7 @@ instantly.
Existing guarantees — don't regress: Existing guarantees — don't regress:
- Auth on `/bookmarks*`: require `Authorization: Bearer <API_TOKEN>`, **constant-time compare**, 401 otherwise. - Auth on `/bookmarks*`: require `Authorization: Bearer <credential>` — the acting Reader's credential, matched by SHA-256 against `readers.token_sha256` — **constant-time compare** (via the hash, never the secret itself), 401 otherwise.
- CORS: reflect `Origin` only when in `ALLOWED_ORIGINS`; allow `GET,PUT,DELETE,OPTIONS` + headers `Authorization,Content-Type`; answer preflight `OPTIONS` with `204`. - CORS: reflect `Origin` only when in `ALLOWED_ORIGINS`; allow `GET,PUT,DELETE,OPTIONS` + headers `Authorization,Content-Type`; answer preflight `OPTIONS` with `204`.
## Secure coding rules (code you write here) ## Secure coding rules (code you write here)
@@ -83,7 +83,7 @@ Go backend:
- `html/template` only for anything a browser parses, never `text/template`. Never wrap stored or fetched strings in `template.HTML`/`JS`/`URL`; that switches off the escaping every template depends on. - `html/template` only for anything a browser parses, never `text/template`. Never wrap stored or fetched strings in `template.HTML`/`JS`/`URL`; that switches off the escaping every template depends on.
- Any outbound fetch of a client-supplied URL passes `fetchableSeriesURL` (site + `https` + host check) first. `series_url` arrives in a PUT body, so without the gate the poller will probe arbitrary hosts from the server's own network position. New fetch path reuses the gate rather than re-deriving one. - Any outbound fetch of a client-supplied URL passes `fetchableSeriesURL` (site + `https` + host check) first. `series_url` arrives in a PUT body, so without the gate the poller will probe arbitrary hosts from the server's own network position. New fetch path reuses the gate rather than re-deriving one.
- Cap every remote body with `io.LimitReader` (`maxBodyBytes`). An unbounded read is an OOM handed to whatever is on the other end. - Cap every remote body with `io.LimitReader` (`maxBodyBytes`). An unbounded read is an OOM handed to whatever is on the other end.
- Compare secrets with `hmac.Equal` / `subtle.ConstantTimeCompare`, never `==`. Covers the API token. - Compare secrets with `hmac.Equal` / `subtle.ConstantTimeCompare`, never `==`. Covers the retired global token during its grace window.
- Errors: generic text to the client (`http.Error(w, "internal error", 500)`), detail to `log.Printf`. Never log `API_TOKEN`, `DISCORD_CLIENT_SECRET`, a session id, or a whole `Authorization` header. - Errors: generic text to the client (`http.Error(w, "internal error", 500)`), detail to `log.Printf`. Never log `API_TOKEN`, `DISCORD_CLIENT_SECRET`, a session id, or a whole `Authorization` header.
- Proxy headers are trusted only where they already are: `X-Forwarded-Proto` for the Secure cookie flag, **rightmost** `X-Forwarded-For` for client IP (leftmost is attacker-supplied). Don't read either anywhere else. - Proxy headers are trusted only where they already are: `X-Forwarded-Proto` for the Secure cookie flag, **rightmost** `X-Forwarded-For` for client IP (leftmost is attacker-supplied). Don't read either anywhere else.
- Session cookies keep `HttpOnly`, `SameSite`, `Secure`-when-HTTPS; expiry is enforced by the `sessions` table lookup, not a signature. - Session cookies keep `HttpOnly`, `SameSite`, `Secure`-when-HTTPS; expiry is enforced by the `sessions` table lookup, not a signature.
@@ -93,8 +93,8 @@ Go backend:
Userscript: Userscript:
- Site-derived and stored strings render via `el(..., {text})` / `textContent`. `{html}` and `innerHTML` are for author-written literal markup only (`TEMPLATE`, `CSS`) — never a title, chapter label, or API response field. The page DOM belongs to a third-party site; treat it as attacker-controlled. - Site-derived and stored strings render via `el(..., {text})` / `textContent`. `{html}` and `innerHTML` are for author-written literal markup only (`TEMPLATE`, `CSS`) — never a title, chapter label, or API response field. The page DOM belongs to a third-party site; treat it as attacker-controlled.
- Isolated world protects the token from the site's JS. It does not protect anything from an `innerHTML` sink you add yourself. - Isolated world protects the credential from the site's JS. It does not protect anything from an `innerHTML` sink you add yourself.
- The `API_TOKEN` literal sits in both userscripts and must equal backend `API_TOKEN`. Never copy it into logs, docs, commit messages, issues, or a new file. Rotation touches three places: backend env plus both scripts. - The userscripts carry `__API_TOKEN__` placeholders, substituted at serve time with the requesting Reader's credential (`internal/userscript`). Never put a real credential in the repo, docs, commit messages, or issues. Rotation is a web-UI action (epoch bump, `internal/token`); `TOKEN_KEY` in backend env is what derives every credential — never log it.
- `fetch()` targets `API_BASE` only — no dynamic origin, no site-supplied URL. `authHeaders()` goes nowhere but the backend. - `fetch()` targets `API_BASE` only — no dynamic origin, no site-supplied URL. `authHeaders()` goes nowhere but the backend.
- `localStorage` is shared with the site's own JS: cache and queue live there, credentials never do. - `localStorage` is shared with the site's own JS: cache and queue live there, credentials never do.
- Wrap every `localStorage` read/write and `JSON.parse` in try/catch (quota, private mode, corrupt entry), as the existing helpers do. - Wrap every `localStorage` read/write and `JSON.parse` in try/catch (quota, private mode, corrupt entry), as the existing helpers do.
+49 -29
View File
@@ -32,8 +32,9 @@ cp .env.example .env
Edit `.env`: Edit `.env`:
```ini ```ini
# Required — long random secret, also goes in the userscript. # Required — secret every Reader's userscript credential is derived from.
API_TOKEN=<paste output of: openssl rand -hex 32> # Only SHA-256 hashes of credentials are stored.
TOKEN_KEY=<paste output of: openssl rand -hex 32>
# Required — the owner's Discord user ID. Seeds the one Reader every bookmark # Required — the owner's Discord user ID. Seeds the one Reader every bookmark
# belongs to; the value is the snowflake in your Discord profile (Settings → # belongs to; the value is the snowflake in your Discord profile (Settings →
@@ -66,11 +67,18 @@ BOOKMARK_WEB_HOST=bookmark.violetcrown.my.id
Generate + insert the two secrets in three lines: Generate + insert the two secrets in three lines:
```bash ```bash
sed -i "s|^API_TOKEN=.*|API_TOKEN=$(openssl rand -hex 32)|" .env sed -i "s|^TOKEN_KEY=.*|TOKEN_KEY=$(openssl rand -hex 32)|" .env
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(openssl rand -hex 24)|" .env sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(openssl rand -hex 24)|" .env
grep -E '^API_TOKEN=' .env # copy this — the userscript needs the same value grep -E '^TOKEN_KEY=' .env
``` ```
`TOKEN_KEY` derives every Reader's userscript credential (issue #24); only
SHA-256 hashes of the credentials are stored, so this secret is what a
database leak alone cannot recover. If you are upgrading across the cutover,
also set `API_TOKEN` (the retired global credential) and
`API_TOKEN_GRACE_UNTIL` in `.env` so already-installed scripts keep working
for the window — see §6.
`POSTGRES_PASSWORD` is read **only while the `postgres-data` volume is empty**, `POSTGRES_PASSWORD` is read **only while the `postgres-data` volume is empty**,
which in practice means at first boot. Changing it afterwards changes the URL which in practice means at first boot. Changing it afterwards changes the URL
the backend dials but not the password the database expects, and `bookmark-api` the backend dials but not the password the database expects, and `bookmark-api`
@@ -133,7 +141,7 @@ gated by membership in one configured guild.
Sessions are rows in the database: the cookie carries only an opaque id, and Sessions are rows in the database: the cookie carries only an opaque id, and
every request looks the row up and checks its expiry. Deleting a session row — every request looks the row up and checks its expiry. Deleting a session row —
or the whole `sessions` table — logs the browser out immediately; nothing is or the whole `sessions` table — logs the browser out immediately; nothing is
signed, so rotating `API_TOKEN` does not affect browser sessions. Sessions signed, so rotating a credential does not affect browser sessions. Sessions
last 60 days. last 60 days.
--- ---
@@ -180,6 +188,8 @@ curl -s https://bookmark-api.violetcrown.my.id/healthz # -> ok
curl -s -o /dev/null -w '%{http_code}\n' \ curl -s -o /dev/null -w '%{http_code}\n' \
https://bookmark-api.violetcrown.my.id/bookmarks # -> 401 https://bookmark-api.violetcrown.my.id/bookmarks # -> 401
# During the grace window the retired global credential still resolves to the
# owner; afterwards it is 401 like any other wrong credential.
TOKEN=$(grep -E '^API_TOKEN=' .env | cut -d= -f2) TOKEN=$(grep -E '^API_TOKEN=' .env | cut -d= -f2)
curl -s -H "Authorization: Bearer $TOKEN" \ curl -s -H "Authorization: Bearer $TOKEN" \
https://bookmark-api.violetcrown.my.id/bookmarks # -> [] https://bookmark-api.violetcrown.my.id/bookmarks # -> []
@@ -199,29 +209,30 @@ a bad cert makes the browser block the userscript's `fetch()` (mixed content).
## 4. Configure the userscript ## 4. Configure the userscript
Edit the config block at the top of `userscript/manga-bookmark.user.js`: The bindmounted `userscript/*.user.js` files carry `__API_TOKEN__` placeholders
and the deployment's `@downloadURL`/`@updateURL` lines. Check the metadata
block — it ships hardcoded to this deployment's domain, so a deployer who
copies the repo to another domain must edit the two lines or the script
auto-updates from someone else's backend:
```js ```js
const API_BASE = "https://bookmark-api.yourdomain.com"; // no trailing slash // @downloadURL https://bookmark-api.yourdomain.com/u/__API_TOKEN__/manga-bookmark.user.js
const API_TOKEN = "<same token as .env>"; // @updateURL https://bookmark-api.yourdomain.com/u/__API_TOKEN__/manga-bookmark.user.js
``` ```
The token sits in the userscript's isolated world — the manga sites' JS can't The backend substitutes `__API_TOKEN__` with the requesting Reader's derived
read it. credential at serve time (issue #24), so no real credential ever sits in the
file. Only the `API_BASE` constant and the metadata hostname are deployer
Also edit the `@downloadURL`/`@updateURL` metadata lines near the top of the edits; do not put a credential in this file.
file — they ship hardcoded to this deployment's domain and token, so a
deployer who skips them ends up auto-updating from someone else's backend.
See "Installing / updating the userscript" below for how those two lines are
used.
--- ---
## 5. Install on Bromite ## 5. Install on Bromite
1. Bromite → **Settings → User scripts** → enable (accept the permission prompt). 1. Bromite → **Settings → User scripts** → enable (accept the permission prompt).
2. Put the edited `manga-bookmark.user.js` on the device (save the file, or open 2. Sign in to the web UI, open the **Userscripts** panel, and open the install
its raw URL). Bromite detects `.user.js` and offers to install. link — Bromite detects `.user.js` and offers to install. The script already
carries your credential; you never see or type one.
3. Confirm install — the `@match` list covers both sites. 3. Confirm install — the `@match` list covers both sites.
4. Open a series on asurascans.com or demonicscans.org → a 📑 button appears 4. Open a series on asurascans.com or demonicscans.org → a 📑 button appears
bottom-right → tap → **+ Bookmark this**. bottom-right → tap → **+ Bookmark this**.
@@ -229,6 +240,9 @@ used.
Optional desktop test: the script is `GM_*`-free, so the same file installs in Optional desktop test: the script is `GM_*`-free, so the same file installs in
Tampermonkey/Violentmonkey for quick checks before going mobile. Tampermonkey/Violentmonkey for quick checks before going mobile.
Rotating the credential in the same web-UI panel invalidates every installed
copy immediately — reinstall on all devices, or they silently stop syncing.
--- ---
## 6. Smoke-test the full loop ## 6. Smoke-test the full loop
@@ -265,9 +279,10 @@ it; see `REDEPLOY.md` §1 for when to remove it.)
| No cert / TLS error at the domain | `TRAEFIK_ENTRYPOINT` or `TRAEFIK_CERTRESOLVER` name wrong; or DNS not resolving yet. Check `docker logs <traefik>`. | | No cert / TLS error at the domain | `TRAEFIK_ENTRYPOINT` or `TRAEFIK_CERTRESOLVER` name wrong; or DNS not resolving yet. Check `docker logs <traefik>`. |
| 404 from Traefik | Service not on the `proxy` network, or `BOOKMARK_API_HOST` mismatch. Confirm `docker network inspect proxy` lists `bookmark-api`. | | 404 from Traefik | Service not on the `proxy` network, or `BOOKMARK_API_HOST` mismatch. Confirm `docker network inspect proxy` lists `bookmark-api`. |
| `fetch` fails in the userscript, `curl` works | Origin missing from `ALLOWED_ORIGINS`, or mixed content (backend not HTTPS). | | `fetch` fails in the userscript, `curl` works | Origin missing from `ALLOWED_ORIGINS`, or mixed content (backend not HTTPS). |
| 401 with the right token | Trailing space/newline in `API_TOKEN`; regenerate and restart. | | 401 with the right credential | The script's credential no longer matches the stored hash — most likely a rotation happened and the device was not reinstalled. Reinstall from the web UI. |
| 401 after rotation, even right after reinstalling | `TOKEN_KEY` changed between the rotation and the reinstall; credentials are derived from it, so changing it invalidates every credential. Keep it stable. |
| Panel button absent | URL didn't match an adapter, or user scripts disabled in Bromite. | | Panel button absent | URL didn't match an adapter, or user scripts disabled in Bromite. |
| `compose ... config` errors about `API_TOKEN`, `OWNER_DISCORD_ID` or `POSTGRES_PASSWORD` | Run compose from the dir with `.env`, or export the vars. All three are required and none has a fallback. | | `compose ... config` errors about `TOKEN_KEY`, `OWNER_DISCORD_ID` or `POSTGRES_PASSWORD` | Run compose from the dir with `.env`, or export the vars. All three are required and none has a fallback. |
| `bookmark-api` restarts in a loop, `password authentication failed for user "bookmarks"` | `POSTGRES_PASSWORD` was changed after first boot; Postgres only applies it to an empty `postgres-data`. Restore the old value, or reset the role (`REDEPLOY.md` troubleshooting). | | `bookmark-api` restarts in a loop, `password authentication failed for user "bookmarks"` | `POSTGRES_PASSWORD` was changed after first boot; Postgres only applies it to an empty `postgres-data`. Restore the old value, or reset the role (`REDEPLOY.md` troubleshooting). |
| `bookmark-api` never logs `listening on :8080` | It is blocked on `postgres` passing `pg_isready`, or a migration failed. `docker compose -f docker-compose.yml -f docker-compose.prod.yml logs postgres`. | | `bookmark-api` never logs `listening on :8080` | It is blocked on `postgres` passing `pg_isready`, or a migration failed. `docker compose -f docker-compose.yml -f docker-compose.prod.yml logs postgres`. |
@@ -278,20 +293,25 @@ Backend config reference and endpoint list: see `README.md`.
## Installing / updating the userscript ## Installing / updating the userscript
The backend serves the script itself, so Violentmonkey can auto-update it. The backend serves the script itself, so Violentmonkey can auto-update it.
Complements §4 above — that step points `API_BASE`/`API_TOKEN` at your Complements §4 above — the `@downloadURL`/`@updateURL` lines point at the
backend; this one points `@downloadURL`/`@updateURL` at the same place so credential-bearing path, so auto-updates come from the same place as the
auto-updates come from it too. install.
Install once, on the phone (Cromite + Violentmonkey): Install once, on the phone (Cromite + Violentmonkey): sign in to the web UI,
open the **Userscripts** panel, and open the install link for the library —
the script is served with your credential already inside it. Its
`@downloadURL`/`@updateURL` point at the same credential-bearing path for
updates:
``` ```
https://bookmark-api.<your-domain>/u/<API_TOKEN>/manga-bookmark.user.js https://bookmark-api.<your-domain>/u/<your credential>/manga-bookmark.user.js
``` ```
Open that URL in Cromite; Violentmonkey offers to install it. The token is in Violentmonkey offers to install it. The credential is in the path because
the path because Violentmonkey's update poll sends no `Authorization` header, Violentmonkey's update poll sends no `Authorization` header, and the script
and the script embeds `API_TOKEN` in plain text — an open URL would leak it. A embeds the credential in plain text — an open URL would leak it. A wrong
wrong token answers 404. credential answers 404. The credential is derived from `TOKEN_KEY` and never
appears anywhere but this URL and the rendered script.
Updating, without a redeploy: Updating, without a redeploy:
+18 -15
View File
@@ -25,7 +25,9 @@ Bromite userscript (isolated world, Shadow DOM UI, localStorage cache)
| Var | Default | Notes | | Var | Default | Notes |
|-----|---------|-------| |-----|---------|-------|
| `API_TOKEN` | *(required)* | Bearer token shared with the userscript. | | `TOKEN_KEY` | *(required)* | Secret every Reader's userscript credential is derived from (issue #24); only SHA-256 hashes of credentials are stored. |
| `API_TOKEN` | *(retired)* | Global credential, honoured only until `API_TOKEN_GRACE_UNTIL` for already-installed scripts; remove both after the window. |
| `API_TOKEN_GRACE_UNTIL` | unset | Moment the retired credential stops resolving to the owner (`YYYY-MM-DD` or RFC3339), enforced in code. |
| `OWNER_DISCORD_ID` | *(required)* | Discord user ID of the owner; seeds the one Reader all bookmarks belong to. | | `OWNER_DISCORD_ID` | *(required)* | Discord user ID of the owner; seeds the one Reader all bookmarks belong to. |
| `ALLOWED_ORIGINS` | Asura + Demonic + Comix + Kagane origins | Comma-separated CORS allowlist. | | `ALLOWED_ORIGINS` | Asura + Demonic + Comix + Kagane origins | Comma-separated CORS allowlist. |
| `DATABASE_URL` | *(required)* | Postgres connection URL, e.g. `postgres://bookmarks:…@postgres:5432/bookmarks?sslmode=disable`. Compose builds it from `POSTGRES_PASSWORD`. | | `DATABASE_URL` | *(required)* | Postgres connection URL, e.g. `postgres://bookmarks:…@postgres:5432/bookmarks?sslmode=disable`. Compose builds it from `POSTGRES_PASSWORD`. |
@@ -36,11 +38,11 @@ Bromite userscript (isolated world, Shadow DOM UI, localStorage cache)
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
|--------|------|------|-------------| |--------|------|------|-------------|
| `GET` | `/bookmarks` | Bearer | All bookmarks (single-user). | | `GET` | `/bookmarks` | Bearer | All bookmarks of the acting Reader. |
| `PUT` | `/bookmarks/{key}` | Bearer | Upsert one series; returns the row as stored. | | `PUT` | `/bookmarks/{key}` | Bearer | Upsert one series; returns the row as stored. |
| `DELETE` | `/bookmarks/{key}` | Bearer | Remove one. | | `DELETE` | `/bookmarks/{key}` | Bearer | Remove one. |
| `GET` | `/healthz` | none | `200 ok`. | | `GET` | `/healthz` | none | `200 ok`. |
| `GET` | `/u/{token}/manga-bookmark.user.js` | token in path | Serves the userscript with an mtime-derived `@version`. | | `GET` | `/u/{token}/manga-bookmark.user.js` | credential in path | Serves the userscript with the requesting Reader's credential substituted in and an mtime-derived `@version`. |
`key` is `<site>:<series_id>` — e.g. `asura:trash-of-the-counts-family-f886a8af`, `key` is `<site>:<series_id>` — e.g. `asura:trash-of-the-counts-family-f886a8af`,
`demonic:Infinite-Level-Up-in-Murim`, `comix:12345`, or `demonic:Infinite-Level-Up-in-Murim`, `comix:12345`, or
@@ -70,7 +72,7 @@ and nothing reaches the network beyond the local Docker daemon.
```bash ```bash
cp .env.example .env cp .env.example .env
# edit .env: set API_TOKEN (openssl rand -hex 32) and # edit .env: set TOKEN_KEY (openssl rand -hex 32) and
# POSTGRES_PASSWORD (openssl rand -hex 24) # POSTGRES_PASSWORD (openssl rand -hex 24)
docker compose up -d --build # binds 127.0.0.1:8080 docker compose up -d --build # binds 127.0.0.1:8080
@@ -114,17 +116,18 @@ CORS headers.
## 2. Userscript ## 2. Userscript
### Configure ### Install
Edit the config block at the top of `userscript/manga-bookmark.user.js`: Sign in to the web UI and open the **Userscripts** panel: it offers one
install link per library. Each link serves a script rendered with your own
credential already inside it — you never see, type or copy a credential. The
served script carries `@downloadURL`/`@updateURL` pointing at its
credential-bearing path, so Violentmonkey keeps auto-updating it.
```js The bindmounted files carry `__API_TOKEN__` placeholders; the backend
const API_BASE = "https://bookmark-api.<domain>"; // no trailing slash substitutes the requesting Reader's credential at serve time, so no real
const API_TOKEN = "<same token as backend>"; credential is ever committed. Rotating the credential (same panel) invalidates
``` every installed copy immediately — reinstall on all devices.
The token lives in the userscript's **isolated world** — the manga sites' own
JS cannot read it.
### Install on Bromite (mobile) ### Install on Bromite (mobile)
@@ -132,8 +135,8 @@ Bromite runs Chromium's native userscript engine (no Tampermonkey needed):
1. Bromite → **Settings → User scripts** → enable user scripts (allow the 1. Bromite → **Settings → User scripts** → enable user scripts (allow the
permission prompt). permission prompt).
2. Save the configured `manga-bookmark.user.js` to the device (or open its raw 2. Open the install link from the web UI — Bromite detects the `.user.js` and
URL). Bromite detects the `.user.js` and offers to install it. offers to install it.
3. Confirm the install; the `@match` list covers both sites. 3. Confirm the install; the `@match` list covers both sites.
4. Open a series on either site — a 📑 button appears bottom-right. 4. Open a series on either site — a 📑 button appears bottom-right.
+2
View File
@@ -224,6 +224,8 @@ Same four API checks as `DEPLOY.md` §3, plus the web UI. Set the host names onc
```bash ```bash
API=https://bookmark-api.violetcrown.my.id API=https://bookmark-api.violetcrown.my.id
WEB=https://bookmark.violetcrown.my.id WEB=https://bookmark.violetcrown.my.id
# During the grace window the retired global credential still resolves to the
# owner; afterwards it is 401 like any other wrong credential.
TOKEN=$(grep -E '^API_TOKEN=' .env | cut -d= -f2) TOKEN=$(grep -E '^API_TOKEN=' .env | cut -d= -f2)
curl -s $API/healthz # -> ok curl -s $API/healthz # -> ok
+24 -11
View File
@@ -21,9 +21,9 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
container per test binary (`TestMain` -> `pgtest.Main`) and hands each test container per test binary (`TestMain` -> `pgtest.Main`) and hands each test
its own database (`pgtest.URL(t)`). A package whose tests touch the store its own database (`pgtest.URL(t)`). A package whose tests touch the store
must have that `TestMain`. must have that `TestMain`.
- **Single-owner store, three tables.** `readers` is keyed by Discord user ID - **Single-owner store, four tables.** `readers` is keyed by Discord user ID
and carries the SHA-256 of the owner's userscript token (the global and carries the SHA-256 of the Reader's userscript credential plus a
`API_TOKEN` today; issue #22). The seed creates exactly one row at startup. `token_epoch` (issue #24). Credentials are derived, never stored: `token.Token(TOKEN_KEY, discord_id, epoch)` (HMAC, `internal/token`), and only its SHA-256 sits in `readers.token_sha256`, so install URLs can be rebuilt after any restart while a database leak yields nothing but hashes. The seed creates exactly one row at startup; its epoch-0 hash is refreshed on every start **only while the row has never been rotated**, so a restart can never resurrect a rotated-away credential. Rotation is `Store.RotateToken` (epoch bump + hash rewrite in one transaction), driven by the web UI.
`series` keyed `(site, series_id)` `series` keyed `(site, series_id)`
(`asura`|`demonic`|`comix`|`kagane`|`novelfull`|`lightnovelworld`) owns the (`asura`|`demonic`|`comix`|`kagane`|`novelfull`|`lightnovelworld`) owns the
shared facts — title, cover, canonical URL, `kind` (`manga`|`novel`), shared facts — title, cover, canonical URL, `kind` (`manga`|`novel`),
@@ -31,12 +31,14 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
differs between readers: progress, favourite, lifecycle bucket, differs between readers: progress, favourite, lifecycle bucket,
`updated_at`. A bookmark is keyed `(reader_id, site, series_id)` — no `updated_at`. A bookmark is keyed `(reader_id, site, series_id)` — no
surrogate id; the wire `key` is derived as `site:series_id` on read — and surrogate id; the wire `key` is derived as `site:series_id` on read — and
every store read/write is scoped to the reader it names. `Store.OwnerID()` every store read/write is scoped to the reader it names. Auth resolves the
is the seeded owner, which every handler passes while the global token is acting Reader from the presented credential (`httpmw.Auth`), and the
still the only credential. Sync **last-write-wins**; the wire format stays reader id travels in the request context; the retired global `API_TOKEN`
flat (ADR-0004). `Store.Upsert` decomposes one flat body across two tables additionally resolves to the owner until `API_TOKEN_GRACE_UNTIL`, with
and enforces the ownership rule: client `title`/`series_url`/`cover` are every such acceptance logged. Sync **last-write-wins**; the wire format
written only when the series row is new (ADR-0003). stays flat (ADR-0004). `Store.Upsert` decomposes one flat body across two
tables and enforces the ownership rule: client `title`/`series_url`/`cover`
are written only when the series row is new (ADR-0003).
- **Endpoints:** `GET /bookmarks`, `PUT /bookmarks/{key}` (upsert; see `updated_at` rule below), `DELETE /bookmarks/{key}`, `GET /healthz` (no auth). - **Endpoints:** `GET /bookmarks`, `PUT /bookmarks/{key}` (upsert; see `updated_at` rule below), `DELETE /bookmarks/{key}`, `GET /healthz` (no auth).
- **Web UI:** same binary serve the browser UI on a second - **Web UI:** same binary serve the browser UI on a second
hostname — `GET /` (list, or login page when no session), hostname — `GET /` (list, or login page when no session),
@@ -101,7 +103,10 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
`excluded.*` is post-evaluation row and default applied there would `excluded.*` is post-evaluation row and default applied there would
wipe bucket on every PUT from client that predates column. See wipe bucket on every PUT from client that predates column. See
`docs/superpowers/specs/2026-07-27-status-buckets-design.md`. `docs/superpowers/specs/2026-07-27-status-buckets-design.md`.
- **Config via env:** `API_TOKEN`, `OWNER_DISCORD_ID` (seeds the owner Reader; - **Config via env:** `TOKEN_KEY` (derives every Reader's userscript credential;
required), `API_TOKEN` + `API_TOKEN_GRACE_UNTIL` (retired global credential
and the moment it stops resolving to the owner — both removed after the
cutover window, enforced in code), `OWNER_DISCORD_ID` (seeds the owner Reader;
required), `ALLOWED_ORIGINS` (comma list), required), `ALLOWED_ORIGINS` (comma list),
`DATABASE_URL` (Postgres connection URL, required — no default), `DATABASE_URL` (Postgres connection URL, required — no default),
`PORT` (default `8080`), `DISCORD_CLIENT_ID`/`_CLIENT_SECRET`/`_GUILD_ID`/ `PORT` (default `8080`), `DISCORD_CLIENT_ID`/`_CLIENT_SECRET`/`_GUILD_ID`/
@@ -113,7 +118,15 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
`USERSCRIPT_PATH` and `NOVEL_USERSCRIPT_PATH` (files served at `USERSCRIPT_PATH` and `NOVEL_USERSCRIPT_PATH` (files served at
`/u/{token}/manga-bookmark.user.js` and `/u/{token}/novel-bookmark.user.js`, `/u/{token}/manga-bookmark.user.js` and `/u/{token}/novel-bookmark.user.js`,
defaults `/userscript/manga-bookmark.user.js` and defaults `/userscript/manga-bookmark.user.js` and
`/userscript/novel-bookmark.user.js`, both supplied by bindmount). `/userscript/novel-bookmark.user.js`, both supplied by bindmount; the
`__API_TOKEN__` placeholder inside them is substituted with the requesting
Reader's credential at serve time).
`BROWSER_WS_URL` (headless-shell CDP endpoint for kagane and novelfull; `BROWSER_WS_URL` (headless-shell CDP endpoint for kagane and novelfull;
unset disables browser polling and leaves those sites to the userscript unset disables browser polling and leaves those sites to the userscript
alone). alone).
- **Web UI also owns:** session-gated `GET /install/{manga,novel}-bookmark.user.js`
(renders the bindmounted script with the acting Reader's derived credential
substituted in — the credential never appears in page markup, the address
bar, or a redirect) and `POST /rotate-token` (atomic epoch bump + hash
rewrite; invalidates every installed copy, so the panel warns to reinstall
on all devices).
+29 -8
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"bytes" "bytes"
"crypto/sha256"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -15,18 +14,36 @@ import (
"bookmarkmanager/backend/internal/pgtest" "bookmarkmanager/backend/internal/pgtest"
"bookmarkmanager/backend/internal/store" "bookmarkmanager/backend/internal/store"
"bookmarkmanager/backend/internal/token"
) )
const testToken = "s3cret-token" const testToken = "s3cret-token"
// testTokenKey derives every test Reader's credential; it must match the key
// newTestStoreURL seeds the owner with, or derived credentials authenticate
// nothing.
const testTokenKey = "test-token-key"
// testDiscordID is the owner row's discord_id (newTestStoreURL); the derived
// credential is a function of it.
const testDiscordID = "test-owner"
func testConfig() Config { func testConfig() Config {
return Config{ return Config{
Token: testToken, Token: testToken,
TokenKey: testTokenKey,
GraceUntil: time.Now().Add(24 * time.Hour),
AllowedOrigins: []string{"https://asuracomic.net", "https://demonicscans.org"}, AllowedOrigins: []string{"https://asuracomic.net", "https://demonicscans.org"},
Port: "8080", Port: "8080",
} }
} }
// ownerCredential is the owner's epoch-0 derived credential: the string the
// install links carry and the userscript routes authenticate.
func ownerCredential() string {
return token.Token([]byte(testTokenKey), testDiscordID, 0)
}
func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) } func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) }
func newTestServer(t *testing.T) http.Handler { func newTestServer(t *testing.T) http.Handler {
@@ -46,7 +63,7 @@ func newTestStoreURL(t *testing.T) (*store.Store, string) {
t.Helper() t.Helper()
url := pgtest.URL(t) url := pgtest.URL(t)
s, err := store.Open(url, store.Owner{ s, err := store.Open(url, store.Owner{
DiscordID: "test-owner", TokenHash: sha256.Sum256([]byte("owner-token-hash")), DiscordID: testDiscordID, TokenHash: token.Hash(ownerCredential()),
}) })
if err != nil { if err != nil {
t.Fatalf("store.Open: %v", err) t.Fatalf("store.Open: %v", err)
@@ -602,10 +619,11 @@ func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
// The userscript route is registered outside the web UI's Discord auth, so it // The userscript route is registered outside the web UI's Discord auth, so it
// must keep working whatever the web config — see internal/userscript for the // must keep working whatever the web config — see internal/userscript for the
// handler's own behaviour. // handler's own behaviour. The credential in the path is the owner's derived
// one, and the served script carries it substituted in.
func TestUserscriptServedWithWebUIDisabled(t *testing.T) { func TestUserscriptServedWithWebUIDisabled(t *testing.T) {
path := filepath.Join(t.TempDir(), "manga-bookmark.user.js") path := filepath.Join(t.TempDir(), "manga-bookmark.user.js")
if err := os.WriteFile(path, []byte("console.log(1);\n"), 0o644); err != nil { if err := os.WriteFile(path, []byte("const API_TOKEN = \"__API_TOKEN__\";\n"), 0o644); err != nil {
t.Fatalf("write script: %v", err) t.Fatalf("write script: %v", err)
} }
@@ -614,15 +632,18 @@ func TestUserscriptServedWithWebUIDisabled(t *testing.T) {
cfg.UserscriptPath = path cfg.UserscriptPath = path
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/u/"+testToken+"/manga-bookmark.user.js", nil) req := httptest.NewRequest(http.MethodGet, "/u/"+ownerCredential()+"/manga-bookmark.user.js", nil)
newRouter(s, cfg).ServeHTTP(rr, req) newRouter(s, cfg).ServeHTTP(rr, req)
if rr.Code != http.StatusOK { if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code) t.Fatalf("status = %d, want 200", rr.Code)
} }
if got := rr.Body.String(); !strings.Contains(got, `API_TOKEN = "`+ownerCredential()+`"`) {
t.Fatalf("served script does not carry the requesting Reader's credential:\n%s", got)
}
} }
// Both scripts are served from the same handler on the same token, outside the // Both scripts are served from the same handler, outside the web UI's auth —
// web UI's auth — a wrong token is a 404, never a 401. // a wrong credential is a 404, never a 401.
func TestNovelUserscriptServed(t *testing.T) { func TestNovelUserscriptServed(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
novelPath := filepath.Join(dir, "novel-bookmark.user.js") novelPath := filepath.Join(dir, "novel-bookmark.user.js")
@@ -638,7 +659,7 @@ func TestNovelUserscriptServed(t *testing.T) {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
"/u/"+testToken+"/novel-bookmark.user.js", nil)) "/u/"+ownerCredential()+"/novel-bookmark.user.js", nil))
if rr.Code != http.StatusOK { if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code) t.Fatalf("status = %d, want 200", rr.Code)
} }
+4 -6
View File
@@ -7,15 +7,13 @@ import (
"strings" "strings"
"time" "time"
"bookmarkmanager/backend/internal/httpmw"
"bookmarkmanager/backend/internal/store" "bookmarkmanager/backend/internal/store"
) )
// Handler serves the userscript-facing JSON bookmark API. // Handler serves the userscript-facing JSON bookmark API.
type Handler struct { type Handler struct {
Store *store.Store Store *store.Store
// ReaderID is the Reader this request acts as. Authentication is still the
// single global token, so that is always the seeded owner (issue #22).
ReaderID int64
} }
func writeJSON(w http.ResponseWriter, status int, v any) { func writeJSON(w http.ResponseWriter, status int, v any) {
@@ -30,7 +28,7 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
// List returns all bookmarks of the acting Reader. GET /bookmarks // List returns all bookmarks of the acting Reader. GET /bookmarks
func (h *Handler) List(w http.ResponseWriter, r *http.Request) { func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
items, err := h.Store.List(h.ReaderID) items, err := h.Store.List(httpmw.ReaderID(r))
if err != nil { if err != nil {
log.Printf("list: %v", err) log.Printf("list: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
@@ -94,7 +92,7 @@ func (h *Handler) Put(w http.ResponseWriter, r *http.Request) {
// reading progress actually moved. Any client value is ignored. // reading progress actually moved. Any client value is ignored.
b.UpdatedAt = time.Now().UnixMilli() b.UpdatedAt = time.Now().UnixMilli()
stored, err := h.Store.Upsert(h.ReaderID, b) stored, err := h.Store.Upsert(httpmw.ReaderID(r), b)
if err != nil { if err != nil {
log.Printf("upsert: %v", err) log.Printf("upsert: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
@@ -112,7 +110,7 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
http.Error(w, "missing key", http.StatusBadRequest) http.Error(w, "missing key", http.StatusBadRequest)
return return
} }
if err := h.Store.Delete(h.ReaderID, key); err != nil { if err := h.Store.Delete(httpmw.ReaderID(r), key); err != nil {
log.Printf("delete: %v", err) log.Printf("delete: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
+46 -6
View File
@@ -2,28 +2,68 @@ package httpmw
import ( import (
"compress/gzip" "compress/gzip"
"context"
"crypto/subtle" "crypto/subtle"
"log"
"net/http" "net/http"
"strings" "strings"
"time"
"bookmarkmanager/backend/internal/store"
"bookmarkmanager/backend/internal/token"
) )
const bearerPrefix = "Bearer " const bearerPrefix = "Bearer "
// Auth guards a handler with a constant-time bearer-token check. type ctxKey int
func Auth(token string, next http.Handler) http.Handler {
want := []byte(token) // readerCtxKey is where Auth stashes the authenticated Reader id.
const readerCtxKey ctxKey = iota
// ReaderID returns the Reader id Auth authenticated, for handlers that take
// the acting Reader from the request rather than from a fixed field.
func ReaderID(r *http.Request) int64 { return r.Context().Value(readerCtxKey).(int64) }
// ResolveReader maps a presented credential to a Reader. The credential is
// hashed and matched against readers.token_sha256 — an equality on 32-byte
// values, never a comparison of the credential itself — and, during the
// cutover window, the retired global token resolves to the owner. Every
// legacy acceptance is logged so the window can be confirmed empty before
// the token is removed. The same resolution backs the API bearer header and
// the userscript download path, so the window covers both.
func ResolveReader(s *store.Store, legacy string, graceUntil time.Time, cred string) (int64, bool) {
if readerID, ok, err := s.ReaderIDForTokenHash(token.Hash(cred)); err != nil {
log.Printf("auth: reader lookup: %v", err)
return 0, false
} else if ok {
return readerID, true
}
if legacy != "" && time.Now().Before(graceUntil) &&
subtle.ConstantTimeCompare([]byte(cred), []byte(legacy)) == 1 {
log.Printf("auth: retired global token accepted for owner reader %d (grace until %s)",
s.OwnerID(), graceUntil.Format(time.RFC3339))
return s.OwnerID(), true
}
return 0, false
}
// Auth guards a handler with a per-Reader bearer credential. The acting
// Reader travels in the request context, so a handler scopes every store call
// to exactly the Reader that authenticated.
func Auth(s *store.Store, legacy string, graceUntil time.Time, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := r.Header.Get("Authorization") h := r.Header.Get("Authorization")
if !strings.HasPrefix(h, bearerPrefix) { if !strings.HasPrefix(h, bearerPrefix) {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
return return
} }
got := []byte(strings.TrimPrefix(h, bearerPrefix)) readerID, ok := ResolveReader(s, legacy, graceUntil, strings.TrimPrefix(h, bearerPrefix))
if subtle.ConstantTimeCompare(got, want) != 1 { if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
return return
} }
next.ServeHTTP(w, r) next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), readerCtxKey, readerID)))
}) })
} }
@@ -0,0 +1,7 @@
-- Rotation is an epoch bump: a Reader's credential is derived from the
-- deployment secret, their Discord id and this epoch, so bumping it issues a
-- new credential and the rewritten token_sha256 invalidates the old one the
-- moment the transaction commits. The seed's ON CONFLICT refresh (Store.Open)
-- is gated on this being 0, so a restart can never undo a rotation by
-- restoring the epoch-0 hash.
ALTER TABLE readers ADD COLUMN token_epoch bigint NOT NULL DEFAULT 0;
+94 -10
View File
@@ -171,12 +171,12 @@ const seriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover,
// Owner is the person running the service: the first Reader, and the only one // Owner is the person running the service: the first Reader, and the only one
// until registration exists. The seed makes sure exactly one readers row // until registration exists. The seed makes sure exactly one readers row
// matches their Discord ID, carrying the SHA-256 of their userscript token — // matches their Discord ID, carrying the SHA-256 of their epoch-0 userscript
// which today is the global API token. // credential (derived by internal/token, not the retired global token).
type Owner struct { type Owner struct {
DiscordID string DiscordID string
// TokenHash is the SHA-256 of the userscript token; the array shape makes // TokenHash is the SHA-256 of the epoch-0 credential; the array shape
// it a compile error to store anything that is not a hash. // makes it a compile error to store anything that is not a hash.
TokenHash [32]byte TokenHash [32]byte
} }
@@ -189,10 +189,69 @@ type Store struct {
ownerID int64 ownerID int64
} }
// OwnerID returns the seeded owner Reader's id — the Reader every request // OwnerID returns the seeded owner Reader's id — the Reader the retired
// acts as while the global token is still the only credential. // global token resolves to during the grace window, and the only Reader while
// registration is closed.
func (s *Store) OwnerID() int64 { return s.ownerID } func (s *Store) OwnerID() int64 { return s.ownerID }
// ReaderIDForTokenHash resolves the Reader whose stored credential hash
// matches, reporting absence with ok=false. The comparison is an equality on
// the 32-byte SHA-256 of the presented credential — never on the credential
// itself — and the indexed lookup reveals only whether some Reader matches,
// which the 401/200 split has to reveal anyway. An attacker's probe is the
// hash of their guess, so even the index's prefix comparisons leak nothing
// about the real credential.
func (s *Store) ReaderIDForTokenHash(hash [32]byte) (int64, bool, error) {
var id int64
err := s.db.QueryRow(
`SELECT id FROM readers WHERE token_sha256 = $1`, hash[:]).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return 0, false, nil
}
if err != nil {
return 0, false, fmt.Errorf("reader by token hash: %w", err)
}
return id, true, nil
}
// ReaderTokenInfo returns the identity halves a Reader's credential is
// derived from (internal/token.Token): their Discord id and token epoch. The
// web UI needs these to rebuild the install URL — the only place a credential
// is ever produced in plaintext.
func (s *Store) ReaderTokenInfo(readerID int64) (string, int64, error) {
var (
discordID string
epoch int64
)
err := s.db.QueryRow(
`SELECT discord_id, token_epoch FROM readers WHERE id = $1`, readerID).
Scan(&discordID, &epoch)
if err != nil {
return "", 0, fmt.Errorf("reader %d token info: %w", readerID, err)
}
return discordID, epoch, nil
}
// RotateToken bumps a Reader's token epoch and rewrites the stored hash in
// one statement, so the new hash always matches the new epoch. expectedEpoch
// is the epoch the caller derived newHash for (ReaderTokenInfo + 1); a
// concurrent rotation — or an unknown reader — leaves the row untouched and
// is reported as an error rather than silently succeeding.
func (s *Store) RotateToken(readerID, expectedEpoch int64, newHash [32]byte) error {
var epoch int64
err := s.db.QueryRow(`
UPDATE readers SET token_epoch = token_epoch + 1, token_sha256 = $3
WHERE id = $1 AND token_epoch = $2
RETURNING token_epoch`, readerID, expectedEpoch, newHash[:]).Scan(&epoch)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("rotate token for reader %d: concurrent rotation or unknown reader", readerID)
}
if err != nil {
return fmt.Errorf("rotate token for reader %d: %w", readerID, err)
}
return nil
}
// readersMigration is the version that creates the readers table. The owner // readersMigration is the version that creates the readers table. The owner
// seed runs between two migrate passes, so that the run-once migration which // seed runs between two migrate passes, so that the run-once migration which
// attaches existing bookmarks (0004) finds the owner row. // attaches existing bookmarks (0004) finds the owner row.
@@ -217,6 +276,10 @@ func Open(url string, owner Owner) (*Store, error) {
db.Close() db.Close()
return nil, fmt.Errorf("migrate schema: %w", err) return nil, fmt.Errorf("migrate schema: %w", err)
} }
// The owner row must exist before 0004 attaches the existing bookmarks to
// it. The hash refresh is a separate statement after all migrations: the
// token_epoch column 0006 adds does not exist yet at this point, and the
// refresh only ever concerns rows that have never been rotated.
if err := seedOwner(db, owner); err != nil { if err := seedOwner(db, owner); err != nil {
db.Close() db.Close()
return nil, fmt.Errorf("seed owner: %w", err) return nil, fmt.Errorf("seed owner: %w", err)
@@ -225,6 +288,10 @@ func Open(url string, owner Owner) (*Store, error) {
db.Close() db.Close()
return nil, fmt.Errorf("migrate: %w", err) return nil, fmt.Errorf("migrate: %w", err)
} }
if err := refreshOwnerToken(db, owner); err != nil {
db.Close()
return nil, fmt.Errorf("refresh owner token: %w", err)
}
var ownerID int64 var ownerID int64
if err := db.QueryRow( if err := db.QueryRow(
`SELECT id FROM readers WHERE discord_id = $1`, owner.DiscordID).Scan(&ownerID); err != nil { `SELECT id FROM readers WHERE discord_id = $1`, owner.DiscordID).Scan(&ownerID); err != nil {
@@ -234,19 +301,36 @@ func Open(url string, owner Owner) (*Store, error) {
return &Store{db: db, ownerID: ownerID}, nil return &Store{db: db, ownerID: ownerID}, nil
} }
// seedOwner makes sure the configured owner exists as exactly one readers row, // seedOwner makes sure the configured owner exists as exactly one readers row.
// and keeps its token hash current on every start: rotating the userscript // The hash is only ever written here for a brand-new row; existing rows keep
// token must refresh the hash, or the stored credential goes stale. // what they have until refreshOwnerToken decides otherwise, so the seed can
// never clobber a rotation.
func seedOwner(db *sql.DB, o Owner) error { func seedOwner(db *sql.DB, o Owner) error {
if _, err := db.Exec(` if _, err := db.Exec(`
INSERT INTO readers (discord_id, token_sha256) VALUES ($1, $2) INSERT INTO readers (discord_id, token_sha256) VALUES ($1, $2)
ON CONFLICT (discord_id) DO UPDATE SET token_sha256 = EXCLUDED.token_sha256`, ON CONFLICT (discord_id) DO NOTHING`,
o.DiscordID, o.TokenHash[:]); err != nil { o.DiscordID, o.TokenHash[:]); err != nil {
return fmt.Errorf("seed owner: %w", err) return fmt.Errorf("seed owner: %w", err)
} }
return nil return nil
} }
// refreshOwnerToken brings a never-rotated owner row's hash current with the
// configured credential. That is the cutover path: a database seeded under
// the retired global token still carries its hash at epoch 0, and the
// epoch-0 derivation is the caller's TokenHash. A rotated row (epoch > 0) is
// left alone — a restart must not resurrect the old credential by
// overwriting the hash a rotation wrote.
func refreshOwnerToken(db *sql.DB, o Owner) error {
if _, err := db.Exec(`
UPDATE readers SET token_sha256 = $2
WHERE discord_id = $1 AND token_epoch = 0`,
o.DiscordID, o.TokenHash[:]); err != nil {
return fmt.Errorf("refresh owner token: %w", err)
}
return nil
}
// migrate applies every embedded migration this database has not recorded, in // migrate applies every embedded migration this database has not recorded, in
// filename order, each in its own transaction. upto caps the highest version // filename order, each in its own transaction. upto caps the highest version
// applied; 0 means all. Files are named "<version>_<name>.sql" and are // applied; 0 means all. Files are named "<version>_<name>.sql" and are
+86
View File
@@ -75,6 +75,92 @@ func TestOpenIsIdempotent(t *testing.T) {
} }
} }
// The hash lookup is the whole authentication path: the store resolves a
// Reader from the SHA-256 of their presented credential, and nothing else.
func TestReaderIDForTokenHash(t *testing.T) {
store := newTestStore(t)
ownerHash := sha256.Sum256([]byte("owner-token-hash"))
id, ok, err := store.ReaderIDForTokenHash(ownerHash)
if err != nil {
t.Fatalf("ReaderIDForTokenHash: %v", err)
}
if !ok || id != store.OwnerID() {
t.Fatalf("owner lookup = (%d, %v), want (%d, true)", id, ok, store.OwnerID())
}
if _, ok, err := store.ReaderIDForTokenHash(sha256.Sum256([]byte("nope"))); err != nil {
t.Fatalf("miss: %v", err)
} else if ok {
t.Fatal("unknown hash resolved to a Reader")
}
}
func TestReaderTokenInfo(t *testing.T) {
store := newTestStore(t)
discordID, epoch, err := store.ReaderTokenInfo(store.OwnerID())
if err != nil {
t.Fatalf("ReaderTokenInfo: %v", err)
}
if discordID != testOwner.DiscordID || epoch != 0 {
t.Fatalf("ReaderTokenInfo = (%q, %d), want (%q, 0)", discordID, epoch, testOwner.DiscordID)
}
}
// Rotation swaps the stored hash and bumps the epoch in one step, and the
// seed must not undo it: a restart re-runs seedOwner, which refreshes the
// epoch-0 hash only while the row has never been rotated.
func TestRotateTokenInvalidatesOldAndSurvivesRestart(t *testing.T) {
url := pgtest.URL(t)
store, err := Open(url, testOwner)
if err != nil {
t.Fatalf("Open: %v", err)
}
oldHash := sha256.Sum256([]byte("owner-token-hash"))
newHash := sha256.Sum256([]byte("rotated-token-hash"))
if err := store.RotateToken(store.OwnerID(), 0, newHash); err != nil {
t.Fatalf("RotateToken: %v", err)
}
// A second rotation against the stale epoch is refused: the stored hash
// must never describe a different epoch than the column says.
if err := store.RotateToken(store.OwnerID(), 0, sha256.Sum256([]byte("third-hash"))); err == nil {
t.Fatal("stale-epoch rotation succeeded, want error")
}
if _, ok, err := store.ReaderIDForTokenHash(oldHash); err != nil {
t.Fatalf("old lookup: %v", err)
} else if ok {
t.Fatal("old hash still resolves after rotation")
}
if id, ok, err := store.ReaderIDForTokenHash(newHash); err != nil {
t.Fatalf("new lookup: %v", err)
} else if !ok || id != store.OwnerID() {
t.Fatalf("new hash resolved to (%d, %v), want owner", id, ok)
}
if _, epoch, err := store.ReaderTokenInfo(store.OwnerID()); err != nil {
t.Fatalf("ReaderTokenInfo: %v", err)
} else if epoch != 1 {
t.Fatalf("epoch = %d after rotation, want 1", epoch)
}
store.Close()
reopened, err := Open(url, testOwner)
if err != nil {
t.Fatalf("reopen: %v", err)
}
t.Cleanup(func() { reopened.Close() })
if _, ok, err := reopened.ReaderIDForTokenHash(oldHash); err != nil {
t.Fatalf("old lookup after reopen: %v", err)
} else if ok {
t.Fatal("restart resurrected the pre-rotation hash")
}
if _, ok, err := reopened.ReaderIDForTokenHash(newHash); err != nil {
t.Fatalf("new lookup after reopen: %v", err)
} else if !ok {
t.Fatal("restart dropped the rotated hash")
}
}
func TestStoreGet(t *testing.T) { func TestStoreGet(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
if _, err := store.Upsert(store.OwnerID(), Bookmark{ if _, err := store.Upsert(store.OwnerID(), Bookmark{
+37
View File
@@ -0,0 +1,37 @@
package token
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
)
// Token derives one Reader's userscript credential from the deployment
// secret, the Reader's Discord id and their token epoch.
//
// The credential is deterministic rather than stored random because the
// server must be able to rebuild the install URL after a restart while the
// database holds only hashes: a random token with no plaintext copy anywhere
// would be unreconstructible, and keeping plaintext in memory would break
// every install link on restart. HMAC output is high-entropy, indistinguishable
// from random to anyone without the secret, and changes whenever the epoch
// does — which is what rotation is. The stored form is Hash of this value,
// so a database leak yields nothing but hashes of unguessable strings.
func Token(key []byte, discordID string, epoch int64) string {
mac := hmac.New(sha256.New, key)
// The separator is unambiguous: discord ids are decimal snowflakes and
// epochs are plain integers, so no two (id, epoch) pairs can collide.
mac.Write([]byte(discordID))
mac.Write([]byte{0})
mac.Write([]byte(strconv.FormatInt(epoch, 10)))
return hex.EncodeToString(mac.Sum(nil))
}
// Hash is the SHA-256 of a credential — the only form that ever touches the
// database (readers.token_sha256). SHA-256 rather than a password hash is
// deliberate: these are unguessable values with nothing to brute-force, so a
// slow hash would only add per-request cost.
func Hash(cred string) [32]byte {
return sha256.Sum256([]byte(cred))
}
+53
View File
@@ -0,0 +1,53 @@
package token
import (
"bytes"
"crypto/sha256"
"testing"
)
func TestTokenDeterministicPerReaderAndEpoch(t *testing.T) {
key := []byte("deployment-secret")
a := Token(key, "reader-1", 0)
b := Token(key, "reader-1", 0)
if a != b {
t.Fatal("same (reader, epoch) derived different credentials")
}
if a == Token(key, "reader-2", 0) {
t.Fatal("different readers derived the same credential")
}
if a == Token(key, "reader-1", 1) {
t.Fatal("rotation epoch derived the same credential")
}
}
func TestTokenChangesWithSecret(t *testing.T) {
a := Token([]byte("key-1"), "reader-1", 0)
b := Token([]byte("key-2"), "reader-1", 0)
if a == b {
t.Fatal("different secrets derived the same credential")
}
}
func TestTokenFormat(t *testing.T) {
cred := Token([]byte("key"), "reader-1", 0)
// 32 bytes of HMAC-SHA256, hex-encoded: the length the install URL and
// the committed placeholder both assume.
if len(cred) != 64 {
t.Fatalf("credential length = %d, want 64", len(cred))
}
for _, c := range cred {
if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
t.Fatalf("credential contains non-hex byte %q", c)
}
}
}
func TestHashIsSha256OfCredential(t *testing.T) {
cred := Token([]byte("key"), "reader-1", 0)
got := Hash(cred)
want := sha256.Sum256([]byte(cred))
if !bytes.Equal(got[:], want[:]) {
t.Fatal("Hash is not the SHA-256 of the credential")
}
}
+68 -16
View File
@@ -1,14 +1,25 @@
package userscript package userscript
import ( import (
"crypto/subtle" "bytes"
"log" "log"
"net/http" "net/http"
"os" "os"
"regexp" "regexp"
"time" "time"
"bookmarkmanager/backend/internal/httpmw"
"bookmarkmanager/backend/internal/store"
"bookmarkmanager/backend/internal/token"
) )
// tokenPlaceholder is what the bindmounted userscript carries where the
// Reader's credential goes: in the API_TOKEN constant and in the @downloadURL
// and @updateURL metadata lines. The handler substitutes the requesting
// Reader's credential for it at serve time, so no credential literal is ever
// committed or deployed, and each Reader's copy carries exactly their own.
var tokenPlaceholder = []byte("__API_TOKEN__")
// versionLine matches the userscript metadata block's @version directive. // versionLine matches the userscript metadata block's @version directive.
var versionLine = regexp.MustCompile(`(?m)^// @version[ \t]+.*$`) var versionLine = regexp.MustCompile(`(?m)^// @version[ \t]+.*$`)
@@ -26,21 +37,21 @@ func stampVersion(src []byte, mod time.Time) []byte {
return versionLine.ReplaceAll(src, []byte("// @version "+mod.UTC().Format("2006.01.02.1504"))) return versionLine.ReplaceAll(src, []byte("// @version "+mod.UTC().Format("2006.01.02.1504")))
} }
// userscriptHandler serves the userscript to Violentmonkey's updater. // substituteToken replaces every tokenPlaceholder with the Reader's
// // credential. A file without the placeholder is returned unchanged so Render
// The token lives in the path because the update poll sends no Authorization // can warn about it rather than silently serving a credential-less script.
// header, and the file embeds API_TOKEN in plain text, so an open path would func substituteToken(src []byte, credential string) []byte {
// hand that token to anyone who guessed the URL. A mismatch answers 404 rather return bytes.ReplaceAll(src, tokenPlaceholder, []byte(credential))
// than 401: a prober learns nothing about whether the route exists.
//
// The file is read per request — that is what lets a bindmounted copy be edited
// on the host without a restart. It is ~50 KB and polled about once a day.
func Handler(token, path string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if subtle.ConstantTimeCompare([]byte(r.PathValue("token")), []byte(token)) != 1 {
http.NotFound(w, r)
return
} }
// Render writes one userscript file with the credential substituted and the
// mtime-derived version stamped. Shared by the download path (Handler) and
// the web UI's install endpoints, so both serve byte-identical scripts.
//
// The file is read per request — that is what lets a bindmounted copy be
// edited on the host without a restart. It is ~50 KB and polled about once a
// day.
func Render(w http.ResponseWriter, r *http.Request, path, credential string) {
info, err := os.Stat(path) info, err := os.Stat(path)
if err != nil { if err != nil {
log.Printf("userscript: stat %s: %v", path, err) log.Printf("userscript: stat %s: %v", path, err)
@@ -53,8 +64,49 @@ func Handler(token, path string) http.HandlerFunc {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
rendered := substituteToken(src, credential)
if bytes.Equal(rendered, src) {
// The bindmounted file was not built for per-Reader rendering. Serving
// it as written is the operator's freedom, but a credential-less copy
// is a deployment bug worth one log line — the symptom (silent 401s on
// every device) is otherwise indistinguishable from a network fault.
log.Printf("userscript: %s has no %s placeholder; serving as written", path, tokenPlaceholder)
}
w.Header().Set("Content-Type", "text/javascript; charset=utf-8") w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
w.Write(stampVersion(src, info.ModTime())) w.Write(stampVersion(rendered, info.ModTime()))
}
// Handler serves the userscript to Violentmonkey's updater, rendered for the
// Reader whose credential is in the path.
//
// The credential lives in the path because the update poll sends no
// Authorization header, and the rendered file embeds the credential in
// plaintext, so an open path would hand it to anyone who guessed the URL. A
// mismatch answers 404 rather than 401: a prober learns nothing about whether
// the route exists. The same credential authenticates the API bearer header,
// so the two are one secret with one blast radius.
//
// The credential substituted is the resolved Reader's derived one, not the
// raw path segment: while the retired global token is still accepted during
// the grace window (httpmw.ResolveReader), an already-installed script
// polling its legacy URL is served a copy carrying the Reader's own
// credential, so the next update poll migrates the device onto its per-Reader
// path — the window empties itself instead of ending in a silent 401 for
// every device that never visited the web UI.
func Handler(s *store.Store, tokenKey []byte, legacy string, graceUntil time.Time, path string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
readerID, ok := httpmw.ResolveReader(s, legacy, graceUntil, r.PathValue("token"))
if !ok {
http.NotFound(w, r)
return
}
discordID, epoch, err := s.ReaderTokenInfo(readerID)
if err != nil {
log.Printf("userscript: reader %d token info: %v", readerID, err)
http.NotFound(w, r)
return
}
Render(w, r, path, token.Token(tokenKey, discordID, epoch))
} }
} }
+39 -87
View File
@@ -1,115 +1,67 @@
package userscript package userscript
import ( import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
) )
const testToken = "s3cret-token"
// sampleScript is a stand-in for the real userscript: a metadata block with a // sampleScript is a stand-in for the real userscript: a metadata block with a
// @version line, plus a body that must survive the rewrite untouched. // @version line, the credential placeholder in its metadata and body, plus
// content that must survive the rewrites untouched.
const sampleScript = `// ==UserScript== const sampleScript = `// ==UserScript==
// @name Manga Bookmark Sync // @name Manga Bookmark Sync
// @version 1.5.0 // @version 1.5.0
// @downloadURL https://api.example/u/__API_TOKEN__/manga-bookmark.user.js
// @match https://asurascans.com/* // @match https://asurascans.com/*
// ==/UserScript== // ==/UserScript==
(function () { "use strict"; })(); (function () { "use strict";
const API_TOKEN = "__API_TOKEN__";
})();
` `
// writeScript drops a userscript in a temp dir with a known mtime and returns func TestStampVersionReplacesVersionLineOnly(t *testing.T) {
// its path plus the version string the handler is expected to stamp.
func writeScript(t *testing.T, body string) (path, wantVersion string) {
t.Helper()
path = filepath.Join(t.TempDir(), "manga-bookmark.user.js")
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write script: %v", err)
}
mod := time.Date(2026, 7, 28, 16, 42, 0, 0, time.UTC) mod := time.Date(2026, 7, 28, 16, 42, 0, 0, time.UTC)
if err := os.Chtimes(path, mod, mod); err != nil { got := string(stampVersion([]byte(sampleScript), mod))
t.Fatalf("chtimes: %v", err)
}
return path, "2026.07.28.1642"
}
// newTestMux registers Handler the same way main.go's router does, without if !strings.Contains(got, "// @version "+mod.UTC().Format("2006.01.02.1504")) {
// pulling in the store or the rest of the app. t.Errorf("body has no stamped version:\n%s", got)
func newTestMux(token, path string) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", Handler(token, path))
return mux
} }
if strings.Contains(got, "1.5.0") {
func getScript(t *testing.T, srv http.Handler, token string) *httptest.ResponseRecorder { t.Errorf("body still carries the file's own version:\n%s", got)
t.Helper()
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/u/"+token+"/manga-bookmark.user.js", nil))
return rr
} }
// Everything outside the @version line is served verbatim, including the
func TestUserscriptServedWithStampedVersion(t *testing.T) { // placeholder — stamping must not do the substitution's job.
path, wantVersion := writeScript(t, sampleScript) if !strings.Contains(got, `const API_TOKEN = "__API_TOKEN__";`) {
rr := getScript(t, newTestMux(testToken, path), testToken) t.Errorf("body was altered beyond the version line:\n%s", got)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
t.Errorf("Content-Type = %q, want text/javascript", ct)
}
if cc := rr.Header().Get("Cache-Control"); cc != "no-cache" {
t.Errorf("Cache-Control = %q, want no-cache", cc)
}
body := rr.Body.String()
if !strings.Contains(body, "// @version "+wantVersion) {
t.Errorf("body has no stamped version %q:\n%s", wantVersion, body)
}
if strings.Contains(body, "1.5.0") {
t.Errorf("body still carries the file's own version:\n%s", body)
}
// Everything outside the @version line is served verbatim.
if !strings.Contains(body, `(function () { "use strict"; })();`) {
t.Errorf("body was altered beyond the version line:\n%s", body)
}
if !strings.Contains(body, "// @name Manga Bookmark Sync") {
t.Errorf("metadata block was altered:\n%s", body)
} }
} }
// The empty-token case ("/u//manga-bookmark.user.js") is covered at the func TestStampVersionWithoutVersionLineServedUnmodified(t *testing.T) {
// router level (see backend's guardEmptyUserscriptToken): ServeMux 307s it to
// "/u/manga-bookmark.user.js" before this handler's own token check ever runs.
func TestUserscriptWrongTokenIs404(t *testing.T) {
path, _ := writeScript(t, sampleScript)
srv := newTestMux(testToken, path)
for _, tok := range []string{"wrong", testToken + "x", testToken[:3]} {
if got := getScript(t, srv, tok).Code; got != http.StatusNotFound {
t.Errorf("token %q: status = %d, want 404", tok, got)
}
}
}
func TestUserscriptMissingFileIs404(t *testing.T) {
srv := newTestMux(testToken, filepath.Join(t.TempDir(), "absent.user.js"))
if got := getScript(t, srv, testToken).Code; got != http.StatusNotFound {
t.Fatalf("status = %d, want 404", got)
}
}
func TestUserscriptWithoutVersionLineServedUnmodified(t *testing.T) {
const noVersion = "// ==UserScript==\n// @name x\n// ==/UserScript==\nconsole.log(1);\n" const noVersion = "// ==UserScript==\n// @name x\n// ==/UserScript==\nconsole.log(1);\n"
path, _ := writeScript(t, noVersion) if got := string(stampVersion([]byte(noVersion), time.Now())); got != noVersion {
rr := getScript(t, newTestMux(testToken, path), testToken) t.Errorf("stampVersion altered a file with no @version line:\n%s", got)
}
}
if rr.Code != http.StatusOK { func TestSubstituteTokenReplacesEveryPlaceholder(t *testing.T) {
t.Fatalf("status = %d, want 200", rr.Code) got := string(substituteToken([]byte(sampleScript), "abc123"))
if strings.Contains(got, "__API_TOKEN__") {
t.Errorf("placeholder survived substitution:\n%s", got)
} }
if rr.Body.String() != noVersion { // The credential lands in the constant and in both metadata lines.
t.Fatalf("body = %q, want it unmodified", rr.Body.String()) if want := `const API_TOKEN = "abc123";`; !strings.Contains(got, want) {
t.Errorf("no substituted constant %q:\n%s", want, got)
}
if want := "https://api.example/u/abc123/manga-bookmark.user.js"; !strings.Contains(got, want) {
t.Errorf("no substituted download URL %q:\n%s", want, got)
}
}
func TestSubstituteTokenWithoutPlaceholderServedUnmodified(t *testing.T) {
const noPlaceholder = "// ==UserScript==\n// @name x\n// ==/UserScript==\n"
if got := string(substituteToken([]byte(noPlaceholder), "abc123")); got != noPlaceholder {
t.Errorf("substituteToken altered a file without the placeholder:\n%s", got)
} }
} }
+47
View File
@@ -243,6 +243,53 @@ button { cursor: pointer; }
/* The label is 15px tall by design; the thumb gets 44 without moving it. */ /* The label is 15px tall by design; the thumb gets 44 without moving it. */
.ghost::after { content: ""; position: absolute; inset: -15px -12px; } .ghost::after { content: ""; position: absolute; inset: -15px -12px; }
/* ---- userscript setup: collapsed by default, one hairline, no card ---- */
.setup {
margin: 0 20px;
padding: 12px 0 0;
border-bottom: 1px solid var(--rule);
color: var(--mute);
}
.setup summary {
display: flex;
align-items: center;
min-height: 44px;
padding: 0;
font: 500 10px/1 var(--font-mono);
letter-spacing: .2em;
text-transform: uppercase;
color: var(--mute-2);
cursor: pointer;
list-style: none;
}
.setup summary::-webkit-details-marker { display: none; }
.setup summary:hover { color: var(--paper); }
.setup[open] { padding-bottom: 16px; }
.setup-copy {
margin: 0;
padding: 4px 0 12px;
font: 14px/1.55 var(--font-body);
color: var(--mute);
}
.setup-links {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
margin: 0 0 14px;
}
.setup-links .ghost { font-size: 11px; }
.setup-rotate { margin: 0; }
/* Rotation confirmation: the one hot state the panel wears, and it is
destruction, not new-chapter signal — danger, never ember. */
.setup-warn {
margin: 0;
padding: 10px 12px;
border: 1px solid var(--danger);
color: var(--danger);
font: 500 12px/1.5 var(--font-mono);
letter-spacing: .04em;
}
.chrome { display: flex; flex-direction: column; } .chrome { display: flex; flex-direction: column; }
.searchbar { .searchbar {
+2
View File
@@ -74,6 +74,8 @@
</nav> </nav>
</div> </div>
{{template "setup" .}}
{{template "keyrow" .}} {{template "keyrow" .}}
{{template "recent" .}} {{template "recent" .}}
+27
View File
@@ -0,0 +1,27 @@
{{/* The userscript install panel. Each link serves the script rendered
with the acting Reader's credential inside it, so the credential never
appears in this page's markup, the address bar, or a redirect. Rotation
is confirm-gated because it invalidates every installed copy at once;
the response swaps this same panel open with the reinstall warning. */}}
{{define "setup"}}
<details class="setup" id="setup"{{if .Rotated}} open{{end}}>
<summary>Userscripts</summary>
<p class="setup-copy">Install each script once per device. They keep your
bookmarks in sync across every site and update themselves from here.</p>
<p class="setup-links">
<a class="ghost" href="/install/manga-bookmark.user.js">Install Manga script</a>
<a class="ghost" href="/install/novel-bookmark.user.js">Install Novels script</a>
</p>
{{if .Rotated}}
<p class="setup-warn" role="status">Credential rotated — the old one no
longer works. Reinstall both scripts on every device now, or they will
silently stop syncing.</p>
{{else}}
<form class="setup-rotate" hx-post="/rotate-token" hx-target="#setup"
hx-swap="outerHTML"
hx-confirm="Rotation invalidates the current credential on every device immediately. You will have to reinstall both scripts everywhere. Rotate?">
<button type="submit" class="ghost">Rotate credential</button>
</form>
{{end}}
</details>
{{end}}
+71 -1
View File
@@ -16,6 +16,8 @@ import (
"bookmarkmanager/backend/internal/session" "bookmarkmanager/backend/internal/session"
"bookmarkmanager/backend/internal/store" "bookmarkmanager/backend/internal/store"
"bookmarkmanager/backend/internal/token"
"bookmarkmanager/backend/internal/userscript"
) )
//go:embed templates //go:embed templates
@@ -36,6 +38,15 @@ type Handler struct {
// while registration is closed (issue #23). Every session row points at // while registration is closed (issue #23). Every session row points at
// it, so it is also the Reader the UI acts as. // it, so it is also the Reader the UI acts as.
readerID int64 readerID int64
// tokenKey derives Readers' userscript credentials (internal/token): the
// install endpoints render the scripts with the credential inside, which
// is the one place the UI needs the secret.
tokenKey []byte
// mangaUserscriptPath / novelUserscriptPath are the bindmounted script
// files the install endpoints render — the same files the /u/ download
// paths serve.
mangaUserscriptPath string
novelUserscriptPath string
tmpl *template.Template tmpl *template.Template
discord DiscordConfig discord DiscordConfig
states *oauthStates states *oauthStates
@@ -62,6 +73,9 @@ type listView struct {
// OOB marks a render of the chrome partials as an out-of-band swap rather // OOB marks a render of the chrome partials as an out-of-band swap rather
// than the inline copy app.html lays out. // than the inline copy app.html lays out.
OOB bool OOB bool
// Rotated marks the setup panel as having just rotated the credential:
// it swaps the reinstall warning in over the button row.
Rotated bool
} }
// PageURL and ListURL are the two link shapes every tab needs. Building them // PageURL and ListURL are the two link shapes every tab needs. Building them
@@ -88,7 +102,7 @@ type loginView struct {
// New parses every template up front so a broken one kills the process at // New parses every template up front so a broken one kills the process at
// startup rather than the first request that touches it. // startup rather than the first request that touches it.
func New(s *store.Store, readerID int64, discord DiscordConfig) (*Handler, error) { func New(s *store.Store, readerID int64, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string) (*Handler, error) {
tmpl, err := template.ParseFS(templateFS, "templates/*.html") tmpl, err := template.ParseFS(templateFS, "templates/*.html")
if err != nil { if err != nil {
return nil, err return nil, err
@@ -96,6 +110,9 @@ func New(s *store.Store, readerID int64, discord DiscordConfig) (*Handler, error
return &Handler{ return &Handler{
store: s, store: s,
readerID: readerID, readerID: readerID,
tokenKey: tokenKey,
mangaUserscriptPath: mangaPath,
novelUserscriptPath: novelPath,
tmpl: tmpl, tmpl: tmpl,
discord: discord, discord: discord,
states: newOAuthStates(), states: newOAuthStates(),
@@ -116,6 +133,14 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("POST /ui/bookmarks/{key}/status", h.requireSession(h.uiStatus)) mux.HandleFunc("POST /ui/bookmarks/{key}/status", h.requireSession(h.uiStatus))
mux.HandleFunc("POST /ui/bookmarks/{key}/chapter", h.requireSession(h.uiChapter)) mux.HandleFunc("POST /ui/bookmarks/{key}/chapter", h.requireSession(h.uiChapter))
mux.HandleFunc("DELETE /ui/bookmarks/{key}", h.requireSession(h.uiDelete)) mux.HandleFunc("DELETE /ui/bookmarks/{key}", h.requireSession(h.uiDelete))
// Install endpoints render the script directly under the session: the
// credential travels inside the served bytes, never in the address bar or
// the page markup. Updates after install use the credential-bearing /u/
// path the script embeds, which needs no session.
mux.HandleFunc("GET /install/manga-bookmark.user.js", h.requireSession(h.installUserscript("manga-bookmark.user.js")))
mux.HandleFunc("GET /install/novel-bookmark.user.js", h.requireSession(h.installUserscript("novel-bookmark.user.js")))
mux.HandleFunc("POST /rotate-token", h.requireSession(h.rotateToken))
} }
// staticHandler serves the embedded assets. An hour, not longer: assets are // staticHandler serves the embedded assets. An hour, not longer: assets are
@@ -517,3 +542,48 @@ func (h *Handler) uiDelete(w http.ResponseWriter, r *http.Request) {
// the library got smaller. // the library got smaller.
h.refreshChrome(w, r) h.refreshChrome(w, r)
} }
// installUserscript renders the bindmounted script with the acting Reader's
// derived credential substituted in. The credential is derived, not stored,
// so installs work after any restart; the Reader never types or copies it —
// clicking Install is the whole setup.
func (h *Handler) installUserscript(name string) http.HandlerFunc {
path := h.mangaUserscriptPath
if name == "novel-bookmark.user.js" {
path = h.novelUserscriptPath
}
return func(w http.ResponseWriter, r *http.Request) {
discordID, epoch, err := h.store.ReaderTokenInfo(readerOf(r))
if err != nil {
log.Printf("install %s: %v", name, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
userscript.Render(w, r, path, token.Token(h.tokenKey, discordID, epoch))
}
}
// rotateToken issues the acting Reader a new credential: the epoch bumps and
// the stored hash is rewritten, so the old credential stops authenticating
// the moment the statement commits. Every device must reinstall, or its
// script keeps failing silently — the setup panel states that warning next
// to the button, and the response repeats it as confirmation.
func (h *Handler) rotateToken(w http.ResponseWriter, r *http.Request) {
readerID := readerOf(r)
discordID, epoch, err := h.store.ReaderTokenInfo(readerID)
if err != nil {
log.Printf("rotate token: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// The hash is computed for epoch+1 and guarded by it in the store, so a
// concurrent rotation cannot leave the stored hash describing another
// epoch.
if err := h.store.RotateToken(readerID, epoch, token.Hash(token.Token(h.tokenKey, discordID, epoch+1))); err != nil {
log.Printf("rotate token: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
view := listView{Lib: store.KindManga, Rotated: true}
h.render(w, http.StatusOK, "setup", view)
}
+58 -12
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"crypto/sha256"
"errors" "errors"
"log" "log"
"net/http" "net/http"
@@ -17,13 +16,25 @@ import (
"bookmarkmanager/backend/internal/httpmw" "bookmarkmanager/backend/internal/httpmw"
"bookmarkmanager/backend/internal/latest" "bookmarkmanager/backend/internal/latest"
"bookmarkmanager/backend/internal/store" "bookmarkmanager/backend/internal/store"
"bookmarkmanager/backend/internal/token"
"bookmarkmanager/backend/internal/userscript" "bookmarkmanager/backend/internal/userscript"
"bookmarkmanager/backend/internal/web" "bookmarkmanager/backend/internal/web"
) )
// Config holds all runtime settings, sourced from environment variables. // Config holds all runtime settings, sourced from environment variables.
type Config struct { type Config struct {
// Token is the retired global API token, kept only for the cutover grace
// window: while GraceUntil has not passed, it resolves to the owner
// Reader so already-installed scripts keep working. Unset after the
// window closes.
Token string Token string
// TokenKey derives every Reader's userscript credential (internal/token).
// Required: without it no install URL can ever be built.
TokenKey string
// GraceUntil is the moment the retired global token stops resolving to
// the owner Reader. Zero means the token is already dead. Enforced in
// code on every request, not by a runbook note.
GraceUntil time.Time
AllowedOrigins []string AllowedOrigins []string
// DatabaseURL is the Postgres connection URL; required, no default, // DatabaseURL is the Postgres connection URL; required, no default,
// because a wrong guess would silently start on an empty database. // because a wrong guess would silently start on an empty database.
@@ -147,9 +158,29 @@ func loadLatestPoll() LatestPoll {
return p return p
} }
// parseGraceUntil reads the retired-token deadline. Both a bare date and a
// full RFC3339 timestamp are accepted; an unparseable value is a
// configuration bug, not a gracefully-degraded feature — the whole point is
// that the window's end is enforced, so fail loud.
func parseGraceUntil(raw string) time.Time {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}
}
for _, layout := range []string{time.RFC3339, "2006-01-02"} {
if t, err := time.Parse(layout, raw); err == nil {
return t
}
}
log.Fatalf("config: API_TOKEN_GRACE_UNTIL=%q is not a date (YYYY-MM-DD) or RFC3339 timestamp", raw)
return time.Time{}
}
func loadConfig() Config { func loadConfig() Config {
c := Config{ c := Config{
Token: os.Getenv("API_TOKEN"), Token: os.Getenv("API_TOKEN"),
TokenKey: os.Getenv("TOKEN_KEY"),
GraceUntil: parseGraceUntil(os.Getenv("API_TOKEN_GRACE_UNTIL")),
DatabaseURL: os.Getenv("DATABASE_URL"), DatabaseURL: os.Getenv("DATABASE_URL"),
Port: envOr("PORT", "8080"), Port: envOr("PORT", "8080"),
OwnerDiscordID: os.Getenv("OWNER_DISCORD_ID"), OwnerDiscordID: os.Getenv("OWNER_DISCORD_ID"),
@@ -183,23 +214,28 @@ func newRouter(s *store.Store, cfg Config) http.Handler {
// Outside httpmw.Auth (the updater sends no Authorization header) and // Outside httpmw.Auth (the updater sends no Authorization header) and
// outside the web UI's Discord auth (the script must be installable // outside the web UI's Discord auth (the script must be installable
// without a browser session). The path segment carries the token instead. // without a browser session). The path segment carries the credential
mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", userscript.Handler(cfg.Token, cfg.UserscriptPath)) // instead, and the script is rendered with the resolved Reader's
mux.HandleFunc("GET /u/{token}/novel-bookmark.user.js", userscript.Handler(cfg.Token, cfg.NovelUserscriptPath)) // credential substituted in.
mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js",
userscript.Handler(s, []byte(cfg.TokenKey), cfg.Token, cfg.GraceUntil, cfg.UserscriptPath))
mux.HandleFunc("GET /u/{token}/novel-bookmark.user.js",
userscript.Handler(s, []byte(cfg.TokenKey), cfg.Token, cfg.GraceUntil, cfg.NovelUserscriptPath))
h := &api.Handler{Store: s, ReaderID: s.OwnerID()} h := &api.Handler{Store: s}
protected := http.NewServeMux() protected := http.NewServeMux()
protected.HandleFunc("GET /bookmarks", h.List) protected.HandleFunc("GET /bookmarks", h.List)
protected.HandleFunc("PUT /bookmarks/{key}", h.Put) protected.HandleFunc("PUT /bookmarks/{key}", h.Put)
protected.HandleFunc("DELETE /bookmarks/{key}", h.Delete) protected.HandleFunc("DELETE /bookmarks/{key}", h.Delete)
auth := httpmw.Auth(cfg.Token, protected) auth := httpmw.Auth(s, cfg.Token, cfg.GraceUntil, protected)
mux.Handle("/bookmarks", auth) mux.Handle("/bookmarks", auth)
mux.Handle("/bookmarks/", auth) mux.Handle("/bookmarks/", auth)
// The browser UI is always registered; signing in is Discord OAuth, so // The browser UI is always registered; signing in is Discord OAuth, so
// there is no password to forget and no gate to leave unset. // there is no password to forget and no gate to leave unset.
wh, err := web.New(s, s.OwnerID(), cfg.Discord) wh, err := web.New(s, s.OwnerID(), cfg.Discord, []byte(cfg.TokenKey),
cfg.UserscriptPath, cfg.NovelUserscriptPath)
if err != nil { if err != nil {
log.Fatalf("web handler: %v", err) log.Fatalf("web handler: %v", err)
} }
@@ -225,8 +261,8 @@ func guardEmptyUserscriptToken(next http.Handler) http.Handler {
func main() { func main() {
cfg := loadConfig() cfg := loadConfig()
if cfg.Token == "" { if cfg.TokenKey == "" {
log.Fatal("API_TOKEN is required") log.Fatal("TOKEN_KEY is required")
} }
if cfg.OwnerDiscordID == "" { if cfg.OwnerDiscordID == "" {
log.Fatal("OWNER_DISCORD_ID is required") log.Fatal("OWNER_DISCORD_ID is required")
@@ -246,10 +282,20 @@ func main() {
log.Fatalf("%s is required", key) log.Fatalf("%s is required", key)
} }
} }
if cfg.Token == "" && !cfg.GraceUntil.IsZero() {
log.Fatal("API_TOKEN_GRACE_UNTIL is set but API_TOKEN is not")
}
if cfg.Token != "" && cfg.GraceUntil.IsZero() {
log.Printf("API_TOKEN is set without API_TOKEN_GRACE_UNTIL: the retired token is dead on arrival")
}
// The owner's userscript token is the global API token today (issue #22); // The owner's userscript credential is derived from TOKEN_KEY at epoch 0
// the readers row carries its SHA-256, not the token itself. // (internal/token); the readers row carries its SHA-256, not the
owner := store.Owner{DiscordID: cfg.OwnerDiscordID, TokenHash: sha256.Sum256([]byte(cfg.Token))} // credential itself.
owner := store.Owner{
DiscordID: cfg.OwnerDiscordID,
TokenHash: token.Hash(token.Token([]byte(cfg.TokenKey), cfg.OwnerDiscordID, 0)),
}
s, err := store.Open(cfg.DatabaseURL, owner) s, err := store.Open(cfg.DatabaseURL, owner)
if err != nil { if err != nil {
+339
View File
@@ -0,0 +1,339 @@
package main
import (
"database/sql"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"bookmarkmanager/backend/internal/store"
"bookmarkmanager/backend/internal/token"
_ "github.com/jackc/pgx/v5/stdlib"
)
// insertReader creates an extra reader row (registration is closed, so the
// store has no path for this — tests reach past it) and returns its id. The
// credential is derived the same way the owner's is, so it authenticates
// through the real router.
func insertReader(t *testing.T, dbURL, discordID string) int64 {
t.Helper()
db, err := sql.Open("pgx", dbURL)
if err != nil {
t.Fatalf("open db: %v", err)
}
defer db.Close()
hash := token.Hash(token.Token([]byte(testTokenKey), discordID, 0))
var id int64
if err := db.QueryRow(
`INSERT INTO readers (discord_id, token_sha256) VALUES ($1, $2) RETURNING id`,
discordID, hash[:]).Scan(&id); err != nil {
t.Fatalf("insert reader: %v", err)
}
return id
}
// credRequest builds a request authenticated as the Reader whose credential
// is passed.
func credRequest(method, target, cred string) *http.Request {
req := httptest.NewRequest(method, target, nil)
req.Header.Set("Authorization", "Bearer "+cred)
return req
}
// readerCredential is the epoch-0 derived credential of an arbitrary Reader.
func readerCredential(discordID string) string {
return token.Token([]byte(testTokenKey), discordID, 0)
}
// withBody attaches a request body, for PUTs that carry a JSON payload.
func withBody(req *http.Request, body string) *http.Request {
req.Body = io.NopCloser(strings.NewReader(body))
req.ContentLength = int64(len(body))
return req
}
// The retired global token resolves to the owner Reader only while the grace
// deadline is in the future — testConfig sets it, so the acceptance path is
// the existing auth() tests; this pins the other side of the window.
func TestLegacyTokenDeadAfterGrace(t *testing.T) {
s := newTestStore(t)
cfg := testConfig()
cfg.GraceUntil = time.Now().Add(-time.Hour)
srv := newRouter(s, cfg)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", testToken))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("legacy token after grace: status = %d, want 401", rr.Code)
}
// The owner's own derived credential is unaffected by the window closing.
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", ownerCredential()))
if rr.Code != http.StatusOK {
t.Fatalf("derived token after grace: status = %d, want 200", rr.Code)
}
}
// A Reader's credential authenticates exactly that Reader: rows written under
// one credential are invisible to the other, on the same key.
func TestPerReaderIsolation(t *testing.T) {
s, dbURL := newTestStoreURL(t)
insertReader(t, dbURL, "other-reader")
srv := newRouter(s, testConfig())
ownerKey := "asura:solo"
putBookmark(t, srv, ownerKey, store.Bookmark{
Key: ownerKey, Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", UpdatedAt: 1,
})
// The other Reader's list is empty even though the owner holds the key.
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", readerCredential("other-reader")))
if rr.Code != http.StatusOK {
t.Fatalf("other reader list: status = %d, want 200", rr.Code)
}
var theirs []store.Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &theirs); err != nil {
t.Fatalf("decode: %v", err)
}
if len(theirs) != 0 {
t.Fatalf("other reader sees %d bookmarks, want 0 (owner's rows leaked)", len(theirs))
}
// The other Reader writes the same key; both rows coexist, each visible
// only to its owner. The series title is shared (ADR-0003) — the
// reader-owned fields are progress and updated_at.
req := credRequest(http.MethodPut, "/bookmarks/"+ownerKey, readerCredential("other-reader"))
req.Header.Set("Content-Type", "application/json")
body := `{"key":"asura:solo","site":"asura","series_id":"solo","title":"Theirs","last_chapter_num":3}`
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, withBody(req, body))
if rr.Code != http.StatusOK {
t.Fatalf("other reader put: status = %d, want 200", rr.Code)
}
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", readerCredential("other-reader")))
var theirs2 []store.Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &theirs2); err != nil {
t.Fatalf("decode: %v", err)
}
if len(theirs2) != 1 || theirs2[0].LastChapterNum != 3 {
t.Fatalf("other reader list = %+v, want their own row with their progress", theirs2)
}
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", ownerCredential()))
var owners []store.Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &owners); err != nil {
t.Fatalf("decode: %v", err)
}
if len(owners) != 1 || owners[0].Title != "Solo Leveling" {
t.Fatalf("owner list = %+v, want their own row", owners)
}
// One Reader's credential cannot delete the other's row.
req = credRequest(http.MethodDelete, "/bookmarks/"+ownerKey, readerCredential("other-reader"))
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("other reader delete: status = %d, want 204", rr.Code)
}
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", ownerCredential()))
if err := json.Unmarshal(rr.Body.Bytes(), &owners); err != nil {
t.Fatalf("decode: %v", err)
}
if len(owners) != 1 {
t.Fatalf("owner's row was deletable by another Reader: list = %+v", owners)
}
}
// The install endpoints are session-gated and render the script directly
// with the Reader's credential inside: the credential never appears in the
// address bar, the page markup, or any Location header.
func TestInstallServesScriptWithCredential(t *testing.T) {
cfg := webConfig()
dir := t.TempDir()
path := filepath.Join(dir, "manga-bookmark.user.js")
novelPath := filepath.Join(dir, "novel-bookmark.user.js")
for _, p := range []string{path, novelPath} {
if err := os.WriteFile(p, []byte("const API_TOKEN = \"__API_TOKEN__\";\n"), 0o644); err != nil {
t.Fatalf("write script: %v", err)
}
}
cfg.UserscriptPath = path
cfg.NovelUserscriptPath = novelPath
srv, st := newWebTestServer(t, cfg)
for _, script := range []string{"manga-bookmark.user.js", "novel-bookmark.user.js"} {
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/install/"+script, nil))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("%s without session: status = %d, want 401", script, rr.Code)
}
req := httptest.NewRequest(http.MethodGet, "/install/"+script, nil)
req.AddCookie(sessionCookie(t, st))
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("%s with session: status = %d, want 200", script, rr.Code)
}
body := rr.Body.String()
if strings.Contains(body, "__API_TOKEN__") {
t.Fatalf("%s served with an unsubstituted placeholder", script)
}
// The credential rides inside the served script — nowhere visible in
// the UI — and is the session holder's own.
if !strings.Contains(body, `API_TOKEN = "`+ownerCredential()+`"`) {
t.Fatalf("%s does not carry the owner's credential:\n%s", script, body)
}
if loc := rr.Header().Get("Location"); loc != "" {
t.Fatalf("%s answered with a redirect, credential in Location %q", script, loc)
}
}
}
// The retired global token also keeps the script download path working during
// the grace window — that is how already-installed scripts auto-update across
// the cutover — and dies with it. The copy served on the legacy path embeds
// the Reader's derived credential, so the next update poll migrates the
// device onto its per-Reader path: the window empties itself.
func TestLegacyTokenUserscriptPathDuringGrace(t *testing.T) {
path := filepath.Join(t.TempDir(), "manga-bookmark.user.js")
if err := os.WriteFile(path, []byte("const API_TOKEN = \"__API_TOKEN__\";\n"), 0o644); err != nil {
t.Fatalf("write script: %v", err)
}
s := newTestStore(t)
cfg := testConfig()
cfg.UserscriptPath = path
// Within the window the legacy URL serves the script, but with the
// owner's derived credential substituted — not the legacy one.
rr := httptest.NewRecorder()
newRouter(s, cfg).ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
"/u/"+testToken+"/manga-bookmark.user.js", nil))
if rr.Code != http.StatusOK {
t.Fatalf("legacy path during grace: status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); !strings.Contains(got, `API_TOKEN = "`+ownerCredential()+`"`) {
t.Fatalf("legacy-path script does not carry the derived credential:\n%s", got)
}
// After the deadline the same URL is a 404 like any unknown credential.
cfg.GraceUntil = time.Now().Add(-time.Hour)
rr = httptest.NewRecorder()
newRouter(s, cfg).ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
"/u/"+testToken+"/manga-bookmark.user.js", nil))
if rr.Code != http.StatusNotFound {
t.Fatalf("legacy path after grace: status = %d, want 404", rr.Code)
}
}
// Rotation through the web UI invalidates the old credential immediately,
// mints one that authenticates the API and the script path, and warns that
// every device must reinstall.
func TestRotateCredentialViaWebUI(t *testing.T) {
s, _ := newTestStoreURL(t)
path := filepath.Join(t.TempDir(), "manga-bookmark.user.js")
if err := os.WriteFile(path, []byte("const API_TOKEN = \"__API_TOKEN__\";\n"), 0o644); err != nil {
t.Fatalf("write script: %v", err)
}
cfg := webConfig()
cfg.UserscriptPath = path
srv := newRouter(s, cfg)
oldCred := ownerCredential()
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", oldCred))
if rr.Code != http.StatusOK {
t.Fatalf("old credential before rotation: status = %d, want 200", rr.Code)
}
req := httptest.NewRequest(http.MethodPost, "/rotate-token", nil)
req.AddCookie(sessionCookie(t, s))
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("rotate: status = %d, want 200", rr.Code)
}
if !strings.Contains(rr.Body.String(), "Credential rotated") {
t.Fatalf("rotation response does not warn about reinstall:\n%s", rr.Body.String())
}
// The old credential is dead on the API and on the script path.
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", oldCred))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("old credential after rotation: status = %d, want 401", rr.Code)
}
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/u/"+oldCred+"/manga-bookmark.user.js", nil))
if rr.Code != http.StatusNotFound {
t.Fatalf("old credential script path after rotation: status = %d, want 404", rr.Code)
}
// The new credential authenticates the API and the script path, and is
// substituted into the served script.
newCred := token.Token([]byte(testTokenKey), testDiscordID, 1)
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", newCred))
if rr.Code != http.StatusOK {
t.Fatalf("new credential after rotation: status = %d, want 200", rr.Code)
}
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/u/"+newCred+"/manga-bookmark.user.js", nil))
if rr.Code != http.StatusOK {
t.Fatalf("new credential script path: status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); !strings.Contains(got, `API_TOKEN = "`+newCred+`"`) {
t.Fatalf("served script does not carry the rotated credential:\n%s", got)
}
// The install link now renders the script with the new credential.
req = httptest.NewRequest(http.MethodGet, "/install/manga-bookmark.user.js", nil)
req.AddCookie(sessionCookie(t, s))
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("install after rotation: status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); !strings.Contains(got, `API_TOKEN = "`+newCred+`"`) {
t.Fatalf("install after rotation does not carry the new credential:\n%s", got)
}
}
// The app page offers the install links; the credential never appears in its
// markup.
func TestIndexShowsSetupPanelWithoutCredential(t *testing.T) {
srv, st := newWebTestServer(t, webConfig())
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
body := rr.Body.String()
for _, want := range []string{
`href="/install/manga-bookmark.user.js"`,
`href="/install/novel-bookmark.user.js"`,
"Rotate credential",
} {
if !strings.Contains(body, want) {
t.Errorf("app page lacks %q", want)
}
}
if strings.Contains(body, ownerCredential()) {
t.Fatal("app page leaks the credential")
}
}
+7 -2
View File
@@ -13,8 +13,13 @@ services:
container_name: bookmark-api container_name: bookmark-api
restart: unless-stopped restart: unless-stopped
environment: environment:
# API_TOKEN is required — compose refuses to start without it. # TOKEN_KEY derives every Reader's userscript credential (issue #24) —
API_TOKEN: ${API_TOKEN:?set API_TOKEN in .env} # compose refuses to start without it.
TOKEN_KEY: ${TOKEN_KEY:?set TOKEN_KEY in .env}
# Retired global credential, optional: only used until the grace
# deadline, for already-installed scripts. Remove after the window.
API_TOKEN: ${API_TOKEN:-}
API_TOKEN_GRACE_UNTIL: ${API_TOKEN_GRACE_UNTIL:-}
# Owner's Discord user ID — required, seeds the one Reader row. # Owner's Discord user ID — required, seeds the one Reader row.
OWNER_DISCORD_ID: ${OWNER_DISCORD_ID:?set OWNER_DISCORD_ID in .env} OWNER_DISCORD_ID: ${OWNER_DISCORD_ID:?set OWNER_DISCORD_ID in .env}
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://asuracomic.net,https://asurascans.com,https://demonicscans.org,https://comix.to,https://kagane.to,https://novelfull.com,https://lightnovelworld.net} ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://asuracomic.net,https://asurascans.com,https://demonicscans.org,https://comix.to,https://kagane.to,https://novelfull.com,https://lightnovelworld.net}
+3 -3
View File
@@ -4,8 +4,8 @@
// @version 1.6.0 // @version 1.6.0
// @description Track read progress on Asura, Demonic, Comix & Kagane and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs). // @description Track read progress on Asura, Demonic, Comix & Kagane and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
// @author you // @author you
// @downloadURL https://bookmark-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js // @downloadURL https://bookmark-api.violetcrown.my.id/u/__API_TOKEN__/manga-bookmark.user.js
// @updateURL https://bookmark-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js // @updateURL https://bookmark-api.violetcrown.my.id/u/__API_TOKEN__/manga-bookmark.user.js
// @match https://asuracomic.net/* // @match https://asuracomic.net/*
// @match https://asurascans.com/* // @match https://asurascans.com/*
// @match https://demonicscans.org/* // @match https://demonicscans.org/*
@@ -22,7 +22,7 @@
// CONFIG — fill these in before installing. // CONFIG — fill these in before installing.
// ============================================================ // ============================================================
const API_BASE = "https://bookmark-api.violetcrown.my.id"; // your backend origin, no trailing slash const API_BASE = "https://bookmark-api.violetcrown.my.id"; // your backend origin, no trailing slash
const API_TOKEN = "40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df"; // must equal backend API_TOKEN const API_TOKEN = "__API_TOKEN__"; // substituted by the backend at serve time (issue #24)
const WEB_BASE = "https://bookmark.violetcrown.my.id"; // the browser UI, for the panel's nav chips const WEB_BASE = "https://bookmark.violetcrown.my.id"; // the browser UI, for the panel's nav chips
// This script owns the manga library; the novel script is a separate install // This script owns the manga library; the novel script is a separate install
+3 -3
View File
@@ -4,8 +4,8 @@
// @version 1.0.0 // @version 1.0.0
// @description Track read progress on NovelFull & LightNovelWorld and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs). // @description Track read progress on NovelFull & LightNovelWorld and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
// @author you // @author you
// @downloadURL https://bookmark-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/novel-bookmark.user.js // @downloadURL https://bookmark-api.violetcrown.my.id/u/__API_TOKEN__/novel-bookmark.user.js
// @updateURL https://bookmark-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/novel-bookmark.user.js // @updateURL https://bookmark-api.violetcrown.my.id/u/__API_TOKEN__/novel-bookmark.user.js
// @match https://novelfull.com/* // @match https://novelfull.com/*
// @match https://lightnovelworld.net/* // @match https://lightnovelworld.net/*
// @run-at document-idle // @run-at document-idle
@@ -19,7 +19,7 @@
// CONFIG — fill these in before installing. // CONFIG — fill these in before installing.
// ============================================================ // ============================================================
const API_BASE = "https://bookmark-api.violetcrown.my.id"; // your backend origin, no trailing slash const API_BASE = "https://bookmark-api.violetcrown.my.id"; // your backend origin, no trailing slash
const API_TOKEN = "40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df"; // must equal backend API_TOKEN const API_TOKEN = "__API_TOKEN__"; // substituted by the backend at serve time (issue #24)
const WEB_BASE = "https://bookmark.violetcrown.my.id"; // the browser UI, for the panel's nav chips const WEB_BASE = "https://bookmark.violetcrown.my.id"; // the browser UI, for the panel's nav chips
// This script owns the novel library; the manga script is a separate install // This script owns the novel library; the manga script is a separate install