8cebb94b92
Closes #22 ## What A `readers` table appears; every Bookmark belongs to one. The owner is seeded as the first and only Reader, and all existing rows are attached to them. - **Migration 0003**: `readers` (discord_id UNIQUE, token_sha256 UNIQUE, created_at). - **Migration 0004** (run-once, version-table-gated): attaches existing bookmarks to the seeded owner, drops the surrogate `key` column, composite PK `(reader_id, site, series_id)`, FK to readers `ON DELETE CASCADE` — a duplicate Bookmark for one Reader and Series is impossible at the database level. - **Seed**: `Store.Open` runs schema to 0003, seeds exactly one owner row from `OWNER_DISCORD_ID` (hash = SHA-256 of `API_TOKEN`, refreshed on every start so rotation stays current), then migrates the rest. - **Scoping**: `List/Get/Upsert/Delete` take `readerID`; the wire `key` is derived as `site:series_id` on read. Handlers act as `Store.OwnerID()` while the global token remains the only credential. - **Unchanged**: authentication and the flat wire format — nothing observable changes from outside. - **New env** `OWNER_DISCORD_ID` (required): compose, .env.example, DEPLOY.md, README.md, backend/AGENTS.md updated. Series-level methods (due queue, mark-checked, set-latest-chapter) stay unscoped deliberately: series are shared rows polled once per due cycle, and the reader_count ordering requires cross-reader visibility (ADR-0003). ## Verification - `go test ./...` green, including new tests: seed idempotency + hash refresh, 0004 attach migration, DB-level duplicate impossibility, per-reader scoping, reader-delete cascade. - Live smoke test on fresh Postgres: seed → PUT/GET (flat wire intact) → restart idempotent; stored hash matches SHA-256 of the token. ## Deploy note `OWNER_DISCORD_ID` is required after this lands — the backend refuses to start without it. Set it to the owner's Discord snowflake (Settings → Advanced → Developer Mode → right-click name → Copy User ID). Reviewed-on: #30 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
303 lines
12 KiB
Markdown
303 lines
12 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. `/opt/bookmarkmanager/` (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 /opt/bookmarkmanager
|
|
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 — the owner's Discord user ID. Seeds the one Reader every bookmark
|
|
# belongs to; 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 you never set
|
|
# WEB_PASSWORD; 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|^API_TOKEN=.*|API_TOKEN=$(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
|
|
```
|
|
|
|
`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.
|
|
|
|
1. Add a DNS `A`/`AAAA` record for `bookmark.<yourdomain>` pointing at the server —
|
|
the same address as `bookmark-api.<yourdomain>`.
|
|
|
|
2. Set both variables in `.env`:
|
|
|
|
```ini
|
|
BOOKMARK_WEB_HOST=bookmark.violetcrown.my.id
|
|
WEB_PASSWORD=<paste output of: openssl rand -base64 18>
|
|
```
|
|
|
|
Generate and insert in one line:
|
|
|
|
```bash
|
|
sed -i "s|^WEB_PASSWORD=.*|WEB_PASSWORD=$(openssl rand -base64 18)|" .env
|
|
grep -E '^WEB_PASSWORD=' .env # this is what you type into the site
|
|
```
|
|
|
|
3. 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.
|
|
|
|
Leaving `WEB_PASSWORD` unset is safe: the web routes are not registered and `/`
|
|
returns 404. The userscript's API on `BOOKMARK_API_HOST` is unaffected either way.
|
|
|
|
`BOOKMARK_WEB_HOST` itself is required by the prod override regardless — like
|
|
`BOOKMARK_API_HOST`, its Traefik label has no fallback, so `docker compose up`
|
|
refuses to start without it even if `WEB_PASSWORD` is unset and the web UI is
|
|
otherwise dormant.
|
|
|
|
Sessions are signed with a key derived from `API_TOKEN` and `WEB_PASSWORD`, so
|
|
rotating either one logs every browser out. The session cookie lasts 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
|
|
|
|
TOKEN=$(grep -E '^API_TOKEN=' .env | cut -d= -f2)
|
|
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
|
|
|
|
Edit the config block at the top of `userscript/manga-bookmark.user.js`:
|
|
|
|
```js
|
|
const API_BASE = "https://bookmark-api.yourdomain.com"; // no trailing slash
|
|
const API_TOKEN = "<same token as .env>";
|
|
```
|
|
|
|
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.
|
|
|
|
---
|
|
|
|
## 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.
|
|
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.
|
|
|
|
---
|
|
|
|
## 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 token | Trailing space/newline in `API_TOKEN`; regenerate and restart. |
|
|
| 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. |
|
|
| `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 — 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.
|
|
|
|
Install once, on the phone (Cromite + Violentmonkey):
|
|
|
|
```
|
|
https://bookmark-api.<your-domain>/u/<API_TOKEN>/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.
|
|
|
|
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.
|