feat: register any guild member as a Reader (#27)
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.
This commit is contained in:
+10
-15
@@ -6,17 +6,10 @@
|
||||
# openssl rand -hex 32
|
||||
TOKEN_KEY=changeme-generate-a-long-random-token
|
||||
|
||||
# Retired global credential, kept only during the cutover window so
|
||||
# already-installed scripts keep working. Remove both it and
|
||||
# API_TOKEN_GRACE_UNTIL once the window has passed and every device has
|
||||
# reinstalled through the web UI.
|
||||
# API_TOKEN=
|
||||
# Moment the retired credential stops resolving to the owner (YYYY-MM-DD or
|
||||
# RFC3339). Enforced in code on every request; unset means it is already dead.
|
||||
# API_TOKEN_GRACE_UNTIL=2026-08-22
|
||||
|
||||
# The owner's Discord user ID — the one Reader every bookmark belongs to
|
||||
# (seeded at startup). Discord snowflake, e.g. 1046923170000000000.
|
||||
# The owner's Discord user ID — seeded at startup as the first Reader, the
|
||||
# administrator (the only one who can revoke another Reader's sessions), and
|
||||
# the owner of every bookmark that predates registration. Discord snowflake,
|
||||
# e.g. 1046923170000000000.
|
||||
OWNER_DISCORD_ID=changeme-your-discord-user-id
|
||||
|
||||
# Comma-separated origins allowed to call the API (CORS). Both Asura domains
|
||||
@@ -42,10 +35,12 @@ POSTGRES_PASSWORD=changeme-generate-a-long-random-password
|
||||
# TRAEFIK_CERTRESOLVER=le
|
||||
|
||||
# --- Web UI (Discord OAuth) ---
|
||||
# Sign-in is a Discord authorization code grant (ADR-0002): the owner signs in
|
||||
# with Discord, and guild membership gates access. Create the application at
|
||||
# https://discord.com/developers/applications and register the exact callback
|
||||
# URL ($BOOKMARK_WEB_HOST/auth/discord/callback) as an OAuth2 redirect.
|
||||
# Sign-in is a Discord authorization code grant (ADR-0002), and it is also
|
||||
# registration: any member of the configured guild becomes a Reader on their
|
||||
# first successful login, with their own empty library. Create the application
|
||||
# at https://discord.com/developers/applications and register the exact
|
||||
# callback URL ($BOOKMARK_WEB_HOST/auth/discord/callback) as an OAuth2
|
||||
# redirect.
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
# The guild whose membership gates sign-in (Developer Mode -> right-click the
|
||||
|
||||
@@ -83,8 +83,8 @@ Go backend:
|
||||
- `html/template` only for anything a browser parses, never `text/template`. Never wrap stored or fetched strings in `template.HTML`/`JS`/`URL`; that switches off the escaping every template depends on.
|
||||
- Any outbound fetch of a client-supplied URL passes `fetchableSeriesURL` (site + `https` + host check) first. `series_url` arrives in a PUT body, so without the gate the poller will probe arbitrary hosts from the server's own network position. New fetch path reuses the gate rather than re-deriving one.
|
||||
- Cap every remote body with `io.LimitReader` (`maxBodyBytes`). An unbounded read is an OOM handed to whatever is on the other end.
|
||||
- Compare secrets with `hmac.Equal` / `subtle.ConstantTimeCompare`, never `==`. Covers the retired global token during its grace window.
|
||||
- Errors: generic text to the client (`http.Error(w, "internal error", 500)`), detail to `log.Printf`. Never log `API_TOKEN`, `DISCORD_CLIENT_SECRET`, a session id, or a whole `Authorization` header.
|
||||
- Compare secrets with `hmac.Equal` / `subtle.ConstantTimeCompare`, never `==`. A credential is matched by the SHA-256 the `readers` table holds, which is already a fixed-width equality — a new secret comparison must not regress to `==`.
|
||||
- Errors: generic text to the client (`http.Error(w, "internal error", 500)`), detail to `log.Printf`. Never log `TOKEN_KEY`, a Reader's credential, `DISCORD_CLIENT_SECRET`, a session id, or a whole `Authorization` header.
|
||||
- Proxy headers are trusted only where they already are: `X-Forwarded-Proto` for the Secure cookie flag, **rightmost** `X-Forwarded-For` for client IP (leftmost is attacker-supplied). Don't read either anywhere else.
|
||||
- Session cookies keep `HttpOnly`, `SameSite`, `Secure`-when-HTTPS; expiry is enforced by the `sessions` table lookup, not a signature.
|
||||
- Stdlib crypto only. No hand-rolled hashing, no MD5/SHA-1 anywhere security-bearing.
|
||||
|
||||
@@ -36,8 +36,9 @@ Edit `.env`:
|
||||
# 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 →
|
||||
# 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>
|
||||
|
||||
@@ -74,10 +75,8 @@ 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.
|
||||
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
|
||||
@@ -124,8 +123,10 @@ gated by membership in one configured guild.
|
||||
|
||||
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. `OWNER_DISCORD_ID` from §1 is the
|
||||
only Discord identity allowed to sign in while registration is closed.
|
||||
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:
|
||||
|
||||
@@ -188,9 +189,10 @@ 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)
|
||||
# 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 # -> []
|
||||
|
||||
|
||||
+20
-14
@@ -8,47 +8,53 @@ web
|
||||
|
||||
## Users
|
||||
|
||||
Single user (self-hosted, no accounts, no multi-user planned). Reads manga on **asurascans.com** and **demonicscans.org** primarily via Bromite on mobile, also checks/updates from a desktop browser. The web UI is the cross-device view into progress captured by the userscript while reading.
|
||||
Members of one private Discord guild, each with their own library. Accounts exist and are created by signing in — there is no signup form, no invite code and no approval step: any member of the configured guild becomes a Reader on their first Discord login. The person running the deployment is the owner, seeded at startup, and the only Reader with an administrative capability (revoking another Reader's sessions).
|
||||
|
||||
Reading happens on **asurascans.com**, **demonicscans.org**, **comix.to** and **kagane.to** for manga and **novelfull.com** and **lightnovelworld.net** for novels, primarily via Bromite on mobile, with checks and corrections from a desktop browser. The web UI is the cross-device view into progress the userscripts capture while reading.
|
||||
|
||||
## Product Purpose
|
||||
|
||||
Tracks read-progress ("last chapter read") per manga series across two otherwise-unrelated manga sites that each have their own separate `localStorage`. A Go backend unifies bookmarks into one store; the web UI is a password-gated browser view of that store for reviewing, favouriting, correcting, or removing bookmarks, and jumping back into a series to continue reading. A background poller also refreshes each series' latest-published-chapter so the list can flag "NEW" without the user visiting the site.
|
||||
Tracks read-progress ("last chapter read") per series across sites that each have their own separate `localStorage`. A Go backend unifies bookmarks into one store; the web UI is a Discord-gated browser view of one Reader's own bookmarks, for reviewing, favouriting, correcting, shelving or removing them, and jumping back into a series to continue reading. A background poller refreshes each series' latest-published-chapter so the list can flag "NEW" without the Reader visiting the site.
|
||||
|
||||
## Positioning
|
||||
|
||||
Not a public reading tracker or social app — a private, self-hosted sync layer purpose-built for two specific scraped sites, with no server-side account system (single bearer token + one password-gated session).
|
||||
Not a public reading tracker or social app — a private, self-hosted sync layer for one Discord community, purpose-built for a fixed set of scraped sites. Multi-Reader, not multi-tenant: libraries are isolated, but the deployment belongs to one group and its membership is the whole access model.
|
||||
|
||||
## Operating Context
|
||||
|
||||
- Primary reading device: Bromite (mobile Chromium), where a userscript captures progress automatically.
|
||||
- Primary reading device: Bromite (mobile Chromium), where a userscript captures progress automatically. Each Reader installs their own copy, rendered with their own credential.
|
||||
- Web UI is a secondary surface: checking list state, correcting a wrong chapter number, removing dead bookmarks, jumping to "continue reading."
|
||||
- Manga cover art and titles come from the source sites' `og:image`/`og:title` — real content, not placeholders.
|
||||
- List order is driven by `updated_at`, which moves only on real reading progress (not favouriting, not a newly detected chapter) — a UI constraint the redesign must not break.
|
||||
- Cover art and titles come from the source sites' `og:image`/`og:title` — real content, not placeholders. They are facts about the series, so they are shared between Readers who track it; progress is not.
|
||||
- List order is driven by `updated_at`, which moves only on real reading progress (not favouriting, not a newly detected chapter) — a UI constraint the design must not break.
|
||||
|
||||
## Capabilities and Constraints
|
||||
|
||||
- Two tabs: All / Favourites. Search-filter by title (client-side, `filter.js`).
|
||||
- Card actions: continue (opens source site), toggle favourite, manual chapter override, delete (with confirm).
|
||||
- "Continue reading" horizontal strip for recently-progressed series.
|
||||
- htmx-driven partial updates (card re-render on favourite/chapter/delete), no client-side framework/build step — templates are Go `html/template`, `go:embed`-ed.
|
||||
- Two libraries (manga, novels) with lifecycle tabs: All / Updated / Favourites / Archived / Finished. Search-filter by title (client-side, `filter.js`).
|
||||
- Card actions: continue (opens source site), toggle favourite, manual chapter override, archive, finish, remove — each move out of the list confirm-gated.
|
||||
- "Continue reading" horizontal strip for series with an unread chapter.
|
||||
- A Reader with no bookmarks at all sees a deliberate empty library offering both userscript install links, not an error and not a blank page.
|
||||
- Isolation is the load-bearing invariant: two Readers cannot see or change each other's bookmarks. A series both track is one shared row polled once, with independent progress on each side.
|
||||
- The owner can revoke a specific Reader's sessions; nothing else in the UI differs by Reader.
|
||||
- htmx-driven partial updates, no client-side framework or build step — templates are Go `html/template`, `go:embed`-ed.
|
||||
- Mobile-first is a hard functional constraint (primary device is a phone), not just a starting breakpoint.
|
||||
|
||||
## Brand Commitments
|
||||
|
||||
- Name: **BookmarkManager**.
|
||||
- **Dark-first is binding**: current dark-by-default / light-follows-system-preference behavior must be preserved as a design constraint, not just a starting default, because reading happens at night.
|
||||
- **Dark-first is binding**: dark-by-default / light-follows-system-preference must be preserved as a design constraint, not just a starting default, because reading happens at night.
|
||||
|
||||
## Evidence on Hand
|
||||
|
||||
- Live templates/CSS at `backend/templates/*.html`, `backend/static/style.css` — current implemented UI, functional but not yet treated as an intentional design system.
|
||||
- No logo, screenshots, or marketing copy exist; none should be fabricated.
|
||||
- Live templates/CSS at `backend/internal/web/templates/*.html`, `backend/internal/web/static/style.css`, governed by the Cinder design system (`docs/design-system.md`).
|
||||
- No logo beyond the wordmark, no screenshots, no marketing copy; none should be fabricated.
|
||||
|
||||
## Product Principles
|
||||
|
||||
- Dark-first, night-reading-optimized — never regress to a light-default or high-glare surface.
|
||||
- Mobile is the primary target; desktop is an enhancement, not the design center.
|
||||
- Progress data integrity over visual flourish: `updated_at`/list-ordering behavior is a correctness constraint the UI must respect, not decorate over.
|
||||
- No accounts, no multi-tenant chrome — the whole product is for one reader.
|
||||
- A leak between Readers fails silently and looks like working software — isolation is asserted from both directions, never inferred from counting one Reader's rows.
|
||||
- No roles, no admin console, no org chrome: one owner capability and otherwise every Reader's view is the same.
|
||||
- Prefer native platform affordances (system dark/light, native touch targets) over custom widgetry — this is a lean self-hosted tool, not a product to demo.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
@@ -26,16 +26,14 @@ Bromite userscript (isolated world, Shadow DOM UI, localStorage cache)
|
||||
| Var | Default | Notes |
|
||||
|-----|---------|-------|
|
||||
| `TOKEN_KEY` | *(required)* | Secret every Reader's userscript credential is derived from (issue #24); only SHA-256 hashes of credentials are stored. |
|
||||
| `API_TOKEN` | *(retired)* | Global credential, honoured only until `API_TOKEN_GRACE_UNTIL` for already-installed scripts; remove both after the window. |
|
||||
| `API_TOKEN_GRACE_UNTIL` | unset | Moment the retired credential stops resolving to the owner (`YYYY-MM-DD` or RFC3339), enforced in code. |
|
||||
| `OWNER_DISCORD_ID` | *(required)* | Discord user ID of the owner; seeds the one Reader all bookmarks belong to. |
|
||||
| `OWNER_DISCORD_ID` | *(required)* | Discord user ID of the owner: seeded as the first Reader, owns every pre-registration bookmark, and is the only Reader who can revoke another's sessions. |
|
||||
| `ALLOWED_ORIGINS` | Asura + Demonic + Comix + Kagane origins | Comma-separated CORS allowlist. |
|
||||
| `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_GUILD_ID` | *(required)* | The one guild whose membership gates sign-in, checked at login only. Membership *is* registration: any member becomes a Reader on first login. |
|
||||
| `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. |
|
||||
@@ -101,7 +99,10 @@ docker compose up -d --build # binds 127.0.0.1:8080
|
||||
Smoke test:
|
||||
|
||||
```bash
|
||||
TOKEN=$(grep '^API_TOKEN=' .env | cut -d= -f2)
|
||||
# The credential is per Reader and derived, so there is no token in .env to
|
||||
# grep. Take yours from the Userscripts panel's install link after signing in,
|
||||
# or read it out of an installed script's API_TOKEN constant.
|
||||
TOKEN=<your Reader credential>
|
||||
curl -s localhost:8080/healthz # ok
|
||||
curl -s localhost:8080/bookmarks # 401
|
||||
curl -s -H "Authorization: Bearer $TOKEN" localhost:8080/bookmarks # []
|
||||
|
||||
+20
-12
@@ -21,9 +21,9 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
|
||||
container per test binary (`TestMain` -> `pgtest.Main`) and hands each test
|
||||
its own database (`pgtest.URL(t)`). A package whose tests touch the store
|
||||
must have that `TestMain`.
|
||||
- **Single-owner store, four tables.** `readers` is keyed by Discord user ID
|
||||
- **Reader-owned store, four tables.** `readers` is keyed by Discord user ID
|
||||
and carries the SHA-256 of the Reader's userscript credential plus a
|
||||
`token_epoch` (issue #24). Credentials are derived, never stored: `token.Token(TOKEN_KEY, discord_id, epoch)` (HMAC, `internal/token`), and only its SHA-256 sits in `readers.token_sha256`, so install URLs can be rebuilt after any restart while a database leak yields nothing but hashes. The seed creates exactly one row at startup; its epoch-0 hash is refreshed on every start **only while the row has never been rotated**, so a restart can never resurrect a rotated-away credential. Rotation is `Store.RotateToken` (epoch bump + hash rewrite in one transaction), driven by the web UI.
|
||||
`token_epoch` (issue #24). Credentials are derived, never stored: `token.Token(TOKEN_KEY, discord_id, epoch)` (HMAC, `internal/token`), and only its SHA-256 sits in `readers.token_sha256`, so install URLs can be rebuilt after any restart while a database leak yields nothing but hashes. The seed creates the **owner** row at startup; its epoch-0 hash is refreshed on every start **only while the row has never been rotated**, so a restart can never resurrect a rotated-away credential. Every other row is created by that Reader's own first login (`Store.EnsureReader`, idempotent on `discord_id`, and it never rewrites an existing row's hash). Rotation is `Store.RotateToken` (epoch bump + hash rewrite in one transaction), driven by the web UI.
|
||||
`series` keyed `(site, series_id)`
|
||||
(`asura`|`demonic`|`comix`|`kagane`|`novelfull`|`lightnovelworld`) owns the
|
||||
shared facts — title, cover, canonical URL, `kind` (`manga`|`novel`),
|
||||
@@ -32,10 +32,9 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
|
||||
`updated_at`. A bookmark is keyed `(reader_id, site, series_id)` — no
|
||||
surrogate id; the wire `key` is derived as `site:series_id` on read — and
|
||||
every store read/write is scoped to the reader it names. Auth resolves the
|
||||
acting Reader from the presented credential (`httpmw.Auth`), and the
|
||||
reader id travels in the request context; the retired global `API_TOKEN`
|
||||
additionally resolves to the owner until `API_TOKEN_GRACE_UNTIL`, with
|
||||
every such acceptance logged. Sync **last-write-wins**; the wire format
|
||||
acting Reader from the presented credential (`httpmw.Auth`) and nothing
|
||||
else — there is no unauthenticated-by-Reader route and no global token; the
|
||||
reader id travels in the request context. Sync **last-write-wins**; the wire format
|
||||
stays flat (ADR-0004). `Store.Upsert` decomposes one flat body across two
|
||||
tables and enforces the ownership rule: client `title`/`series_url`/`cover`
|
||||
are written only when the series row is new (ADR-0003).
|
||||
@@ -49,8 +48,15 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
|
||||
`internal/` tree, not just `*.go`. Sessions are rows in the `sessions`
|
||||
table: the cookie carries only an opaque id, looked up (and expiry-
|
||||
checked) on every request, and deleting the row revokes the session.
|
||||
The owner's Discord ID is the only identity that can sign in while
|
||||
registration is closed. UI mutations read-modify-write
|
||||
Guild membership *is* registration (issue #27): `discordCallback` gates on
|
||||
membership (and `DISCORD_REQUIRED_ROLE` when set) and then calls
|
||||
`Store.EnsureReader`, so a refusal creates nothing and a returning Reader
|
||||
reuses their row. The owner is the only Reader with administrative reach:
|
||||
`POST /readers/{id}/revoke` (404 for anyone else) drops that Reader's
|
||||
sessions, and the `readers` panel renders only on the owner's page.
|
||||
A Reader with no bookmarks at all sees `listView.Fresh`, whose empty state
|
||||
offers both install links instead of describing a filter.
|
||||
UI mutations read-modify-write
|
||||
through `Store.Get` + `Store.Upsert` so `updated_at` rule stays one
|
||||
place. See `docs/superpowers/specs/2026-07-25-web-ui-design.md`.
|
||||
**Design-tool caveat:** templates link `/static/style.css` root-absolutely
|
||||
@@ -104,10 +110,9 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
|
||||
wipe bucket on every PUT from client that predates column. See
|
||||
`docs/superpowers/specs/2026-07-27-status-buckets-design.md`.
|
||||
- **Config via env:** `TOKEN_KEY` (derives every Reader's userscript credential;
|
||||
required), `API_TOKEN` + `API_TOKEN_GRACE_UNTIL` (retired global credential
|
||||
and the moment it stops resolving to the owner — both removed after the
|
||||
cutover window, enforced in code), `OWNER_DISCORD_ID` (seeds the owner Reader;
|
||||
required), `ALLOWED_ORIGINS` (comma list),
|
||||
required), `OWNER_DISCORD_ID` (seeds the owner Reader — the administrator and
|
||||
the owner of every pre-registration bookmark; required),
|
||||
`ALLOWED_ORIGINS` (comma list),
|
||||
`DATABASE_URL` (Postgres connection URL, required — no default),
|
||||
`PORT` (default `8080`), `DISCORD_CLIENT_ID`/`_CLIENT_SECRET`/`_GUILD_ID`/
|
||||
`_REDIRECT_URI` (required; Discord OAuth for the browser UI),
|
||||
@@ -132,3 +137,6 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
|
||||
`POST /rotate-token` (atomic epoch bump + hash
|
||||
rewrite; invalidates every installed copy, so the panel warns to reinstall
|
||||
on all devices).
|
||||
Owner-only `POST /readers/{id}/revoke` (drops one Reader's session rows and
|
||||
re-renders the `readers` panel; 404 for any non-owner) is the only route that
|
||||
reaches across Readers.
|
||||
|
||||
+5
-7
@@ -17,8 +17,6 @@ import (
|
||||
"bookmarkmanager/backend/internal/token"
|
||||
)
|
||||
|
||||
const testToken = "s3cret-token"
|
||||
|
||||
// testTokenKey derives every test Reader's credential; it must match the key
|
||||
// newTestStoreURL seeds the owner with, or derived credentials authenticate
|
||||
// nothing.
|
||||
@@ -30,9 +28,7 @@ const testDiscordID = "test-owner"
|
||||
|
||||
func testConfig() Config {
|
||||
return Config{
|
||||
Token: testToken,
|
||||
TokenKey: testTokenKey,
|
||||
GraceUntil: time.Now().Add(24 * time.Hour),
|
||||
AllowedOrigins: []string{"https://asuracomic.net", "https://demonicscans.org"},
|
||||
Port: "8080",
|
||||
}
|
||||
@@ -72,8 +68,10 @@ func newTestStoreURL(t *testing.T) (*store.Store, string) {
|
||||
return s, url
|
||||
}
|
||||
|
||||
// auth authenticates a request as the owner Reader, whose derived credential
|
||||
// is the only thing the API accepts.
|
||||
func auth(req *http.Request) *http.Request {
|
||||
req.Header.Set("Authorization", "Bearer "+testToken)
|
||||
req.Header.Set("Authorization", "Bearer "+ownerCredential())
|
||||
return req
|
||||
}
|
||||
|
||||
@@ -134,7 +132,7 @@ func TestAuthRequired(t *testing.T) {
|
||||
}{
|
||||
{"no header", ""},
|
||||
{"bad token", "Bearer wrong"},
|
||||
{"not bearer", "Basic " + testToken},
|
||||
{"not bearer", "Basic " + ownerCredential()},
|
||||
{"empty bearer", "Bearer "},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
@@ -604,7 +602,7 @@ func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
|
||||
"series_url":"https://asurascans.com/comics/x",
|
||||
"last_chapter":"Chapter 5","last_chapter_num":5}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:x", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+testToken)
|
||||
req.Header.Set("Authorization", "Bearer "+ownerCredential())
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
@@ -3,11 +3,9 @@ package httpmw
|
||||
import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
"bookmarkmanager/backend/internal/token"
|
||||
@@ -26,39 +24,29 @@ func ReaderID(r *http.Request) int64 { return r.Context().Value(readerCtxKey).(i
|
||||
|
||||
// ResolveReader maps a presented credential to a Reader. The credential is
|
||||
// hashed and matched against readers.token_sha256 — an equality on 32-byte
|
||||
// values, never a comparison of the credential itself — and, during the
|
||||
// cutover window, the retired global token resolves to the owner. Every
|
||||
// legacy acceptance is logged so the window can be confirmed empty before
|
||||
// the token is removed. The same resolution backs the API bearer header and
|
||||
// the userscript download path, so the window covers both.
|
||||
func ResolveReader(s *store.Store, legacy string, graceUntil time.Time, cred string) (int64, bool) {
|
||||
if readerID, ok, err := s.ReaderIDForTokenHash(token.Hash(cred)); err != nil {
|
||||
// values, never a comparison of the credential itself. The same resolution
|
||||
// backs the API bearer header and the userscript download path, so a Reader
|
||||
// has exactly one credential with one blast radius.
|
||||
func ResolveReader(s *store.Store, cred string) (int64, bool) {
|
||||
readerID, ok, err := s.ReaderIDForTokenHash(token.Hash(cred))
|
||||
if err != nil {
|
||||
log.Printf("auth: reader lookup: %v", err)
|
||||
return 0, false
|
||||
} else if ok {
|
||||
return readerID, true
|
||||
}
|
||||
|
||||
if legacy != "" && time.Now().Before(graceUntil) &&
|
||||
subtle.ConstantTimeCompare([]byte(cred), []byte(legacy)) == 1 {
|
||||
log.Printf("auth: retired global token accepted for owner reader %d (grace until %s)",
|
||||
s.OwnerID(), graceUntil.Format(time.RFC3339))
|
||||
return s.OwnerID(), true
|
||||
}
|
||||
return 0, false
|
||||
return readerID, ok
|
||||
}
|
||||
|
||||
// Auth guards a handler with a per-Reader bearer credential. The acting
|
||||
// Reader travels in the request context, so a handler scopes every store call
|
||||
// to exactly the Reader that authenticated.
|
||||
func Auth(s *store.Store, legacy string, graceUntil time.Time, next http.Handler) http.Handler {
|
||||
func Auth(s *store.Store, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(h, bearerPrefix) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
readerID, ok := ResolveReader(s, legacy, graceUntil, strings.TrimPrefix(h, bearerPrefix))
|
||||
readerID, ok := ResolveReader(s, strings.TrimPrefix(h, bearerPrefix))
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
|
||||
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -67,3 +68,13 @@ func (s *Store) DeleteSession(id string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM sessions WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteReaderSessions revokes every session one Reader holds — the owner's
|
||||
// remedy when a Reader's browser must be logged out everywhere at once. The
|
||||
// next request carrying any of those cookies finds no row and is rejected.
|
||||
func (s *Store) DeleteReaderSessions(readerID int64) error {
|
||||
if _, err := s.db.Exec(`DELETE FROM sessions WHERE reader_id = $1`, readerID); err != nil {
|
||||
return fmt.Errorf("delete sessions for reader %d: %w", readerID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -169,10 +169,11 @@ const bookmarkColumns = `b.site, b.series_id, s.title, s.series_url, s.cover,
|
||||
const seriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover,
|
||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at`
|
||||
|
||||
// Owner is the person running the service: the first Reader, and the only one
|
||||
// until registration exists. The seed makes sure exactly one readers row
|
||||
// matches their Discord ID, carrying the SHA-256 of their epoch-0 userscript
|
||||
// credential (derived by internal/token, not the retired global token).
|
||||
// Owner is the person running the service: the first Reader, seeded at startup
|
||||
// so a fresh deployment has a library before anyone logs in. The seed makes
|
||||
// sure exactly one readers row matches their Discord ID, carrying the SHA-256
|
||||
// of their epoch-0 userscript credential (derived by internal/token). Every
|
||||
// other Reader is created by their own first login (EnsureReader).
|
||||
type Owner struct {
|
||||
DiscordID string
|
||||
// TokenHash is the SHA-256 of the epoch-0 credential; the array shape
|
||||
@@ -183,15 +184,14 @@ type Owner struct {
|
||||
// Store is the Postgres-backed bookmark store.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
// ownerID is the seeded owner Reader (issue #22). Authentication is still
|
||||
// the single global token, so every request acts as this Reader; the store
|
||||
// methods take the id explicitly so the scoping survives per-Reader auth.
|
||||
// ownerID is the seeded owner Reader (issue #22) — the only Reader with
|
||||
// administrative reach (revoking another Reader's sessions). Every store
|
||||
// method takes a reader id explicitly, so ownership is never implicit.
|
||||
ownerID int64
|
||||
}
|
||||
|
||||
// OwnerID returns the seeded owner Reader's id — the Reader the retired
|
||||
// global token resolves to during the grace window, and the only Reader while
|
||||
// registration is closed.
|
||||
// OwnerID returns the seeded owner Reader's id: the administrator, and the
|
||||
// Reader every pre-registration bookmark belongs to.
|
||||
func (s *Store) OwnerID() int64 { return s.ownerID }
|
||||
|
||||
// ReaderIDForTokenHash resolves the Reader whose stored credential hash
|
||||
@@ -252,6 +252,66 @@ func (s *Store) RotateToken(readerID, expectedEpoch int64, newHash [32]byte) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureReader returns the Reader registered to discordID, creating the row on
|
||||
// first sight. Registration is open to every guild member (issue #27), and the
|
||||
// Discord identity is the only thing that decides which Reader a login is: one
|
||||
// code path serves the first login and every later one, so a returning Reader
|
||||
// can never end up with a second library.
|
||||
//
|
||||
// epochZeroHash is only used for a brand-new row. An existing row keeps its
|
||||
// stored hash untouched, or a login would silently undo a rotation and revive
|
||||
// the credential the Reader rotated away from.
|
||||
func (s *Store) EnsureReader(discordID string, epochZeroHash [32]byte) (int64, error) {
|
||||
var id int64
|
||||
// DO UPDATE rather than DO NOTHING because only an updated row is
|
||||
// returned by RETURNING; assigning the column to itself is the no-op that
|
||||
// makes the existing id come back.
|
||||
err := s.db.QueryRow(`
|
||||
INSERT INTO readers (discord_id, token_sha256) VALUES ($1, $2)
|
||||
ON CONFLICT (discord_id) DO UPDATE SET discord_id = readers.discord_id
|
||||
RETURNING id`, discordID, epochZeroHash[:]).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ensure reader: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ReaderSummary is one Reader as the owner's administration panel sees them:
|
||||
// who they are and how many live sessions they hold. No credential material,
|
||||
// hashed or otherwise, is exposed.
|
||||
type ReaderSummary struct {
|
||||
ID int64
|
||||
DiscordID string
|
||||
// Sessions counts unexpired session rows — what the owner revokes.
|
||||
Sessions int
|
||||
}
|
||||
|
||||
// Readers lists every Reader with their live session count, oldest first, so
|
||||
// the owner row (always the oldest) heads the list.
|
||||
func (s *Store) Readers() ([]ReaderSummary, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT r.id, r.discord_id,
|
||||
count(sess.id) FILTER (WHERE sess.expires_at > now()) AS sessions
|
||||
FROM readers r
|
||||
LEFT JOIN sessions sess ON sess.reader_id = r.id
|
||||
GROUP BY r.id, r.discord_id
|
||||
ORDER BY r.id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query readers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []ReaderSummary{}
|
||||
for rows.Next() {
|
||||
var r ReaderSummary
|
||||
if err := rows.Scan(&r.ID, &r.DiscordID, &r.Sessions); err != nil {
|
||||
return nil, fmt.Errorf("scan reader: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// readersMigration is the version that creates the readers table. The owner
|
||||
// seed runs between two migrate passes, so that the run-once migration which
|
||||
// attaches existing bookmarks (0004) finds the owner row.
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) }
|
||||
|
||||
// testOwner is the owner every test store seeds. Tests that need a second
|
||||
// reader insert one directly (see secondReader).
|
||||
// reader register one (see secondReader).
|
||||
var testOwner = Owner{DiscordID: "test-owner", TokenHash: sha256.Sum256([]byte("owner-token-hash"))}
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
@@ -29,17 +29,14 @@ func newTestStore(t *testing.T) *Store {
|
||||
return store
|
||||
}
|
||||
|
||||
// secondReader inserts an extra reader row and returns its id. The store API
|
||||
// has no reader-creation path yet — the seed is the only one — so tests that
|
||||
// need reader isolation insert directly.
|
||||
// secondReader registers an extra reader through the same path a first login
|
||||
// takes, and returns its id.
|
||||
func secondReader(t *testing.T, s *Store) int64 {
|
||||
t.Helper()
|
||||
hash := sha256.Sum256([]byte("second-token-hash"))
|
||||
var id int64
|
||||
if err := s.db.QueryRow(
|
||||
`INSERT INTO readers (discord_id, token_sha256) VALUES ($1, $2) RETURNING id`,
|
||||
"second-"+strconv.FormatInt(time.Now().UnixNano(), 10), hash[:]).Scan(&id); err != nil {
|
||||
t.Fatalf("seed second reader: %v", err)
|
||||
discordID := "second-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
id, err := s.EnsureReader(discordID, sha256.Sum256([]byte("token-"+discordID)))
|
||||
if err != nil {
|
||||
t.Fatalf("register second reader: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -1052,3 +1049,153 @@ func TestDeleteReaderCascadesToBookmarks(t *testing.T) {
|
||||
t.Fatalf("series = %+v, want it kept after its only reader was deleted", sr)
|
||||
}
|
||||
}
|
||||
|
||||
// Registration is one code path: the first sight of a Discord identity creates
|
||||
// the Reader, every later one returns the same row. The epoch-0 hash argument
|
||||
// is for creation only — a returning Reader who has rotated must not have that
|
||||
// rotation undone by logging in again.
|
||||
func TestEnsureReaderCreatesOnceAndNeverClobbersARotation(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
first, err := s.EnsureReader("new-member", sha256.Sum256([]byte("cred-epoch-0")))
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureReader: %v", err)
|
||||
}
|
||||
if first == s.OwnerID() {
|
||||
t.Fatal("a new Discord identity resolved to the owner Reader")
|
||||
}
|
||||
if id, ok, err := s.ReaderIDForTokenHash(sha256.Sum256([]byte("cred-epoch-0"))); err != nil || !ok || id != first {
|
||||
t.Fatalf("new Reader's credential resolved to (%d, %v, %v), want (%d, true, nil)", id, ok, err, first)
|
||||
}
|
||||
|
||||
rotated := sha256.Sum256([]byte("cred-epoch-1"))
|
||||
if err := s.RotateToken(first, 0, rotated); err != nil {
|
||||
t.Fatalf("RotateToken: %v", err)
|
||||
}
|
||||
|
||||
again, err := s.EnsureReader("new-member", sha256.Sum256([]byte("cred-epoch-0")))
|
||||
if err != nil {
|
||||
t.Fatalf("second EnsureReader: %v", err)
|
||||
}
|
||||
if again != first {
|
||||
t.Fatalf("second login returned Reader %d, want the existing %d", again, first)
|
||||
}
|
||||
if _, ok, err := s.ReaderIDForTokenHash(sha256.Sum256([]byte("cred-epoch-0"))); err != nil {
|
||||
t.Fatalf("stale lookup: %v", err)
|
||||
} else if ok {
|
||||
t.Fatal("logging in again revived the pre-rotation credential")
|
||||
}
|
||||
if id, ok, err := s.ReaderIDForTokenHash(rotated); err != nil || !ok || id != first {
|
||||
t.Fatalf("rotated credential resolved to (%d, %v, %v), want the same Reader", id, ok, err)
|
||||
}
|
||||
|
||||
// Signing in as the owner's own Discord identity reuses the seeded row
|
||||
// rather than minting a duplicate library.
|
||||
if id, err := s.EnsureReader(testOwner.DiscordID, sha256.Sum256([]byte("ignored"))); err != nil {
|
||||
t.Fatalf("EnsureReader(owner): %v", err)
|
||||
} else if id != s.OwnerID() {
|
||||
t.Fatalf("owner login returned Reader %d, want the seeded owner %d", id, s.OwnerID())
|
||||
}
|
||||
}
|
||||
|
||||
// The owner's administration view: who exists and how many live sessions each
|
||||
// holds. Revocation drops all of one Reader's sessions and nobody else's.
|
||||
func TestReadersAndSessionRevocation(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
other := secondReader(t, s)
|
||||
for _, id := range []string{"own-1", "own-2"} {
|
||||
if _, err := s.CreateSession(id, s.OwnerID(), time.Hour); err != nil {
|
||||
t.Fatalf("CreateSession(%s): %v", id, err)
|
||||
}
|
||||
}
|
||||
if _, err := s.CreateSession("other-1", other, time.Hour); err != nil {
|
||||
t.Fatalf("CreateSession(other): %v", err)
|
||||
}
|
||||
// An expired row must not be counted as a session the owner can revoke.
|
||||
if _, err := s.CreateSession("other-dead", other, -time.Minute); err != nil {
|
||||
t.Fatalf("CreateSession(expired): %v", err)
|
||||
}
|
||||
|
||||
readers, err := s.Readers()
|
||||
if err != nil {
|
||||
t.Fatalf("Readers: %v", err)
|
||||
}
|
||||
if len(readers) != 2 || readers[0].ID != s.OwnerID() || readers[1].ID != other {
|
||||
t.Fatalf("readers = %+v, want the owner then the second Reader", readers)
|
||||
}
|
||||
if readers[0].DiscordID != testOwner.DiscordID {
|
||||
t.Fatalf("owner discord id = %q, want %q", readers[0].DiscordID, testOwner.DiscordID)
|
||||
}
|
||||
if readers[0].Sessions != 2 || readers[1].Sessions != 1 {
|
||||
t.Fatalf("session counts = %d, %d; want 2 and 1 live", readers[0].Sessions, readers[1].Sessions)
|
||||
}
|
||||
|
||||
if err := s.DeleteReaderSessions(other); err != nil {
|
||||
t.Fatalf("DeleteReaderSessions: %v", err)
|
||||
}
|
||||
if _, ok, err := s.GetSession("other-1", time.Now()); err != nil || ok {
|
||||
t.Fatalf("revoked session still resolves: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if _, ok, err := s.GetSession("own-1", time.Now()); err != nil || !ok {
|
||||
t.Fatalf("owner's session was collateral: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Two Readers on one Series: one series row, two independent progresses. The
|
||||
// second Reader starts at zero however far the first has read, and the shared
|
||||
// row is still due exactly once.
|
||||
func TestTwoReadersShareOneSeriesWithIndependentProgress(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
other := secondReader(t, s)
|
||||
if _, err := s.Upsert(s.OwnerID(), Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/comics/solo",
|
||||
LastChapter: "Chapter 200", LastChapterNum: 200, UpdatedAt: 1000,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed owner: %v", err)
|
||||
}
|
||||
theirs, err := s.Upsert(other, Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 2000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed other: %v", err)
|
||||
}
|
||||
if theirs.LastChapterNum != 0 || theirs.LastChapter != "" {
|
||||
t.Fatalf("second Reader's progress = %+v, want zero regardless of the first's 200", theirs)
|
||||
}
|
||||
// The shared facts are still shared: the series row it joined to is the
|
||||
// one the first Reader created.
|
||||
if theirs.Title != "Solo Leveling" {
|
||||
t.Fatalf("second Reader's title = %q, want the shared series title", theirs.Title)
|
||||
}
|
||||
var series int
|
||||
if err := s.db.QueryRow(`SELECT count(*) FROM series`).Scan(&series); err != nil {
|
||||
t.Fatalf("count series: %v", err)
|
||||
}
|
||||
if series != 1 {
|
||||
t.Fatalf("series rows = %d, want 1 shared row for two bookmarks", series)
|
||||
}
|
||||
|
||||
due, err := s.DueForLatestCheck(time.Now().UnixMilli(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
if len(due) != 1 || due[0].Key() != "asura:solo" {
|
||||
t.Fatalf("due = %+v, want the shared series exactly once per cycle", due)
|
||||
}
|
||||
|
||||
// One Reader dropping their bookmark leaves the other's intact and the
|
||||
// series still polled.
|
||||
if err := s.Delete(other, "asura:solo"); err != nil {
|
||||
t.Fatalf("Delete(other): %v", err)
|
||||
}
|
||||
if b, ok, err := s.Get(s.OwnerID(), "asura:solo"); err != nil || !ok || b.LastChapterNum != 200 {
|
||||
t.Fatalf("owner's bookmark after the other's delete = %+v ok=%v err=%v, want it intact", b, ok, err)
|
||||
}
|
||||
due, err = s.DueForLatestCheck(time.Now().UnixMilli(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck after delete: %v", err)
|
||||
}
|
||||
if len(due) != 1 || due[0].Key() != "asura:solo" {
|
||||
t.Fatalf("due after one Reader left = %+v, want the series still polled", due)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"bookmarkmanager/backend/internal/httpmw"
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
"bookmarkmanager/backend/internal/token"
|
||||
)
|
||||
|
||||
// tokenPlaceholder is what the bindmounted userscript carries where the
|
||||
@@ -87,26 +86,15 @@ func Render(w http.ResponseWriter, r *http.Request, path, credential string) {
|
||||
// the route exists. The same credential authenticates the API bearer header,
|
||||
// so the two are one secret with one blast radius.
|
||||
//
|
||||
// The credential substituted is the resolved Reader's derived one, not the
|
||||
// raw path segment: while the retired global token is still accepted during
|
||||
// the grace window (httpmw.ResolveReader), an already-installed script
|
||||
// polling its legacy URL is served a copy carrying the Reader's own
|
||||
// credential, so the next update poll migrates the device onto its per-Reader
|
||||
// path — the window empties itself instead of ending in a silent 401 for
|
||||
// every device that never visited the web UI.
|
||||
func Handler(s *store.Store, tokenKey []byte, legacy string, graceUntil time.Time, path string) http.HandlerFunc {
|
||||
// The path segment is the credential itself, so once it resolves it is also
|
||||
// exactly what the served copy must carry — no re-derivation needed.
|
||||
func Handler(s *store.Store, path string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
readerID, ok := httpmw.ResolveReader(s, legacy, graceUntil, r.PathValue("token"))
|
||||
if !ok {
|
||||
cred := r.PathValue("token")
|
||||
if _, ok := httpmw.ResolveReader(s, cred); !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
discordID, epoch, err := s.ReaderTokenInfo(readerID)
|
||||
if err != nil {
|
||||
log.Printf("userscript: reader %d token info: %v", readerID, err)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
Render(w, r, path, token.Token(tokenKey, discordID, epoch))
|
||||
Render(w, r, path, cred)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/session"
|
||||
"bookmarkmanager/backend/internal/token"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -50,9 +51,6 @@ type DiscordConfig struct {
|
||||
// RedirectURI is the full public URL of the callback — Discord requires
|
||||
// the exact string, so it is configured, never derived from headers.
|
||||
RedirectURI string
|
||||
// OwnerDiscordID is the only Discord identity allowed to sign in until
|
||||
// registration exists (issue #23).
|
||||
OwnerDiscordID string
|
||||
}
|
||||
|
||||
// oauthStates stores one-time sign-in states. A state is generated at
|
||||
@@ -166,14 +164,6 @@ func (h *Handler) discordCallback(w http.ResponseWriter, r *http.Request) {
|
||||
"Discord sign-in is unavailable right now. Try again in a moment.")
|
||||
return
|
||||
}
|
||||
|
||||
if userID != h.discord.OwnerDiscordID {
|
||||
h.limiter.Fail(ip, time.Now())
|
||||
h.renderLogin(w, http.StatusForbidden,
|
||||
"This Discord account is not the library owner.")
|
||||
return
|
||||
}
|
||||
|
||||
member, isMember, err := h.discordMember(r.Context(), tok.AccessToken)
|
||||
if err != nil {
|
||||
h.limiter.Fail(ip, time.Now())
|
||||
@@ -185,6 +175,10 @@ func (h *Handler) discordCallback(w http.ResponseWriter, r *http.Request) {
|
||||
// The refusal is the same for a non-member and a member without the
|
||||
// required role, and it names neither the guild nor its id: an outsider
|
||||
// cannot tell whether the guild exists, let alone which one gates.
|
||||
//
|
||||
// It also returns before EnsureReader, so a refused sign-in leaves no
|
||||
// Reader row behind — the gate is the only thing standing between guild
|
||||
// membership and a library.
|
||||
if !isMember || (h.discord.RequiredRole != "" && !slices.Contains(member.Roles, h.discord.RequiredRole)) {
|
||||
h.limiter.Fail(ip, time.Now())
|
||||
h.renderLogin(w, http.StatusForbidden,
|
||||
@@ -192,8 +186,19 @@ func (h *Handler) discordCallback(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Registration is the login (issue #27): first sight of a guild member
|
||||
// creates their Reader, every later sight returns the same one. Their
|
||||
// userscript credential is derived at epoch 0 the way the owner's is, so
|
||||
// the install links work before they have read anything.
|
||||
readerID, err := h.store.EnsureReader(userID, token.Hash(token.Token(h.tokenKey, userID, 0)))
|
||||
if err != nil {
|
||||
log.Printf("register reader: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.limiter.Reset(ip)
|
||||
sess, err := h.store.CreateSession(session.NewID(), h.readerID, session.SessionTTL)
|
||||
sess, err := h.store.CreateSession(session.NewID(), readerID, session.SessionTTL)
|
||||
if err != nil {
|
||||
log.Printf("create session: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
|
||||
@@ -290,6 +290,28 @@ button { cursor: pointer; }
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
/* ---- reader roster (owner only): same hairline panel, one row per Reader ---- */
|
||||
.readerlist { margin: 0; padding: 0; list-style: none; }
|
||||
.readerlist li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
min-height: 44px;
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
.readerlist form { margin: 0 0 0 auto; }
|
||||
.reader-id { font: 400 13px/1.4 var(--font-mono); color: var(--paper); }
|
||||
.reader-sessions {
|
||||
font: 400 11px/1.4 var(--font-mono);
|
||||
letter-spacing: .04em;
|
||||
color: var(--mute);
|
||||
}
|
||||
/* Revocation cuts someone off, so it wears --danger. Ember stays reserved for
|
||||
the new-chapter signal. */
|
||||
.ghost.danger { color: var(--danger); }
|
||||
.ghost.danger:hover { color: var(--danger); border-bottom-color: var(--danger); }
|
||||
|
||||
.chrome { display: flex; flex-direction: column; }
|
||||
|
||||
.searchbar {
|
||||
|
||||
@@ -76,6 +76,8 @@
|
||||
|
||||
{{template "setup" .}}
|
||||
|
||||
{{if .Owner}}{{template "readers" .}}{{end}}
|
||||
|
||||
{{template "keyrow" .}}
|
||||
|
||||
{{template "recent" .}}
|
||||
|
||||
@@ -30,9 +30,11 @@
|
||||
{{/* The action key. The icon strip on a card is unlabelled, so one permanent
|
||||
line under the tabs names every glyph. It follows the tab rather than the
|
||||
row: the archived and finished buckets swap Archive for Restore, and a
|
||||
finished series has no Done to offer. */}}
|
||||
finished series has no Done to offer. A Reader with no cards at all has
|
||||
nothing for it to name, so it hides rather than disappearing — see the
|
||||
note above about out-of-band swaps needing their target to exist. */}}
|
||||
{{define "keyrow"}}
|
||||
<div class="keyrow" id="keyrow" aria-label="Action key"{{if .OOB}} hx-swap-oob="true"{{end}}>
|
||||
<div class="keyrow" id="keyrow" aria-label="Action key"{{if .OOB}} hx-swap-oob="true"{{end}}{{if .Fresh}} hidden{{end}}>
|
||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-play"/></svg><span>Read</span></span>
|
||||
<span class="pair brass"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-star"/></svg><span>Fav</span></span>
|
||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg><span>Chapter</span></span>
|
||||
|
||||
@@ -8,6 +8,18 @@
|
||||
<strong>No titles match “<span class="no-match-q"></span>”.</strong>
|
||||
<button type="button" class="clear-search">Clear search</button>
|
||||
</div>
|
||||
{{else if .Fresh}}
|
||||
{{/* Nothing anywhere, not an empty bucket: this Reader has just registered,
|
||||
so the empty state is the setup instruction rather than a filter
|
||||
report. Both scripts, because the two libraries are separate installs. */}}
|
||||
<div class="empty">
|
||||
<strong>Your library is empty.</strong>
|
||||
<p>Install both userscripts, then open a series and read a chapter — bookmarks arrive on their own.</p>
|
||||
<p class="setup-links">
|
||||
<a class="ghost" href="/install/manga-bookmark.user.js">Install Manga script</a>
|
||||
<a class="ghost" href="/install/novel-bookmark.user.js">Install Novels script</a>
|
||||
</p>
|
||||
</div>
|
||||
{{else if eq .Tab "fav"}}
|
||||
<div class="empty"><strong>No favourites yet.</strong><p>Star a series to pin it here.</p></div>
|
||||
{{else if eq .Tab "new"}}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{{/* The owner's Reader roster. Rendered only for the owner (listView.Owner),
|
||||
and re-rendered whole as the response to a revocation so the session
|
||||
counts cannot describe the state before the tap. Revocation is
|
||||
confirm-gated: it signs someone out of every device at once. */}}
|
||||
{{define "readers"}}
|
||||
<details class="setup" id="readers">
|
||||
<summary>Readers</summary>
|
||||
<p class="setup-copy">Everyone who has signed in through Discord. Revoking
|
||||
signs a Reader out of every device; their library and bookmarks are
|
||||
untouched, and they can sign in again.</p>
|
||||
<ul class="readerlist">
|
||||
{{range .Readers}}
|
||||
<li>
|
||||
<span class="reader-id">{{.DiscordID}}</span>
|
||||
<span class="reader-sessions">{{.Sessions}} session{{if ne .Sessions 1}}s{{end}}</span>
|
||||
{{if .Sessions}}
|
||||
<form hx-post="/readers/{{.ID}}/revoke" hx-target="#readers" hx-swap="outerHTML"
|
||||
hx-confirm="Revoking signs this Reader out on every device immediately. Revoke?">
|
||||
<button type="submit" class="ghost danger">Revoke sessions</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</details>
|
||||
{{end}}
|
||||
@@ -34,10 +34,6 @@ const RecentCount = 5
|
||||
// representations (HTML versus JSON) to different clients under different auth.
|
||||
type Handler struct {
|
||||
store *store.Store
|
||||
// readerID is the owner Reader's id, the only Reader that can exist
|
||||
// while registration is closed (issue #23). Every session row points at
|
||||
// it, so it is also the Reader the UI acts as.
|
||||
readerID int64
|
||||
// tokenKey derives Readers' userscript credentials (internal/token): the
|
||||
// install endpoints render the scripts with the credential inside, which
|
||||
// is the one place the UI needs the secret.
|
||||
@@ -76,6 +72,16 @@ type listView struct {
|
||||
// Rotated marks the setup panel as having just rotated the credential:
|
||||
// it swaps the reinstall warning in over the button row.
|
||||
Rotated bool
|
||||
// Fresh means this Reader has no bookmarks at all, in either library — a
|
||||
// brand-new registration rather than an empty bucket. The empty state then
|
||||
// explains how a library gets filled instead of describing a filter.
|
||||
Fresh bool
|
||||
// Owner marks the acting Reader as the deployment's owner, which unlocks
|
||||
// the Readers panel. Nothing else in the UI differs.
|
||||
Owner bool
|
||||
// Readers is the owner's roster, populated only for the owner's own page
|
||||
// render and the revocation fragment.
|
||||
Readers []store.ReaderSummary
|
||||
}
|
||||
|
||||
// PageURL and ListURL are the two link shapes every tab needs. Building them
|
||||
@@ -102,14 +108,13 @@ type loginView struct {
|
||||
|
||||
// New parses every template up front so a broken one kills the process at
|
||||
// startup rather than the first request that touches it.
|
||||
func New(s *store.Store, readerID int64, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string) (*Handler, error) {
|
||||
func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string) (*Handler, error) {
|
||||
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Handler{
|
||||
store: s,
|
||||
readerID: readerID,
|
||||
tokenKey: tokenKey,
|
||||
mangaUserscriptPath: mangaPath,
|
||||
novelUserscriptPath: novelPath,
|
||||
@@ -141,6 +146,9 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /install/manga-bookmark.user.js", h.requireSession(h.installUserscript("manga-bookmark.user.js")))
|
||||
mux.HandleFunc("GET /install/novel-bookmark.user.js", h.requireSession(h.installUserscript("novel-bookmark.user.js")))
|
||||
mux.HandleFunc("POST /rotate-token", h.requireSession(h.rotateToken))
|
||||
|
||||
// Owner-only: the one place the UI crosses the Reader boundary.
|
||||
mux.HandleFunc("POST /readers/{id}/revoke", h.requireSession(h.revokeReaderSessions))
|
||||
}
|
||||
|
||||
// staticHandler serves the embedded assets. An hour, not longer: assets are
|
||||
@@ -229,6 +237,14 @@ func (h *Handler) index(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if readerID == h.store.OwnerID() {
|
||||
view.Owner = true
|
||||
if view.Readers, err = h.store.Readers(); err != nil {
|
||||
log.Printf("index readers: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
h.render(w, http.StatusOK, "app", view)
|
||||
}
|
||||
|
||||
@@ -276,6 +292,10 @@ func (h *Handler) buildListView(readerID int64, lib, tab string) (listView, erro
|
||||
if err != nil {
|
||||
return listView{}, err
|
||||
}
|
||||
// Fresh is about the Reader, not the library, so it is taken before the
|
||||
// filter narrows the slice: it decides whether an empty list reads as
|
||||
// "install the scripts" or "this bucket is empty".
|
||||
fresh := len(all) == 0
|
||||
// Narrow to one library first: reading, withNew and recent all derive from
|
||||
// this slice, so doing it later would let the other library's rows into the
|
||||
// strip and the Updated badge.
|
||||
@@ -319,7 +339,8 @@ func (h *Handler) buildListView(readerID int64, lib, tab string) (listView, erro
|
||||
recent = recent[:RecentCount]
|
||||
}
|
||||
}
|
||||
return listView{Lib: lib, Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil
|
||||
return listView{Lib: lib, Tab: tab, Recent: recent, Items: items,
|
||||
NewCount: len(withNew), Fresh: fresh}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) uiList(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -594,3 +615,32 @@ func (h *Handler) rotateToken(w http.ResponseWriter, r *http.Request) {
|
||||
view := listView{Lib: store.KindManga, Rotated: true}
|
||||
h.render(w, http.StatusOK, "setup", view)
|
||||
}
|
||||
|
||||
// revokeReaderSessions logs one Reader out of every browser they are signed
|
||||
// in on. Owner-only: it reaches across the Reader boundary every other handler
|
||||
// respects, so the guard is a comparison against the seeded owner rather than
|
||||
// a role a Reader could acquire. A non-owner gets 404 — the panel does not
|
||||
// exist for them, so neither should the endpoint.
|
||||
func (h *Handler) revokeReaderSessions(w http.ResponseWriter, r *http.Request) {
|
||||
if readerOf(r) != h.store.OwnerID() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
target, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "bad reader id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteReaderSessions(target); err != nil {
|
||||
log.Printf("revoke sessions: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
readers, err := h.store.Readers()
|
||||
if err != nil {
|
||||
log.Printf("revoke sessions: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.render(w, http.StatusOK, "readers", listView{Owner: true, Readers: readers})
|
||||
}
|
||||
|
||||
+7
-43
@@ -23,26 +23,18 @@ import (
|
||||
|
||||
// Config holds all runtime settings, sourced from environment variables.
|
||||
type Config struct {
|
||||
// Token is the retired global API token, kept only for the cutover grace
|
||||
// window: while GraceUntil has not passed, it resolves to the owner
|
||||
// Reader so already-installed scripts keep working. Unset after the
|
||||
// window closes.
|
||||
Token string
|
||||
// TokenKey derives every Reader's userscript credential (internal/token).
|
||||
// Required: without it no install URL can ever be built.
|
||||
TokenKey string
|
||||
// GraceUntil is the moment the retired global token stops resolving to
|
||||
// the owner Reader. Zero means the token is already dead. Enforced in
|
||||
// code on every request, not by a runbook note.
|
||||
GraceUntil time.Time
|
||||
AllowedOrigins []string
|
||||
// DatabaseURL is the Postgres connection URL; required, no default,
|
||||
// because a wrong guess would silently start on an empty database.
|
||||
DatabaseURL string
|
||||
Port string
|
||||
// OwnerDiscordID identifies the seeded owner Reader (issue #22). Required:
|
||||
// bookmarks are scoped to a Reader, and without an owner there is none.
|
||||
// It is also the only Discord identity allowed to sign in (issue #23).
|
||||
// bookmarks are scoped to a Reader, and a fresh deployment needs one
|
||||
// before anybody logs in. The owner is also the only Reader who can revoke
|
||||
// another Reader's sessions.
|
||||
OwnerDiscordID string
|
||||
// Discord is the OAuth application the browser UI signs in with.
|
||||
Discord web.DiscordConfig
|
||||
@@ -158,29 +150,9 @@ func loadLatestPoll() LatestPoll {
|
||||
return p
|
||||
}
|
||||
|
||||
// parseGraceUntil reads the retired-token deadline. Both a bare date and a
|
||||
// full RFC3339 timestamp are accepted; an unparseable value is a
|
||||
// configuration bug, not a gracefully-degraded feature — the whole point is
|
||||
// that the window's end is enforced, so fail loud.
|
||||
func parseGraceUntil(raw string) time.Time {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02"} {
|
||||
if t, err := time.Parse(layout, raw); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
log.Fatalf("config: API_TOKEN_GRACE_UNTIL=%q is not a date (YYYY-MM-DD) or RFC3339 timestamp", raw)
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func loadConfig() Config {
|
||||
c := Config{
|
||||
Token: os.Getenv("API_TOKEN"),
|
||||
TokenKey: os.Getenv("TOKEN_KEY"),
|
||||
GraceUntil: parseGraceUntil(os.Getenv("API_TOKEN_GRACE_UNTIL")),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
Port: envOr("PORT", "8080"),
|
||||
OwnerDiscordID: os.Getenv("OWNER_DISCORD_ID"),
|
||||
@@ -195,7 +167,6 @@ func loadConfig() Config {
|
||||
RequiredRole: os.Getenv("DISCORD_REQUIRED_ROLE"),
|
||||
APIBase: envOr("DISCORD_API_BASE", "https://discord.com/api/v10"),
|
||||
RedirectURI: os.Getenv("DISCORD_REDIRECT_URI"),
|
||||
OwnerDiscordID: c.OwnerDiscordID,
|
||||
}
|
||||
for _, o := range strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",") {
|
||||
if o = strings.TrimSpace(o); o != "" {
|
||||
@@ -218,9 +189,9 @@ func newRouter(s *store.Store, cfg Config) http.Handler {
|
||||
// instead, and the script is rendered with the resolved Reader's
|
||||
// credential substituted in.
|
||||
mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js",
|
||||
userscript.Handler(s, []byte(cfg.TokenKey), cfg.Token, cfg.GraceUntil, cfg.UserscriptPath))
|
||||
userscript.Handler(s, cfg.UserscriptPath))
|
||||
mux.HandleFunc("GET /u/{token}/novel-bookmark.user.js",
|
||||
userscript.Handler(s, []byte(cfg.TokenKey), cfg.Token, cfg.GraceUntil, cfg.NovelUserscriptPath))
|
||||
userscript.Handler(s, cfg.NovelUserscriptPath))
|
||||
|
||||
h := &api.Handler{Store: s}
|
||||
protected := http.NewServeMux()
|
||||
@@ -228,13 +199,13 @@ func newRouter(s *store.Store, cfg Config) http.Handler {
|
||||
protected.HandleFunc("PUT /bookmarks/{key}", h.Put)
|
||||
protected.HandleFunc("DELETE /bookmarks/{key}", h.Delete)
|
||||
|
||||
auth := httpmw.Auth(s, cfg.Token, cfg.GraceUntil, protected)
|
||||
auth := httpmw.Auth(s, protected)
|
||||
mux.Handle("/bookmarks", auth)
|
||||
mux.Handle("/bookmarks/", auth)
|
||||
|
||||
// The browser UI is always registered; signing in is Discord OAuth, so
|
||||
// there is no password to forget and no gate to leave unset.
|
||||
wh, err := web.New(s, s.OwnerID(), cfg.Discord, []byte(cfg.TokenKey),
|
||||
wh, err := web.New(s, cfg.Discord, []byte(cfg.TokenKey),
|
||||
cfg.UserscriptPath, cfg.NovelUserscriptPath)
|
||||
if err != nil {
|
||||
log.Fatalf("web handler: %v", err)
|
||||
@@ -282,13 +253,6 @@ func main() {
|
||||
log.Fatalf("%s is required", key)
|
||||
}
|
||||
}
|
||||
if cfg.Token == "" && !cfg.GraceUntil.IsZero() {
|
||||
log.Fatal("API_TOKEN_GRACE_UNTIL is set but API_TOKEN is not")
|
||||
}
|
||||
if cfg.Token != "" && cfg.GraceUntil.IsZero() {
|
||||
log.Printf("API_TOKEN is set without API_TOKEN_GRACE_UNTIL: the retired token is dead on arrival")
|
||||
}
|
||||
|
||||
// The owner's userscript credential is derived from TOKEN_KEY at epoch 0
|
||||
// (internal/token); the readers row carries its SHA-256, not the
|
||||
// credential itself.
|
||||
|
||||
@@ -202,7 +202,7 @@ func TestPutOmittedStatusPreservesArchivedAndAppliesProgress(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGzipCompressesTextNotFonts(t *testing.T) {
|
||||
srv, _ := newWebTestServer(t, webConfig())
|
||||
srv, _ := newWebTestServer(t, testConfig())
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -10,31 +9,19 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
"bookmarkmanager/backend/internal/token"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
// insertReader creates an extra reader row (registration is closed, so the
|
||||
// store has no path for this — tests reach past it) and returns its id. The
|
||||
// credential is derived the same way the owner's is, so it authenticates
|
||||
// through the real router.
|
||||
func insertReader(t *testing.T, dbURL, discordID string) int64 {
|
||||
// registerReader creates an extra Reader the way a first login does and
|
||||
// returns its id. The credential is derived the same way the owner's is, so it
|
||||
// authenticates through the real router.
|
||||
func registerReader(t *testing.T, s *store.Store, discordID string) int64 {
|
||||
t.Helper()
|
||||
db, err := sql.Open("pgx", dbURL)
|
||||
id, err := s.EnsureReader(discordID, token.Hash(readerCredential(discordID)))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
hash := token.Hash(token.Token([]byte(testTokenKey), discordID, 0))
|
||||
var id int64
|
||||
if err := db.QueryRow(
|
||||
`INSERT INTO readers (discord_id, token_sha256) VALUES ($1, $2) RETURNING id`,
|
||||
discordID, hash[:]).Scan(&id); err != nil {
|
||||
t.Fatalf("insert reader: %v", err)
|
||||
t.Fatalf("register reader %q: %v", discordID, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -59,34 +46,29 @@ func withBody(req *http.Request, body string) *http.Request {
|
||||
return req
|
||||
}
|
||||
|
||||
// The retired global token resolves to the owner Reader only while the grace
|
||||
// deadline is in the future — testConfig sets it, so the acceptance path is
|
||||
// the existing auth() tests; this pins the other side of the window.
|
||||
func TestLegacyTokenDeadAfterGrace(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
cfg := testConfig()
|
||||
cfg.GraceUntil = time.Now().Add(-time.Hour)
|
||||
srv := newRouter(s, cfg)
|
||||
// A refused credential is refused however plausible it looks: only a hash the
|
||||
// readers table holds authenticates anything.
|
||||
func TestUnknownCredentialRejected(t *testing.T) {
|
||||
srv := newRouter(newTestStore(t), testConfig())
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", testToken))
|
||||
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", readerCredential("never-registered")))
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("legacy token after grace: status = %d, want 401", rr.Code)
|
||||
t.Fatalf("unregistered Reader's credential: status = %d, want 401", rr.Code)
|
||||
}
|
||||
|
||||
// The owner's own derived credential is unaffected by the window closing.
|
||||
rr = httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", ownerCredential()))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("derived token after grace: status = %d, want 200", rr.Code)
|
||||
t.Fatalf("owner's derived credential: status = %d, want 200", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A Reader's credential authenticates exactly that Reader: rows written under
|
||||
// one credential are invisible to the other, on the same key.
|
||||
func TestPerReaderIsolation(t *testing.T) {
|
||||
s, dbURL := newTestStoreURL(t)
|
||||
insertReader(t, dbURL, "other-reader")
|
||||
s := newTestStore(t)
|
||||
registerReader(t, s, "other-reader")
|
||||
srv := newRouter(s, testConfig())
|
||||
|
||||
ownerKey := "asura:solo"
|
||||
@@ -141,20 +123,50 @@ func TestPerReaderIsolation(t *testing.T) {
|
||||
t.Fatalf("owner list = %+v, want their own row", owners)
|
||||
}
|
||||
|
||||
// One Reader's credential cannot delete the other's row.
|
||||
req = credRequest(http.MethodDelete, "/bookmarks/"+ownerKey, readerCredential("other-reader"))
|
||||
rr = httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("other reader delete: status = %d, want 204", rr.Code)
|
||||
}
|
||||
rr = httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", ownerCredential()))
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &owners); err != nil {
|
||||
// DELETE is scoped to its caller too, asserted in both directions: each
|
||||
// Reader's delete on the shared key takes only their own row.
|
||||
list := func(cred string) []store.Bookmark {
|
||||
t.Helper()
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", cred))
|
||||
var got []store.Bookmark
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(owners) != 1 {
|
||||
t.Fatalf("owner's row was deletable by another Reader: list = %+v", owners)
|
||||
return got
|
||||
}
|
||||
del := func(cred string) {
|
||||
t.Helper()
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, credRequest(http.MethodDelete, "/bookmarks/"+ownerKey, cred))
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete: status = %d, want 204", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
del(readerCredential("other-reader"))
|
||||
if got := list(ownerCredential()); len(got) != 1 {
|
||||
t.Fatalf("owner's row was deletable by the other Reader: %+v", got)
|
||||
}
|
||||
if got := list(readerCredential("other-reader")); len(got) != 0 {
|
||||
t.Fatalf("other Reader's own delete left %+v behind", got)
|
||||
}
|
||||
|
||||
// The mirror: the other Reader takes the key again, the owner deletes
|
||||
// theirs, and the other's row is untouched.
|
||||
req = credRequest(http.MethodPut, "/bookmarks/"+ownerKey, readerCredential("other-reader"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr = httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, withBody(req, body))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("other reader re-put: status = %d, want 200", rr.Code)
|
||||
}
|
||||
del(ownerCredential())
|
||||
if got := list(readerCredential("other-reader")); len(got) != 1 {
|
||||
t.Fatalf("other Reader's row was deletable by the owner: %+v", got)
|
||||
}
|
||||
if got := list(ownerCredential()); len(got) != 0 {
|
||||
t.Fatalf("owner's own delete left %+v behind", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +174,7 @@ func TestPerReaderIsolation(t *testing.T) {
|
||||
// with the Reader's credential inside: the credential never appears in the
|
||||
// address bar, the page markup, or any Location header.
|
||||
func TestInstallServesScriptWithCredential(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "manga-bookmark.user.js")
|
||||
novelPath := filepath.Join(dir, "novel-bookmark.user.js")
|
||||
@@ -225,43 +237,6 @@ func TestInstallServesScriptWithCredential(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The retired global token also keeps the script download path working during
|
||||
// the grace window — that is how already-installed scripts auto-update across
|
||||
// the cutover — and dies with it. The copy served on the legacy path embeds
|
||||
// the Reader's derived credential, so the next update poll migrates the
|
||||
// device onto its per-Reader path: the window empties itself.
|
||||
func TestLegacyTokenUserscriptPathDuringGrace(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "manga-bookmark.user.js")
|
||||
if err := os.WriteFile(path, []byte("const API_TOKEN = \"__API_TOKEN__\";\n"), 0o644); err != nil {
|
||||
t.Fatalf("write script: %v", err)
|
||||
}
|
||||
|
||||
s := newTestStore(t)
|
||||
cfg := testConfig()
|
||||
cfg.UserscriptPath = path
|
||||
|
||||
// Within the window the legacy URL serves the script, but with the
|
||||
// owner's derived credential substituted — not the legacy one.
|
||||
rr := httptest.NewRecorder()
|
||||
newRouter(s, cfg).ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
|
||||
"/u/"+testToken+"/manga-bookmark.user.js", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("legacy path during grace: status = %d, want 200", rr.Code)
|
||||
}
|
||||
if got := rr.Body.String(); !strings.Contains(got, `API_TOKEN = "`+ownerCredential()+`"`) {
|
||||
t.Fatalf("legacy-path script does not carry the derived credential:\n%s", got)
|
||||
}
|
||||
|
||||
// After the deadline the same URL is a 404 like any unknown credential.
|
||||
cfg.GraceUntil = time.Now().Add(-time.Hour)
|
||||
rr = httptest.NewRecorder()
|
||||
newRouter(s, cfg).ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
|
||||
"/u/"+testToken+"/manga-bookmark.user.js", nil))
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("legacy path after grace: status = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Rotation through the web UI invalidates the old credential immediately,
|
||||
// mints one that authenticates the API and the script path, and warns that
|
||||
// every device must reinstall.
|
||||
@@ -271,7 +246,7 @@ func TestRotateCredentialViaWebUI(t *testing.T) {
|
||||
if err := os.WriteFile(path, []byte("const API_TOKEN = \"__API_TOKEN__\";\n"), 0o644); err != nil {
|
||||
t.Fatalf("write script: %v", err)
|
||||
}
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
cfg.UserscriptPath = path
|
||||
srv := newRouter(s, cfg)
|
||||
|
||||
@@ -338,7 +313,7 @@ func TestRotateCredentialViaWebUI(t *testing.T) {
|
||||
// The app page offers the install links; the credential never appears in its
|
||||
// markup.
|
||||
func TestIndexShowsSetupPanelWithoutCredential(t *testing.T) {
|
||||
srv, st := newWebTestServer(t, webConfig())
|
||||
srv, st := newWebTestServer(t, testConfig())
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
+221
-73
@@ -1,13 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -17,20 +18,13 @@ import (
|
||||
"bookmarkmanager/backend/internal/session"
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
"bookmarkmanager/backend/internal/web"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
// testOwnerID is the Discord identity the stub reports for a sign-in. It is
|
||||
// deliberately not the seeded owner's (testDiscordID): registration is open,
|
||||
// so the default sign-in is a second Reader registering.
|
||||
const testOwnerID = "owner-snowflake"
|
||||
|
||||
// webConfig returns a config whose web UI is usable: Discord identity is set,
|
||||
// though the OAuth endpoints still need the stub URL from discordConfig.
|
||||
func webConfig() Config {
|
||||
cfg := testConfig()
|
||||
cfg.Discord.OwnerDiscordID = testOwnerID
|
||||
return cfg
|
||||
}
|
||||
|
||||
// newWebTestServer returns the full router plus the store behind it, so tests
|
||||
// can seed rows and assert on what the handlers wrote back.
|
||||
func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *store.Store) {
|
||||
@@ -142,7 +136,6 @@ func discordConfig(stubURL string) web.DiscordConfig {
|
||||
GuildID: "guild-1",
|
||||
APIBase: stubURL,
|
||||
RedirectURI: "https://bm.example.com/auth/discord/callback",
|
||||
OwnerDiscordID: testOwnerID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +144,7 @@ func discordConfig(stubURL string) web.DiscordConfig {
|
||||
func oauthWebTestServer(t *testing.T) (http.Handler, *store.Store, *discordStub) {
|
||||
t.Helper()
|
||||
stub, srv := newDiscordStub(t)
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
cfg.Discord = discordConfig(srv.URL)
|
||||
router, st := newWebTestServer(t, cfg)
|
||||
return router, st, stub
|
||||
@@ -190,24 +183,40 @@ func completeSignIn(t *testing.T, srv http.Handler, state string) *httptest.Resp
|
||||
return rr
|
||||
}
|
||||
|
||||
// readerCount pokes the readers table directly — the refusal contract is that
|
||||
// nothing was created, which the store API would not show.
|
||||
func readerCount(t *testing.T, url string) int {
|
||||
// storeReaders is the roster, ordered oldest first — the owner heads it.
|
||||
func storeReaders(t *testing.T, st *store.Store) []store.ReaderSummary {
|
||||
t.Helper()
|
||||
db, err := sql.Open("pgx", url)
|
||||
readers, err := st.Readers()
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
t.Fatalf("Readers: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM readers`).Scan(&n); err != nil {
|
||||
t.Fatalf("count readers: %v", err)
|
||||
return readers
|
||||
}
|
||||
return n
|
||||
|
||||
// signInCookie runs a whole Discord sign-in and returns the session cookie it
|
||||
// minted, for the Reader the stub reports (testOwnerID).
|
||||
func signInCookie(t *testing.T, srv http.Handler) *http.Cookie {
|
||||
t.Helper()
|
||||
rr := completeSignIn(t, srv, startSignIn(t, srv))
|
||||
cookies := rr.Result().Cookies()
|
||||
if rr.Code != http.StatusSeeOther || len(cookies) != 1 {
|
||||
t.Fatalf("sign-in status = %d with %d cookies, want 303 and one", rr.Code, len(cookies))
|
||||
}
|
||||
return cookies[0]
|
||||
}
|
||||
|
||||
// signedInReader is signInCookie plus the Reader the session names.
|
||||
func signedInReader(t *testing.T, srv http.Handler, st *store.Store) int64 {
|
||||
t.Helper()
|
||||
sess, ok, err := st.GetSession(signInCookie(t, srv).Value, time.Now())
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("session lookup: ok=%v err=%v", ok, err)
|
||||
}
|
||||
return sess.ReaderID
|
||||
}
|
||||
|
||||
func TestIndexWithoutSessionShowsLogin(t *testing.T) {
|
||||
srv, _ := newWebTestServer(t, webConfig())
|
||||
srv, _ := newWebTestServer(t, testConfig())
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
|
||||
@@ -220,7 +229,7 @@ func TestIndexWithoutSessionShowsLogin(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIndexWithSessionShowsList(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
if _, err := st.Upsert(st.OwnerID(), store.Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
@@ -397,10 +406,10 @@ func TestDiscordLoginRefusesNonMember(t *testing.T) {
|
||||
stub.member = tc.member
|
||||
stub.memberStatus = tc.memberStatus
|
||||
stub.roles = tc.roles
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
cfg.Discord = discordConfig(srv.URL)
|
||||
cfg.Discord.RequiredRole = tc.require
|
||||
st, dbURL := newTestStoreURL(t)
|
||||
st := newTestStore(t)
|
||||
router := newRouter(st, cfg)
|
||||
|
||||
rr := completeSignIn(t, router, startSignIn(t, router))
|
||||
@@ -417,7 +426,7 @@ func TestDiscordLoginRefusesNonMember(t *testing.T) {
|
||||
t.Fatal("a refused sign-in set a cookie")
|
||||
}
|
||||
// The seed owner is still the only Reader, and no session exists.
|
||||
if n := readerCount(t, dbURL); n != 1 {
|
||||
if n := len(storeReaders(t, st)); n != 1 {
|
||||
t.Fatalf("readers = %d after a refusal, want 1", n)
|
||||
}
|
||||
})
|
||||
@@ -428,7 +437,7 @@ func TestDiscordLoginRefusesNonMember(t *testing.T) {
|
||||
func TestDiscordLoginRequiresRolePositive(t *testing.T) {
|
||||
stub, srv := newDiscordStub(t)
|
||||
stub.roles = []string{"role-1"}
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
cfg.Discord = discordConfig(srv.URL)
|
||||
cfg.Discord.RequiredRole = "role-1"
|
||||
router, st := newWebTestServer(t, cfg)
|
||||
@@ -446,32 +455,171 @@ func TestDiscordLoginRequiresRolePositive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscordLoginRefusesNonOwner(t *testing.T) {
|
||||
stub, srv := newDiscordStub(t)
|
||||
stub.ownerID = "someone-elses-snowflake"
|
||||
cfg := webConfig()
|
||||
cfg.Discord = discordConfig(srv.URL)
|
||||
// Registration is the login: a guild member who is not the owner gets their
|
||||
// own Reader on first sight, and every later sign-in reuses it rather than
|
||||
// minting a second library.
|
||||
func TestGuildMemberRegistersOnFirstLoginAndReusesIt(t *testing.T) {
|
||||
router, st, _ := oauthWebTestServer(t)
|
||||
if n := len(storeReaders(t, st)); n != 1 {
|
||||
t.Fatalf("readers before any login = %d, want just the seeded owner", n)
|
||||
}
|
||||
|
||||
first := signedInReader(t, router, st)
|
||||
if first == st.OwnerID() {
|
||||
t.Fatal("a non-owner member's session landed on the owner Reader")
|
||||
}
|
||||
readers := storeReaders(t, st)
|
||||
if len(readers) != 2 {
|
||||
t.Fatalf("readers after first login = %d, want 2", len(readers))
|
||||
}
|
||||
if readers[1].DiscordID != testOwnerID {
|
||||
t.Fatalf("registered Reader's discord id = %q, want %q", readers[1].DiscordID, testOwnerID)
|
||||
}
|
||||
|
||||
second := signedInReader(t, router, st)
|
||||
if second != first {
|
||||
t.Fatalf("second login landed on Reader %d, want the existing %d", second, first)
|
||||
}
|
||||
if n := len(storeReaders(t, st)); n != 2 {
|
||||
t.Fatalf("readers after second login = %d, want 2 (no duplicate)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A brand-new Reader's page explains how a library gets filled and offers both
|
||||
// install links, and the script it serves carries their credential — not the
|
||||
// owner's.
|
||||
func TestNewReaderSeesEmptyLibraryAndTheirOwnScript(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "manga-bookmark.user.js")
|
||||
if err := os.WriteFile(path, []byte("const API_TOKEN = \"__API_TOKEN__\";\n"), 0o644); err != nil {
|
||||
t.Fatalf("write script: %v", err)
|
||||
}
|
||||
_, stubSrv := newDiscordStub(t)
|
||||
cfg := testConfig()
|
||||
cfg.Discord = discordConfig(stubSrv.URL)
|
||||
cfg.UserscriptPath = path
|
||||
router, st := newWebTestServer(t, cfg)
|
||||
|
||||
rr := completeSignIn(t, router, startSignIn(t, router))
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rr.Code)
|
||||
cookie := signInCookie(t, router)
|
||||
reader, _, err := st.GetSession(cookie.Value, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "not the library owner") {
|
||||
t.Fatalf("refusal body = %q, want the owner-only explanation", rr.Body.String())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(cookie)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET / status = %d, want 200", rr.Code)
|
||||
}
|
||||
if len(rr.Result().Cookies()) != 0 {
|
||||
t.Fatal("a refused sign-in set a cookie")
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{
|
||||
"Your library is empty",
|
||||
`href="/install/manga-bookmark.user.js"`,
|
||||
`href="/install/novel-bookmark.user.js"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("empty library page lacks %q", want)
|
||||
}
|
||||
if sess, ok, _ := st.GetSession("anything", time.Now()); ok && sess.ID != "" {
|
||||
t.Fatal("a refused sign-in created a session")
|
||||
}
|
||||
// The Readers panel is the owner's alone.
|
||||
if strings.Contains(body, `id="readers"`) {
|
||||
t.Error("a non-owner Reader was shown the Readers panel")
|
||||
}
|
||||
|
||||
theirCred := readerCredential(testOwnerID)
|
||||
if theirCred == ownerCredential() {
|
||||
t.Fatal("test setup: the new Reader's credential collides with the owner's")
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/install/manga-bookmark.user.js", nil)
|
||||
req.AddCookie(cookie)
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if got := rr.Body.String(); !strings.Contains(got, `API_TOKEN = "`+theirCred+`"`) {
|
||||
t.Fatalf("new Reader's script does not carry their own credential:\n%s", got)
|
||||
}
|
||||
if strings.Contains(rr.Body.String(), ownerCredential()) {
|
||||
t.Fatal("new Reader's script carries the owner's credential")
|
||||
}
|
||||
if reader.ReaderID == st.OwnerID() {
|
||||
t.Fatal("the new Reader's session points at the owner")
|
||||
}
|
||||
}
|
||||
|
||||
// Only the owner may revoke, and a revocation kills every session that Reader
|
||||
// holds while leaving everyone else signed in.
|
||||
func TestOwnerRevokesAnotherReadersSessions(t *testing.T) {
|
||||
router, st, _ := oauthWebTestServer(t)
|
||||
theirCookie := signInCookie(t, router)
|
||||
theirSession, _, err := st.GetSession(theirCookie.Value, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
ownerCookie := sessionCookie(t, st)
|
||||
|
||||
// A non-owner cannot reach the endpoint at all: for them it does not exist.
|
||||
req := httptest.NewRequest(http.MethodPost,
|
||||
"/readers/"+strconv.FormatInt(st.OwnerID(), 10)+"/revoke", nil)
|
||||
req.AddCookie(theirCookie)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("non-owner revoke: status = %d, want 404", rr.Code)
|
||||
}
|
||||
if _, ok, _ := st.GetSession(ownerCookie.Value, time.Now()); !ok {
|
||||
t.Fatal("a non-owner's revoke attempt still killed the owner's session")
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost,
|
||||
"/readers/"+strconv.FormatInt(theirSession.ReaderID, 10)+"/revoke", nil)
|
||||
req.AddCookie(ownerCookie)
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("owner revoke: status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), `id="readers"`) {
|
||||
t.Fatalf("revoke response did not re-render the roster:\n%s", rr.Body.String())
|
||||
}
|
||||
|
||||
// The revoked Reader's next request is rejected; the owner is untouched.
|
||||
req = httptest.NewRequest(http.MethodGet, "/ui/list", nil)
|
||||
req.AddCookie(theirCookie)
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("revoked session: status = %d, want 401", rr.Code)
|
||||
}
|
||||
if _, ok, _ := st.GetSession(ownerCookie.Value, time.Now()); !ok {
|
||||
t.Fatal("revoking another Reader took the owner's session with it")
|
||||
}
|
||||
}
|
||||
|
||||
// The owner's own page carries the roster; nobody else's does.
|
||||
func TestOwnerSeesReadersPanel(t *testing.T) {
|
||||
router, st, _ := oauthWebTestServer(t)
|
||||
signInCookie(t, router)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, `id="readers"`) {
|
||||
t.Fatal("the owner's page lacks the Readers panel")
|
||||
}
|
||||
if !strings.Contains(body, testOwnerID) {
|
||||
t.Fatalf("the roster does not list the registered Reader:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "Revoke sessions") {
|
||||
t.Fatal("the roster offers no revocation control for a signed-in Reader")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscordLoginTokenEndpointDown(t *testing.T) {
|
||||
stub, srv := newDiscordStub(t)
|
||||
stub.tokenStatus = http.StatusInternalServerError
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
cfg.Discord = discordConfig(srv.URL)
|
||||
router, _ := newWebTestServer(t, cfg)
|
||||
|
||||
@@ -514,7 +662,7 @@ func TestCallbackRateLimited(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLogoutDeletesSession(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
cookie := sessionCookie(t, st)
|
||||
|
||||
@@ -544,7 +692,7 @@ func TestLogoutDeletesSession(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExpiredSessionRejected(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
sess, err := st.CreateSession(session.NewID(), st.OwnerID(), -time.Minute)
|
||||
if err != nil {
|
||||
@@ -570,7 +718,7 @@ func TestExpiredSessionRejected(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBookmarksAPIStillBearerOnly(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
|
||||
// A session cookie must not grant access to the userscript's JSON API.
|
||||
@@ -591,7 +739,7 @@ func TestBookmarksAPIStillBearerOnly(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStaticAssetsServed(t *testing.T) {
|
||||
srv, _ := newWebTestServer(t, webConfig())
|
||||
srv, _ := newWebTestServer(t, testConfig())
|
||||
for _, path := range []string{"/static/style.css", "/static/htmx.min.js", "/static/filter.js", "/static/logo.svg", "/static/login-art.png"} {
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
@@ -628,7 +776,7 @@ func uiRequest(t *testing.T, st *store.Store, method, path string, form url.Valu
|
||||
}
|
||||
|
||||
func TestUIRoutesRequireSession(t *testing.T) {
|
||||
srv, _ := newWebTestServer(t, webConfig())
|
||||
srv, _ := newWebTestServer(t, testConfig())
|
||||
cases := []struct{ method, path string }{
|
||||
{http.MethodGet, "/ui/list"},
|
||||
{http.MethodPost, "/ui/bookmarks/asura:solo/favorite"},
|
||||
@@ -647,7 +795,7 @@ func TestUIRoutesRequireSession(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFavoriteTogglesWithoutReordering(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
before := seed(t, st, store.Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
@@ -696,7 +844,7 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
|
||||
// rendered attribute's shape — it is not proof the browser accepts the
|
||||
// selector, just a regression guard against reintroducing the bare-id form.
|
||||
func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
@@ -722,7 +870,7 @@ func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
before := seed(t, st, store.Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
@@ -757,7 +905,7 @@ func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
before := seed(t, st, store.Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
@@ -797,7 +945,7 @@ func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestChapterOverrideRejectsBadInput(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
@@ -821,7 +969,7 @@ func TestChapterOverrideRejectsBadInput(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMutationsOnMissingKey(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -842,7 +990,7 @@ func TestMutationsOnMissingKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUIDeleteRemovesRow(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
@@ -869,7 +1017,7 @@ func TestUIDeleteRemovesRow(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUIListFavouritesTab(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
@@ -895,7 +1043,7 @@ func TestUIListFavouritesTab(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUIListNewTab(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
@@ -949,7 +1097,7 @@ func seedStatusRows(t *testing.T, st *store.Store) {
|
||||
}
|
||||
|
||||
func TestTabsShowOnlyTheirBucket(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, st)
|
||||
|
||||
@@ -1006,7 +1154,7 @@ func stripOf(t *testing.T, srv http.Handler, st *store.Store, tab string) string
|
||||
// The strip carries the series with a chapter waiting — the one thing the
|
||||
// updated_at-ordered list below it does not already say — and only on All.
|
||||
func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, st) // ReadingOne is at 10 with 11 out; the rest are not reading
|
||||
|
||||
@@ -1057,7 +1205,7 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
|
||||
|
||||
// The strip never grows past web.RecentCount, however many series are waiting.
|
||||
func TestRecentStripCapped(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
for i := 0; i <= web.RecentCount; i++ {
|
||||
b := store.Bookmark{
|
||||
@@ -1088,7 +1236,7 @@ func postStatus(t *testing.T, srv http.Handler, st *store.Store, key, status str
|
||||
}
|
||||
|
||||
func TestUIStatusSetsBucket(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, st)
|
||||
|
||||
@@ -1107,7 +1255,7 @@ func TestUIStatusSetsBucket(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUIStatusRejectsUnknownValue(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, st)
|
||||
|
||||
@@ -1121,7 +1269,7 @@ func TestUIStatusRejectsUnknownValue(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUIStatusRequiresSession(t *testing.T) {
|
||||
srv, st := newWebTestServer(t, webConfig())
|
||||
srv, st := newWebTestServer(t, testConfig())
|
||||
seedStatusRows(t, st)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/asura:reading/status",
|
||||
@@ -1136,7 +1284,7 @@ func TestUIStatusRequiresSession(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUIStatusDoesNotReorderList(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, st)
|
||||
|
||||
@@ -1152,7 +1300,7 @@ func TestUIStatusDoesNotReorderList(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCardShowsStatusControls(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, st)
|
||||
|
||||
@@ -1190,7 +1338,7 @@ func TestCardShowsStatusControls(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAppRendersNewTabs(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, st)
|
||||
|
||||
@@ -1209,7 +1357,7 @@ func TestAppRendersNewTabs(t *testing.T) {
|
||||
// A mutation has to bring the chrome with it: the strip and the badge live
|
||||
// outside the swapped card, so nothing else would correct them.
|
||||
func TestMutationRefreshesChromeOutOfBand(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling",
|
||||
@@ -1255,7 +1403,7 @@ func seedLibraries(t *testing.T, st *store.Store) {
|
||||
}
|
||||
|
||||
func TestLibrariesAreDisjoint(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedLibraries(t, st)
|
||||
|
||||
@@ -1286,7 +1434,7 @@ func TestLibrariesAreDisjoint(t *testing.T) {
|
||||
|
||||
// A row written before the kind column existed has none. It is manga.
|
||||
func TestKindlessRowShowsInMangaLibrary(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:legacy", Site: "asura", SeriesID: "legacy",
|
||||
@@ -1301,7 +1449,7 @@ func TestKindlessRowShowsInMangaLibrary(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNovelPageOmitsUpdatedTab(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedLibraries(t, st)
|
||||
|
||||
@@ -1332,7 +1480,7 @@ func TestNovelPageOmitsUpdatedTab(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMangaPageKeepsUpdatedTab(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedLibraries(t, st)
|
||||
|
||||
@@ -1350,7 +1498,7 @@ func TestMangaPageKeepsUpdatedTab(t *testing.T) {
|
||||
// tab=new is not offered for novels, so a hand-typed one must land on All
|
||||
// rather than an empty page.
|
||||
func TestNovelNewTabFallsBackToAll(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
cfg := testConfig()
|
||||
srv, st := newWebTestServer(t, cfg)
|
||||
seedLibraries(t, st)
|
||||
|
||||
|
||||
+4
-6
@@ -16,19 +16,17 @@ services:
|
||||
# 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}
|
||||
# Retired global credential, optional: only used until the grace
|
||||
# deadline, for already-installed scripts. Remove after the window.
|
||||
API_TOKEN: ${API_TOKEN:-}
|
||||
API_TOKEN_GRACE_UNTIL: ${API_TOKEN_GRACE_UNTIL:-}
|
||||
# Owner's Discord user ID — required, seeds the one Reader row.
|
||||
# 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 (issue #23). The first four are
|
||||
# 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}
|
||||
|
||||
Reference in New Issue
Block a user