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.
143 lines
7.0 KiB
YAML
143 lines
7.0 KiB
YAML
# Base stack — works standalone for local smoke testing (`docker compose up`).
|
|
# The service binds 127.0.0.1:8080; a host reverse proxy (nginx/Caddy/Traefik)
|
|
# terminates TLS for bookmark-api.<domain> and forwards to it.
|
|
#
|
|
# If your proxy runs in Docker on its own network, use the prod override which
|
|
# attaches to that network instead of publishing a port:
|
|
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
|
|
|
services:
|
|
bookmark-api:
|
|
build: ./backend
|
|
image: bookmarkmanager-backend:latest
|
|
container_name: bookmark-api
|
|
restart: unless-stopped
|
|
environment:
|
|
# TOKEN_KEY derives every Reader's userscript credential (issue #24) —
|
|
# compose refuses to start without it.
|
|
TOKEN_KEY: ${TOKEN_KEY:?set TOKEN_KEY in .env}
|
|
# Owner's Discord user ID — required. Seeds the owner Reader (the
|
|
# administrator); every other Reader registers on their first login.
|
|
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}
|
|
# The bookmarks database. Host is the compose service name; the password
|
|
# comes from .env so it is never committed.
|
|
DATABASE_URL: ${DATABASE_URL:-postgres://bookmarks:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/bookmarks?sslmode=disable}
|
|
PORT: "8080"
|
|
# Discord OAuth for the browser UI (ADR-0002). The first four are
|
|
# required; DISCORD_REQUIRED_ROLE is optional and empty by default.
|
|
# Guild membership is the whole gate: any member becomes a Reader.
|
|
DISCORD_CLIENT_ID: ${DISCORD_CLIENT_ID:?set DISCORD_CLIENT_ID in .env}
|
|
DISCORD_CLIENT_SECRET: ${DISCORD_CLIENT_SECRET:?set DISCORD_CLIENT_SECRET in .env}
|
|
DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:?set DISCORD_GUILD_ID in .env}
|
|
DISCORD_REQUIRED_ROLE: ${DISCORD_REQUIRED_ROLE:-}
|
|
DISCORD_API_BASE: ${DISCORD_API_BASE:-https://discord.com/api/v10}
|
|
DISCORD_REDIRECT_URI: ${DISCORD_REDIRECT_URI:?set DISCORD_REDIRECT_URI in .env}
|
|
# Path inside the container; matches the bindmount above.
|
|
USERSCRIPT_PATH: ${USERSCRIPT_PATH:-/userscript/manga-bookmark.user.js}
|
|
# Second script from the same bindmount; the novel library is a separate
|
|
# Violentmonkey install.
|
|
NOVEL_USERSCRIPT_PATH: ${NOVEL_USERSCRIPT_PATH:-/userscript/novel-bookmark.user.js}
|
|
# Latest-chapter poller. LATEST_CHAPTER_POLL_ENABLED=0 in .env is the kill
|
|
# switch; it only takes effect because these are listed here.
|
|
LATEST_CHAPTER_POLL_ENABLED: ${LATEST_CHAPTER_POLL_ENABLED:-1}
|
|
LATEST_CHAPTER_POLL_COOLDOWN: ${LATEST_CHAPTER_POLL_COOLDOWN:-1h}
|
|
LATEST_CHAPTER_POLL_INTERVAL: ${LATEST_CHAPTER_POLL_INTERVAL:-10m}
|
|
LATEST_CHAPTER_POLL_BATCH: ${LATEST_CHAPTER_POLL_BATCH:-14}
|
|
LATEST_CHAPTER_POLL_STAGGER: ${LATEST_CHAPTER_POLL_STAGGER:-20s}
|
|
# CDP endpoint for sites behind a JavaScript challenge (kagane). Unset
|
|
# disables browser polling for those sites; the userscript still covers them.
|
|
# Must be an IP, not the "headless-shell" DNS name: Chrome's DevTools HTTP
|
|
# handler rejects the discovery request (GET /json/version) with a 500
|
|
# unless the Host header is an IP address or "localhost" — confirmed
|
|
# 2026-08-03 against chromedp/headless-shell:stable, independent of
|
|
# chromedp's own dial logic. The sidecar's static address below exists so
|
|
# this URL survives container recreation.
|
|
BROWSER_WS_URL: ${BROWSER_WS_URL:-ws://172.28.0.10:9222}
|
|
depends_on:
|
|
headless-shell:
|
|
condition: service_started
|
|
# The migration runner is the first thing the binary does, so a Postgres
|
|
# that is still initialising means a crash-loop until it is not.
|
|
postgres:
|
|
condition: service_healthy
|
|
volumes:
|
|
# The userscript is served from here, read fresh on every request. Editing
|
|
# the file in this checkout takes effect on the next Violentmonkey poll —
|
|
# no rebuild, no restart. `git pull` restores the committed version, which
|
|
# is why a redeploy always ships the repo's script.
|
|
- ./userscript:/userscript:ro
|
|
# Bound to loopback only: the proxy (or curl during smoke test) reaches it,
|
|
# the public internet does not.
|
|
ports:
|
|
- "127.0.0.1:8080:8080"
|
|
networks:
|
|
- browser
|
|
- db
|
|
|
|
postgres:
|
|
image: postgres:17-alpine
|
|
restart: unless-stopped
|
|
environment:
|
|
POSTGRES_DB: bookmarks
|
|
POSTGRES_USER: bookmarks
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U bookmarks -d bookmarks"]
|
|
interval: 5s
|
|
timeout: 3s
|
|
retries: 10
|
|
volumes:
|
|
- postgres-data:/var/lib/postgresql/data
|
|
# Deliberately no `ports:` — only bookmark-api, over the `db` network,
|
|
# reaches it. Use `docker compose exec postgres psql` for a shell.
|
|
networks:
|
|
- db
|
|
|
|
headless-shell:
|
|
image: chromedp/headless-shell:stable
|
|
restart: unless-stopped
|
|
# Chrome allocates shared memory per tab and dies on Docker's 64MB default.
|
|
shm_size: '1gb'
|
|
# Reaps zombie renderer processes, which otherwise accumulate for the
|
|
# container's lifetime.
|
|
init: true
|
|
# Deliberately no `ports:` — an exposed CDP endpoint is remote code
|
|
# execution. Only bookmark-api, via the `browser` network below, may reach it.
|
|
# Don't pass --remote-debugging-address/--remote-debugging-port here: the
|
|
# image's own entrypoint (/headless-shell/run.sh) already starts Chrome on
|
|
# 127.0.0.1:9223 and fronts it with a socat proxy listening on 0.0.0.0:9222.
|
|
# Redeclaring the port flag here overrides Chrome's, so it binds 9222
|
|
# directly (IPv6 loopback only) instead of 9223 — collides with socat's own
|
|
# bind on 9222 and leaves nothing listening on 9223, so every external
|
|
# connection to headless-shell:9222 fails with EOF. Only pass flags the
|
|
# entrypoint doesn't already set.
|
|
command:
|
|
- --disable-gpu
|
|
- --no-sandbox
|
|
networks:
|
|
browser:
|
|
# Pinned so BROWSER_WS_URL can name an IP (required, see above) that
|
|
# survives `docker compose up` recreating this container.
|
|
ipv4_address: 172.28.0.10
|
|
|
|
volumes:
|
|
postgres-data:
|
|
# The pre-Postgres SQLite volume (bookmarks-data) is deliberately no longer
|
|
# declared here: undeclared means `docker compose down -v` cannot take it
|
|
# with the rest, so the old database survives the cutover until someone
|
|
# removes it by hand.
|
|
|
|
networks:
|
|
# Not `internal: true`: headless Chrome still needs outbound access to reach
|
|
# kagane.to. Isolation here comes from membership (only bookmark-api and
|
|
# headless-shell join it), not from cutting egress.
|
|
browser:
|
|
ipam:
|
|
config:
|
|
- subnet: 172.28.0.0/24
|
|
# Postgres needs no egress and nothing outside bookmark-api needs to reach
|
|
# it, so this one really can be cut off from the outside world.
|
|
db:
|
|
internal: true
|