Phase 16: Petal authenticates for itself
Petal is now an OIDC client in its own right rather than trusting a header from the proxy. The Phase-0 Resolver seam was the only integration point: main.go picks the session store when Authentik is configured and the static local user otherwise, and no handler or query moved for either. internal/auth gains three pieces. session.go issues an opaque cookie token and stores only its SHA-256, so a database copy yields nothing usable; the 30-day expiry slides on every request, throttled to one write an hour, and logout deletes the row rather than just the cookie. oidc.go runs the authorization-code flow with state, nonce and PKCE, and discovers the provider lazily and on retry — an Authentik outage should block new logins without stopping Petal booting or invalidating live sessions. users.go provisions accounts from the token's claims and gates them on an allowlist that matches emails as well as subject ids, since a subject is an opaque uuid that doesn't exist until someone has already logged in once. Migration 0010 lands sessions, images and users.pair_lang together. The images table closes the capability-URL hole the Phase-0 audit flagged: a hash was previously enough to fetch anyone's picture. Rows are keyed (name, user_id) so one file can have several owners and deduplication survives; a stranger gets 404 rather than 403, the cache header drops to private, and files already on disk are claimed at startup or every image already pasted into a document would 404. On the frontend a single 401 interceptor feeds a warm bilingual sign-in overlay, drawn over a still-visible editor because nothing has been taken away. Behind it is the part that matters: a save that comes back 401 stashes its body to localStorage before anything else and stops the auto-save loop, and reopening that document after signing in merges the draft back and saves it. An expired session must not cost writing. Writing the round-trip test against a stub identity provider turned up a real bug: the one-shot state/nonce/PKCE cookies were cleared in a defer, which runs after the redirect has written the response header, so the clearing Set-Cookie was silently dropped and they lingered for their full ten minutes. Also swaps the emoji favicon for a drawn sakura, which renders as Petal's own rose palette everywhere instead of whatever each platform's font decides, and doubles as the app tile in Authentik. Migration 0010 verified against a VACUUM INTO copy of the live millenia database: counts intact, FTS still matching, the one existing image claimed. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
+16
-5
@@ -32,13 +32,24 @@ TTS_CACHE_DIR=./data/tts # on-disk store for synthesized clips (content
|
||||
TTS_TIMEOUT=15s
|
||||
TTS_AUDIO_FORMAT=mp3 # mp3 | opus | wav — mp3/opus transcode Piper's WAV via ffmpeg
|
||||
|
||||
# --- Deferred (not wired in the local-dev build) ---
|
||||
|
||||
# Auth (Authentik OIDC) — deferred; single hardcoded local user for now
|
||||
# SESSION_SECRET=change-me-to-random-64-char-string
|
||||
# AUTHENTIK_URL=https://auth.parodia.dev
|
||||
# --- Auth (Authentik OIDC) ---
|
||||
#
|
||||
# Login turns on only when the issuer, client id and secret are all set. Leave
|
||||
# them commented out for local development and Petal runs as the single
|
||||
# hardcoded `local` user, exactly as it did before auth landed.
|
||||
#
|
||||
# AUTHENTIK_URL is the issuer of the Petal provider in Authentik (the value of
|
||||
# its "OpenID Configuration Issuer" field). The redirect URI to register there
|
||||
# is BASE_URL + /auth/callback.
|
||||
# AUTHENTIK_URL=https://auth.parodia.dev/application/o/petal/
|
||||
# AUTHENTIK_CLIENT_ID=petal
|
||||
# AUTHENTIK_CLIENT_SECRET=
|
||||
#
|
||||
# Who may sign in: comma-separated OIDC subject ids and/or email addresses.
|
||||
# Empty = anyone Authentik authenticates.
|
||||
# PETAL_ALLOWED_SUBS=her@example.com,me@example.com
|
||||
|
||||
# --- Deferred (not wired in the local-dev build) ---
|
||||
|
||||
# Copyleaks (Tier-2 plagiarism) — deferred; needs a public webhook
|
||||
# COPYLEAKS_ENABLED=false
|
||||
|
||||
+15
-11
@@ -162,20 +162,23 @@ Petal hosted on the parodia.dev VPS; vLLM stays on millenia over headscale. Auth
|
||||
- [x] **Interim edge gate** (not in the original plan; added once the instance was live). Petal authenticates nobody yet — `StaticResolver` hands every request the same `local` user — so on a public host the whole API was open to read/write and image upload. Traefik basic auth holds the door until Phase 16, with `/api/health` exempt on its own higher-priority router. Deleted when OIDC lands.
|
||||
- [x] Acceptance — verified over public HTTPS **with the LLM link down** (it genuinely is): Hunspell dictionaries 200, gloss + word lookup (incl. phonetic) 200, doc create/save, FTS search on 春天, md + docx export, vocab capture/list, read-aloud EN + zh (real mp3 via ffmpeg, cache hit on repeat, 404 for an unconfigured language so the client falls back). `POST /check` → the warm 502 that renders as 小助手在休息. `/api/health` public; HTTP 301 → HTTPS with a valid cert.
|
||||
|
||||
### Phase 16 — Auth (in-app OIDC) + image-store ownership
|
||||
Option B ratified. `go-oidc` + `x/oauth2`; config fields already exist. The `Resolver` seam from Phase 0 is the only integration point.
|
||||
- [ ] OIDC login flow: `/auth/login` → Authentik → `/auth/callback` (state/CSRF checked) → provision user (upsert from `sub`/email/name) → session
|
||||
- [ ] `sessions` table migration (id, user_id, expires_at, created_at, user_agent); opaque cookie (`HttpOnly`, `SameSite=Lax`, `Secure` when https); **30-day sliding expiry**; `/auth/logout` revokes server-side
|
||||
- [ ] Allowlist: `PETAL_ALLOWED_SUBS` env (comma-separated); rejected valid logins get a warm bilingual page, not an error dump
|
||||
- [ ] `SessionResolver` replaces `StaticResolver` in `main.go` (keep `StaticResolver` for dev via env flag)
|
||||
- [ ] Frontend 401 interceptor in `api/client.ts`: halt auto-save, preserve the draft (localStorage keyed by doc id), warm bilingual "请重新登录 · Please sign in again" overlay, resume cleanly after re-login — **a 401 mid-draft must never lose writing**
|
||||
- [ ] **Image store ownership** (same phase, per OPEN #5): `images` table migration (hash, user_id, content_type, size, created_at); fetch joins on caller; dedup preserved (one file, N rows); last-row delete removes the file; backfill existing images to the current user
|
||||
- [ ] `users.pair_lang` column (default `'zh'`) in this phase's provisioning migration — read by Phase 19+, cheap to add now
|
||||
- [ ] Isolation tests: sessions (expiry, revocation, cross-user), images, allowlist paths
|
||||
### Phase 16 — Auth (in-app OIDC) + image-store ownership — code complete (2026-07-27), not yet live
|
||||
Option B ratified. `go-oidc` + `x/oauth2`; config fields already existed. The `Resolver` seam from Phase 0 was the only integration point — no handler or query moved.
|
||||
- [x] OIDC login flow (`internal/auth/oidc.go`): `/auth/login` → Authentik → `/auth/callback` → provision → session. **state** (cookie vs param, constant-time) + **nonce** (ID-token claim vs cookie, so a token minted for another attempt is refused) + **PKCE S256**. Discovery is **lazy and retried**: an Authentik outage blocks new logins but leaves every existing session working, since those need only Petal's own DB — the app must not fail to boot because the IdP is briefly down.
|
||||
- [x] `sessions` table (migration `0010`); opaque token in `petal_session` (`HttpOnly`, `SameSite=Lax`, `Secure` only when `BASE_URL` is https — flagging it on a plain-http dev server makes the browser silently drop it). **The table stores only the token's SHA-256**, so a DB copy yields no usable session. **30-day sliding expiry**, all time math in SQLite `datetime()` (canonical UTC), the extension throttled to one write per hour per session. `/auth/logout` deletes the row, not just the cookie; `RevokeAll` signs one writer out everywhere; expired rows pruned at startup.
|
||||
- [x] Allowlist: `PETAL_ALLOWED_SUBS`, comma-separated. **Matches a subject id *or* an email**, case-insensitively — a deliberate widening of the plan: a subject is an opaque uuid that doesn't exist until first login, so a subject-only list means letting someone in, reading a log line, and editing config. Empty = anyone Authentik authenticates. A rejected valid login gets the warm bilingual "这个 Petal 不是给你写的 · This Petal isn't yours to write in" page and **no provisioned account**.
|
||||
- [x] `main.go` picks the resolver from config: `SessionStore` (which is itself the `Resolver`) when `AUTHENTIK_URL`/id/secret are all set, `StaticResolver(local)` otherwise — so local dev and every pre-auth deployment behave exactly as before. `/auth/*` mounts on the root router, outside `/api`.
|
||||
- [x] Frontend: one 401 interceptor in `api/client.ts` (`UnauthorizedError` + an `onUnauthorized` hook covering `req`, the image upload and the SSE chat stream) → `useSession` → `SignInOverlay` (warm bilingual, editor still visible behind it — nothing has been taken away). **Draft rescue** (`lib/drafts.ts`): a save that 401s stashes its body in `localStorage` keyed by doc id *before* anything else, auto-save then stops (further attempts would only 401 and re-stash), and opening that doc after re-login merges it back and schedules a save. StatusBar says 已保存在本机 · Kept on this device — where the writing is, not what failed. Sidebar footer gains the account + 退出 · Sign out (hidden when the id is still `local`).
|
||||
- [x] **Image store ownership** (OPEN #5): `images` table (`PRIMARY KEY (name, user_id)`) — **one row per owner, not one owner per file**, so the same picture uploaded by two people is still stored once and dedup survives; the file is deleted only with its last row. Fetch joins on the caller and answers **404, not 403** (whether a hash exists is itself information). `Cache-Control` went `public` → `private` — a shared cache must never hand one writer's image to another. Files already on disk are claimed for the local user at startup (idempotent), because a row is now what makes an image fetchable and every picture already pasted into a document would otherwise 404.
|
||||
- [x] `users.pair_lang` (default `'zh'`) added in the same migration; login refreshes email/display name but never touches it — it's Petal's setting, not the IdP's.
|
||||
- [x] Tests: `session_test.go` (lifecycle, expiry + prune, sliding renewal, hash-not-token storage, cross-user non-interchangeability, `RevokeAll`, FK cascade, middleware wiring, user upsert, allowlist matrix) and `oidc_test.go` — **the whole round trip against a stub IdP** (RSA-signed ID tokens, real discovery + JWKS): PKCE challenge present, verifier reaches the token endpoint, state mismatch → 400, **replayed nonce from another attempt → 400**, allowlist refusal → the bilingual 403 with no account created, provider error → 403, already-signed-in login short-circuits home. `images/handler_test.go` gained two-user isolation, cross-user dedup + last-owner file deletion, and backfill idempotency. `web/src/lib/drafts.test.ts` covers the rescue (round-trip, per-doc, take-consumes, expiry, corrupted entry, storage that throws).
|
||||
- **A real bug the round-trip test caught:** the one-shot state/nonce/PKCE cookies were cleared with `defer o.clearTemp(w)` — which runs *after* the redirect has written the response header, so the `Set-Cookie` was silently dropped and they lingered in the browser for their full 10 minutes. Now cleared up front.
|
||||
- Verified: go build/vet/test, tsc, vite build, vitest 76/76 all clean. Migration `0010` applied to **a copy of the live millenia DB** (`VACUUM INTO` snapshot): 10 migrations apply, documents/vocab/versions counts unchanged, FTS search still returns hits, the one existing image claimed, `pair_lang` defaulted. Live smoke against the binary on a throwaway DB: auth-off → `/api/me` is `local` and everything 200s; auth-on → `/api/docs` and `/api/me` 401, `/api/health` still public, `/auth/login` with an unreachable IdP renders the warm 503 page, `/auth/logout` redirects home; a hand-inserted session row → 200 with the cookie, 401 without it, with a bad one, and once expired.
|
||||
- **Not done: this is not live yet.** Registering the provider in Authentik and switching `petal.parodia.dev` over is an outward-facing change on a shared public host, left for the user's go-ahead. `deploy/README.md` §4 is the runbook (provider fields, redirect URI, env, verification, how to force a sign-out). Until it's configured the Traefik basic-auth gate stays — Petal falls back to the single `local` user, so removing the gate before configuring OIDC would open the API to anyone.
|
||||
|
||||
### Phase 17 — Migrate the `local` user
|
||||
Script, app stopped, backup first (OPEN #4). Depends on: she logs in once so her OIDC `sub` exists.
|
||||
- [ ] `scripts/migrate_local_user.*`: single transaction, `PRAGMA foreign_keys=OFF`, re-point `documents`/`tags`/`vocab_words` (versions/suggestions follow parents), delete the empty provisioned row, verify row counts before commit; refuses to run if the app is up or the target has data
|
||||
- [ ] `scripts/migrate_local_user.*`: single transaction, `PRAGMA foreign_keys=OFF`, re-point `documents`/`tags`/`vocab_words` **and `images`** (versions/suggestions follow parents; `images` is new in Phase 16 and carries `user_id` directly — miss it and every pasted picture 404s), delete the empty provisioned row, verify row counts before commit; refuses to run if the app is up or the target has data
|
||||
- [ ] Runbook documented in the script header; dry-run mode
|
||||
|
||||
### Phase 18 — Per-user, per-language client state
|
||||
@@ -228,6 +231,7 @@ Each item independent and small; order within is free (SUGGESTIONS §5–§6).
|
||||
- [x] **Phase 14 — companion warmth + bedtime nag + night mode**: more encouraging phrases, a gentle "go to bed" nudge after 11pm, and a calm dark theme + falling stars at night. ✅ (see Phase 14 above)
|
||||
|
||||
## Session log
|
||||
- 2026-07-27: **Phase 16 built — Petal authenticates for itself** (user: "let's continue the build plan"; box access granted mid-session). New `internal/auth` surface on top of the Phase-0 `Resolver` seam: `session.go` (opaque cookie, **SHA-256-at-rest**, 30-day sliding expiry throttled to one write an hour, revoke/revoke-all/prune), `oidc.go` (login/callback/logout with state + nonce + PKCE, **lazy retried discovery** so an IdP outage can't stop Petal booting or invalidate live sessions), `users.go` (provisioning upsert keyed on `sub`, `/api/me`, allowlist). Migration `0010` lands `sessions`, `images` and `users.pair_lang` together. `main.go` picks the resolver from config, so a laptop build is unchanged. **Image ownership** closes the capability-URL hole flagged in the Phase-0 audit — one row per owner keeps dedup, a stranger gets 404 not 403, `Cache-Control` dropped to `private`, and pre-existing files are claimed at startup or they'd all 404. Frontend: a single 401 interceptor, a warm bilingual sign-in overlay over a still-visible editor, and a **draft rescue** to localStorage so an expired session can't cost writing — the auto-save stashes the body it couldn't send and reclaims it after re-login. **Three deliberate deviations from the plan**, all noted above: the allowlist matches emails as well as subject ids (a subject doesn't exist until first login, so a subject-only list is unusable in advance); `SESSION_SECRET` was dropped from config rather than left unused (nothing signs anything — sessions are opaque and server-side); and image rows are keyed `(name, user_id)` rather than owned singly, which is what preserves deduplication. **A real bug caught by writing the round-trip test rather than by reading the code**: the one-shot state/nonce/PKCE cookies were cleared in a `defer`, i.e. after the redirect had already written the header, so the clearing `Set-Cookie` was silently dropped. Verified: full go/tsc/vite/vitest suites, migration `0010` against a `VACUUM INTO` copy of the live millenia DB (counts intact, FTS still matching, image claimed), and a live smoke against the binary in both auth-off and auth-on modes including a hand-inserted session (valid → 200; absent/forged/expired → 401). **Deliberately not deployed**: registering the provider in Authentik and cutting `petal.parodia.dev` over is outward-facing on a shared public host and waits on the user's go-ahead; `deploy/README.md` §4 is the runbook, and the Traefik basic-auth gate stays until it's done.
|
||||
- 2026-07-27: **Phase 15 finished off on the two boxes** (user granted millenia access mid-session: `ssh reala@192.168.1.212`, and parodia is `ssh reala@100.64.0.1` over headscale). **LLM link**: rather than rebinding vLLM as planned, `deploy/vllm-headscale-proxy.service` (socat) adds a listener on `100.64.0.2` only — `vllm-chat.service` is shared with **Gogobee** and **Open WebUI** (whose endpoint lives in its own DB, not env), so a rebind meant three consumer edits and a 35B reload; the forwarder cost nothing and no downtime. Grammar checkpoint from the VPS now returns real suggestions in ~3s. **Backups**: the user pointed out the VPS already has daily provider VM backups *and* an age-encrypted offsite `parodia-backup` job, so Petal was folded into the latter instead of running a parallel cron — and doing so **exposed a real bug in that job's `sqlite_dump` helper**: Python's `iterdump` does not reproduce an FTS5 virtual table, so any restore would have come back with cross-document search silently missing (fixed with a `VACUUM INTO`-based helper, round-trip verified). The **bigger** find: millenia, which holds her actual writing, had **no scheduled backup at all** — now `petal-backup.timer`, age-encrypted with the parodia public recipient and pushed off-box, neither machine able to decrypt it. **Encryption at rest** (user raised it; correctly): VPS data dir is now LUKS2 covering the DB, images *and* the TTS cache; key on-box as a deliberate availability tradeoff, documented for what it does and doesn't stop. Rehearsing a reboot caught two bugs a clean run would have hidden — the plaintext originals were still on the unencrypted root fs *under* the mount, and `systemd-cryptsetup` wasn't installed so crypttab was being ignored entirely and the volume would never have unlocked at boot. Added a `.volume-ok` guard so an unmounted volume fails loudly instead of serving a blank DB. **Millenia hygiene**: Piper had been dead since the Jul 26 reboot — **26,800+ failed restarts**, read-aloud silently degrading to browser Web Speech — because an OS upgrade moved `/usr/bin/python3` 3.13→3.14 and the venv's `site-packages` went invisible; venv recreated (lands Piper 1.6.0, which is what `TTS_PATH` exists for), both voices verified through Petal. Petal itself was running unsupervised at PPID 1 and is now `petal.service` (verified by `kill -9`); the Piper units got `StartLimitIntervalSec`/`Burst` so a broken service enters `failed` instead of looping forever unnoticed. Remaining: an external uptime-kuma probe (needs the UI), a true VPS reboot test (shared public host, user's call), and millenia is still unencrypted at rest.
|
||||
- 2026-07-26: **Phase 15 complete — Petal is deployed at https://petal.parodia.dev** (user: "let's start this build plan"; scope confirmed as artifacts **plus** the actual deploy, millenia stays canonical, hostname `petal.parodia.dev`). Stack: `Dockerfile` (node → go → alpine; CGO off, so the runtime layer carries only ffmpeg + tzdata), `docker-compose.yml` behind the host's existing Traefik, and **two Piper sidecars** instead of the planned host systemd units — Piper turned out never to have been installed on the VPS and the account has no lingering session, so containers on an internal network with no published ports are both simpler and tighter. **Three real problems found by deploying rather than by planning:** (1) the image's `petal` user (uid 10001) has no claim on a bind-mounted host directory → SQLite `unable to open database file (14)` and a restart loop; the container now runs as the stack directory's owner (still non-root, and the host account keeps write access the backup script needs); (2) piper-tts **1.6.0 moved synthesis from `POST /` to `POST /synthesize`** with an identical body → every read-aloud 405'd; rather than pin both deployments to one Piper release the path became config (`TTS_PATH`, default `/`, so millenia is untouched); (3) once the instance was live it was **a public, writable, unauthenticated API** — Petal authenticates nobody yet, so Traefik basic auth now holds the door until Phase 16, with `/api/health` exempt on its own higher-priority router. Backups: `db.Backup` via **`VACUUM INTO`** (WAL-coherent, no write lock, single file, refuses to overwrite) behind a `-backup` flag so the nightly job snapshots the running container; `deploy/backup-petal.sh` compresses, pushes to millenia with a size check, prunes both sides; cron at 03:15; restore documented and verified by round-tripping an archive through the binary. Tests: `internal/db/backup_test.go` (WAL capture, seeded user survives, no `-wal`/`-shm` companions, refuses an existing destination, missing source), `internal/tts` path-normalisation + configured-path. go build/vet/test clean. **Acceptance verified over public HTTPS with the LLM link genuinely down**: dictionaries, gloss, word lookup + phonetic, doc create/save, CJK FTS search, md/docx export, vocab capture, read-aloud EN + zh (real mp3, cache hit, 404-fallback for an unconfigured language) — all fine; `/check` → the warm 502 that renders as 小助手在休息; health public, HTTP→HTTPS with a valid cert. **Two items outstanding, both needing millenia access I don't have**: vLLM isn't bound to its headscale interface (so no AI pass works yet), and parodia's ssh key isn't authorized on millenia (so backups are VPS-local only — not yet a real off-box backup). Both have one-command fixes in `deploy/README.md` §3 and §5. Also this session: **DreamDict gained Spanish**, so the es pair is no longer gated — folded into Phases 20/21 and the "Later" bucket. Next: **Phase 16 (auth)** — Authentik already runs on the same VPS.
|
||||
- 2026-07-26: **Product direction + execution plan ratified** (user: "make it so, number one"). New `SUGGESTIONS.md` (product rationale for the language-learning direction): the **pair model** — every user gets one (English + X) pair, X ∈ {zh, pt-PT, fr, maybe es}, bilingual UI in the pair, type in either language, direction inferred (no detector: both-dictionaries spellcheck, show-both gloss on collision); **langpacks** keyed by X; **LLM-minimalism** as a standing principle (LLM is garnish, never a gatekeeper — grammar-lite rule pack + embedded miscollocation list planned as code-first layers). Deployment settled: Petal on the **parodia.dev VPS**, vLLM on millenia over **headscale** (the only cross-VPN dependency; Piper is VPS-local). All `MULTIUSER_PLAN.md` OPENs ratified: in-app OIDC (B), 30-day sliding sessions, allowlist, migration script, image-store fix with auth, DreamDict via package import (Option 3, module rename prereq in the dreamdict repo), zh stays on ECDICT until compared. Everything expanded into **Phases 15–22** above with standing rules (isolation tests same-commit, LLM-minimalism, bilingual aesthetic). Ready for implementation handoff starting at Phase 15.
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# Petal multi-user plan
|
||||
|
||||
**Status:** Phase 0 (identity plumbing) landed 2026-07-26 in `6901cdb`.
|
||||
**Phase A (authentication) + Phase C's image store built 2026-07-27** — in-app
|
||||
OIDC, server-side sessions, allowlist, provisioning, the frontend 401 path and
|
||||
per-owner images. Not yet configured against the live Authentik; see
|
||||
`BUILD_PLAN.md` Phase 16 and `deploy/README.md` §4. Phase B (migrating the
|
||||
`local` user) still waits on her first real login.
|
||||
**All OPEN decisions ratified by the user 2026-07-26** (recommendations
|
||||
accepted as written) — see each OPEN for its settled answer. Execution phases
|
||||
live in `BUILD_PLAN.md` (Phase 15 onward); product rationale for the language
|
||||
|
||||
+48
-8
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
@@ -53,6 +54,34 @@ func main() {
|
||||
defer database.Close()
|
||||
log.Printf("database ready at %s", cfg.DatabasePath)
|
||||
|
||||
// Identity. With Authentik configured, Petal is an OIDC client in its own
|
||||
// right: /auth/login starts a real login and the session cookie it issues is
|
||||
// what every API request is resolved from. Without it — local development,
|
||||
// and every deployment before auth landed — StaticResolver hands out the
|
||||
// single hardcoded local user, so nothing about running Petal on a laptop
|
||||
// changes.
|
||||
sessions := auth.NewSessionStore(database.DB)
|
||||
users := auth.NewUserStore(database.DB)
|
||||
|
||||
var resolver auth.Resolver = auth.StaticResolver(db.LocalUserID)
|
||||
var oidcClient *auth.OIDC
|
||||
if cfg.AuthEnabled() {
|
||||
oidcClient = auth.NewOIDC(context.Background(), auth.Options{
|
||||
IssuerURL: cfg.AuthentikURL,
|
||||
ClientID: cfg.AuthentikClientID,
|
||||
ClientSecret: cfg.AuthentikClientSecret,
|
||||
BaseURL: cfg.BaseURL,
|
||||
Allowed: auth.ParseAllowlist(cfg.AllowedSubs),
|
||||
}, sessions, users)
|
||||
resolver = sessions
|
||||
if n, err := sessions.Prune(); err == nil && n > 0 {
|
||||
log.Printf("auth: pruned %d expired session(s)", n)
|
||||
}
|
||||
log.Printf("auth: OIDC enabled (issuer=%s, redirect=%s)", cfg.AuthentikURL, oidcClient.RedirectURI())
|
||||
} else {
|
||||
log.Printf("auth: OIDC not configured — running as the single %q user", db.LocalUserID)
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
@@ -90,13 +119,16 @@ func main() {
|
||||
// client's update poll) must not need a session to reach them.
|
||||
//
|
||||
// The middleware resolves the caller once and hands handlers the answer via
|
||||
// auth.UserID(r.Context()), replacing the db.LocalUserID constant those
|
||||
// queries used to name directly. Petal is still single-user — StaticResolver
|
||||
// returns that same local user for every request — but the identity now
|
||||
// travels the same path a real one will. Swapping this line for an Authentik
|
||||
// session resolver is the whole remaining change; no handler or query moves.
|
||||
// auth.UserID(r.Context()). Which resolver it runs is the only thing that
|
||||
// changed when auth landed: the session store in a deployment with
|
||||
// Authentik configured, the static local user otherwise. No handler or
|
||||
// query moved for either.
|
||||
api.Group(func(pr chi.Router) {
|
||||
pr.Use(auth.Middleware(auth.StaticResolver(db.LocalUserID)))
|
||||
pr.Use(auth.Middleware(resolver))
|
||||
|
||||
// Who am I? The frontend namespaces its per-account browser state by
|
||||
// this id and shows the signed-in writer.
|
||||
pr.Get("/me", users.MeHandler())
|
||||
|
||||
llmClient := llm.NewLLMClient(cfg)
|
||||
sug := suggestions.New(database, llmClient)
|
||||
@@ -128,8 +160,10 @@ func main() {
|
||||
// surfaced for gentle spaced-repetition review.
|
||||
pr.Mount("/vocab", vocab.New(database).Routes())
|
||||
|
||||
// Editor image uploads, stored on disk and served back by content hash.
|
||||
imgHandler, err := images.New(cfg.ImageDir)
|
||||
// Editor image uploads, stored on disk and served back by content hash
|
||||
// to whoever owns them. Files already on disk from before ownership
|
||||
// existed are claimed for the local user at startup.
|
||||
imgHandler, err := images.New(cfg.ImageDir, database.DB, db.LocalUserID)
|
||||
if err != nil {
|
||||
log.Fatalf("image store: %v", err)
|
||||
}
|
||||
@@ -145,6 +179,12 @@ func main() {
|
||||
})
|
||||
})
|
||||
|
||||
// Login lives outside /api: these are browser navigations, and they must be
|
||||
// reachable without a session — that is their entire job.
|
||||
if oidcClient != nil {
|
||||
r.Mount("/auth", oidcClient.Routes())
|
||||
}
|
||||
|
||||
// Everything else: serve the embedded SPA (with index.html fallback for client routing).
|
||||
r.NotFound(spaHandler())
|
||||
|
||||
|
||||
+85
-7
@@ -126,12 +126,89 @@ real suggestions in ~3s over the VPN.
|
||||
|
||||
---
|
||||
|
||||
## 4. Interim edge gate (delete when Phase 16 lands)
|
||||
## 4. Sign-in (Authentik OIDC)
|
||||
|
||||
Petal authenticates nobody yet — `StaticResolver` hands every request the same
|
||||
`local` user. On a public host that means anyone who finds the hostname can read
|
||||
and write documents and fill the disk with image uploads, so Traefik holds the
|
||||
door with basic auth until the OIDC flow exists.
|
||||
Petal is an OIDC client in its own right: it runs the login itself rather than
|
||||
trusting a header from the proxy. Nothing about the container has to be
|
||||
unreachable for that to be safe.
|
||||
|
||||
Login turns on only when `AUTHENTIK_URL`, `AUTHENTIK_CLIENT_ID` and
|
||||
`AUTHENTIK_CLIENT_SECRET` are all set. With any of them missing Petal falls back
|
||||
to the single hardcoded `local` user — which is what local development wants,
|
||||
and what makes the interim edge gate below still necessary until this is
|
||||
configured.
|
||||
|
||||
### Register Petal in Authentik
|
||||
|
||||
In the Authentik admin UI (**Applications → Providers → Create → OAuth2/OpenID
|
||||
Provider**):
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Client type | Confidential |
|
||||
| Redirect URI | `https://petal.parodia.dev/auth/callback` (strict) |
|
||||
| Scopes | `openid`, `profile`, `email` |
|
||||
| Signing key | any (Petal fetches the JWKS from discovery) |
|
||||
|
||||
Then create an **Application** bound to that provider, and copy the client id,
|
||||
the client secret, and the provider's **OpenID Configuration Issuer** (it looks
|
||||
like `https://auth.parodia.dev/application/o/petal/` — the issuer, not the
|
||||
`.well-known` URL; Petal appends that itself).
|
||||
|
||||
Put them in `.env`:
|
||||
|
||||
```
|
||||
AUTHENTIK_URL=https://auth.parodia.dev/application/o/petal/
|
||||
AUTHENTIK_CLIENT_ID=…
|
||||
AUTHENTIK_CLIENT_SECRET=…
|
||||
PETAL_ALLOWED_SUBS=her@example.com,me@example.com
|
||||
```
|
||||
|
||||
`PETAL_ALLOWED_SUBS` is the guest list: comma-separated OIDC subject ids and/or
|
||||
email addresses. Authentik fronts several applications on this host, and being a
|
||||
valid user there does not mean being a user here. Leaving it empty lets in
|
||||
everyone Authentik authenticates. Emails are accepted alongside subject ids
|
||||
precisely so the list can be written *before* anyone has logged in — a subject
|
||||
is an opaque uuid that doesn't exist until first sign-in.
|
||||
|
||||
A valid login that isn't on the list gets a warm bilingual "this Petal isn't
|
||||
yours to write in" page, and no account is provisioned.
|
||||
|
||||
### Checking it
|
||||
|
||||
```bash
|
||||
curl -si https://petal.parodia.dev/api/docs | head -1 # 401 without a session
|
||||
curl -si https://petal.parodia.dev/auth/login | grep -i location # → Authentik
|
||||
docker compose logs petal | grep '^.*auth:' # issuer + redirect at boot
|
||||
```
|
||||
|
||||
The startup log prints the redirect URI it will use; if Authentik rejects the
|
||||
login with a redirect-uri mismatch, compare that line against what's registered.
|
||||
|
||||
Discovery is lazy and retried, so an Authentik outage blocks *new* logins but
|
||||
leaves existing sessions working — those only need Petal's own database.
|
||||
|
||||
### Sessions
|
||||
|
||||
Opaque token in a `petal_session` cookie (`HttpOnly`, `SameSite=Lax`, `Secure`
|
||||
on https); the `sessions` table stores only its SHA-256, so a database copy
|
||||
yields nothing usable. Thirty-day sliding expiry — every request pushes it out,
|
||||
throttled to one write an hour. `/auth/logout` deletes the row, not just the
|
||||
cookie. Expired rows are pruned at startup.
|
||||
|
||||
To sign someone out everywhere immediately:
|
||||
|
||||
```bash
|
||||
docker compose exec petal sh -c \
|
||||
"sqlite3 /data/petal.db \"DELETE FROM sessions WHERE user_id = '<sub>'\""
|
||||
```
|
||||
|
||||
### Interim edge gate (delete once the above is configured)
|
||||
|
||||
Until `AUTHENTIK_*` is filled in, Petal authenticates nobody — `StaticResolver`
|
||||
hands every request the same `local` user. On a public host that means anyone who
|
||||
finds the hostname can read and write documents and fill the disk with image
|
||||
uploads, so Traefik holds the door with basic auth.
|
||||
|
||||
Generate a credential:
|
||||
|
||||
@@ -141,8 +218,9 @@ htpasswd -nbB petal 'your-password' # or any bcrypt htpasswd generator
|
||||
|
||||
and put the resulting `user:hash` pair in `.env` as `PETAL_BASIC_AUTH`.
|
||||
|
||||
When Phase 16 lands, delete the `petal-auth` middleware label, the
|
||||
`petal-health` router labels, and this section.
|
||||
Once OIDC is configured and a real login works, delete the `petal-auth`
|
||||
middleware label, the `petal-health` router labels, and this subsection. Keeping
|
||||
both is harmless but means two passwords to get to one editor.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -54,10 +54,17 @@ TTS_VOICE_ZH=zh_CN-huayan-medium
|
||||
TTS_AUDIO_FORMAT=mp3
|
||||
TTS_TIMEOUT=15s
|
||||
|
||||
# --- Auth (Phase 16 — not wired yet) -----------------------------------------
|
||||
# Authentik already runs on this host. Filled in when the OIDC flow lands.
|
||||
# SESSION_SECRET=
|
||||
# AUTHENTIK_URL=https://auth.parodia.dev
|
||||
# --- Auth (Authentik OIDC) ---------------------------------------------------
|
||||
# Authentik already runs on this host. Set all three and Petal authenticates
|
||||
# for itself; leave any unset and it falls back to the single `local` user
|
||||
# (which on a public host means the Traefik basic-auth gate must stay).
|
||||
#
|
||||
# AUTHENTIK_URL is the provider's issuer, and the redirect URI to register in
|
||||
# Authentik is https://petal.parodia.dev/auth/callback.
|
||||
# AUTHENTIK_URL=https://auth.parodia.dev/application/o/petal/
|
||||
# AUTHENTIK_CLIENT_ID=petal
|
||||
# AUTHENTIK_CLIENT_SECRET=
|
||||
#
|
||||
# Who may sign in: comma-separated subject ids and/or emails. Empty = anyone
|
||||
# Authentik authenticates, which is wider than this instance wants.
|
||||
# PETAL_ALLOWED_SUBS=
|
||||
|
||||
@@ -3,7 +3,10 @@ module gitea.parodia.dev/drwily/petal
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.20.0
|
||||
github.com/go-chi/chi/v5 v5.3.0
|
||||
github.com/go-jose/go-jose/v4 v4.1.4
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
modernc.org/sqlite v1.53.0
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
|
||||
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -16,6 +20,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// Temporary cookies that carry one login attempt from /auth/login to
|
||||
// /auth/callback. They live for ten minutes and are cleared the moment the
|
||||
// callback runs.
|
||||
const (
|
||||
stateCookie = "petal_oidc_state"
|
||||
nonceCookie = "petal_oidc_nonce"
|
||||
pkceCookie = "petal_oidc_pkce"
|
||||
|
||||
loginAttemptTTL = 600 // seconds
|
||||
)
|
||||
|
||||
// Options configures the OIDC client.
|
||||
type Options struct {
|
||||
IssuerURL string // Authentik's issuer, e.g. https://auth.example.com/application/o/petal/
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
BaseURL string // Petal's public base URL; the redirect URI is derived from it
|
||||
Allowed Allowlist
|
||||
}
|
||||
|
||||
// OIDC implements Petal's half of an authorization-code login against
|
||||
// Authentik: /auth/login starts it, /auth/callback finishes it by provisioning
|
||||
// the account and issuing a session, /auth/logout ends it.
|
||||
//
|
||||
// Petal is the OIDC client itself rather than trusting a proxy-injected header.
|
||||
// The header approach is far less code, but it is only safe while the container
|
||||
// is unreachable except through that proxy — an invariant enforced by network
|
||||
// configuration, not by anything in the repository, on a public host that also
|
||||
// runs half a dozen other services. Petal holds someone's private journals; it
|
||||
// should be safe to expose directly.
|
||||
type OIDC struct {
|
||||
opts Options
|
||||
sessions *SessionStore
|
||||
users *UserStore
|
||||
secure bool
|
||||
|
||||
// The provider is discovered over the network, which means it can fail at
|
||||
// startup for reasons that have nothing to do with Petal. Discovery is
|
||||
// therefore lazy and retried: an Authentik outage blocks new logins but
|
||||
// leaves every existing session working, since those only need the database.
|
||||
mu sync.Mutex
|
||||
provider *oidc.Provider
|
||||
oauth *oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
// NewOIDC builds the login flow. It attempts discovery once so a misconfigured
|
||||
// issuer shows up in the startup log rather than on the writer's first login,
|
||||
// but a failure here is not fatal.
|
||||
func NewOIDC(ctx context.Context, opts Options, sessions *SessionStore, users *UserStore) *OIDC {
|
||||
o := &OIDC{
|
||||
opts: opts,
|
||||
sessions: sessions,
|
||||
users: users,
|
||||
secure: strings.HasPrefix(strings.ToLower(opts.BaseURL), "https://"),
|
||||
}
|
||||
if err := o.discover(ctx); err != nil {
|
||||
log.Printf("auth: OIDC discovery failed (%v) — login will retry on demand", err)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// RedirectURI is the callback Authentik must have registered for this client.
|
||||
func (o *OIDC) RedirectURI() string {
|
||||
return strings.TrimSuffix(o.opts.BaseURL, "/") + "/auth/callback"
|
||||
}
|
||||
|
||||
// discover resolves the provider metadata and builds the oauth2 config.
|
||||
func (o *OIDC) discover(ctx context.Context) error {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
if o.provider != nil {
|
||||
return nil
|
||||
}
|
||||
provider, err := oidc.NewProvider(ctx, strings.TrimSuffix(o.opts.IssuerURL, "/"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.provider = provider
|
||||
o.verifier = provider.Verifier(&oidc.Config{ClientID: o.opts.ClientID})
|
||||
o.oauth = &oauth2.Config{
|
||||
ClientID: o.opts.ClientID,
|
||||
ClientSecret: o.opts.ClientSecret,
|
||||
Endpoint: provider.Endpoint(),
|
||||
RedirectURL: o.RedirectURI(),
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ready returns the discovered client, discovering it first if an earlier
|
||||
// attempt failed.
|
||||
func (o *OIDC) ready(ctx context.Context) (*oauth2.Config, *oidc.IDTokenVerifier, error) {
|
||||
if err := o.discover(ctx); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
return o.oauth, o.verifier, nil
|
||||
}
|
||||
|
||||
// Routes mounts the login endpoints. Mount at "/auth", outside /api: these are
|
||||
// browser navigations, not API calls, and they must be reachable without a
|
||||
// session — that is their whole purpose.
|
||||
func (o *OIDC) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/login", o.login)
|
||||
r.Get("/callback", o.callback)
|
||||
r.Get("/logout", o.logout)
|
||||
r.Post("/logout", o.logout)
|
||||
return r
|
||||
}
|
||||
|
||||
// login starts an authorization-code flow with PKCE.
|
||||
func (o *OIDC) login(w http.ResponseWriter, r *http.Request) {
|
||||
// Already signed in? Don't bounce a valid session through the IdP.
|
||||
if _, err := o.sessions.Resolve(r); err == nil {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
conf, _, err := o.ready(r.Context())
|
||||
if err != nil {
|
||||
o.page(w, http.StatusServiceUnavailable,
|
||||
"登录暂时不可用", "Sign-in is unavailable right now",
|
||||
"Petal 联系不上登录服务。请稍后再试。",
|
||||
"Petal can't reach the sign-in service. Please try again in a moment.")
|
||||
return
|
||||
}
|
||||
|
||||
state, err := randomToken()
|
||||
if err != nil {
|
||||
o.page(w, http.StatusInternalServerError, "出了点问题", "Something went wrong", "请再试一次。", "Please try again.")
|
||||
return
|
||||
}
|
||||
nonce, err := randomToken()
|
||||
if err != nil {
|
||||
o.page(w, http.StatusInternalServerError, "出了点问题", "Something went wrong", "请再试一次。", "Please try again.")
|
||||
return
|
||||
}
|
||||
pkce := oauth2.GenerateVerifier()
|
||||
|
||||
// state defends the callback against CSRF (a forged callback can't know the
|
||||
// cookie); nonce ties the returned ID token to this attempt; PKCE binds the
|
||||
// code to this client even if it leaks in transit.
|
||||
o.setTemp(w, stateCookie, state)
|
||||
o.setTemp(w, nonceCookie, nonce)
|
||||
o.setTemp(w, pkceCookie, pkce)
|
||||
|
||||
http.Redirect(w, r, conf.AuthCodeURL(state,
|
||||
oidc.Nonce(nonce),
|
||||
oauth2.S256ChallengeOption(pkce),
|
||||
), http.StatusFound)
|
||||
}
|
||||
|
||||
// callback completes the flow: verify, allowlist, provision, issue a session.
|
||||
func (o *OIDC) callback(w http.ResponseWriter, r *http.Request) {
|
||||
// Expire the one-shot login cookies up front, not on the way out: every exit
|
||||
// from here writes a response, and a Set-Cookie added after the header is
|
||||
// written is silently dropped. They're read from the request below, so
|
||||
// clearing them on the response now costs nothing.
|
||||
o.clearTemp(w)
|
||||
|
||||
if errParam := r.URL.Query().Get("error"); errParam != "" {
|
||||
o.page(w, http.StatusForbidden,
|
||||
"登录未完成", "Sign-in didn't finish",
|
||||
"登录服务拒绝了这次请求。你可以再试一次。",
|
||||
"The sign-in service turned that request down. You can try again.")
|
||||
return
|
||||
}
|
||||
|
||||
state, err := r.Cookie(stateCookie)
|
||||
if err != nil || state.Value == "" ||
|
||||
subtle.ConstantTimeCompare([]byte(state.Value), []byte(r.URL.Query().Get("state"))) != 1 {
|
||||
o.page(w, http.StatusBadRequest,
|
||||
"这个登录链接过期了", "That sign-in link expired",
|
||||
"请回到 Petal 重新登录。",
|
||||
"Head back to Petal and sign in again.")
|
||||
return
|
||||
}
|
||||
|
||||
conf, verifier, err := o.ready(r.Context())
|
||||
if err != nil {
|
||||
o.page(w, http.StatusServiceUnavailable,
|
||||
"登录暂时不可用", "Sign-in is unavailable right now",
|
||||
"Petal 联系不上登录服务。请稍后再试。",
|
||||
"Petal can't reach the sign-in service. Please try again in a moment.")
|
||||
return
|
||||
}
|
||||
|
||||
pkce, err := r.Cookie(pkceCookie)
|
||||
if err != nil {
|
||||
o.page(w, http.StatusBadRequest, "这个登录链接过期了", "That sign-in link expired",
|
||||
"请回到 Petal 重新登录。", "Head back to Petal and sign in again.")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := conf.Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(pkce.Value))
|
||||
if err != nil {
|
||||
log.Printf("auth: code exchange failed: %v", err)
|
||||
o.page(w, http.StatusBadGateway, "登录没有成功", "Sign-in didn't go through",
|
||||
"请再试一次。", "Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := o.claims(r.Context(), verifier, token)
|
||||
if err != nil {
|
||||
log.Printf("auth: id token rejected: %v", err)
|
||||
o.page(w, http.StatusBadGateway, "登录没有成功", "Sign-in didn't go through",
|
||||
"请再试一次。", "Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
// Nonce check: this ID token must belong to the attempt that started here.
|
||||
nonce, err := r.Cookie(nonceCookie)
|
||||
if err != nil || subtle.ConstantTimeCompare([]byte(nonce.Value), []byte(claims.nonce)) != 1 {
|
||||
o.page(w, http.StatusBadRequest, "这个登录链接过期了", "That sign-in link expired",
|
||||
"请回到 Petal 重新登录。", "Head back to Petal and sign in again.")
|
||||
return
|
||||
}
|
||||
|
||||
if !o.opts.Allowed.Permits(claims.Subject, claims.Email) {
|
||||
log.Printf("auth: rejected sign-in for sub=%s email=%s (not on the allowlist)", claims.Subject, claims.Email)
|
||||
o.page(w, http.StatusForbidden,
|
||||
"这个 Petal 不是给你写的", "This Petal isn't yours to write in",
|
||||
"你的账号是有效的,但还没有被邀请到这个 Petal。如果这是个误会,找管理员说一声就好。",
|
||||
"Your account is valid, but it hasn't been invited to this Petal. If that's a mistake, a word with whoever runs it will sort it out.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := o.users.Upsert(claims.Subject, claims.Email, claims.displayName()); err != nil {
|
||||
log.Printf("auth: provisioning failed: %v", err)
|
||||
o.page(w, http.StatusInternalServerError, "出了点问题", "Something went wrong",
|
||||
"请再试一次。", "Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
session, err := o.sessions.Create(claims.Subject, r.UserAgent())
|
||||
if err != nil {
|
||||
log.Printf("auth: session creation failed: %v", err)
|
||||
o.page(w, http.StatusInternalServerError, "出了点问题", "Something went wrong",
|
||||
"请再试一次。", "Please try again.")
|
||||
return
|
||||
}
|
||||
SetSessionCookie(w, session, o.secure)
|
||||
log.Printf("auth: signed in %s (%s)", claims.Email, claims.Subject)
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
// logout revokes the session server-side and clears the cookie. Doing both
|
||||
// matters: clearing only the cookie leaves a token that still works if it was
|
||||
// ever captured.
|
||||
func (o *OIDC) logout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(SessionCookie); err == nil && c.Value != "" {
|
||||
if err := o.sessions.Revoke(c.Value); err != nil {
|
||||
log.Printf("auth: revoke failed: %v", err)
|
||||
}
|
||||
}
|
||||
ClearSessionCookie(w, o.secure)
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
// idClaims is the subset of the ID token Petal cares about.
|
||||
type idClaims struct {
|
||||
Subject string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
|
||||
nonce string
|
||||
}
|
||||
|
||||
func (c idClaims) displayName() string {
|
||||
if c.Name != "" {
|
||||
return c.Name
|
||||
}
|
||||
if c.PreferredUsername != "" {
|
||||
return c.PreferredUsername
|
||||
}
|
||||
return c.Email
|
||||
}
|
||||
|
||||
// claims verifies the ID token in a token response and extracts its claims.
|
||||
func (o *OIDC) claims(ctx context.Context, verifier *oidc.IDTokenVerifier, token *oauth2.Token) (idClaims, error) {
|
||||
raw, ok := token.Extra("id_token").(string)
|
||||
if !ok || raw == "" {
|
||||
return idClaims{}, errors.New("no id_token in the token response")
|
||||
}
|
||||
idToken, err := verifier.Verify(ctx, raw)
|
||||
if err != nil {
|
||||
return idClaims{}, err
|
||||
}
|
||||
var claims idClaims
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return idClaims{}, err
|
||||
}
|
||||
if claims.Subject == "" {
|
||||
claims.Subject = idToken.Subject
|
||||
}
|
||||
claims.nonce = idToken.Nonce
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (o *OIDC) setTemp(w http.ResponseWriter, name, value string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: "/auth",
|
||||
HttpOnly: true,
|
||||
Secure: o.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: loginAttemptTTL,
|
||||
})
|
||||
}
|
||||
|
||||
func (o *OIDC) clearTemp(w http.ResponseWriter) {
|
||||
for _, name := range []string{stateCookie, nonceCookie, pkceCookie} {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name, Value: "", Path: "/auth",
|
||||
HttpOnly: true, Secure: o.secure, SameSite: http.SameSiteLaxMode, MaxAge: -1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func randomToken() (string, error) {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// page renders one of the flow's dead ends. Every one of them is a full stop in
|
||||
// front of someone who was just trying to write, so they read as warm bilingual
|
||||
// sentences rather than as a status code — the same standard as the rest of the
|
||||
// app, and the reason these aren't plain http.Error calls.
|
||||
func (o *OIDC) page(w http.ResponseWriter, status int, titleZH, titleEN, bodyZH, bodyEN string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprintf(w, `<!doctype html>
|
||||
<html lang="zh"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>%s · Petal</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
|
||||
background:#fdf8f5; color:#5b4b52;
|
||||
font-family:'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',system-ui,sans-serif; }
|
||||
main { max-width:30rem; padding:2.5rem; text-align:center; }
|
||||
.mark { font-size:2.5rem; }
|
||||
h1 { font-size:1.5rem; margin:.75rem 0 .25rem; font-weight:700; }
|
||||
h2 { font-size:1rem; margin:0 0 1.25rem; font-weight:600; opacity:.65; }
|
||||
p { line-height:1.7; margin:.4rem 0; }
|
||||
p.en { opacity:.7; font-size:.95rem; }
|
||||
a { display:inline-block; margin-top:1.75rem; padding:.6rem 1.4rem; border-radius:999px;
|
||||
background:#f3c7d3; color:#5b4b52; text-decoration:none; font-weight:700; }
|
||||
@media (prefers-color-scheme: dark) { body { background:#231b28; color:#e9dfe6; } a { background:#7c5f78; color:#fdf8f5; } }
|
||||
</style></head>
|
||||
<body><main>
|
||||
<div class="mark">🌸</div>
|
||||
<h1>%s</h1><h2>%s</h2>
|
||||
<p>%s</p><p class="en">%s</p>
|
||||
<a href="/">回到 Petal · Back to Petal</a>
|
||||
</main></body></html>
|
||||
`, titleEN, titleZH, titleEN, bodyZH, bodyEN)
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
jose "github.com/go-jose/go-jose/v4"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
// These tests run the whole login round-trip against a stub identity provider:
|
||||
// discovery, the redirect out, the callback back, and the session that comes out
|
||||
// the other end. The flow is the one place in Petal where getting a detail wrong
|
||||
// (an unchecked state, a nonce nobody compares) is both easy and invisible —
|
||||
// everything still "works" from the browser's point of view.
|
||||
|
||||
// stubIdP is a minimal OpenID provider: discovery, a JWKS, and a token endpoint
|
||||
// that mints a signed ID token for whoever the test says just logged in.
|
||||
type stubIdP struct {
|
||||
*httptest.Server
|
||||
key *rsa.PrivateKey
|
||||
clientID string
|
||||
|
||||
// Claims the next token exchange will assert.
|
||||
sub, email, name string
|
||||
// nonce echoed into the token; set from the login attempt's cookie.
|
||||
nonce string
|
||||
// lastForm records what Petal sent to /token, so the test can assert PKCE.
|
||||
lastForm url.Values
|
||||
}
|
||||
|
||||
func newStubIdP(t *testing.T, clientID string) *stubIdP {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idp := &stubIdP{key: key, clientID: clientID}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
idp.Server = httptest.NewServer(mux)
|
||||
t.Cleanup(idp.Close)
|
||||
|
||||
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"issuer": idp.URL,
|
||||
"authorization_endpoint": idp.URL + "/authorize",
|
||||
"token_endpoint": idp.URL + "/token",
|
||||
"jwks_uri": idp.URL + "/jwks",
|
||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(jose.JSONWebKeySet{
|
||||
Keys: []jose.JSONWebKey{{Key: key.Public(), Algorithm: "RS256", Use: "sig"}},
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
idp.lastForm = r.PostForm
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "stub-access-token",
|
||||
"token_type": "Bearer",
|
||||
"id_token": idp.idToken(t),
|
||||
})
|
||||
})
|
||||
|
||||
return idp
|
||||
}
|
||||
|
||||
// idToken mints a signed ID token asserting the currently configured claims.
|
||||
func (idp *stubIdP) idToken(t *testing.T) string {
|
||||
t.Helper()
|
||||
signer, err := jose.NewSigner(
|
||||
jose.SigningKey{Algorithm: jose.RS256, Key: idp.key},
|
||||
(&jose.SignerOptions{}).WithType("JWT"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"iss": idp.URL,
|
||||
"aud": idp.clientID,
|
||||
"sub": idp.sub,
|
||||
"email": idp.email,
|
||||
"name": idp.name,
|
||||
"nonce": idp.nonce,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
})
|
||||
signed, err := signer.Sign(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := signed.CompactSerialize()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// newFlow wires Petal's login routes to a stub provider.
|
||||
func newFlow(t *testing.T, allowed Allowlist) (*stubIdP, http.Handler, *SessionStore, *UserStore) {
|
||||
t.Helper()
|
||||
sessions, users, _ := newStores(t)
|
||||
idp := newStubIdP(t, "petal")
|
||||
|
||||
o := NewOIDC(context.Background(), Options{
|
||||
IssuerURL: idp.URL,
|
||||
ClientID: "petal",
|
||||
ClientSecret: "shh",
|
||||
BaseURL: "http://petal.test",
|
||||
Allowed: allowed,
|
||||
}, sessions, users)
|
||||
return idp, o.Routes(), sessions, users
|
||||
}
|
||||
|
||||
// cookieJar collects Set-Cookie headers across the redirect chain, standing in
|
||||
// for the browser that would normally carry them.
|
||||
type cookieJar map[string]string
|
||||
|
||||
func (j cookieJar) absorb(rec *httptest.ResponseRecorder) {
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.MaxAge < 0 || c.Value == "" {
|
||||
delete(j, c.Name)
|
||||
continue
|
||||
}
|
||||
j[c.Name] = c.Value
|
||||
}
|
||||
}
|
||||
|
||||
func (j cookieJar) attach(r *http.Request) *http.Request {
|
||||
for name, value := range j {
|
||||
r.AddCookie(&http.Cookie{Name: name, Value: value})
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// start runs /auth/login and returns the redirect target plus the cookies it set.
|
||||
func start(t *testing.T, flow http.Handler) (*url.URL, cookieJar) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
flow.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
target, err := url.Parse(rec.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jar := cookieJar{}
|
||||
jar.absorb(rec)
|
||||
return target, jar
|
||||
}
|
||||
|
||||
func TestLoginRoundTrip(t *testing.T) {
|
||||
idp, flow, sessions, users := newFlow(t, nil)
|
||||
idp.sub, idp.email, idp.name = "sub-her", "her@example.com", "Her Name"
|
||||
|
||||
target, jar := start(t, flow)
|
||||
|
||||
// The redirect must carry everything the flow depends on later.
|
||||
q := target.Query()
|
||||
if q.Get("state") == "" || q.Get("nonce") == "" {
|
||||
t.Fatalf("login redirect missing state/nonce: %s", target)
|
||||
}
|
||||
if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
|
||||
t.Fatalf("login redirect missing PKCE challenge: %s", target)
|
||||
}
|
||||
if q.Get("redirect_uri") != "http://petal.test/auth/callback" {
|
||||
t.Fatalf("redirect_uri = %q", q.Get("redirect_uri"))
|
||||
}
|
||||
if jar[stateCookie] != q.Get("state") {
|
||||
t.Fatal("the state cookie does not match the state sent to the provider")
|
||||
}
|
||||
idp.nonce = jar[nonceCookie]
|
||||
|
||||
// Come back as the provider would.
|
||||
rec := httptest.NewRecorder()
|
||||
flow.ServeHTTP(rec, jar.attach(
|
||||
httptest.NewRequest(http.MethodGet, "/callback?code=abc&state="+url.QueryEscape(q.Get("state")), nil)))
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/" {
|
||||
t.Fatalf("callback status=%d location=%q body=%s", rec.Code, rec.Header().Get("Location"), rec.Body)
|
||||
}
|
||||
|
||||
// PKCE: the code verifier must reach the token endpoint.
|
||||
if v := idp.lastForm.Get("code_verifier"); v == "" {
|
||||
t.Fatal("token exchange sent no code_verifier")
|
||||
}
|
||||
|
||||
// The account was provisioned from the token's claims...
|
||||
user, err := users.Get("sub-her")
|
||||
if err != nil {
|
||||
t.Fatalf("user was not provisioned: %v", err)
|
||||
}
|
||||
if user.Email != "her@example.com" || user.DisplayName != "Her Name" {
|
||||
t.Fatalf("unexpected provisioned user %+v", user)
|
||||
}
|
||||
|
||||
// ...and the response carries a session that resolves to them.
|
||||
jar.absorb(rec)
|
||||
token := jar[SessionCookie]
|
||||
if token == "" {
|
||||
t.Fatal("callback issued no session cookie")
|
||||
}
|
||||
got, err := sessions.Resolve(withCookie(token))
|
||||
if err != nil || got != "sub-her" {
|
||||
t.Fatalf("session resolved to %q (err=%v), want sub-her", got, err)
|
||||
}
|
||||
|
||||
// The one-shot login cookies must not linger.
|
||||
for _, name := range []string{stateCookie, nonceCookie, pkceCookie} {
|
||||
if jar[name] != "" {
|
||||
t.Fatalf("%s survived the callback", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Signing out revokes server-side, not just in the browser.
|
||||
out := httptest.NewRecorder()
|
||||
flow.ServeHTTP(out, jar.attach(httptest.NewRequest(http.MethodGet, "/logout", nil)))
|
||||
if out.Code != http.StatusFound {
|
||||
t.Fatalf("logout status=%d", out.Code)
|
||||
}
|
||||
if _, err := sessions.Resolve(withCookie(token)); err == nil {
|
||||
t.Fatal("the session survived signing out")
|
||||
}
|
||||
}
|
||||
|
||||
// A callback whose state doesn't match the cookie is a forged one.
|
||||
func TestCallbackRejectsBadState(t *testing.T) {
|
||||
idp, flow, sessions, _ := newFlow(t, nil)
|
||||
idp.sub, idp.email = "sub-her", "her@example.com"
|
||||
|
||||
_, jar := start(t, flow)
|
||||
idp.nonce = jar[nonceCookie]
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
flow.ServeHTTP(rec, jar.attach(
|
||||
httptest.NewRequest(http.MethodGet, "/callback?code=abc&state=some-other-state", nil)))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d, want 400", rec.Code)
|
||||
}
|
||||
assertNoSession(t, sessions, rec)
|
||||
|
||||
// And so is one with no state cookie at all.
|
||||
bare := httptest.NewRecorder()
|
||||
flow.ServeHTTP(bare, httptest.NewRequest(http.MethodGet, "/callback?code=abc&state=x", nil))
|
||||
if bare.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d for a cookieless callback, want 400", bare.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// An ID token minted for a different login attempt must not be accepted, even
|
||||
// though it is perfectly valid and correctly signed.
|
||||
func TestCallbackRejectsReplayedNonce(t *testing.T) {
|
||||
idp, flow, sessions, _ := newFlow(t, nil)
|
||||
idp.sub, idp.email = "sub-her", "her@example.com"
|
||||
|
||||
_, jarA := start(t, flow)
|
||||
_, jarB := start(t, flow)
|
||||
idp.nonce = jarB[nonceCookie] // a token belonging to the *other* attempt
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
flow.ServeHTTP(rec, jarA.attach(
|
||||
httptest.NewRequest(http.MethodGet, "/callback?code=abc&state="+url.QueryEscape(jarA[stateCookie]), nil)))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d, want 400", rec.Code)
|
||||
}
|
||||
assertNoSession(t, sessions, rec)
|
||||
}
|
||||
|
||||
// Being a valid user at the identity provider is not the same as being a user
|
||||
// here, and the refusal has to read like Petal rather than like a stack trace.
|
||||
func TestCallbackHonoursAllowlist(t *testing.T) {
|
||||
idp, flow, sessions, users := newFlow(t, ParseAllowlist("her@example.com"))
|
||||
idp.sub, idp.email, idp.name = "sub-stranger", "stranger@example.com", "A Stranger"
|
||||
|
||||
_, jar := start(t, flow)
|
||||
idp.nonce = jar[nonceCookie]
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
flow.ServeHTTP(rec, jar.attach(
|
||||
httptest.NewRequest(http.MethodGet, "/callback?code=abc&state="+url.QueryEscape(jar[stateCookie]), nil)))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status=%d, want 403", rec.Code)
|
||||
}
|
||||
if body := rec.Body.String(); !strings.Contains(body, "这个 Petal 不是给你写的") ||
|
||||
!strings.Contains(body, "isn't yours to write in") {
|
||||
t.Fatalf("refusal page is not the warm bilingual one: %s", body)
|
||||
}
|
||||
assertNoSession(t, sessions, rec)
|
||||
if _, err := users.Get("sub-stranger"); err == nil {
|
||||
t.Fatal("a rejected login still provisioned an account")
|
||||
}
|
||||
|
||||
// The person on the list gets in through the same door.
|
||||
idp.sub, idp.email, idp.name = "sub-her", "her@example.com", "Her Name"
|
||||
_, jar2 := start(t, flow)
|
||||
idp.nonce = jar2[nonceCookie]
|
||||
ok := httptest.NewRecorder()
|
||||
flow.ServeHTTP(ok, jar2.attach(
|
||||
httptest.NewRequest(http.MethodGet, "/callback?code=abc&state="+url.QueryEscape(jar2[stateCookie]), nil)))
|
||||
if ok.Code != http.StatusFound {
|
||||
t.Fatalf("an allowed writer was turned away: status=%d body=%s", ok.Code, ok.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// The provider refusing the login (a cancelled consent, a locked account) is a
|
||||
// dead end, not a session.
|
||||
func TestCallbackHandlesProviderError(t *testing.T) {
|
||||
_, flow, sessions, _ := newFlow(t, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
flow.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/callback?error=access_denied", nil))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status=%d, want 403", rec.Code)
|
||||
}
|
||||
assertNoSession(t, sessions, rec)
|
||||
}
|
||||
|
||||
// Signing in when already signed in shouldn't bounce a good session through the
|
||||
// identity provider.
|
||||
func TestLoginSkipsWhenAlreadySignedIn(t *testing.T) {
|
||||
_, flow, sessions, _ := newFlow(t, nil)
|
||||
token, err := sessions.Create(db.LocalUserID, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
flow.ServeHTTP(rec, withCookie(token))
|
||||
// withCookie builds a GET "/" request; point it at the login route.
|
||||
rec = httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: token})
|
||||
flow.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/" {
|
||||
t.Fatalf("status=%d location=%q, want a redirect home", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoSession fails if a response handed out a usable session cookie.
|
||||
func assertNoSession(t *testing.T, sessions *SessionStore, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == SessionCookie && c.Value != "" {
|
||||
if _, err := sessions.Resolve(withCookie(c.Value)); err == nil {
|
||||
t.Fatal("a rejected login was given a working session")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionCookie is the cookie carrying the opaque session token.
|
||||
const SessionCookie = "petal_session"
|
||||
|
||||
const (
|
||||
// sessionTTL is how long a session lives without use. Thirty days, sliding:
|
||||
// every authenticated request pushes the expiry back out. An editor that
|
||||
// logs you out mid-draft is hostile, and Petal auto-saves every 1.5s, so a
|
||||
// surprise 401 costs real writing.
|
||||
sessionTTL = 30 * 24 * time.Hour
|
||||
|
||||
// sessionTTLModifier is the same span as a SQLite datetime() modifier. All
|
||||
// expiry math happens inside SQLite so stored values stay canonical UTC and
|
||||
// never depend on the server's local clock or on Go/SQLite parsing agreeing.
|
||||
sessionTTLModifier = "+30 days"
|
||||
|
||||
// sessionRenewAfter throttles the sliding extension: a session is only
|
||||
// pushed forward once its expiry has drifted this far from the maximum. It
|
||||
// turns "a write on every request" into "a write at most once an hour per
|
||||
// session" while leaving the sliding window indistinguishable to the user.
|
||||
sessionRenewAfter = "-1 hour"
|
||||
)
|
||||
|
||||
// ErrNoSession means the request carried no session cookie, or one that is
|
||||
// unknown or expired. It is not an internal failure: the caller is simply not
|
||||
// signed in.
|
||||
var ErrNoSession = errors.New("no valid session")
|
||||
|
||||
// SessionStore issues, validates and revokes login sessions, and is itself the
|
||||
// [Resolver] the API middleware runs on.
|
||||
//
|
||||
// The cookie holds a random token; the table stores only its SHA-256. A dump of
|
||||
// the database therefore hands an attacker no usable session — the same reason
|
||||
// passwords are never stored as given. Server-side rows (rather than a signed
|
||||
// stateless cookie) are what make logout and revocation actually revoke.
|
||||
type SessionStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSessionStore returns a store backed by the given database.
|
||||
func NewSessionStore(db *sql.DB) *SessionStore { return &SessionStore{db: db} }
|
||||
|
||||
// Create issues a new session for userID and returns the token to put in the
|
||||
// cookie. The token is never stored; only its hash is.
|
||||
func (s *SessionStore) Create(userID, userAgent string) (string, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(raw)
|
||||
|
||||
if len(userAgent) > 256 {
|
||||
userAgent = userAgent[:256]
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO sessions (id, user_id, expires_at, user_agent)
|
||||
VALUES (?, ?, datetime('now', ?), ?)`,
|
||||
hashToken(token), userID, sessionTTLModifier, userAgent,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Resolve implements [Resolver]: it reads the session cookie, validates it, and
|
||||
// returns the user it belongs to — extending the session's life while it does.
|
||||
func (s *SessionStore) Resolve(r *http.Request) (string, error) {
|
||||
c, err := r.Cookie(SessionCookie)
|
||||
if err != nil || c.Value == "" {
|
||||
return "", ErrNoSession
|
||||
}
|
||||
return s.userFor(c.Value)
|
||||
}
|
||||
|
||||
// userFor validates a raw token and slides its expiry forward.
|
||||
func (s *SessionStore) userFor(token string) (string, error) {
|
||||
id := hashToken(token)
|
||||
|
||||
var userID string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT user_id FROM sessions WHERE id = ? AND expires_at > datetime('now')`, id,
|
||||
).Scan(&userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrNoSession
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Slide the window. Throttled, and deliberately not fatal: a failed
|
||||
// extension shortens one session's life, which is no reason to reject a
|
||||
// request that is otherwise perfectly authenticated.
|
||||
_, _ = s.db.Exec(
|
||||
`UPDATE sessions SET expires_at = datetime('now', ?)
|
||||
WHERE id = ? AND expires_at < datetime('now', ?, ?)`,
|
||||
sessionTTLModifier, id, sessionTTLModifier, sessionRenewAfter,
|
||||
)
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// Revoke deletes the session behind a token. Unknown tokens are not an error —
|
||||
// signing out of a session that is already gone is a success, not a failure.
|
||||
func (s *SessionStore) Revoke(token string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM sessions WHERE id = ?`, hashToken(token))
|
||||
return err
|
||||
}
|
||||
|
||||
// RevokeAll deletes every session for a user, signing them out everywhere.
|
||||
func (s *SessionStore) RevokeAll(userID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM sessions WHERE user_id = ?`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Prune removes expired rows and returns how many it deleted. Nothing depends
|
||||
// on it for correctness — expired sessions are already rejected on lookup — it
|
||||
// just keeps the table from accumulating dead rows forever.
|
||||
func (s *SessionStore) Prune() (int64, error) {
|
||||
res, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= datetime('now')`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// hashToken maps a raw session token to the id stored in the table.
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// SetSessionCookie writes the session cookie. Secure is set only when Petal is
|
||||
// served over https — flagging it on a plain-http dev server would make the
|
||||
// browser drop the cookie and silently break local login.
|
||||
func SetSessionCookie(w http.ResponseWriter, token string, secure bool) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: SessionCookie,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(sessionTTL / time.Second),
|
||||
})
|
||||
}
|
||||
|
||||
// ClearSessionCookie expires the session cookie in the browser. The matching
|
||||
// server-side row must be revoked separately — that's the half that counts.
|
||||
func ClearSessionCookie(w http.ResponseWriter, secure bool) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: SessionCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
// newStores opens a database holding two users and returns the session and user
|
||||
// stores over it.
|
||||
func newStores(t *testing.T) (*SessionStore, *UserStore, *db.DB) {
|
||||
t.Helper()
|
||||
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
|
||||
if _, err := database.Exec(
|
||||
`INSERT INTO users (id, email, display_name) VALUES (?, ?, ?)`,
|
||||
"bob", "bob@petal.local", "Bob",
|
||||
); err != nil {
|
||||
t.Fatalf("seed second user: %v", err)
|
||||
}
|
||||
return NewSessionStore(database.DB), NewUserStore(database.DB), database
|
||||
}
|
||||
|
||||
// withCookie builds a request carrying a session token.
|
||||
func withCookie(token string) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.AddCookie(&http.Cookie{Name: SessionCookie, Value: token})
|
||||
return r
|
||||
}
|
||||
|
||||
func TestSessionLifecycle(t *testing.T) {
|
||||
sessions, _, _ := newStores(t)
|
||||
|
||||
token, err := sessions.Create(db.LocalUserID, "test-agent")
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
got, err := sessions.Resolve(withCookie(token))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if got != db.LocalUserID {
|
||||
t.Fatalf("resolved %q, want %q", got, db.LocalUserID)
|
||||
}
|
||||
|
||||
// Signing out must invalidate the token server-side, not just in the browser:
|
||||
// clearing only the cookie leaves a token that still works if it ever leaked.
|
||||
if err := sessions.Revoke(token); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
if _, err := sessions.Resolve(withCookie(token)); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("revoked token still resolves (err=%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A request with no cookie, or a token nobody issued, is simply not signed in.
|
||||
func TestSessionRejectsUnknown(t *testing.T) {
|
||||
sessions, _, _ := newStores(t)
|
||||
|
||||
if _, err := sessions.Resolve(httptest.NewRequest(http.MethodGet, "/", nil)); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("bare request err=%v, want ErrNoSession", err)
|
||||
}
|
||||
if _, err := sessions.Resolve(withCookie("not-a-real-token")); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("forged token err=%v, want ErrNoSession", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The table stores a hash, so a database dump yields no usable session.
|
||||
func TestSessionTokenIsNotStored(t *testing.T) {
|
||||
sessions, _, database := newStores(t)
|
||||
|
||||
token, err := sessions.Create(db.LocalUserID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
var stored string
|
||||
if err := database.QueryRow(`SELECT id FROM sessions`).Scan(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored == token || strings.Contains(stored, token) {
|
||||
t.Fatal("the raw session token is stored in the database")
|
||||
}
|
||||
if stored != hashToken(token) {
|
||||
t.Fatal("stored id is not the token's hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionExpiry(t *testing.T) {
|
||||
sessions, _, database := newStores(t)
|
||||
|
||||
token, err := sessions.Create(db.LocalUserID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(
|
||||
`UPDATE sessions SET expires_at = datetime('now','-1 minute')`,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := sessions.Resolve(withCookie(token)); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("expired session still resolves (err=%v)", err)
|
||||
}
|
||||
|
||||
n, err := sessions.Prune()
|
||||
if err != nil {
|
||||
t.Fatalf("prune: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("pruned %d rows, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The window slides: using a session pushes its expiry back out, so someone who
|
||||
// writes in Petal every few days is never signed out mid-draft.
|
||||
func TestSessionSlidesForward(t *testing.T) {
|
||||
sessions, _, database := newStores(t)
|
||||
|
||||
token, err := sessions.Create(db.LocalUserID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
// Pretend the session has been idle for a fortnight.
|
||||
if _, err := database.Exec(
|
||||
`UPDATE sessions SET expires_at = datetime('now','+16 days')`,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := sessions.Resolve(withCookie(token)); err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
|
||||
var extended bool
|
||||
if err := database.QueryRow(
|
||||
`SELECT expires_at > datetime('now','+29 days') FROM sessions`,
|
||||
).Scan(&extended); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !extended {
|
||||
t.Fatal("using a session did not extend it")
|
||||
}
|
||||
}
|
||||
|
||||
// Two live sessions must each resolve to their own writer — the whole point.
|
||||
func TestSessionsAreNotInterchangeable(t *testing.T) {
|
||||
sessions, _, _ := newStores(t)
|
||||
|
||||
aliceToken, err := sessions.Create(db.LocalUserID, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bobToken, err := sessions.Create("bob", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for token, want := range map[string]string{aliceToken: db.LocalUserID, bobToken: "bob"} {
|
||||
got, err := sessions.Resolve(withCookie(token))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("token resolved to %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Revoking one session leaves the other alone.
|
||||
if err := sessions.Revoke(aliceToken); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := sessions.Resolve(withCookie(bobToken)); err != nil {
|
||||
t.Fatalf("bob was signed out by alice's logout: %v", err)
|
||||
}
|
||||
|
||||
// RevokeAll signs one writer out everywhere and nobody else.
|
||||
second, _ := sessions.Create("bob", "phone")
|
||||
if err := sessions.RevokeAll("bob"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, token := range []string{bobToken, second} {
|
||||
if _, err := sessions.Resolve(withCookie(token)); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("RevokeAll left a session alive (err=%v)", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The session store is itself the Resolver the API middleware runs on, so a
|
||||
// valid cookie must carry all the way through to the handler.
|
||||
func TestSessionStoreDrivesMiddleware(t *testing.T) {
|
||||
sessions, _, _ := newStores(t)
|
||||
token, err := sessions.Create("bob", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var seen string
|
||||
h := Middleware(sessions)(http.HandlerFunc(
|
||||
func(_ http.ResponseWriter, r *http.Request) { seen = UserID(r.Context()) },
|
||||
))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, withCookie(token))
|
||||
if rec.Code != http.StatusOK || seen != "bob" {
|
||||
t.Fatalf("status=%d user=%q, want 200/bob", rec.Code, seen)
|
||||
}
|
||||
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if rec2.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d for a cookieless request, want 401", rec2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserUpsert(t *testing.T) {
|
||||
_, users, database := newStores(t)
|
||||
|
||||
if err := users.Upsert("sub-123", "her@example.com", "Her Name"); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
user, err := users.Get("sub-123")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if user.Email != "her@example.com" || user.DisplayName != "Her Name" {
|
||||
t.Fatalf("unexpected user %+v", user)
|
||||
}
|
||||
if user.PairLang != "zh" {
|
||||
t.Fatalf("pair_lang = %q, want the zh default", user.PairLang)
|
||||
}
|
||||
|
||||
// A rename upstream is reflected here; Petal's own settings are not touched.
|
||||
if _, err := database.Exec(`UPDATE users SET pair_lang = 'pt-PT' WHERE id = 'sub-123'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := users.Upsert("sub-123", "new@example.com", "New Name"); err != nil {
|
||||
t.Fatalf("second upsert: %v", err)
|
||||
}
|
||||
user, _ = users.Get("sub-123")
|
||||
if user.Email != "new@example.com" || user.DisplayName != "New Name" {
|
||||
t.Fatalf("login did not refresh the profile: %+v", user)
|
||||
}
|
||||
if user.PairLang != "pt-PT" {
|
||||
t.Fatalf("login reset pair_lang to %q", user.PairLang)
|
||||
}
|
||||
|
||||
// Falling back to the email keeps the sidebar from showing an empty name.
|
||||
if err := users.Upsert("sub-456", "them@example.com", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u, _ := users.Get("sub-456"); u.DisplayName != "them@example.com" {
|
||||
t.Fatalf("display name = %q, want the email fallback", u.DisplayName)
|
||||
}
|
||||
|
||||
if err := users.Upsert("", "nobody@example.com", "Nobody"); err == nil {
|
||||
t.Fatal("a login with no subject was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// Sessions belong to their account: deleting a user takes their logins with it.
|
||||
func TestSessionsCascadeWithUser(t *testing.T) {
|
||||
sessions, _, database := newStores(t)
|
||||
token, err := sessions.Create("bob", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.Exec(`DELETE FROM users WHERE id = 'bob'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := sessions.Resolve(withCookie(token)); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("session outlived its account (err=%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowlist(t *testing.T) {
|
||||
// No list configured = anyone the IdP authenticates, which is the right
|
||||
// default for a household instance.
|
||||
if !ParseAllowlist("").Permits("anyone", "anyone@example.com") {
|
||||
t.Fatal("an empty allowlist turned someone away")
|
||||
}
|
||||
if !ParseAllowlist(" ").Permits("anyone", "anyone@example.com") {
|
||||
t.Fatal("a whitespace-only allowlist turned someone away")
|
||||
}
|
||||
|
||||
list := ParseAllowlist(" sub-123 , Her@Example.com ,, ")
|
||||
cases := []struct {
|
||||
sub, email string
|
||||
want bool
|
||||
}{
|
||||
{"sub-123", "someone@example.com", true}, // by subject
|
||||
{"sub-999", "her@example.com", true}, // by email
|
||||
{"sub-999", "HER@EXAMPLE.COM", true}, // case-insensitively
|
||||
{"sub-999", "stranger@example.com", false}, // neither
|
||||
{"", "", false}, // no claims at all
|
||||
{"sub-1", "", false}, // a near-miss subject
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := list.Permits(c.sub, c.email); got != c.want {
|
||||
t.Fatalf("Permits(%q, %q) = %v, want %v", c.sub, c.email, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||
)
|
||||
|
||||
// UserStore provisions and reads accounts. Petal has no signup flow: a row
|
||||
// appears the first time someone Authentik vouches for signs in, and that is
|
||||
// the only way one is ever created.
|
||||
type UserStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewUserStore returns a store backed by the given database.
|
||||
func NewUserStore(sqlDB *sql.DB) *UserStore { return &UserStore{db: sqlDB} }
|
||||
|
||||
// Upsert records the account behind an OIDC login, keyed by the issuer's
|
||||
// subject id.
|
||||
//
|
||||
// The subject is the id — not the email, which people change and which
|
||||
// Authentik does not promise is stable. Email and display name are refreshed on
|
||||
// every login so a rename upstream shows up here; pair_lang is deliberately not
|
||||
// touched, because it is Petal's own setting rather than the IdP's.
|
||||
func (u *UserStore) Upsert(sub, email, displayName string) error {
|
||||
if sub == "" {
|
||||
return errors.New("oidc: empty subject")
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = email
|
||||
}
|
||||
_, err := u.db.Exec(
|
||||
`INSERT INTO users (id, email, display_name) VALUES (?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
email = excluded.email,
|
||||
display_name = excluded.display_name`,
|
||||
sub, email, displayName,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// Get loads one account.
|
||||
func (u *UserStore) Get(id string) (db.User, error) {
|
||||
var user db.User
|
||||
err := u.db.QueryRow(
|
||||
`SELECT id, email, COALESCE(display_name, ''), created_at, pair_lang
|
||||
FROM users WHERE id = ?`, id,
|
||||
).Scan(&user.ID, &user.Email, &user.DisplayName, &user.CreatedAt, &user.PairLang)
|
||||
return user, err
|
||||
}
|
||||
|
||||
// MeHandler reports who the caller is. The frontend uses it to namespace
|
||||
// per-account browser state and to show the signed-in writer; it sits behind
|
||||
// the auth middleware, so reaching it at all already proves a valid session.
|
||||
func (u *UserStore) MeHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := u.Get(UserID(r.Context()))
|
||||
if err != nil {
|
||||
httputil.ErrorJSON(w, http.StatusUnauthorized, "not signed in")
|
||||
return
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, user)
|
||||
}
|
||||
}
|
||||
|
||||
// Allowlist decides which of Authentik's users may write in this Petal.
|
||||
// Authentik fronts several applications; being a valid user there does not mean
|
||||
// being a user here.
|
||||
//
|
||||
// An entry matches a subject id or an email address, case-insensitively. Both
|
||||
// are accepted on purpose: a subject is an opaque uuid nobody can know before
|
||||
// that person's first login, so a subject-only list means the operator must let
|
||||
// someone in, read a log line, and edit config — whereas an email is knowable in
|
||||
// advance. An empty list allows everyone the IdP authenticates, which is the
|
||||
// right default for a single-household instance.
|
||||
type Allowlist map[string]bool
|
||||
|
||||
// ParseAllowlist builds an Allowlist from a comma-separated env value.
|
||||
func ParseAllowlist(raw string) Allowlist {
|
||||
list := Allowlist{}
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
if p := strings.ToLower(strings.TrimSpace(part)); p != "" {
|
||||
list[p] = true
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// Permits reports whether this login may proceed.
|
||||
func (a Allowlist) Permits(sub, email string) bool {
|
||||
if len(a) == 0 {
|
||||
return true
|
||||
}
|
||||
return a[strings.ToLower(sub)] || (email != "" && a[strings.ToLower(email)])
|
||||
}
|
||||
@@ -36,11 +36,24 @@ type Config struct {
|
||||
TTSTimeout time.Duration
|
||||
TTSFormat string // mp3 | opus | wav — mp3/opus transcode Piper's WAV via ffmpeg
|
||||
|
||||
// Auth (deferred — not wired in the local-dev build, kept for later)
|
||||
AuthentikURL string
|
||||
// Auth. OIDC against Authentik. Login is enabled only when the issuer, the
|
||||
// client id and the secret are all present; with any of them missing Petal
|
||||
// falls back to the single hardcoded local user, which is what local
|
||||
// development wants and what every deployment did before Phase 16.
|
||||
AuthentikURL string // issuer URL of the Petal provider in Authentik
|
||||
AuthentikClientID string
|
||||
AuthentikClientSecret string
|
||||
SessionSecret string
|
||||
// AllowedSubs gates who may sign in, as a comma-separated list of OIDC
|
||||
// subject ids and/or email addresses. Empty means everyone Authentik
|
||||
// authenticates — right for a single-household instance, wrong the moment
|
||||
// the IdP serves an audience wider than Petal's.
|
||||
AllowedSubs string
|
||||
}
|
||||
|
||||
// AuthEnabled reports whether real logins are configured. When false, Petal
|
||||
// resolves every request to the local user.
|
||||
func (c *Config) AuthEnabled() bool {
|
||||
return c.AuthentikURL != "" && c.AuthentikClientID != "" && c.AuthentikClientSecret != ""
|
||||
}
|
||||
|
||||
// Load reads configuration from the environment, applying sane local-dev defaults.
|
||||
@@ -69,7 +82,7 @@ func Load() *Config {
|
||||
AuthentikURL: env("AUTHENTIK_URL", ""),
|
||||
AuthentikClientID: env("AUTHENTIK_CLIENT_ID", ""),
|
||||
AuthentikClientSecret: env("AUTHENTIK_CLIENT_SECRET", ""),
|
||||
SessionSecret: env("SESSION_SECRET", "dev-insecure-secret-change-me"),
|
||||
AllowedSubs: env("PETAL_ALLOWED_SUBS", ""),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -390,6 +390,52 @@ CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
|
||||
ALTER TABLE documents ADD COLUMN preserve_history INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE document_versions ADD COLUMN content_hash TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE document_versions ADD COLUMN prev_hash TEXT NOT NULL DEFAULT '';
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Real accounts. Three separate things land together because they are
|
||||
// one change: Petal can now tell users apart.
|
||||
//
|
||||
// `sessions` backs server-side login state. The cookie carries an opaque
|
||||
// random token and this table stores only its SHA-256 — a leaked database
|
||||
// copy therefore yields no usable session, the same reason passwords are
|
||||
// hashed. Server-side rows (rather than a signed stateless cookie) are
|
||||
// what make logout and revocation actually revoke.
|
||||
//
|
||||
// `images` gives the content-addressed image store an owner. Until now it
|
||||
// was a flat directory with no database row at all: any caller holding a
|
||||
// hash could fetch anyone's image, which is capability-URL security, not
|
||||
// access control. The primary key is (name, user_id), so the same picture
|
||||
// uploaded by two people is still stored once on disk and simply has two
|
||||
// rows — deduplication survives; the file is deleted only with its last
|
||||
// row. Rows for images already on disk are backfilled at startup by the
|
||||
// images package, which is the only code that knows the storage path.
|
||||
//
|
||||
// `users.pair_lang` is the writer's language pair (English + X). It is
|
||||
// unused until the langpack work, but it belongs to provisioning and
|
||||
// costs nothing to add while the users table is already being touched.
|
||||
name: "0010_sessions_images_and_pair_lang",
|
||||
stmt: `
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
user_agent TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
|
||||
|
||||
CREATE TABLE images (
|
||||
name TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
content_type TEXT NOT NULL DEFAULT '',
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (name, user_id)
|
||||
);
|
||||
CREATE INDEX idx_images_user_id ON images(user_id);
|
||||
|
||||
ALTER TABLE users ADD COLUMN pair_lang TEXT NOT NULL DEFAULT 'zh';
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,14 +2,19 @@ package db
|
||||
|
||||
import "time"
|
||||
|
||||
// User is an account. With auth deferred, the app runs as a single hardcoded
|
||||
// `local` user (see LocalUserID); the user_id columns and this type exist so
|
||||
// real auth can drop in later without a schema migration.
|
||||
// User is an account. Its ID is the OIDC subject for anyone who signed in, or
|
||||
// LocalUserID for the pre-auth single user (and for local development, where
|
||||
// StaticResolver still hands out that id).
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// PairLang is the X in this writer's (English + X) language pair — "zh"
|
||||
// today, "pt-PT"/"fr"/"es" once the langpacks land. It selects the UI copy
|
||||
// and dictionary set, not the language they may type in.
|
||||
PairLang string `json:"pair_lang"`
|
||||
}
|
||||
|
||||
// Document is a single piece of writing. `Content` is the Tiptap JSON document
|
||||
|
||||
+159
-13
@@ -2,20 +2,35 @@
|
||||
// images from the editor, they're saved to disk under the configured directory,
|
||||
// and served back by hashed filename. Content addressing means the same image
|
||||
// pasted twice is stored once, and URLs are stable and cacheable forever.
|
||||
//
|
||||
// Each stored file also has one row per owner in the `images` table, and a fetch
|
||||
// joins on the caller. Before that, the store was a flat directory with no
|
||||
// database presence at all: any authenticated user holding a sha256 could fetch
|
||||
// anyone else's image. Hashes aren't guessable, so it was never an emergency —
|
||||
// but "unguessable filename" is not access control, and images pasted into a
|
||||
// private journal are exactly the content that shouldn't depend on it.
|
||||
//
|
||||
// One row per owner (rather than one owner per file) is what keeps deduplication:
|
||||
// the same picture uploaded by two people is stored once and simply has two rows.
|
||||
// The file is removed only with its last row.
|
||||
package images
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/auth"
|
||||
)
|
||||
|
||||
// maxUploadBytes caps a single image at 10 MiB — generous for a writing tool,
|
||||
@@ -32,25 +47,81 @@ var extByContentType = map[string]string{
|
||||
"image/svg+xml": ".svg",
|
||||
}
|
||||
|
||||
// Handler serves the upload + fetch endpoints, backed by a directory on disk.
|
||||
// Handler serves the upload + fetch endpoints, backed by a directory on disk and
|
||||
// an ownership table.
|
||||
type Handler struct {
|
||||
dir string
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// New constructs a Handler, ensuring the storage directory exists.
|
||||
func New(dir string) (*Handler, error) {
|
||||
// New constructs a Handler, ensuring the storage directory exists and that every
|
||||
// file already in it has an owner.
|
||||
func New(dir string, database *sql.DB, backfillOwner string) (*Handler, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Handler{dir: dir}, nil
|
||||
h := &Handler{dir: dir, db: database}
|
||||
if err := h.backfill(backfillOwner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// backfill claims pre-existing files for one user. Images uploaded before
|
||||
// ownership existed have no row, and a row is now what makes them fetchable —
|
||||
// so without this every picture already pasted into a document would 404.
|
||||
// Attributing them to the account that has been the only one until now is the
|
||||
// only answer the data supports. Idempotent: files that already have an owner
|
||||
// are left alone.
|
||||
func (h *Handler) backfill(owner string) error {
|
||||
if owner == "" {
|
||||
return nil
|
||||
}
|
||||
entries, err := os.ReadDir(h.dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
claimed := 0
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
var exists bool
|
||||
if err := h.db.QueryRow(
|
||||
`SELECT EXISTS(SELECT 1 FROM images WHERE name = ?)`, e.Name(),
|
||||
).Scan(&exists); err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
var size int64
|
||||
if info, err := e.Info(); err == nil {
|
||||
size = info.Size()
|
||||
}
|
||||
if _, err := h.db.Exec(
|
||||
`INSERT INTO images (name, user_id, content_type, size) VALUES (?, ?, '', ?)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
e.Name(), owner, size,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
claimed++
|
||||
}
|
||||
if claimed > 0 {
|
||||
log.Printf("images: claimed %d pre-existing image(s) for %s", claimed, owner)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Routes mounts the image endpoints. Mount under "/images" so the full paths are
|
||||
// POST /api/images (upload) and GET /api/images/{name} (fetch).
|
||||
// POST /api/images (upload), GET /api/images/{name} (fetch) and
|
||||
// DELETE /api/images/{name} (drop your copy).
|
||||
func (h *Handler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Post("/", h.upload)
|
||||
r.Get("/{name}", h.serve)
|
||||
r.Delete("/{name}", h.remove)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -79,7 +150,7 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request) {
|
||||
ext, ok := extByContentType[ct]
|
||||
if !ok {
|
||||
if looksLikeSVG(data) {
|
||||
ext, ok = ".svg", true
|
||||
ct, ext, ok = "image/svg+xml", ".svg", true
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
@@ -99,28 +170,103 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Record the caller as an owner. Re-uploading your own image is a no-op;
|
||||
// uploading someone else's identical image adds a second row over one file.
|
||||
if _, err := h.db.Exec(
|
||||
`INSERT INTO images (name, user_id, content_type, size) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (name, user_id) DO NOTHING`,
|
||||
name, auth.UserID(r.Context()), ct, len(data),
|
||||
); err != nil {
|
||||
log.Printf("images: could not record ownership of %s: %v", name, err)
|
||||
http.Error(w, "could not store image", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"url": "/api/images/" + name})
|
||||
}
|
||||
|
||||
// serve returns a stored image by its hashed filename. The filename is validated
|
||||
// to be a bare name (no path separators) so it can't escape the storage dir, and
|
||||
// served with a long-lived cache header since content-addressed URLs never change.
|
||||
// serve returns a stored image by its hashed filename, but only to someone who
|
||||
// owns it. The filename is validated to be a bare name (no path separators) so
|
||||
// it can't escape the storage dir, and served with a long-lived cache header
|
||||
// since content-addressed URLs never change.
|
||||
//
|
||||
// Someone else's image is a 404, not a 403: whether a hash exists is itself
|
||||
// information the caller has no business learning.
|
||||
func (h *Handler) serve(w http.ResponseWriter, r *http.Request) {
|
||||
name := chi.URLParam(r, "name")
|
||||
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, `/\`) {
|
||||
name, ok := safeName(chi.URLParam(r, "name"))
|
||||
if !ok || !h.owns(name, auth.UserID(r.Context())) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
path := filepath.Join(h.dir, filepath.Base(name))
|
||||
path := filepath.Join(h.dir, name)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
// Private: a shared cache must never hand one writer's image to another.
|
||||
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
// remove drops the caller's claim on an image, and deletes the file itself once
|
||||
// nobody is left holding it.
|
||||
func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
|
||||
name, ok := safeName(chi.URLParam(r, "name"))
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
res, err := h.db.Exec(`DELETE FROM images WHERE name = ? AND user_id = ?`,
|
||||
name, auth.UserID(r.Context()))
|
||||
if err != nil {
|
||||
http.Error(w, "could not delete image", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var others bool
|
||||
if err := h.db.QueryRow(
|
||||
`SELECT EXISTS(SELECT 1 FROM images WHERE name = ?)`, name,
|
||||
).Scan(&others); err != nil {
|
||||
// The row is gone either way; leaving an orphaned file behind is a
|
||||
// wasted block, not a correctness problem.
|
||||
log.Printf("images: could not check remaining owners of %s: %v", name, err)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if !others {
|
||||
if err := os.Remove(filepath.Join(h.dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
log.Printf("images: could not remove %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// owns reports whether userID has a claim on a stored image.
|
||||
func (h *Handler) owns(name, userID string) bool {
|
||||
var ok bool
|
||||
if err := h.db.QueryRow(
|
||||
`SELECT EXISTS(SELECT 1 FROM images WHERE name = ? AND user_id = ?)`, name, userID,
|
||||
).Scan(&ok); err != nil {
|
||||
log.Printf("images: ownership check failed for %s: %v", name, err)
|
||||
return false
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// safeName rejects anything that isn't a bare filename, so a request can't walk
|
||||
// out of the storage directory.
|
||||
func safeName(name string) (string, bool) {
|
||||
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, `/\`) {
|
||||
return "", false
|
||||
}
|
||||
return name, true
|
||||
}
|
||||
|
||||
// looksLikeSVG does a cheap check for an <svg root tag near the start of the
|
||||
// file, since DetectContentType doesn't recognize SVG.
|
||||
func looksLikeSVG(data []byte) bool {
|
||||
|
||||
+169
-31
@@ -6,8 +6,13 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/auth"
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
// a 1x1 transparent PNG.
|
||||
@@ -19,6 +24,39 @@ var pngBytes = []byte{
|
||||
0x42, 0x60, 0x82,
|
||||
}
|
||||
|
||||
// another 1x1 PNG, differing in one pixel byte, so it hashes elsewhere.
|
||||
var otherPNG = append(append([]byte{}, pngBytes[:len(pngBytes)-8]...),
|
||||
0x01, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44)
|
||||
|
||||
// newStore returns a handler over a fresh directory and database, plus a router
|
||||
// per user: identical but for who the auth middleware says is calling. Two users
|
||||
// over one store is the situation that ownership exists to handle.
|
||||
func newStore(t *testing.T) (dir string, alice, bob http.Handler) {
|
||||
t.Helper()
|
||||
dir = t.TempDir()
|
||||
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
|
||||
if _, err := database.Exec(
|
||||
`INSERT INTO users (id, email, display_name) VALUES (?, ?, ?)`,
|
||||
"bob", "bob@petal.local", "Bob",
|
||||
); err != nil {
|
||||
t.Fatalf("seed second user: %v", err)
|
||||
}
|
||||
|
||||
h, err := New(dir, database.DB, db.LocalUserID)
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
mount := func(userID string) http.Handler {
|
||||
return auth.Middleware(auth.StaticResolver(userID))(h.Routes())
|
||||
}
|
||||
return dir, mount(db.LocalUserID), mount("bob")
|
||||
}
|
||||
|
||||
func uploadReq(t *testing.T, field string, data []byte) *http.Request {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
@@ -34,16 +72,11 @@ func uploadReq(t *testing.T, field string, data []byte) *http.Request {
|
||||
return req
|
||||
}
|
||||
|
||||
func TestUploadAndServe(t *testing.T) {
|
||||
h, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := h.Routes()
|
||||
|
||||
// Upload a PNG → expect a JSON url under /api/images/.
|
||||
// upload posts an image and returns its stored name.
|
||||
func upload(t *testing.T, h http.Handler, data []byte) string {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, uploadReq(t, "image", pngBytes))
|
||||
h.ServeHTTP(rec, uploadReq(t, "image", data))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("upload code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
@@ -54,44 +87,149 @@ func TestUploadAndServe(t *testing.T) {
|
||||
if !strings.HasPrefix(resp.URL, "/api/images/") || !strings.HasSuffix(resp.URL, ".png") {
|
||||
t.Fatalf("unexpected url %q", resp.URL)
|
||||
}
|
||||
return strings.TrimPrefix(resp.URL, "/api/images/")
|
||||
}
|
||||
|
||||
func get(t *testing.T, h http.Handler, name string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/"+name, nil))
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestUploadAndServe(t *testing.T) {
|
||||
_, alice, _ := newStore(t)
|
||||
|
||||
name := upload(t, alice, pngBytes)
|
||||
|
||||
// The same content uploaded again dedupes to the same URL.
|
||||
rec2 := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec2, uploadReq(t, "image", pngBytes))
|
||||
var resp2 struct{ URL string }
|
||||
json.Unmarshal(rec2.Body.Bytes(), &resp2)
|
||||
if resp2.URL != resp.URL {
|
||||
t.Fatalf("expected dedup to same url, got %q vs %q", resp2.URL, resp.URL)
|
||||
if again := upload(t, alice, pngBytes); again != name {
|
||||
t.Fatalf("expected dedup to same name, got %q vs %q", again, name)
|
||||
}
|
||||
|
||||
// Fetch it back.
|
||||
name := strings.TrimPrefix(resp.URL, "/api/images/")
|
||||
rec3 := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec3, httptest.NewRequest(http.MethodGet, "/"+name, nil))
|
||||
if rec3.Code != http.StatusOK {
|
||||
t.Fatalf("serve code=%d", rec3.Code)
|
||||
rec := get(t, alice, name)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("serve code=%d", rec.Code)
|
||||
}
|
||||
if !bytes.Equal(rec3.Body.Bytes(), pngBytes) {
|
||||
if !bytes.Equal(rec.Body.Bytes(), pngBytes) {
|
||||
t.Fatal("served bytes differ from uploaded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadRejectsNonImage(t *testing.T) {
|
||||
h, _ := New(t.TempDir())
|
||||
r := h.Routes()
|
||||
// The point of the ownership table: a hash is not a capability.
|
||||
func TestImageIsolation(t *testing.T) {
|
||||
_, alice, bob := newStore(t)
|
||||
name := upload(t, alice, pngBytes)
|
||||
|
||||
if rec := get(t, bob, name); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("bob fetched alice's image: code=%d", rec.Code)
|
||||
}
|
||||
|
||||
// Nor can he delete it out from under her.
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, uploadReq(t, "image", []byte("this is plainly not an image at all")))
|
||||
bob.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/"+name, nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("bob deleted alice's image: code=%d", rec.Code)
|
||||
}
|
||||
if got := get(t, alice, name); got.Code != http.StatusOK {
|
||||
t.Fatalf("alice's image disappeared: code=%d", got.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplication has to survive ownership: one file, one row each.
|
||||
func TestDedupAcrossUsers(t *testing.T) {
|
||||
dir, alice, bob := newStore(t)
|
||||
|
||||
name := upload(t, alice, pngBytes)
|
||||
if bobName := upload(t, bob, pngBytes); bobName != name {
|
||||
t.Fatalf("expected the same stored name, got %q vs %q", bobName, name)
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(dir)
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 file on disk, found %d", len(entries))
|
||||
}
|
||||
for _, h := range []http.Handler{alice, bob} {
|
||||
if rec := get(t, h, name); rec.Code != http.StatusOK {
|
||||
t.Fatalf("owner could not fetch shared image: code=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Alice dropping her copy must not take Bob's picture away with it.
|
||||
rec := httptest.NewRecorder()
|
||||
alice.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/"+name, nil))
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete code=%d", rec.Code)
|
||||
}
|
||||
if got := get(t, alice, name); got.Code != http.StatusNotFound {
|
||||
t.Fatalf("alice still sees a deleted image: code=%d", got.Code)
|
||||
}
|
||||
if got := get(t, bob, name); got.Code != http.StatusOK {
|
||||
t.Fatalf("bob lost his image when alice deleted hers: code=%d", got.Code)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
|
||||
t.Fatalf("file removed while still owned: %v", err)
|
||||
}
|
||||
|
||||
// The last owner leaving takes the file with them.
|
||||
rec2 := httptest.NewRecorder()
|
||||
bob.ServeHTTP(rec2, httptest.NewRequest(http.MethodDelete, "/"+name, nil))
|
||||
if rec2.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete code=%d", rec2.Code)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(err) {
|
||||
t.Fatalf("file survived its last owner: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Images that predate ownership must not vanish from documents that use them.
|
||||
func TestBackfillClaimsExistingFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
orphan := "deadbeefdeadbeefdeadbeefdeadbeef.png"
|
||||
if err := os.WriteFile(filepath.Join(dir, orphan), pngBytes, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
h, err := New(dir, database.DB, db.LocalUserID)
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
alice := auth.Middleware(auth.StaticResolver(db.LocalUserID))(h.Routes())
|
||||
if rec := get(t, alice, orphan); rec.Code != http.StatusOK {
|
||||
t.Fatalf("pre-existing image not claimed: code=%d", rec.Code)
|
||||
}
|
||||
|
||||
// Re-running the backfill (i.e. a restart) must not double up or reassign.
|
||||
if _, err := New(dir, database.DB, "bob"); err != nil {
|
||||
t.Fatalf("second backfill: %v", err)
|
||||
}
|
||||
var owners int
|
||||
if err := database.QueryRow(`SELECT COUNT(*) FROM images WHERE name = ?`, orphan).Scan(&owners); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if owners != 1 {
|
||||
t.Fatalf("expected the backfill to be idempotent, got %d owners", owners)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadRejectsNonImage(t *testing.T) {
|
||||
_, alice, _ := newStore(t)
|
||||
rec := httptest.NewRecorder()
|
||||
alice.ServeHTTP(rec, uploadReq(t, "image", []byte("this is plainly not an image at all")))
|
||||
if rec.Code != http.StatusUnsupportedMediaType {
|
||||
t.Fatalf("expected 415, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeMissing(t *testing.T) {
|
||||
h, _ := New(t.TempDir())
|
||||
r := h.Routes()
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/deadbeef.png", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
_, alice, _ := newStore(t)
|
||||
if rec := get(t, alice, "deadbeef.png"); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -2,7 +2,11 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='14' font-size='14'>🌸</text></svg>" />
|
||||
<!-- A drawn sakura rather than the 🌸 emoji: the emoji renders as whatever
|
||||
each platform's font decides, which on some is not pink and on others
|
||||
is not a blossom. This one is Petal's own rose palette everywhere, and
|
||||
it doubles as the app tile in Authentik. -->
|
||||
<link rel="icon" type="image/svg+xml" href="/petal.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Petal</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Petal">
|
||||
<title>Petal</title>
|
||||
<defs>
|
||||
<!-- Each petal is lighter at the tip and deepens toward the middle, the way
|
||||
a real sakura petal does. Petal's own rose tokens: #E8A0BF / #D98AAF. -->
|
||||
<radialGradient id="petal-fill" cx="50%" cy="88%" r="82%">
|
||||
<stop offset="0%" stop-color="#D98AAF" />
|
||||
<stop offset="45%" stop-color="#E8A0BF" />
|
||||
<stop offset="100%" stop-color="#FBDDE7" />
|
||||
</radialGradient>
|
||||
<radialGradient id="petal-heart" cx="50%" cy="42%" r="65%">
|
||||
<stop offset="0%" stop-color="#FFF3D6" />
|
||||
<stop offset="100%" stop-color="#F2C878" />
|
||||
</radialGradient>
|
||||
<!-- One petal, pointing up from the flower's centre, notched at the tip. -->
|
||||
<path id="petal-leaf"
|
||||
d="M32 34
|
||||
C 21.5 31.5, 15.5 23, 18 14.6
|
||||
C 20 8.2, 26 5.4, 29.6 9.8
|
||||
L 32 12.8
|
||||
L 34.4 9.8
|
||||
C 38 5.4, 44 8.2, 46 14.6
|
||||
C 48.5 23, 42.5 31.5, 32 34 Z" />
|
||||
</defs>
|
||||
|
||||
<g>
|
||||
<!-- Five petals around the centre, each a rotated copy of the same shape.
|
||||
They overlap generously and each keeps its own outline, so the petals
|
||||
stay individually readable rather than merging into one silhouette. -->
|
||||
<g fill="url(#petal-fill)" stroke="#D98AAF" stroke-width="1.4" stroke-linejoin="round">
|
||||
<use href="#petal-leaf" transform="rotate(0 32 32)" />
|
||||
<use href="#petal-leaf" transform="rotate(72 32 32)" />
|
||||
<use href="#petal-leaf" transform="rotate(144 32 32)" />
|
||||
<use href="#petal-leaf" transform="rotate(216 32 32)" />
|
||||
<use href="#petal-leaf" transform="rotate(288 32 32)" />
|
||||
</g>
|
||||
|
||||
<!-- Stamens: little dots on short stalks, kept inside the centre. -->
|
||||
<g stroke="#E7B15F" stroke-width="1.3" stroke-linecap="round">
|
||||
<path d="M32 32 L32 25.2" />
|
||||
<path d="M32 32 L38.4 27.4" />
|
||||
<path d="M32 32 L36 35.6" />
|
||||
<path d="M32 32 L28 35.6" />
|
||||
<path d="M32 32 L25.6 27.4" />
|
||||
</g>
|
||||
<g fill="#FFE9AE">
|
||||
<circle cx="32" cy="24.6" r="1.9" />
|
||||
<circle cx="38.9" cy="26.9" r="1.9" />
|
||||
<circle cx="36.4" cy="36.2" r="1.9" />
|
||||
<circle cx="27.6" cy="36.2" r="1.9" />
|
||||
<circle cx="25.1" cy="26.9" r="1.9" />
|
||||
</g>
|
||||
|
||||
<!-- The flower's heart. -->
|
||||
<circle cx="32" cy="32" r="5.1" fill="url(#petal-heart)" stroke="#E7B15F" stroke-width="1.1" />
|
||||
<circle cx="30.3" cy="30.4" r="1.5" fill="#FFF8E6" opacity="0.9" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
+45
-2
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, type DocSummary, type Document, type Suggestion, type Tag, type TagColor } from './api/client'
|
||||
import { api, type DocSummary, type DocUpdate, type Document, type Suggestion, type Tag, type TagColor } from './api/client'
|
||||
import { useAutoSave } from './hooks/useAutoSave'
|
||||
import { useCheckpoint } from './hooks/useCheckpoint'
|
||||
import { useSpellChecker } from './hooks/useSpellChecker'
|
||||
@@ -13,6 +13,9 @@ import { GardenPanel } from './components/Garden/GardenPanel'
|
||||
import { StatusBar } from './components/StatusBar/StatusBar'
|
||||
import { PetalCompanion } from './components/Companion/PetalCompanion'
|
||||
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
|
||||
import { SignInOverlay } from './components/Auth/SignInOverlay'
|
||||
import { useSession } from './hooks/useSession'
|
||||
import { takeDraft } from './lib/drafts'
|
||||
import { useVersionWatch } from './hooks/useVersionWatch'
|
||||
import { PetalFall } from './effects/PetalFall'
|
||||
import { useNightMode } from './hooks/useNightMode'
|
||||
@@ -24,6 +27,12 @@ export default function App() {
|
||||
// toggles the `petal-night` class on <html>; we pass the flag to the ambient
|
||||
// layer so the petals become stars.
|
||||
const night = useNightMode()
|
||||
// Who's writing, and whether the server still recognises them. `signedOut`
|
||||
// flips the moment any call comes back 401.
|
||||
const { me, signedOut } = useSession()
|
||||
// A real account to sign out of, as opposed to the hardcoded local user a
|
||||
// build without auth configured runs as.
|
||||
const account = me && me.id !== 'local' ? { name: me.display_name || me.email } : null
|
||||
const [docs, setDocs] = useState<DocSummary[]>([])
|
||||
const [currentDoc, setCurrentDoc] = useState<Document | null>(null)
|
||||
const [title, setTitle] = useState('')
|
||||
@@ -132,6 +141,35 @@ export default function App() {
|
||||
[createTag, setDocTag],
|
||||
)
|
||||
|
||||
// If the session lapsed while she was writing, the body that couldn't be
|
||||
// saved was stashed on this device. Opening the document again is where it
|
||||
// comes back: the stashed fields win over the server's older copy, and a save
|
||||
// is scheduled straight away so it stops being local-only. Version history
|
||||
// makes this safe to do silently — the server's copy is one restore away.
|
||||
const pendingRescueRef = useRef<{ id: string; body: DocUpdate } | null>(null)
|
||||
const rescueDraft = useCallback((doc: Document): Document => {
|
||||
const stashed = takeDraft(doc.id)
|
||||
if (!stashed) return doc
|
||||
const patch = Object.fromEntries(
|
||||
Object.entries(stashed.body).filter(([, v]) => v !== undefined),
|
||||
)
|
||||
if (Object.keys(patch).length === 0) return doc
|
||||
const merged = { ...doc, ...patch } as Document
|
||||
if (merged.content === doc.content && merged.title === doc.title) return doc
|
||||
// Save it, but only once this really is the open document — the auto-save
|
||||
// writes to whichever doc is current when its timer fires.
|
||||
pendingRescueRef.current = { id: doc.id, body: stashed.body }
|
||||
return merged
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const rescued = pendingRescueRef.current
|
||||
if (rescued && currentDoc?.id === rescued.id) {
|
||||
pendingRescueRef.current = null
|
||||
schedule(rescued.body)
|
||||
}
|
||||
}, [currentDoc?.id, schedule])
|
||||
|
||||
const openDoc = useCallback(
|
||||
async (id: string) => {
|
||||
setDrawerOpen(false) // close the mobile drawer when a doc is chosen
|
||||
@@ -139,7 +177,7 @@ export default function App() {
|
||||
const leaving = currentDocRef.current
|
||||
const leavingBlank = isBlankDraft()
|
||||
await saveNow() // flush any pending edits to the doc we're leaving
|
||||
const doc = await api.getDoc(id)
|
||||
const doc = rescueDraft(await api.getDoc(id))
|
||||
setCurrentDoc(doc)
|
||||
setTitle(doc.title)
|
||||
setWordCount(doc.word_count)
|
||||
@@ -432,6 +470,7 @@ export default function App() {
|
||||
onDuplicate={handleDuplicate}
|
||||
onToggleTag={handleToggleTag}
|
||||
onCreateTag={handleCreateTag}
|
||||
account={account}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -543,6 +582,10 @@ export default function App() {
|
||||
|
||||
{updateAvailable && <UpdateBanner />}
|
||||
|
||||
{/* The session lapsed. The editor stays visible behind this — nothing has
|
||||
been taken away — and anything unsaved is already on disk. */}
|
||||
{signedOut && <SignInOverlay hasDraft={status === 'signed-out'} />}
|
||||
|
||||
<div className="petal-no-print">
|
||||
<PetalCompanion
|
||||
wordCount={wordCount}
|
||||
|
||||
@@ -150,11 +150,45 @@ export interface MechanicsFinding {
|
||||
explanation: string
|
||||
}
|
||||
|
||||
// Who's writing. Mirrors the backend db.User.
|
||||
export interface Me {
|
||||
id: string
|
||||
email: string
|
||||
display_name: string
|
||||
created_at: string
|
||||
pair_lang: string
|
||||
}
|
||||
|
||||
// Thrown when the server says the session is gone. Callers can tell it apart
|
||||
// from a real failure — losing your session is not the same as a save going
|
||||
// wrong, and the auto-save has to treat them very differently.
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor() {
|
||||
super('not signed in')
|
||||
this.name = 'UnauthorizedError'
|
||||
}
|
||||
}
|
||||
|
||||
// Sessions expire, so *any* call can come back 401 — including the auto-save
|
||||
// that fires 1.5s after every keystroke. One place notices, and the app reacts
|
||||
// once, rather than each call site inventing its own answer.
|
||||
let unauthorizedHandler: (() => void) | null = null
|
||||
|
||||
export function onUnauthorized(handler: () => void) {
|
||||
unauthorizedHandler = handler
|
||||
}
|
||||
|
||||
function signedOut(): UnauthorizedError {
|
||||
unauthorizedHandler?.()
|
||||
return new UnauthorizedError()
|
||||
}
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...init,
|
||||
})
|
||||
if (res.status === 401) throw signedOut()
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => '')
|
||||
throw new Error(`${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`)
|
||||
@@ -164,6 +198,10 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// The signed-in writer. With auth unconfigured (local development) this is
|
||||
// the hardcoded local user, so the frontend needs no separate mode for it.
|
||||
me: () => req<Me>('/me'),
|
||||
|
||||
listDocs: () => req<DocSummary[]>('/docs'),
|
||||
createDoc: () => req<Document>('/docs', { method: 'POST' }),
|
||||
getDoc: (id: string) => req<Document>(`/docs/${id}`),
|
||||
@@ -254,6 +292,7 @@ export const api = {
|
||||
const form = new FormData()
|
||||
form.append('image', file)
|
||||
const res = await fetch('/api/images', { method: 'POST', body: form })
|
||||
if (res.status === 401) throw signedOut()
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => '')
|
||||
throw new Error(`${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`)
|
||||
@@ -370,6 +409,7 @@ export async function streamSuggestionChat(
|
||||
body: JSON.stringify({ messages }),
|
||||
signal,
|
||||
})
|
||||
if (res.status === 401) throw signedOut()
|
||||
if (!res.ok || !res.body) {
|
||||
const detail = await res.text().catch(() => '')
|
||||
throw new Error(`${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// SignInOverlay appears when the session has lapsed mid-session.
|
||||
//
|
||||
// The tone matters more than usual here. Being logged out of a writing app is
|
||||
// alarming — the first thing anyone wants to know is whether their words
|
||||
// survived — so the overlay leads with the reassurance and treats signing in
|
||||
// again as an errand, not an error. The editor stays visible behind the scrim
|
||||
// (dimmed, still there) for the same reason: nothing has been taken away.
|
||||
|
||||
interface Props {
|
||||
// Whether there is unsaved writing waiting on this device, which changes the
|
||||
// reassurance from a promise to a statement of fact.
|
||||
hasDraft: boolean
|
||||
}
|
||||
|
||||
export function SignInOverlay({ hasDraft }: Props) {
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="petal-signin-title"
|
||||
className="petal-no-print fixed inset-0 z-[60] flex items-center justify-center px-4"
|
||||
style={{ background: 'color-mix(in srgb, var(--color-bg) 78%, transparent)', backdropFilter: 'blur(3px)' }}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md px-7 py-8 text-center"
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
}}
|
||||
>
|
||||
<div aria-hidden style={{ fontSize: 34, lineHeight: 1 }}>
|
||||
🌸
|
||||
</div>
|
||||
<h2
|
||||
id="petal-signin-title"
|
||||
className="mt-3 text-lg font-bold"
|
||||
style={{ color: 'var(--color-plum)' }}
|
||||
>
|
||||
请重新登录
|
||||
</h2>
|
||||
<p className="text-sm font-semibold" style={{ color: 'var(--color-muted)' }}>
|
||||
Please sign in again
|
||||
</p>
|
||||
|
||||
<p className="mt-4 text-sm leading-relaxed" style={{ color: 'var(--color-plum)' }}>
|
||||
{hasDraft
|
||||
? '你刚写的内容已经安全地留在这台电脑上,登录后会自动接着保存。'
|
||||
: '登录状态过期了。你的文字都已经保存好了。'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-relaxed" style={{ color: 'var(--color-muted)' }}>
|
||||
{hasDraft
|
||||
? "What you just wrote is safe on this device — it'll save itself once you're back in."
|
||||
: 'Your session expired. Everything you wrote is already saved.'}
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="/auth/login"
|
||||
className="mt-6 inline-block rounded-full px-6 py-2.5 text-sm font-bold text-white transition-colors"
|
||||
style={{ background: 'var(--color-accent)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||
>
|
||||
去登录 · Sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,9 @@ interface Props {
|
||||
onDuplicate: (id: string) => void
|
||||
onToggleTag: (docId: string, tag: Tag) => void
|
||||
onCreateTag: (docId: string, name: string, color: TagColor) => void
|
||||
// The signed-in writer, when there is real auth to sign out of. Null in a
|
||||
// local-dev build, where there is nothing to leave.
|
||||
account: { name: string } | null
|
||||
}
|
||||
|
||||
// Sidebar sort orders. 'recent' keeps the server's updated_at-desc ordering.
|
||||
@@ -36,6 +39,7 @@ export function DocList({
|
||||
onDuplicate,
|
||||
onToggleTag,
|
||||
onCreateTag,
|
||||
account,
|
||||
}: Props) {
|
||||
// Active tag filter (null = show all). Cleared automatically if the tag
|
||||
// disappears from the roster.
|
||||
@@ -62,7 +66,7 @@ export function DocList({
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="flex h-full w-[280px] flex-col gap-2 p-3"
|
||||
className="flex h-full w-full flex-col gap-2 p-3"
|
||||
style={{ borderRight: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<SearchBox onSelect={onSelect} />
|
||||
@@ -149,6 +153,26 @@ export function DocList({
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Who's writing, and the way out. Shown only when there's a real account
|
||||
behind the session — a local-dev build has nobody to sign out as. */}
|
||||
{account && (
|
||||
<div
|
||||
className="flex items-center gap-2 px-1 text-xs"
|
||||
style={{ color: 'var(--color-muted)' }}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate" title={account.name}>
|
||||
🌸 {account.name}
|
||||
</span>
|
||||
<a
|
||||
href="/auth/logout"
|
||||
className="shrink-0 font-bold hover:underline"
|
||||
style={{ color: 'var(--color-accent-hover)' }}
|
||||
>
|
||||
退出 · Sign out
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ const SAVE_LABEL: Record<SaveStatus, string> = {
|
||||
saving: 'Saving…',
|
||||
saved: 'Saved just now',
|
||||
error: "Couldn't save",
|
||||
// The session lapsed. Say where the writing is, not what failed — it's safe
|
||||
// on this device and goes up the moment she signs back in.
|
||||
'signed-out': '已保存在本机 · Kept on this device',
|
||||
}
|
||||
|
||||
// StatusBar is the slim footer: word count on the left, save state and the
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, type DocUpdate } from '../api/client'
|
||||
import { api, UnauthorizedError, type DocUpdate } from '../api/client'
|
||||
import { clearDraft, stashDraft } from '../lib/drafts'
|
||||
|
||||
export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error'
|
||||
export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error' | 'signed-out'
|
||||
|
||||
const DEBOUNCE_MS = 1500
|
||||
const SAVED_FADE_MS = 3000
|
||||
@@ -19,20 +20,43 @@ export function useAutoSave(docId: string | null) {
|
||||
const docIdRef = useRef(docId)
|
||||
docIdRef.current = docId
|
||||
|
||||
// Set once the server stops recognising the session. Further saves are
|
||||
// pointless (every one would 401) and would keep overwriting the stash, so
|
||||
// the loop stops here and the writing waits in localStorage instead.
|
||||
const signedOutRef = useRef(false)
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
const id = docIdRef.current
|
||||
const body = pendingRef.current
|
||||
pendingRef.current = null
|
||||
if (!id || !body) return
|
||||
|
||||
if (signedOutRef.current) {
|
||||
stashDraft(id, body)
|
||||
setStatus('signed-out')
|
||||
return
|
||||
}
|
||||
|
||||
setStatus('saving')
|
||||
try {
|
||||
await api.updateDoc(id, body)
|
||||
clearDraft(id) // it's on the server now; the rescue copy is redundant
|
||||
setStatus('saved')
|
||||
clearTimeout(fadeRef.current)
|
||||
fadeRef.current = setTimeout(() => setStatus('idle'), SAVED_FADE_MS)
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
// The session went away mid-draft. Keep the body — on disk, where a
|
||||
// full-page trip through the identity provider can't take it with it —
|
||||
// and stop trying until there's a session again.
|
||||
signedOutRef.current = true
|
||||
stashDraft(id, body)
|
||||
setStatus('signed-out')
|
||||
return
|
||||
}
|
||||
console.error('auto-save failed', err)
|
||||
// Put the body back so the next edit retries it rather than dropping it.
|
||||
pendingRef.current = { ...body, ...(pendingRef.current ?? {}) }
|
||||
setStatus('error')
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, onUnauthorized, type Me } from '../api/client'
|
||||
|
||||
// useSession tracks who is writing, and notices the moment the server stops
|
||||
// recognising them.
|
||||
//
|
||||
// `signedOut` going true is not an error state to report — it's a state to
|
||||
// recover from: the app stops auto-saving, keeps the draft, and shows a warm
|
||||
// invitation to sign in again. Every API call routes its 401 here through the
|
||||
// client's single interceptor, so it fires once no matter which call noticed.
|
||||
export function useSession() {
|
||||
const [me, setMe] = useState<Me | null>(null)
|
||||
const [signedOut, setSignedOut] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
onUnauthorized(() => setSignedOut(true))
|
||||
let cancelled = false
|
||||
api
|
||||
.me()
|
||||
.then((user) => {
|
||||
if (!cancelled) setMe(user)
|
||||
})
|
||||
.catch(() => {
|
||||
// A 401 has already flipped signedOut through the interceptor; anything
|
||||
// else (the server briefly down) leaves `me` null, which only costs the
|
||||
// display name.
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { me, signedOut }
|
||||
}
|
||||
+1
-1
@@ -388,7 +388,7 @@ button, a, input {
|
||||
canvas (centered, max-width) re-centers into the full pane. Width + transform
|
||||
animate together for a smooth slide. (Spec → Distraction-free mode.) */
|
||||
.petal-sidebar {
|
||||
width: 260px;
|
||||
width: 280px;
|
||||
overflow: hidden;
|
||||
transition: width 280ms ease, transform 280ms ease, opacity 200ms ease;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { clearDraft, peekDraft, stashDraft, takeDraft } from './drafts'
|
||||
|
||||
// The draft stash is the last thing between an expired session and lost
|
||||
// writing, so these tests care about two things above all: that a rescued body
|
||||
// comes back intact, and that nothing here can ever throw into a save handler.
|
||||
|
||||
// A minimal in-memory Storage, since vitest runs these in node.
|
||||
function fakeStorage(): Storage {
|
||||
const map = new Map<string, string>()
|
||||
return {
|
||||
get length() {
|
||||
return map.size
|
||||
},
|
||||
key: (i: number) => [...map.keys()][i] ?? null,
|
||||
getItem: (k: string) => map.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void map.set(k, v),
|
||||
removeItem: (k: string) => void map.delete(k),
|
||||
clear: () => map.clear(),
|
||||
} as Storage
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', fakeStorage())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('draft rescue', () => {
|
||||
it('round-trips an unsaved body', () => {
|
||||
stashDraft('doc-1', { content: '{"type":"doc"}', content_text: '春天来了', word_count: 4 })
|
||||
|
||||
const draft = peekDraft('doc-1')
|
||||
expect(draft?.body.content_text).toBe('春天来了')
|
||||
expect(draft?.body.word_count).toBe(4)
|
||||
})
|
||||
|
||||
it('keeps documents apart', () => {
|
||||
stashDraft('doc-1', { content_text: 'mine' })
|
||||
expect(peekDraft('doc-2')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps only the newest attempt', () => {
|
||||
stashDraft('doc-1', { content_text: 'first' })
|
||||
stashDraft('doc-1', { content_text: 'second' })
|
||||
expect(peekDraft('doc-1')?.body.content_text).toBe('second')
|
||||
})
|
||||
|
||||
// A rescue must not be applied twice — the second application would overwrite
|
||||
// whatever was written after the first.
|
||||
it('take consumes the draft', () => {
|
||||
stashDraft('doc-1', { content_text: 'rescued' })
|
||||
expect(takeDraft('doc-1')?.body.content_text).toBe('rescued')
|
||||
expect(takeDraft('doc-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('clears on a successful save', () => {
|
||||
stashDraft('doc-1', { content_text: 'rescued' })
|
||||
clearDraft('doc-1')
|
||||
expect(peekDraft('doc-1')).toBeNull()
|
||||
})
|
||||
|
||||
// A draft surfacing a fortnight later is a surprise, not a save.
|
||||
it('expires stale drafts', () => {
|
||||
stashDraft('doc-1', { content_text: 'ancient' })
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(Date.now() + 8 * 24 * 60 * 60 * 1000)
|
||||
expect(peekDraft('doc-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores a corrupted entry rather than throwing', () => {
|
||||
localStorage.setItem('petal.draft.doc-1', 'not json at all')
|
||||
expect(peekDraft('doc-1')).toBeNull()
|
||||
})
|
||||
|
||||
// Storage can be full, disabled, or absent. Losing the safety net is bad;
|
||||
// throwing from inside a failed save is worse.
|
||||
it('survives storage that refuses to write', () => {
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => {
|
||||
throw new Error('nope')
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('nope')
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error('nope')
|
||||
},
|
||||
} as unknown as Storage)
|
||||
|
||||
expect(() => stashDraft('doc-1', { content_text: 'x' })).not.toThrow()
|
||||
expect(() => clearDraft('doc-1')).not.toThrow()
|
||||
expect(peekDraft('doc-1')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
// Local draft rescue: the last thing standing between an expired session and
|
||||
// lost writing.
|
||||
//
|
||||
// Petal auto-saves 1.5s after every keystroke, which is exactly what makes a
|
||||
// surprise 401 expensive — the writer has no idea the save stopped landing, and
|
||||
// signing in again means a full-page trip through Authentik that throws away
|
||||
// everything the editor is holding in memory. So when a save comes back
|
||||
// unauthorized, the body it failed to send is written to localStorage first,
|
||||
// and reclaimed when the document is opened again after signing in.
|
||||
//
|
||||
// The stash is deliberately per-document and short-lived: it is a rescue, not a
|
||||
// second source of truth. Anything reclaimed is immediately written back to the
|
||||
// server, and the entry is cleared the moment a normal save succeeds.
|
||||
|
||||
import type { DocUpdate } from '../api/client'
|
||||
|
||||
const PREFIX = 'petal.draft.'
|
||||
|
||||
// A rescued draft, plus when it was stashed (shown to the writer, and used to
|
||||
// let anything implausibly old expire rather than resurface).
|
||||
export interface StashedDraft {
|
||||
body: DocUpdate
|
||||
stashedAt: number
|
||||
}
|
||||
|
||||
// MAX_AGE_MS bounds how long a rescue is worth honouring. A draft recovered a
|
||||
// week later is more likely to be a surprise than a save.
|
||||
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
function key(docId: string): string {
|
||||
return PREFIX + docId
|
||||
}
|
||||
|
||||
// stashDraft records the unsaved body for a document, replacing any earlier one
|
||||
// (the newest attempt is always the most complete).
|
||||
export function stashDraft(docId: string, body: DocUpdate): void {
|
||||
try {
|
||||
const draft: StashedDraft = { body, stashedAt: Date.now() }
|
||||
localStorage.setItem(key(docId), JSON.stringify(draft))
|
||||
} catch {
|
||||
// A full or unavailable localStorage must never break the editor. Losing
|
||||
// the safety net is bad; throwing from a failed save handler is worse.
|
||||
}
|
||||
}
|
||||
|
||||
// peekDraft returns a stashed draft without consuming it, or null if there
|
||||
// isn't a usable one.
|
||||
export function peekDraft(docId: string): StashedDraft | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(key(docId))
|
||||
if (!raw) return null
|
||||
const draft = JSON.parse(raw) as StashedDraft
|
||||
if (!draft?.body || typeof draft.stashedAt !== 'number') return null
|
||||
if (Date.now() - draft.stashedAt > MAX_AGE_MS) {
|
||||
clearDraft(docId)
|
||||
return null
|
||||
}
|
||||
return draft
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// takeDraft returns a stashed draft and removes it in the same breath, so a
|
||||
// rescue can't be applied twice.
|
||||
export function takeDraft(docId: string): StashedDraft | null {
|
||||
const draft = peekDraft(docId)
|
||||
if (draft) clearDraft(docId)
|
||||
return draft
|
||||
}
|
||||
|
||||
export function clearDraft(docId: string): void {
|
||||
try {
|
||||
localStorage.removeItem(key(docId))
|
||||
} catch {
|
||||
// See stashDraft.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user