From 01de8903b456532ae0fe1c585cab186cca7dcb47 Mon Sep 17 00:00:00 2001
From: Sulthan Zaki
Date: Sat, 8 Aug 2026 15:46:05 +0700
Subject: [PATCH 1/4] docs: correct cutover and redeploy runbooks against the
real deployment (#26)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Dry-running CUTOVER.md against production surfaced four things that would
have failed mid-cutover:
- The volume is named after the compose project, which is the lowercased
directory name (mangabookmark), not the repo name. Both runbooks hardcoded
bookmarkmanager_bookmarks-data. Derive it from docker instead.
- §1 asserted a clean stop leaves no -wal. compose stop SIGKILLs after 10s,
and a surviving -wal holds writes bookmarks.db alone does not, so the
export would silently lose them. Check rather than assume.
- jq is not installed on the server; §5's read-path check now uses python3,
which the runbook already requires.
- REDEPLOY §1 still listed the pre-split table set, omitting readers and
sessions from both the \dt output and the pg_restore contents.
Also records the git-pull failure the redeploy hits on a checkout whose
remote is the HTTPS clone URL.
---
CUTOVER.md | 38 ++++++++++++++++++++++++++------------
REDEPLOY.md | 15 ++++++++++-----
2 files changed, 36 insertions(+), 17 deletions(-)
diff --git a/CUTOVER.md b/CUTOVER.md
index a255475..86c5236 100644
--- a/CUTOVER.md
+++ b/CUTOVER.md
@@ -1,7 +1,7 @@
# SQLite → Postgres cutover runbook
One-way, one-time. Moves the owner's reading history out of the retired SQLite
-volume (`bookmarkmanager_bookmarks-data`, holding `/data/bookmarks.db`) and into
+volume (`_bookmarks-data`, holding `/data/bookmarks.db`) and into
the Postgres schema the migration runner builds. There is no dual-write period:
the old database is read once, at cutover, from a **fresh export** — anything
written to SQLite after the export is lost, so the old API must already be down.
@@ -32,10 +32,10 @@ is dropped) — writing it from the spec below costs less than maintaining it
would.
Beyond `DEPLOY.md`'s prerequisites (Docker and Compose), this runbook needs
-`python3` on the machine running §3 — its stdlib `sqlite3` module is the whole
-SQLite dependency — and `jq` for the one read-path check in §5. Neither has to
-be the server: §3 only reads the snapshot copy, so it can run on a laptop and
-the resulting `import.sql` be copied over.
+`python3` — its stdlib `sqlite3` module is the whole SQLite dependency, and it
+stands in for `jq` in §5, which is not installed on the server. It does not have
+to run on the server: §3 only reads the snapshot copy, so it can run on a laptop
+and the resulting `import.sql` be copied over.
---
@@ -45,21 +45,34 @@ the resulting `import.sql` be copied over.
already stale.
```bash
-cd /opt/bookmarkmanager
+cd ~/mangaBookmark # wherever the checkout lives
COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
-BACKUP_DIR="$(cd .. && pwd)/bookmarkmanager-backups"; mkdir -p "$BACKUP_DIR"
+BACKUP_DIR="$(cd .. && pwd)/$(basename "$PWD")-backups"; mkdir -p "$BACKUP_DIR"
STAMP=$(date -u +%Y%m%d-%H%M%S)
+# The volume is _bookmarks-data, and the project name defaults
+# to the lowercased *directory* name, not the repo name — here that makes it
+# mangabookmark_bookmarks-data. Ask Docker instead of typing it out.
+VOL=$(docker volume ls -q --filter name=_bookmarks-data); echo "$VOL"
+
$COMPOSE stop bookmark-api
-# Copy the file straight out of the retired volume. Nothing is writing to it,
-# so a plain copy is consistent — no -wal to worry about after a clean stop.
-docker run --rm -v bookmarkmanager_bookmarks-data:/from:ro -v "$BACKUP_DIR":/to \
+# A clean SIGTERM closes the store, which checkpoints and unlinks the -wal, so
+# bookmarks.db alone is then the whole database. But `compose stop` SIGKILLs
+# after 10s, and a surviving -wal holds writes the main file does not — assert
+# it is gone rather than assuming the shutdown was clean.
+docker run --rm -v "$VOL":/d:ro alpine ls -l /d # -> bookmarks.db, alone
+
+docker run --rm -v "$VOL":/from:ro -v "$BACKUP_DIR":/to \
alpine cp /from/bookmarks.db "/to/bookmarks-$STAMP.db"
ls -lh "$BACKUP_DIR/bookmarks-$STAMP.db"
```
+If `-wal` and `-shm` are still there, the container was killed mid-write. Copy
+all three under the same basename and let SQLite replay the log when §3 opens
+it — copying only `bookmarks.db` silently drops whatever the log still holds.
+
Work on a **copy** of that file for the rest of this runbook. The export is the
last line of retreat; nothing below should be able to write to it.
@@ -256,7 +269,8 @@ would catch a correct import behind a broken join:
```bash
API=https://bookmark-api.violetcrown.my.id
TOKEN=$(grep -E '^API_TOKEN=' .env | cut -d= -f2) # grace-window credential
-curl -s -H "Authorization: Bearer $TOKEN" $API/bookmarks | jq 'length' # -> 29
+curl -s -H "Authorization: Bearer $TOKEN" $API/bookmarks |
+ python3 -c 'import json,sys; print(len(json.load(sys.stdin)))' # -> 29
```
---
@@ -266,7 +280,7 @@ curl -s -H "Authorization: Bearer $TOKEN" $API/bookmarks | jq 'length' # -> 29
- **Keep the old SQLite volume for a month.** It is already undeclared in
compose, so `docker compose down -v` cannot take it. Remove it by hand once
the Postgres data has been trusted for a while:
- `docker volume rm bookmarkmanager_bookmarks-data` (see `REDEPLOY.md` §1).
+ `docker volume rm "$VOL"` (see `REDEPLOY.md` §1).
- **Delete the generator and the working copies:** `rm -rf /tmp/cutover`. The
timestamped export in `$BACKUP_DIR` is the copy that is kept.
- **Take the first Postgres dump immediately** — `REDEPLOY.md` §1. Until that
diff --git a/REDEPLOY.md b/REDEPLOY.md
index 0b2965a..dca70a5 100644
--- a/REDEPLOY.md
+++ b/REDEPLOY.md
@@ -59,7 +59,7 @@ network can reach it — so every command below goes in through the container:
```bash
$COMPOSE exec -T postgres psql -U bookmarks -d bookmarks -c '\dt'
-# -> bookmarks, schema_migrations, series
+# -> bookmarks, readers, schema_migrations, series, sessions
```
Inside the container that connects over the local socket as the `bookmarks`
@@ -99,8 +99,10 @@ you will act as though you have one:
docker run --rm -v "$BACKUP_DIR":/backup postgres:17-alpine \
pg_restore --list "/backup/bookmarks-$STAMP.dump" | grep 'TABLE DATA'
# -> 1234; 0 0 TABLE DATA public bookmarks bookmarks
-# -> 1235; 0 0 TABLE DATA public schema_migrations bookmarks
-# -> 1236; 0 0 TABLE DATA public series series
+# -> 1235; 0 0 TABLE DATA public readers bookmarks
+# -> 1236; 0 0 TABLE DATA public schema_migrations bookmarks
+# -> 1237; 0 0 TABLE DATA public series bookmarks
+# -> 1238; 0 0 TABLE DATA public sessions bookmarks
# 2. Sanity-check the live row count you just captured.
$COMPOSE exec -T postgres psql -U bookmarks -d bookmarks \
@@ -161,10 +163,12 @@ ls -1t "$BACKUP_DIR"/bookmarks-*.dump | tail -n +31 | xargs -r rm -v
declared in `docker-compose.yml` any more, which is what keeps `docker compose
down -v` from taking it with the rest of the stack. It is not the live database
and nothing reads it — the one-way move out of it is `CUTOVER.md`. Once the
-Postgres data has been trusted for a while, remove it by hand — nothing else will:
+Postgres data has been trusted for a while, remove it by hand — nothing else will.
+Its full name is `_bookmarks-data`, and the project name is the
+lowercased directory name of the checkout, so ask Docker rather than typing it:
```bash
-docker volume rm bookmarkmanager_bookmarks-data
+docker volume rm "$(docker volume ls -q --filter name=_bookmarks-data)"
```
---
@@ -395,6 +399,7 @@ panel works on the phone.
| `postgres` never leaves `starting`; `bookmark-api` never starts either | The healthcheck (`pg_isready`) is failing and `bookmark-api` waits on it. `$COMPOSE logs postgres` — usually `postgres-data` was initialised by a different major version ("database files are incompatible with server"), or the disk is full. |
| `pg_restore`: `cannot drop … other objects depend on it` / `being accessed by other users` | Live connections block `--clean`. `$COMPOSE stop bookmark-api` first (§6). If they persist: `$COMPOSE exec -T postgres psql -U bookmarks -d postgres -c "select pg_terminate_backend(pid) from pg_stat_activity where datname='bookmarks' and pid <> pg_backend_pid()"`. |
| Dump is 0 bytes, or `pg_restore`: `did not find magic string in file header` | You ran `exec` without `-T`. The allocated TTY rewrites newlines in the binary stream and corrupts the archive in flight (§1). |
+| `git pull`: `could not read Username for 'https://…'` | The checkout's remote is the HTTPS clone URL and the server has no credential helper, so the pull prompts into a closed stdin. Switch it to SSH once — `git remote set-url origin ssh://git@gitea.violetcrown.my.id:2222/sulthan/mangaBookmark.git`. Gitea's SSH listens on **2222**, not 22; port 22 is the host's own sshd and answers `Permission denied (publickey)` no matter which key is registered. |
Full first-time setup: `DEPLOY.md`. The one-off SQLite→Postgres move:
`CUTOVER.md`. Config reference and endpoints: `README.md`.
--
2.52.0
From 77965c3d7698c513da27bff4eba3102643224df1 Mon Sep 17 00:00:00 2001
From: Sulthan Zaki
Date: Sat, 8 Aug 2026 15:50:59 +0700
Subject: [PATCH 2/4] docs: close the environment contract and de-ambiguate
volume derivation (#26)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Review findings on the previous commit:
- README's config table listed 8 of the backend's 25 environment variables,
omitting the whole DISCORD_* set that the backend refuses to start without.
The canonical reference cannot be missing the vars that gate startup.
- `docker volume ls --filter name=` is a substring match. Both runbooks used
it to find a volume they then mount read-only or delete; a second matching
volume makes the mount fail obscurely and the delete take both. Derive the
name exactly from the compose project instead.
- CUTOVER §6 removes the retired volume 'once the Postgres data has been
trusted for a while', in a shell where §1's $VOL no longer exists.
- REDEPLOY and DEPLOY still pointed at /opt/bookmarkmanager, so CUTOVER §6's
handoff to REDEPLOY §1 sent the operator to a path that does not exist.
---
CUTOVER.md | 23 ++++++++++++++---------
DEPLOY.md | 4 ++--
README.md | 20 ++++++++++++++++++++
REDEPLOY.md | 31 +++++++++++++++++--------------
4 files changed, 53 insertions(+), 25 deletions(-)
diff --git a/CUTOVER.md b/CUTOVER.md
index 86c5236..a44aa92 100644
--- a/CUTOVER.md
+++ b/CUTOVER.md
@@ -32,10 +32,10 @@ is dropped) — writing it from the spec below costs less than maintaining it
would.
Beyond `DEPLOY.md`'s prerequisites (Docker and Compose), this runbook needs
-`python3` — its stdlib `sqlite3` module is the whole SQLite dependency, and it
-stands in for `jq` in §5, which is not installed on the server. It does not have
-to run on the server: §3 only reads the snapshot copy, so it can run on a laptop
-and the resulting `import.sql` be copied over.
+`python3`: its stdlib `sqlite3` module is the whole SQLite dependency, and §5's
+read-path check uses it in place of `jq`, which the server does not have. It
+does not have to run on the server — §3 only reads the snapshot copy, so it can
+run on a laptop and the resulting `import.sql` be copied over.
---
@@ -51,9 +51,12 @@ BACKUP_DIR="$(cd .. && pwd)/$(basename "$PWD")-backups"; mkdir -p "$BACKUP_DIR"
STAMP=$(date -u +%Y%m%d-%H%M%S)
# The volume is _bookmarks-data, and the project name defaults
-# to the lowercased *directory* name, not the repo name — here that makes it
-# mangabookmark_bookmarks-data. Ask Docker instead of typing it out.
-VOL=$(docker volume ls -q --filter name=_bookmarks-data); echo "$VOL"
+# to the lowercased *directory* name, not the repo name — on this host the
+# checkout is ~/mangaBookmark, so the volume is mangabookmark_bookmarks-data.
+# Derive it exactly rather than with a `--filter name=` substring match, which
+# would return every volume whose name merely contains the string.
+VOL="$(basename "$PWD" | tr '[:upper:]' '[:lower:]')_bookmarks-data"
+docker volume inspect "$VOL" >/dev/null && echo "$VOL"
$COMPOSE stop bookmark-api
@@ -279,8 +282,10 @@ curl -s -H "Authorization: Bearer $TOKEN" $API/bookmarks |
- **Keep the old SQLite volume for a month.** It is already undeclared in
compose, so `docker compose down -v` cannot take it. Remove it by hand once
- the Postgres data has been trusted for a while:
- `docker volume rm "$VOL"` (see `REDEPLOY.md` §1).
+ the Postgres data has been trusted for a while. That happens in a shell where
+ `$VOL` from §1 is long gone, so re-derive it:
+ `docker volume rm "$(basename ~/mangaBookmark | tr '[:upper:]' '[:lower:]')_bookmarks-data"`
+ (see `REDEPLOY.md` §1).
- **Delete the generator and the working copies:** `rm -rf /tmp/cutover`. The
timestamped export in `$BACKUP_DIR` is the copy that is kept.
- **Take the first Postgres dump immediately** — `REDEPLOY.md` §1. Until that
diff --git a/DEPLOY.md b/DEPLOY.md
index cedecb0..dd4b02c 100644
--- a/DEPLOY.md
+++ b/DEPLOY.md
@@ -11,7 +11,7 @@ ACME/cert resolver, and control a domain.
- 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.` pointing at the server.
-- The repo copied to the server, e.g. `/opt/bookmarkmanager/` (needs `backend/`,
+- 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):
@@ -25,7 +25,7 @@ docker network ls | grep proxy || docker network create proxy
## 1. Configure `.env`
```bash
-cd /opt/bookmarkmanager
+cd ~/mangaBookmark
cp .env.example .env
```
diff --git a/README.md b/README.md
index 4742b15..5ef8763 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,26 @@ Bromite userscript (isolated world, Shadow DOM UI, localStorage cache)
| `DATABASE_URL` | *(required)* | Postgres connection URL, e.g. `postgres://bookmarks:…@postgres:5432/bookmarks?sslmode=disable`. Compose builds it from `POSTGRES_PASSWORD`. |
| `PORT` | `8080` | Plain HTTP; TLS terminated by the proxy. |
| `BROWSER_WS_URL` | `ws://172.28.0.10:9222` | Headless-shell CDP endpoint used to poll Kagane past its JS challenge. Must be an IP or `localhost` — Chrome's DevTools handler 500s any other Host header. |
+| `DISCORD_CLIENT_ID` | *(required)* | Discord application credentials for the browser sign-in (ADR-0002). |
+| `DISCORD_CLIENT_SECRET` | *(required)* | As above. Never logged, never echoed in an error. |
+| `DISCORD_GUILD_ID` | *(required)* | The one guild whose membership gates sign-in, checked at login only. |
+| `DISCORD_REDIRECT_URI` | *(required)* | Exact callback URL; Discord matches it verbatim against the registered redirect. |
+| `DISCORD_REQUIRED_ROLE` | empty | Role snowflake a member must additionally hold. Empty means guild membership alone suffices. |
+| `DISCORD_API_BASE` | `https://discord.com/api/v10` | Test seam — tests point it at a local stub so the real token exchange runs. |
+| `USERSCRIPT_PATH` | `/userscript/manga-bookmark.user.js` | Bindmounted file served at `/u/{token}/manga-bookmark.user.js`. |
+| `NOVEL_USERSCRIPT_PATH` | `/userscript/novel-bookmark.user.js` | Same, for the novel library. |
+| `LATEST_CHAPTER_POLL_ENABLED` | `1` | `0` turns the poller off entirely. |
+| `LATEST_CHAPTER_POLL_COOLDOWN` | `1h` | Rest between checks of one series; floor `15m`. |
+| `LATEST_CHAPTER_POLL_INTERVAL` | `10m` | How often the poller wakes. Cannot shorten a cooldown. |
+| `LATEST_CHAPTER_POLL_BATCH` | `14` | Series per wake. Keep `BATCH × STAGGER` under `INTERVAL`. |
+| `LATEST_CHAPTER_POLL_STAGGER` | `20s` | Delay between fetches in a batch — this is the outbound request rate. |
+
+Compose reads a few more from the same `.env` that the backend never sees:
+`POSTGRES_PASSWORD` (required — `DATABASE_URL` is built from it, and Postgres
+only applies it while `postgres-data` is empty), `BOOKMARK_API_HOST` and
+`BOOKMARK_WEB_HOST` (required by the prod override), and the optional
+`PROXY_NETWORK` / `TRAEFIK_ENTRYPOINT` / `TRAEFIK_CERTRESOLVER`. Full commentary
+is in `.env.example`; deployment order is `DEPLOY.md`.
### Endpoints
diff --git a/REDEPLOY.md b/REDEPLOY.md
index dca70a5..6ef8ba4 100644
--- a/REDEPLOY.md
+++ b/REDEPLOY.md
@@ -9,16 +9,16 @@ Whole thing is ~5 minutes, most of it waiting on `docker build`. Order matters:
**back up before you pull.** A backup taken after a bad migration is a backup of
the damage.
-Paths below assume the checkout is at `/opt/bookmarkmanager`; substitute your own. The
-one absolute rule about paths: **backups live in `../bookmarkmanager-backups/`**, a
-sibling of the project directory (`/opt/bookmarkmanager-backups`), never inside it. It
+Paths below assume the checkout is at `~/mangaBookmark`, which is where it lives
+on this deployment; substitute your own. The one absolute rule about paths:
+**backups live in a `-backups` sibling of the checkout**, never inside it. It
sits outside the repo so `git pull`, `git clean -fd` and a bad `rm -rf` inside
the checkout cannot take the backups with them.
```
-/opt/
-├── bookmarkmanager/ <- the checkout (this repo)
-└── bookmarkmanager-backups/ <- bookmarks-YYYYmmdd-HHMMSS.dump
+~/
+├── mangaBookmark/ <- the checkout (this repo)
+└── mangaBookmark-backups/ <- bookmarks-YYYYmmdd-HHMMSS.dump
```
---
@@ -26,7 +26,7 @@ the checkout cannot take the backups with them.
## 0. Preflight
```bash
-cd /opt/bookmarkmanager
+cd ~/mangaBookmark
# Both -f flags, every time. The prod override is not standalone.
COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
@@ -44,9 +44,9 @@ dirty tree fails halfway and leaves you in a worse spot than either.
Create the backup directory once, and make sure it is a sibling, not a child:
```bash
-mkdir -p ../bookmarkmanager-backups
-BACKUP_DIR="$(cd .. && pwd)/bookmarkmanager-backups" # absolute — Docker needs it
-echo "$BACKUP_DIR" # -> /opt/bookmarkmanager-backups
+BACKUP_DIR="$(cd .. && pwd)/$(basename "$PWD")-backups" # absolute — Docker needs it
+mkdir -p "$BACKUP_DIR"
+echo "$BACKUP_DIR" # -> /home/sulthan/mangaBookmark-backups
```
---
@@ -131,8 +131,11 @@ container stopped the shutdown checkpoint has already flushed everything and a
plain archive of the volume is consistent.
```bash
-VOL=$(docker volume ls --filter name=postgres-data -q | head -1)
-echo "$VOL" # -> bookmarkmanager_postgres-data
+# Derived exactly, not with a `--filter name=` substring match plus `head -1`:
+# that quietly picks the first of however many volumes happen to contain the
+# string, and archiving the wrong data directory is not a visible failure.
+VOL="$(basename "$PWD" | tr '[:upper:]' '[:lower:]')_postgres-data"
+docker volume inspect "$VOL" >/dev/null && echo "$VOL" # -> mangabookmark_postgres-data
$COMPOSE stop
docker run --rm -v "$VOL":/from:ro -v "$BACKUP_DIR":/to alpine \
@@ -165,10 +168,10 @@ down -v` from taking it with the rest of the stack. It is not the live database
and nothing reads it — the one-way move out of it is `CUTOVER.md`. Once the
Postgres data has been trusted for a while, remove it by hand — nothing else will.
Its full name is `_bookmarks-data`, and the project name is the
-lowercased directory name of the checkout, so ask Docker rather than typing it:
+lowercased directory name of the checkout:
```bash
-docker volume rm "$(docker volume ls -q --filter name=_bookmarks-data)"
+docker volume rm "$(basename "$PWD" | tr '[:upper:]' '[:lower:]')_bookmarks-data"
```
---
--
2.52.0
From 40378192b14e494685bb74f573a9760d13cfd74c Mon Sep 17 00:00:00 2001
From: Sulthan Zaki
Date: Sat, 8 Aug 2026 15:56:37 +0700
Subject: [PATCH 3/4] fix(web): check guild membership on the OAuth endpoint,
not the bot one
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The login gate called GET /guilds/{guild}/members/{user} — the Guild
resource's Get Guild Member, which wants a Bot token and the application
present in the guild. Handed a user Bearer token it answers 401, which
discordMember reports as an error, so every sign-in rendered "Discord
sign-in is unavailable right now" and nobody could get in.
The endpoint guilds.members.read actually grants is Get Current User Guild
Member, GET /users/@me/guilds/{guild}/member. Same single-guild question,
same privacy property, and it takes the token we hold. #18 flagged this as
verified from Discord's documentation but never from a live flow; it was
wrong.
The stub mirrored the implementation, so the suite could not see it. It now
serves the OAuth path and answers the bot path 401 the way Discord does —
without that, a regression falls through to 404 and reads as an ordinary
"not a member" refusal instead of failing.
---
backend/internal/web/discord.go | 23 +++++++++++++++--------
backend/web_test.go | 13 ++++++++++---
2 files changed, 25 insertions(+), 11 deletions(-)
diff --git a/backend/internal/web/discord.go b/backend/internal/web/discord.go
index 930c72c..f25ab4c 100644
--- a/backend/internal/web/discord.go
+++ b/backend/internal/web/discord.go
@@ -174,7 +174,7 @@ func (h *Handler) discordCallback(w http.ResponseWriter, r *http.Request) {
return
}
- member, isMember, err := h.discordMember(r.Context(), tok.AccessToken, userID)
+ member, isMember, err := h.discordMember(r.Context(), tok.AccessToken)
if err != nil {
h.limiter.Fail(ip, time.Now())
log.Printf("discord member check: %v", err)
@@ -272,13 +272,20 @@ type discordMember struct {
Roles []string `json:"roles"`
}
-// discordMember fetches the user's membership in the configured guild — the
-// single-guild endpoint, not the list of every guild the user is in, so the
-// gate asks exactly the question it names. A 404 or 403 (not in the guild, or
-// the token lacks the scope) is a non-member, not an error.
-func (h *Handler) discordMember(ctx context.Context, accessToken, userID string) (discordMember, bool, error) {
- u := h.discord.APIBase + "/guilds/" + url.PathEscape(h.discord.GuildID) +
- "/members/" + url.PathEscape(userID)
+// discordMember fetches the current user's membership in the configured guild.
+//
+// This is the OAuth endpoint (Get Current User Guild Member), the one the
+// guilds.members.read scope grants. Its bot-side twin, GET /guilds/{id}/
+// members/{user}, reads almost identically and is the wrong one: it wants a
+// Bot token and the application present in the guild, and answers a user
+// Bearer token with 401 — which fails as an outage rather than a refusal, so
+// nobody could sign in at all.
+//
+// A 404 or 403 (not in the guild, or the token lacks the scope) is a
+// non-member, not an error.
+func (h *Handler) discordMember(ctx context.Context, accessToken string) (discordMember, bool, error) {
+ u := h.discord.APIBase + "/users/@me/guilds/" +
+ url.PathEscape(h.discord.GuildID) + "/member"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return discordMember{}, false, err
diff --git a/backend/web_test.go b/backend/web_test.go
index 8240c48..336208b 100644
--- a/backend/web_test.go
+++ b/backend/web_test.go
@@ -103,7 +103,13 @@ func newDiscordStub(t *testing.T) (*discordStub, *httptest.Server) {
if status == http.StatusOK {
fmt.Fprintf(w, `{"id":%q,"username":"owner"}`, st.ownerID)
}
+ // Discord answers the bot endpoint with 401 for a user Bearer token.
+ // Standing in for that keeps a regression onto it loud: without this
+ // the request would fall through to 404 and read as "not a member",
+ // which is a refusal the caller treats as ordinary.
case strings.HasPrefix(r.URL.Path, "/guilds/"):
+ w.WriteHeader(http.StatusUnauthorized)
+ case strings.HasPrefix(r.URL.Path, "/users/@me/guilds/"):
st.memberPaths = append(st.memberPaths, r.URL.Path)
st.memberAuth = append(st.memberAuth, r.Header.Get("Authorization"))
status := st.memberStatus
@@ -299,12 +305,13 @@ func TestDiscordLoginFullFlow(t *testing.T) {
}
// Identity and membership were fetched with the exchanged token, and the
- // membership check used the single-guild endpoint.
+ // membership check used the OAuth single-guild endpoint — the one
+ // guilds.members.read grants, not its bot-token twin.
if len(stub.userAuth) != 1 || stub.userAuth[0] != "Bearer tok-1" {
t.Fatalf("users/@me Authorization = %v, want [Bearer tok-1]", stub.userAuth)
}
- if len(stub.memberPaths) != 1 || stub.memberPaths[0] != "/guilds/guild-1/members/owner-snowflake" {
- t.Fatalf("member requests = %v, want the single-guild endpoint", stub.memberPaths)
+ if len(stub.memberPaths) != 1 || stub.memberPaths[0] != "/users/@me/guilds/guild-1/member" {
+ t.Fatalf("member requests = %v, want the OAuth single-guild endpoint", stub.memberPaths)
}
if len(stub.memberAuth) != 1 || stub.memberAuth[0] != "Bearer tok-1" {
t.Fatalf("member Authorization = %v, want [Bearer tok-1]", stub.memberAuth)
--
2.52.0
From 083001672920c3d6efb00a0b6108ad085543fe91 Mon Sep 17 00:00:00 2001
From: Sulthan Zaki
Date: Sat, 8 Aug 2026 16:36:45 +0700
Subject: [PATCH 4/4] feat(web): offer the userscripts as a download for mobile
Violentmonkey on mobile Chromium does not intercept navigation to a
.user.js URL, so the Install link renders the script as text and there is
no way to get it installed. Adding ?download=1 sets Content-Disposition:
attachment on the same session-gated endpoint, so the Reader saves the
file and adds it from Violentmonkey's own menu.
The plain link stays inline on purpose: the updater polls the /u/ path and
an attachment disposition there would break auto-update. The test asserts
both halves.
Refs #26
---
backend/AGENTS.md | 4 +++-
backend/internal/web/templates/setup.html | 7 +++++++
backend/internal/web/web.go | 7 +++++++
backend/reader_credential_test.go | 23 +++++++++++++++++++++++
4 files changed, 40 insertions(+), 1 deletion(-)
diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index 3ddb6e4..7e24ffa 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -127,6 +127,8 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
- **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
+ bar, or a redirect; `?download=1` adds `Content-Disposition: attachment` for
+ mobile Violentmonkey, which ignores a `.user.js` navigation) and
+ `POST /rotate-token` (atomic epoch bump + hash
rewrite; invalidates every installed copy, so the panel warns to reinstall
on all devices).
diff --git a/backend/internal/web/templates/setup.html b/backend/internal/web/templates/setup.html
index d770c6e..9b93bd8 100644
--- a/backend/internal/web/templates/setup.html
+++ b/backend/internal/web/templates/setup.html
@@ -12,6 +12,13 @@
Install Manga script
Install Novels script
+ On mobile, Violentmonkey does not pick up the install
+ links — the script opens as text. Download the file instead, then add it
+ from Violentmonkey's own menu.
+
+ Download Manga script
+ Download Novels script
+
{{if .Rotated}}
Credential rotated — the old one no
longer works. Reinstall both scripts on every device now, or they will
diff --git a/backend/internal/web/web.go b/backend/internal/web/web.go
index 629750d..f9c0f39 100644
--- a/backend/internal/web/web.go
+++ b/backend/internal/web/web.go
@@ -547,6 +547,10 @@ func (h *Handler) uiDelete(w http.ResponseWriter, r *http.Request) {
// 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.
+//
+// ?download=1 forces a save instead. Mobile Violentmonkey (Chromium) does not
+// intercept navigation to a .user.js URL, so the Install link only renders the
+// source as text there; the Reader needs the file on disk to add it by hand.
func (h *Handler) installUserscript(name string) http.HandlerFunc {
path := h.mangaUserscriptPath
if name == "novel-bookmark.user.js" {
@@ -559,6 +563,9 @@ func (h *Handler) installUserscript(name string) http.HandlerFunc {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
+ if r.URL.Query().Has("download") {
+ w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
+ }
userscript.Render(w, r, path, token.Token(h.tokenKey, discordID, epoch))
}
}
diff --git a/backend/reader_credential_test.go b/backend/reader_credential_test.go
index 798140a..e248de5 100644
--- a/backend/reader_credential_test.go
+++ b/backend/reader_credential_test.go
@@ -201,6 +201,27 @@ func TestInstallServesScriptWithCredential(t *testing.T) {
if loc := rr.Header().Get("Location"); loc != "" {
t.Fatalf("%s answered with a redirect, credential in Location %q", script, loc)
}
+ // The plain link must stay inline: Violentmonkey's updater polls the
+ // /u/ path and an attachment disposition there would break updates.
+ if cd := rr.Header().Get("Content-Disposition"); cd != "" {
+ t.Fatalf("%s served as %q, want inline", script, cd)
+ }
+
+ // ?download=1 is the mobile path: Violentmonkey on Chromium ignores a
+ // .user.js navigation, so the Reader saves the file and adds it by hand.
+ req = httptest.NewRequest(http.MethodGet, "/install/"+script+"?download=1", nil)
+ req.AddCookie(sessionCookie(t, st))
+ rr = httptest.NewRecorder()
+ srv.ServeHTTP(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Fatalf("%s?download=1: status = %d, want 200", script, rr.Code)
+ }
+ if got, want := rr.Header().Get("Content-Disposition"), `attachment; filename="`+script+`"`; got != want {
+ t.Fatalf("%s?download=1: Content-Disposition = %q, want %q", script, got, want)
+ }
+ if !strings.Contains(rr.Body.String(), `API_TOKEN = "`+ownerCredential()+`"`) {
+ t.Fatalf("%s?download=1 does not carry the owner's credential", script)
+ }
}
}
@@ -327,6 +348,8 @@ func TestIndexShowsSetupPanelWithoutCredential(t *testing.T) {
for _, want := range []string{
`href="/install/manga-bookmark.user.js"`,
`href="/install/novel-bookmark.user.js"`,
+ `href="/install/manga-bookmark.user.js?download=1"`,
+ `href="/install/novel-bookmark.user.js?download=1"`,
"Rotate credential",
} {
if !strings.Contains(body, want) {
--
2.52.0