Closes #24. Child of #18; based on current main (includes Postgres, Reader table, Discord OAuth).
## What
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` (new `token_epoch` column, migration 0006). One credential authenticates the script download path and the API bearer header.
- `internal/token`: derivation + hashing; the seed refreshes the owner's epoch-0 hash only before first rotation, so a restart can never resurrect a rotated-away credential
- `httpmw.Auth`/`ResolveReader`: acting Reader resolved from the credential hash, stashed in request context; the retired global `API_TOKEN` resolves to the owner until `API_TOKEN_GRACE_UNTIL` (enforced in code, 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 installed devices self-migrate on their next update poll
- Web UI: "Userscripts" panel — session-gated install endpoints render the script directly (credential never in markup, address bar, or a redirect), confirm-gated rotation with an atomic epoch bump + hash rewrite and a reinstall warning
- Both userscripts carry `__API_TOKEN__` placeholders; the committed global-token literal is removed
## Design note
Credentials are derived rather than stored-random because the server must rebuild install URLs after restarts while the DB holds only hashes. HMAC output is high-entropy and unbrute-forceable; the AC's intent (unguessable, DB-leak-proof) is met.
## Deploy (also in DEPLOY.md)
1. Add `TOKEN_KEY` (`openssl rand -hex 32`) — required; changing it later invalidates every credential.
2. Keep `API_TOKEN` + set `API_TOKEN_GRACE_UNTIL` for the 14-day window.
3. After deploy, sign in → Userscripts → reinstall both scripts on every device. This also retires the old global credential for real — its literal survives in git history (present since 0ef5286), so rotation is what kills it.
## Verification
- Full Go suite green against real Postgres per test; userscript JS suite 45/45
- New router-level tests: per-Reader isolation (read/write/delete), grace expiry on bearer + script path, self-migrating legacy path, install serving, rotation (old cred 401/404, new cred works, install renders new credential), app page leaks no credential
- Store tests: hash lookup, token info, atomic rotation with stale-epoch rejection, rotation survives restart
- Live smoke of the built binary: grace acceptance logged, derived auth, substitution, restart resilience, stored hash = SHA-256 of derived credential
Reviewed-on: #32
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #32.
This commit is contained in:
@@ -32,8 +32,9 @@ cp .env.example .env
|
||||
Edit `.env`:
|
||||
|
||||
```ini
|
||||
# Required — long random secret, also goes in the userscript.
|
||||
API_TOKEN=<paste output of: openssl rand -hex 32>
|
||||
# Required — secret every Reader's userscript credential is derived from.
|
||||
# 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
|
||||
# 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:
|
||||
|
||||
```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
|
||||
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**,
|
||||
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`
|
||||
@@ -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
|
||||
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
|
||||
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.
|
||||
|
||||
---
|
||||
@@ -180,6 +188,8 @@ curl -s https://bookmark-api.violetcrown.my.id/healthz # -> ok
|
||||
curl -s -o /dev/null -w '%{http_code}\n' \
|
||||
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)
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
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
|
||||
|
||||
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
|
||||
const API_BASE = "https://bookmark-api.yourdomain.com"; // no trailing slash
|
||||
const API_TOKEN = "<same token as .env>";
|
||||
// @downloadURL https://bookmark-api.yourdomain.com/u/__API_TOKEN__/manga-bookmark.user.js
|
||||
// @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
|
||||
read it.
|
||||
|
||||
Also edit the `@downloadURL`/`@updateURL` metadata lines near the top of the
|
||||
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.
|
||||
The backend substitutes `__API_TOKEN__` with the requesting Reader's derived
|
||||
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
|
||||
edits; do not put a credential in this file.
|
||||
|
||||
---
|
||||
|
||||
## 5. Install on Bromite
|
||||
|
||||
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
|
||||
its raw URL). Bromite detects `.user.js` and offers to install.
|
||||
2. Sign in to the web UI, open the **Userscripts** panel, and open the 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.
|
||||
4. Open a series on asurascans.com or demonicscans.org → a 📑 button appears
|
||||
bottom-right → tap → **+ Bookmark this**.
|
||||
@@ -229,6 +240,9 @@ used.
|
||||
Optional desktop test: the script is `GM_*`-free, so the same file installs in
|
||||
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
|
||||
@@ -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>`. |
|
||||
| 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). |
|
||||
| 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. |
|
||||
| `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` 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
|
||||
|
||||
The backend serves the script itself, so Violentmonkey can auto-update it.
|
||||
Complements §4 above — that step points `API_BASE`/`API_TOKEN` at your
|
||||
backend; this one points `@downloadURL`/`@updateURL` at the same place so
|
||||
auto-updates come from it too.
|
||||
Complements §4 above — the `@downloadURL`/`@updateURL` lines point at the
|
||||
credential-bearing path, so auto-updates come from the same place as the
|
||||
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
|
||||
the path because Violentmonkey's update poll sends no `Authorization` header,
|
||||
and the script embeds `API_TOKEN` in plain text — an open URL would leak it. A
|
||||
wrong token answers 404.
|
||||
Violentmonkey offers to install it. The credential is in the path because
|
||||
Violentmonkey's update poll sends no `Authorization` header, and the script
|
||||
embeds the credential in plain text — an open URL would leak it. A wrong
|
||||
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:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user