b0bf6fe770
Guild membership is now the whole gate: discordCallback checks membership
(and DISCORD_REQUIRED_ROLE when set), then Store.EnsureReader creates the
Reader on first sight and returns the same row on every later login. The
refusal returns before EnsureReader, so nothing is created as a side
effect of being turned away. OWNER_DISCORD_ID keeps seeding the owner, but
only as the administrator — it no longer gates sign-in.
The cutover grace path is gone with it: API_TOKEN, API_TOKEN_GRACE_UNTIL
and the legacy branch in httpmw.ResolveReader are deleted, so a credential
authenticates exactly one Reader or nothing. That also lets
userscript.Handler drop the re-derivation — the resolved path segment is
already the credential to substitute.
New surfaces: an empty library offers both install links instead of
describing a filter (listView.Fresh, which also hides the action key it has
nothing to name), and the owner alone gets a Readers panel with
POST /readers/{id}/revoke (404 for anyone else) to sign a Reader out
everywhere.
Isolation is asserted from both directions rather than by counting one
Reader's rows, and the shared-series invariant is pinned: two Readers on
one series produce one series row, two independent progresses, one poll
per due cycle, and one Reader's delete leaves the other's bookmark and the
poll intact.
337 lines
14 KiB
Markdown
337 lines
14 KiB
Markdown
# Deployment
|
|
|
|
Step-by-step for the backend (Docker + Traefik) and the Bromite userscript.
|
|
Assumes you already run Traefik in Docker with a working HTTPS entrypoint and an
|
|
ACME/cert resolver, and control a domain.
|
|
|
|
---
|
|
|
|
## 0. Prerequisites
|
|
|
|
- Docker + Docker Compose on the server.
|
|
- A Traefik instance watching a Docker network (default name assumed: `proxy`).
|
|
- DNS: an `A`/`AAAA` record for `bookmark-api.<yourdomain>` pointing at the server.
|
|
- The repo copied to the server, e.g. `~/mangaBookmark/` (needs `backend/`,
|
|
`docker-compose.yml`, `docker-compose.prod.yml`, `.env.example`).
|
|
|
|
Confirm the Traefik network exists (create if not):
|
|
|
|
```bash
|
|
docker network ls | grep proxy || docker network create proxy
|
|
```
|
|
|
|
---
|
|
|
|
## 1. Configure `.env`
|
|
|
|
```bash
|
|
cd ~/mangaBookmark
|
|
cp .env.example .env
|
|
```
|
|
|
|
Edit `.env`:
|
|
|
|
```ini
|
|
# 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 first Reader: the
|
|
# administrator, and the owner of every bookmark that predates registration.
|
|
# The value is the snowflake in your Discord profile (Settings →
|
|
# Advanced → Developer Mode → right-click your name → Copy User ID).
|
|
OWNER_DISCORD_ID=<discord user id>
|
|
|
|
# CORS allowlist — leave as-is unless a site changes hostname.
|
|
ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicscans.org,https://comix.to,https://kagane.to
|
|
|
|
# Required — password for the bundled Postgres container. Compose builds the
|
|
# backend's DATABASE_URL out of it and has no fallback for either.
|
|
POSTGRES_PASSWORD=<paste output of: openssl rand -hex 24>
|
|
|
|
# Leave unset. Only set this to point the backend at a Postgres compose does
|
|
# not run; it then replaces the URL built from POSTGRES_PASSWORD above.
|
|
# DATABASE_URL=postgres://user:pass@host:5432/bookmarks?sslmode=require
|
|
|
|
# Required for the Traefik override. Both have no fallback — compose refuses
|
|
# to start without them. BOOKMARK_WEB_HOST is required even if the web UI
|
|
# were unused; see 1b.
|
|
BOOKMARK_API_HOST=bookmark-api.violetcrown.my.id
|
|
BOOKMARK_WEB_HOST=bookmark.violetcrown.my.id
|
|
|
|
# Only if your Traefik setup differs from these defaults:
|
|
# PROXY_NETWORK=proxy
|
|
# TRAEFIK_ENTRYPOINT=websecure
|
|
# TRAEFIK_CERTRESOLVER=le
|
|
```
|
|
|
|
Generate + insert the two secrets in three lines:
|
|
|
|
```bash
|
|
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 '^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. Changing it invalidates every installed
|
|
script at once.
|
|
|
|
`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`
|
|
crash-loops on `password authentication failed`. Set it before §2 and leave it
|
|
alone.
|
|
|
|
> Match `TRAEFIK_ENTRYPOINT` / `TRAEFIK_CERTRESOLVER` to your Traefik's actual
|
|
> names (check your Traefik static config — common alternatives: `https`,
|
|
> `myresolver`, `cloudflare`). Wrong names = no certificate issued.
|
|
|
|
---
|
|
|
|
## 1b. Web UI
|
|
|
|
The browser UI is served by the same container on a second hostname. Sign-in
|
|
is a Discord authorization code grant (ADR-0002): the owner's Discord account,
|
|
gated by membership in one configured guild.
|
|
|
|
1. Add a DNS `A`/`AAAA` record for `bookmark.<yourdomain>` pointing at the
|
|
server — the same address as `bookmark-api.<yourdomain>`.
|
|
|
|
2. Create the Discord application at <https://discord.com/developers/applications>:
|
|
- **OAuth2 → Redirects:** add the exact callback URL
|
|
`https://bookmark.violetcrown.my.id/auth/discord/callback`. Discord
|
|
matches it verbatim — a trailing slash or different hostname breaks
|
|
sign-in.
|
|
- **OAuth2 → General:** note the Client ID, and generate a Client Secret.
|
|
- No scopes or bot setup are needed in the dashboard; the service requests
|
|
`identify` and `guilds.members.read` itself, and checks the *user's*
|
|
membership of the guild, not the application's.
|
|
|
|
3. Set the variables in `.env`:
|
|
|
|
```ini
|
|
BOOKMARK_WEB_HOST=bookmark.violetcrown.my.id
|
|
DISCORD_CLIENT_ID=<client id>
|
|
DISCORD_CLIENT_SECRET=<client secret>
|
|
DISCORD_GUILD_ID=<guild snowflake>
|
|
DISCORD_REDIRECT_URI=https://bookmark.violetcrown.my.id/auth/discord/callback
|
|
# Optional: only members holding this role may sign in.
|
|
# DISCORD_REQUIRED_ROLE=<role snowflake>
|
|
```
|
|
|
|
The guild id is in Discord's client with Developer Mode on: right-click the
|
|
server name → Copy Server ID. The four uncommented variables are required —
|
|
the backend refuses to start without them. Guild membership *is*
|
|
registration: any member of `DISCORD_GUILD_ID` becomes a Reader with their
|
|
own library on their first sign-in. `OWNER_DISCORD_ID` from §1 is only the
|
|
administrator — the Reader who can revoke another Reader's sessions.
|
|
|
|
4. Redeploy and check:
|
|
|
|
```bash
|
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
|
curl -s -o /dev/null -w '%{http_code}\n' https://bookmark.violetcrown.my.id/
|
|
```
|
|
|
|
Expected `200`, serving the login page with the Discord button. Signing in
|
|
lands on the library; an account outside the guild is refused with a message
|
|
that names neither the guild nor its id.
|
|
|
|
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 a credential does not affect browser sessions. Sessions
|
|
last 60 days.
|
|
|
|
---
|
|
|
|
## 2. Build + start
|
|
|
|
```bash
|
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
|
```
|
|
|
|
This merges the base file (build/image/env/volume) with the prod override
|
|
(no host port, Traefik network + router labels). Always pass **both** `-f`
|
|
flags — the prod file is not standalone.
|
|
|
|
Three services come up: `bookmark-api` (the backend), `postgres` (its database,
|
|
`postgres:17-alpine`), and `headless-shell`, a CDP sidecar the poller uses to
|
|
fetch kagane (behind a Cloudflare JS challenge). Neither of the latter two
|
|
publishes a port: `postgres` sits alone with `bookmark-api` on an
|
|
`internal: true` network, and `headless-shell` is reachable only over
|
|
`BROWSER_WS_URL`. A missing headless-shell just makes the poller skip kagane and
|
|
log it. A missing Postgres stops everything — `bookmark-api` waits for
|
|
`pg_isready` to pass, then applies its embedded migrations, and only then
|
|
listens. The schema is created that way; there is nothing to import by hand.
|
|
|
|
Check it's up and healthy:
|
|
|
|
```bash
|
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml ps
|
|
# bookmark-api Up; postgres Up (healthy)
|
|
docker logs bookmark-api --tail 20 # expect: "listening on :8080 ..."
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Verify over HTTPS
|
|
|
|
Give Traefik a few seconds to issue the cert, then:
|
|
|
|
```bash
|
|
# Health (no auth) — must be valid TLS, no cert warning.
|
|
curl -s https://bookmark-api.violetcrown.my.id/healthz # -> ok
|
|
|
|
# Auth enforced.
|
|
curl -s -o /dev/null -w '%{http_code}\n' \
|
|
https://bookmark-api.violetcrown.my.id/bookmarks # -> 401
|
|
|
|
# A Reader's own credential. It is derived, never stored in .env — take it from
|
|
# the Userscripts panel's install link after signing in, or from an installed
|
|
# script's API_TOKEN constant.
|
|
TOKEN=<your Reader credential>
|
|
curl -s -H "Authorization: Bearer $TOKEN" \
|
|
https://bookmark-api.violetcrown.my.id/bookmarks # -> []
|
|
|
|
# CORS preflight from a real site origin.
|
|
curl -s -i -X OPTIONS \
|
|
-H 'Origin: https://asurascans.com' \
|
|
-H 'Access-Control-Request-Method: PUT' \
|
|
https://bookmark-api.violetcrown.my.id/bookmarks/x | grep -i access-control
|
|
# -> Access-Control-Allow-Origin: https://asurascans.com (+ Methods/Headers)
|
|
```
|
|
|
|
All four must pass. Valid TLS is non-negotiable — the manga sites are HTTPS, so
|
|
a bad cert makes the browser block the userscript's `fetch()` (mixed content).
|
|
|
|
---
|
|
|
|
## 4. Configure the userscript
|
|
|
|
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
|
|
// @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 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. 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**.
|
|
|
|
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
|
|
|
|
1. Bookmark a series on Asura.
|
|
2. `curl -s -H "Authorization: Bearer $TOKEN" https://bookmark-api.yourdomain.com/bookmarks`
|
|
on the server — the series should appear.
|
|
3. Open a chapter of that series — reopen the panel; last-read updates to that
|
|
chapter (auto, never regresses on older chapters).
|
|
4. Open Demonic, open the panel — the Asura bookmark shows there too (shared
|
|
store, cross-site unified list).
|
|
|
|
---
|
|
|
|
## Updating
|
|
|
|
Pull new code, then rebuild:
|
|
|
|
```bash
|
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
|
```
|
|
|
|
Data persists in the named volume `postgres-data` across rebuilds. (If this
|
|
server predates the Postgres migration, the old SQLite volume `bookmarks-data`
|
|
is still on disk and deliberately undeclared in compose so `down -v` cannot take
|
|
it; see `REDEPLOY.md` §1 for when to remove it.)
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
| Symptom | Likely cause / fix |
|
|
|---------|--------------------|
|
|
| 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 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 `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`. |
|
|
|
|
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 — 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): 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/<your credential>/manga-bookmark.user.js
|
|
```
|
|
|
|
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:
|
|
|
|
```bash
|
|
vi userscript/manga-bookmark.user.js # on the VPS, in this checkout
|
|
```
|
|
|
|
`./userscript` is bindmounted read-only into the container and read fresh on
|
|
every request, so the edit is live immediately. The served `@version` is derived
|
|
from the file's mtime (`YYYY.MM.DD.HHMM`, UTC), not from the `@version` in the
|
|
file, so any edit outranks the installed copy and Violentmonkey pulls it on its
|
|
next check. The `@version` in the repo is a human marker only.
|
|
|
|
Updating via redeploy: `git pull` overwrites the file with the committed
|
|
version, which is the intended behaviour — a deploy always ships the repo's
|
|
script. Note that `git pull` sets mtime to checkout time, so even a rollback
|
|
serves a *higher* version and is adopted.
|
|
|
|
If the mount is missing, the endpoint answers 404 and logs it; bookmark sync is
|
|
unaffected.
|