Compare commits

...
7 Commits
Author SHA1 Message Date
prosolis 8410b6315b Phase 15: containerize Petal for the parodia.dev VPS
Deploy plumbing so Petal can run on the public VPS behind the Traefik
already on that box, with vLLM reached over headscale.

- Dockerfile: node build -> go build -> alpine runtime. CGO stays off
  (modernc SQLite is pure Go), so the runtime layer exists only for
  ffmpeg (read-aloud transcodes Piper's WAV) and tzdata (the companion's
  bedtime nag and night mode read the local clock). Runs as uid 10001
  with /data as the single writable mount.

- docker-compose.yml: Traefik labels following this host's convention
  (external `traefik` network, `web-secure` entrypoint, `default` cert
  resolver). Petal publishes no host port. ./data is a bind mount, not a
  named volume, so the nightly backup and a restore are reachable from
  the host.

- Piper runs as two sibling containers rather than host systemd units.
  The plan assumed Piper was already installed on the VPS; it is not,
  the host has no lingering user session to keep user units alive, and
  containers keep the TTS ports on an internal network unreachable from
  anywhere but Petal. One image, voice chosen per service, model cached
  in a shared volume -- so the pt-PT voice is a new service, not a new
  image.

- db.Backup + a `-backup` flag: VACUUM INTO, not a file copy. Petal runs
  in WAL mode, so the newest committed pages may live in petal.db-wal;
  copying the three files separately can capture a torn mid-checkpoint
  state. VACUUM INTO reads one coherent snapshot without taking a write
  lock, and emits a single file with no -wal/-shm companions. Refuses an
  existing destination so a failed run can't destroy the last good
  backup.

- deploy/backup-petal.sh: nightly snapshot, compress, push to millenia
  over headscale with a post-transfer size check, prune both sides.

- deploy/petal.env.example: LLM_TIMEOUT raised 30s -> 90s for the
  WAN+VPN round trip, since the voice and collocation passes send a
  whole document and the timeout is a hard deadline on Complete.
2026-07-26 23:08:22 -07:00
prosolis dae1213c68 Ratify the language-learning direction and expand it into Phases 15-22
SUGGESTIONS.md (new): the product rationale. Every user gets one
(English + X) pair, X in {zh, pt-PT, fr, maybe es} - bilingual UI in the
pair, type in either language, direction inferred without a detector
(both-dictionaries spellcheck, show-both gloss on collision). Langpacks
keyed by X. LLM-minimalism as a standing principle: the LLM never gates
essential functionality; grammar-lite rules and an embedded
miscollocation list are planned as code-first layers beneath the LLM
families.

MULTIUSER_PLAN.md: all OPEN decisions settled - in-app OIDC (the
parodia.dev VPS hosting plan decides it), 30-day sliding sessions,
allowlist, migration-by-script, image-store ownership fixed alongside
auth, DreamDict imported as a package reading dict.db (module rename
prereq lives in the dreamdict repo), zh stays on ECDICT until compared.

BUILD_PLAN.md: the deferred bucket becomes checkboxed Phases 15-22
(deploy plumbing / OIDC / local-user migration / client-state
namespacing / langpack extraction / DreamDict provider / pt-PT pair /
learning loop + code-first layers) with standing rules: isolation tests
in the same commit as any user-scoped endpoint, LLM-minimalism, and
bilingual aesthetic as acceptance criteria.
2026-07-26 22:24:04 -07:00
prosolis 316b6b305d Plan: DreamDict answers the Portuguese dictionary question
OPEN #6 assumed the pt-PT gloss was gated on finding a dataset of
ECDICT's quality. It isn't — dreamdict already covers en, fr, pt-PT and
zh, and its API maps almost 1:1 onto lexicon.Result.

Replaces the data question with an integration one (OPEN #6a): HTTP
service, build-time extraction into Petal's embedded gz format, or
importing the dictionary package and opening dict.db read-only. Argues
for the third — same CGO-free sqlite driver Petal already depends on, no
runtime service, and it deletes ~11.6 MB of embedded data plus the
ECDICT build scripts.

Adds the migration caution that matters most: the zh path is in daily
use, so wire pt-PT/fr first (nothing to regress) and leave zh on ECDICT
until CC-CEDICT gloss quality has been compared on her real lookups.
2026-07-26 21:48:51 -07:00
prosolis 023882a722 Add multi-user plan for review
Phase 0 (identity plumbing) is done; this writes down what real
multi-user still needs and, more importantly, which decisions are
genuinely open. Six OPEN items are flagged for a reviewer to push back
on — chief among them whether Petal authenticates via Traefik
forward-auth or becomes an OIDC client itself, which everything else
depends on.
2026-07-26 21:44:16 -07:00
prosolis 6901cdbbe4 Multi-user groundwork: request-scoped user identity
Petal ran as a single hardcoded user, with db.LocalUserID named directly
at ~35 query sites. That made the caller's identity a compile-time
constant scattered across every package — nothing a real login could
replace without touching all of them.

New internal/auth moves it into the request context:

  - Middleware(Resolver) resolves the caller once per API request
  - handlers read auth.UserID(r.Context()) instead of naming a user
  - Resolver is the seam an Authentik session check drops into
  - StaticResolver(db.LocalUserID) keeps Petal single-user today

Behavior is unchanged. UserID returns "" rather than panicking when the
middleware is absent, so a mis-wired route fails closed: every query is
WHERE user_id = ?, which then matches nothing.

main.go splits /api into a public group (/health, /version) and an
authenticated group for everything else — a monitoring probe must not
need a session.

Two pre-existing access-control gaps fixed while threading, both
harmless with one user and not with two:

  - setStatus (accept/dismiss) updated a suggestion by bare id with no
    ownership check at all
  - listForDoc/fetchPending read a document's suggestions by doc_id
    alone; a suggestion quotes the sentence it corrects, so that leaked
    the source prose

Both now scope through documents.user_id.

Tests: internal/auth covers the context round-trip, the absent-context
case, and both 401 paths. Two-user isolation suites in docs and
suggestions mount the same routers twice behind two resolvers over one
database and assert a stranger gets 404 on every id-taking path, sees
nothing in list/search, and leaves the owner's data untouched.

Those suites earned their keep immediately: docs.fetch gained a userID
parameter but kept binding db.LocalUserID in the query. Unused
parameters are legal Go, so it compiled clean, vet was silent, and every
existing test passed while the lookup stayed unscoped.

Still global, out of scope and flagged in BUILD_PLAN.md: the image store
has no per-user association, and frontend localStorage keys are
per-browser rather than per-account.
2026-07-26 21:42:37 -07:00
prosolis 61b3c6cd62 Disable thinking on the vLLM backend
Qwen3-family models reason by default and prepend a plain-text preamble
ahead of the answer — not a <think> block, so it cannot be stripped
after the fact. Every Petal pass parses a JSON object out of the
completion, so an unsuppressed preamble fails the parse outright.

Send chat_template_kwargs {"enable_thinking": false} on every request,
matching the unconditional think:false the Ollama backend already sends.
2026-07-26 21:25:56 -07:00
prosolis 78ed1dd281 Writing passport: evidence of process instead of an AI score
She's submitting work that gets run through an AI detector and wants to
pre-check she won't be wrongly flagged. Petal should not answer that with
a detector of its own: they misfire badly on non-native English (Stanford
2023 found >50% of TOEFL essays flagged as AI vs. near-zero for native
writers), so a percentage aimed at an ESL writer is worse than nothing —
it either scares her off her own voice or gives false comfort.

So the artifact is provenance, not a verdict. Petal already snapshots
every ~3 minutes; this turns that history into a standalone printable
report: session breakdown, word-count growth, span, active time. No score
is emitted anywhere.

Two schema additions back it. preserve_history opts a document out of the
40-snapshot prune cap — right for recovery, wrong for provenance, where
you want the whole span including the oldest rows. content_hash/prev_hash
chain each snapshot to the one before it, so a history edited or thinned
after the fact fails verification. Pruning legitimately severs links, so a
link break reports as "gaps" unless preserve_history is on; only a hash
that fails against its own contents is unconditionally "broken".

The chart's x axis is snapshot order, not wall-clock, and that is the load
-bearing decision. On a linear time axis an essay written in three
sittings across three days renders as three vertical cliffs separated by
empty space — visually identical to text pasted in three chunks, i.e. the
report would have argued the opposite of the truth. Breaks are compressed
into explicitly labelled gutters instead. TestChartGivesWidthToWriting
pins it.

The report volunteers its largest single word-count jump and states its
own limits: it cannot show who was at the keyboard, or whether typed text
was composed or copied in. Overclaiming would be self-defeating — a reader
who catches it overstating discounts all of it.

HTML rather than server-rendered PDF, as with the other exports: a CJK-safe
PDF needs an embedded Unicode font or a headless browser. Print styles are
there so the browser's Save as PDF is the handoff path.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-07-19 11:45:08 -07:00
39 changed files with 3331 additions and 128 deletions
+33
View File
@@ -0,0 +1,33 @@
# Keep the build context small and the image reproducible. Anything the build
# needs but that is gitignored (web/dist) is produced inside the image instead.
.git
.gitignore
.env
# Built by stage 1 — never copy a stale local build into the image.
web/dist
web/node_modules
# Local runtime state: the live database, images and TTS cache must never end
# up baked into an image layer.
data/
*.db
*.db-shm
*.db-wal
backups/
# Local build outputs
/petal
*.test
*.out
*.log
# Docs and tooling that don't affect the binary
*.md
!web/**/*.md
deploy/
scripts/
.vscode/
.idea/
.DS_Store
+80 -3
View File
@@ -135,9 +135,84 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
- [x] Verified: tsc clean, vite build OK, companion vitest 45/45. **Real-browser screenshots** (local Playwright + Chromium, clock mocked to 23:30): day = warm cream + pink sakura petals; night = dark plum-indigo + twinkling stars + glowing sleepy kitten. Both pretty (acceptance criterion).
### Deferred (post-v1-local)
- [ ] Authentik OIDC auth + session middleware ← **on hold: user doing foundational work first**
- [ ] Copyleaks Tier-2 + webhook HMAC
- [ ] Dockerfile, docker-compose, Traefik, deploy to write.parodia.dev
- [x] **Multi-user groundwork** (2026-07-26) — request-scoped identity. New `internal/auth`: `Middleware(Resolver)` resolves the caller once per API request and stores the id in the context; handlers read it via `auth.UserID(r.Context())` instead of naming `db.LocalUserID`. `StaticResolver(db.LocalUserID)` keeps Petal single-user today. **Auth itself is still deferred** — but every query is now scoped to whoever the resolver says is calling, so landing Authentik is a one-line change in `main.go` plus a `Resolver` implementation.
- [ ] Copyleaks Tier-2 + webhook HMAC (still parked — needs a public webhook; revisit after Phase 15)
- Authentik OIDC + deploy: **no longer deferred — expanded into Phases 1517 below** (decisions ratified 2026-07-26; see `MULTIUSER_PLAN.md`)
## Execution phases 1522 (added 2026-07-26)
Decisions behind these are ratified in `MULTIUSER_PLAN.md` (all OPENs settled) and `SUGGESTIONS.md` (the *why*; Q1Q3 settled). **Standing rules for every phase below:**
- **Isolation tests in the same commit** as any new user-scoped endpoint (the `docs/isolation_test.go` suites are the template — this discipline caught a real unscoped-query bug once already).
- **LLM-minimalism** (SUGGESTIONS §6): the LLM never gates essential functionality; new essential features are code+data first.
- **Aesthetic + bilingual-in-the-pair copy remain acceptance criteria** on every user-visible change.
- Verify per project convention: go build/vet/test, tsc, vite build, vitest, live smoke on a throwaway DB/port.
### Phase 15 — Deploy plumbing (parodia.dev + headscale)
Petal hosted on the parodia.dev VPS; vLLM stays on millenia over headscale. Auth (Phase 16) needs the stable `BASE_URL`/redirect URI this phase creates.
- [ ] Dockerfile (multi-stage: `npm run build``go build`, single binary + data dir volume) + docker-compose
- [ ] Traefik route + HTTPS on the chosen hostname (e.g. `write.parodia.dev`); stable `BASE_URL` env
- [ ] `LLM_ENDPOINT` → millenia's **headscale** address; verify vLLM binds only to the headscale interface (not LAN/0.0.0.0); revisit `LLM_TIMEOUT` for the WAN+VPN round trip
- [ ] TTS → the Piper already installed on parodia (VPS-local; per-language instances as on millenia — EN now, zh; see `deploy/` artifacts for the service pattern)
- [ ] Off-VPS nightly backup of `petal.db` (+`-wal`/`-shm` coherently, e.g. `sqlite3 .backup`) to millenia over the VPN; document restore
- [ ] Migrate the live millenia data to the VPS (app stopped, backup first, counts verified) — or decide the millenia instance stays canonical until Phase 17 completes
- [ ] Acceptance: full editor works with the VPN link **down** (spell/gloss/garden/search/export/TTS all fine; checkpoint shows the warm 小助手在休息 state); `/api/health` public; HTTPS end-to-end
### 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 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
- [ ] Runbook documented in the script header; dry-run mode
### Phase 18 — Per-user, per-language client state
- [ ] Namespace `localStorage` keys by user id once known: `petal.spell.personal`, `petal.companion`, sound/petals prefs
- [ ] Personal spell dictionary keyed by **user + language** (en and pt-PT word lists must not merge); consider promoting it to a server table so it follows her across devices (nice-to-have, decide during)
### Phase 19 — Langpack extraction (the copy chore)
Pure refactor, zero visible change; prerequisite for every new pair (SUGGESTIONS §2, Q2 settled).
- [ ] Extract the `中文 · English` strings from the ~29 frontend files + companion `tips.ts` into a langpack copy module keyed by the pair's X; today's strings become the `zh` pack **verbatim**
- [ ] Parameterize `internal/llm/prompts.go` bilingual copy the same way (explanation language, "natives usually say…" framing, both-directions preamble)
- [ ] Wire pack selection to `users.pair_lang`; acceptance: pixel-identical UI for the zh pair, vitest snapshots unchanged
### Phase 20 — DreamDict as a lexicon provider
Option 3 ratified (import package, read-only `dict.db`). **Prerequisite in the dreamdict repo:** rename its module path (or add a `replace` for dev).
- [ ] Provider seam behind the existing `lexicon` interface; DreamDict provider opens `dict.db` read-only (modernc driver, second handle beside `petal.db`); graceful "no data" when the file is absent
- [ ] pt-PT + fr wired to DreamDict (nothing to regress); **zh stays on ECDICT** until compared on real lookups from her documents — converge only if quality holds
- [ ] Surface the new fields where cheap: frequency/difficulty chip in WordCard; etymology line (cognate hook for en-natives)
- [ ] Deploy: `dict.db` ships in the data dir alongside `petal.db`
### Phase 21 — The pt-PT pair (first Latin pair, proves the model)
SUGGESTIONS §1/§3/§3a. French follows the same groove afterwards; Spanish stays gated on DreamDict es data.
- [ ] Hunspell pt-PT vendored like en-US; **both-dictionaries spellcheck** (flag only if wrong in both; pills from both) — the no-detector stance, Q1 settled
- [ ] Gloss/WordCard both directions; on en/pt collisions show both compactly, never hide either
- [ ] Prompts pinned to **European Portuguese, never pt-BR** (explicit in every prompt); pt-PT langpack copy written and **reviewed by a pt-PT speaker before trusted**
- [ ] Piper pt-PT voice instance on parodia; read-aloud + L1 voice wired; slow toggle (`length_scale`) while in there (SUGGESTIONS §5e)
- [ ] Companion tips/cheers/bedtime lines in the pt-PT pack (the kitten speaks pt+en to this user)
- [ ] Acceptance: a pt-PT-pair user gets the full experience end-to-end with the VPN down except LLM passes; zh-pair user sees zero change
### Phase 22 — Learning loop + code-first layers
Each item independent and small; order within is free (SUGGESTIONS §5–§6).
- [ ] **Growth journal** (Q3 settled): local aggregation over accepted suggestions; growth-only, self-comparison-only framing; feeds companion cheers
- [ ] **Plant accepted collocations** in the vocabulary garden as phrase cards (scheduler unchanged)
- [ ] **Daily writing invitation** from the companion (no streaks, declining is fine)
- [ ] **False-friend list** per pair (curated data, WordCard heads-up + gentle flag)
- [ ] **Embedded miscollocation list** (code-first under the collocation family; LLM adds the long tail when reachable)
- [ ] **Grammar lite** rule-pack as a fourth suggestion family: instant, offline, precision-over-recall (near-certain or silent); per-pair L1-interference rules; sourcing per SUGGESTIONS Q6 (hand-curate vs mine LanguageTool's corpus — decide at build time)
### Later / explicitly not now
- Learner-facing Chinese writing (the zh pair's second direction) — own phase with its own spec (SUGGESTIONS §4); only after Phases 1921 prove the pair model
- Spanish pair — gated on DreamDict growing an es dataset
- Reactive-animation puppy companion — wishlist, low priority; `companions.ts` roster + mood engine is the drop-in point
- Copyleaks Tier-2 — revisit once Phase 15 provides a public webhook endpoint
### Next-up (post-v1 product, agreed with user 2026-06-26)
- [x] **Phase 9 — ESL superpowers**: inline Chinese gloss on hover/select; "say it more naturally" / tone-rewrite. ✅ (see Phase 9 above)
@@ -147,6 +222,8 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
- [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-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 1522** above with standing rules (isolation tests same-commit, LLM-minimalism, bilingual aesthetic). Ready for implementation handoff starting at Phase 15.
- 2026-07-26: **Multi-user groundwork** (user: "let's start preparing Petal for multi-user support"; scope agreed as plumbing-only, aimed at Authentik). New **`internal/auth`** package — context-carried identity (`WithUser`/`UserID`), a `Resolver` seam (`Resolve(*http.Request) (string, error)`), `StaticResolver` for today's single user, and `Middleware` that 401s anything unresolved. `main.go` splits `/api` into a **public group** (`/health`, `/version` — a monitoring probe must not need a session) and an **authenticated group** carrying everything else. All ~35 `db.LocalUserID` call sites across `docs`/`suggestions`/`vocab` now read the caller from the request; helpers that had no request in scope (`fetch`, `ownsDoc`, `ownsTag`, `tagsByDoc`, `fetchVersion`, `passportVersions`, `fetchPending`, vocab `fetch`) take an explicit `userID` param. `UserID` returns `""` rather than panicking when middleware is absent, so a mis-wired route **fails closed** (every query is `WHERE user_id = ?` → matches nothing). **Two real access-control gaps found and fixed while threading**: `setStatus` (accept/dismiss) updated a suggestion by bare id with **no ownership check at all**, and `fetchPending`/`listForDoc` read a document's suggestions by `doc_id` alone — a leak of the quoted source sentences. Both now scope through `documents.user_id`. **A third bug was caught by the new tests, not by the compiler**: `docs.fetch` gained a `userID` parameter but kept binding `db.LocalUserID` in the query — legal Go (unused params compile), silently unscoped, and it would have shipped. New tests: `internal/auth/auth_test.go` (round-trip, absent-context, both 401 paths) and **two-user isolation suites** (`docs/isolation_test.go`, `suggestions/isolation_test.go`) that mount the same routers twice behind two resolvers over one DB and assert a stranger gets 404 on get/update/delete/export/passport/snapshot/version-preview/restore/tag-assign/tag-rename/tag-delete/suggestion-accept/dismiss, sees nothing in list/search/version-list, and leaves the owner's data untouched. go build/vet/test all clean. **Still global, deliberately out of scope** (flagged for the auth phase): the image store is content-addressed with no per-user association or DB row — any authenticated user holding a hash can fetch any image (capability-URL security, needs a table + migration to fix); `export-all` is correctly scoped; frontend `localStorage` keys (`petal.spell.personal`, `petal.companion`, sound/petals prefs) are per-browser, not per-account, so they'd bleed across users sharing a device.
- 2026-06-26: **Phases 12 + 13 complete** (collocation coach + vocabulary garden — "finish the rest of the build plan except Authentik/Traefik"). **Phase 12**: collocation drops in as a third suggestion family reusing the whole `runPass`/`pendingScope` machinery — `llm/collocation.go` (`RunCollocation`, 25s floor, reuses `ParseCheckpoint`), `collocationSystemPrompt`/`CollocationMessages` (warm "Natives usually say…" + Mandarin gloss, defers grammar elsewhere), migration `0005` **rebuilds** the suggestions table to extend the `type` CHECK (SQLite can't ALTER a CHECK), `collocationScope` + `CollocationLimit` + `POST /{id}/collocation`. **Caught a latent bug**: `grammarScope` was `type != 'voice'` → would wipe collocation flags; fixed to `type NOT IN ('voice','collocation')`. Frontend: `--color-blossom` pink, "Make it sound natural 🌸" toolbar pill, `collocating`/`runCollocation` in `useCheckpoint`, StatusBar dot — all into the existing rail/card. **Phase 13**: new `internal/vocab` package — migration `0006_vocab_garden` (`vocab_words`, SM-2-lite columns, doc_id `ON DELETE SET NULL`, `UNIQUE(user_id,word)`), `scheduler.go` (Leitner ladder 1/3/7/16/35 → geometric; gentle "again", no streak-shaming), `handlers.go` (capture-upsert/list/due/review/delete, all owner-scoped, time math via SQLite `datetime()` so stored values stay canonical-UTC). Auto-capture wired into `EditorCore.openWordLookup` (only dictionary-known words, captures the surrounding sentence + doc_id) + a 🤍/💚 toggle on `WordCard`. `GardenPanel` slide-over: blossom grid (bloom stage by reps), flashcard review (sentence blanked, flip, again/good/easy, direction alternates recognition↔production), sleepy-kitten footer; opened from a global 🌷 header button. Tests: `TestCollocationPassCoexists`, vocab `scheduler_test.go` + `handlers_test.go`, db CHECK test extended. go build/vet/test + tsc + vite + vitest (51/51) all clean; migration verified against a copy of the live DB; live backend smoke (throwaway DB) walked the full vocab lifecycle + the warm-502 collocation path. **Remaining: only the deferred infra bucket** — Authentik auth, Copyleaks Tier-2 (needs a public webhook), Docker/Traefik/deploy — all on hold per the user's "except Authentik/Traefik".
- 2026-06-26: **Phase 14 complete** (companion warmth + bedtime nag + night mode). `tips.ts`: `ENCOURAGEMENTS` 5→10 lines; new `BEDTIME` array (4 lines, user-supplied English wit + gentle Mandarin leads). `useCompanion.ts`: bedtime branch in the 10s heartbeat (after idle-return + break, before the generic tip); only nudges while actively writing; own `lastBedtime` ref + 30min `BEDTIME_GAP`, respects `PROACTIVE_GAP`; new `'bedtime'` `BubbleTone` lingers ~4s longer. **Night mode** (added same session, user request): `lib/night.ts` centralizes `isBedtime()` + window (now shared by the nag too); `hooks/useNightMode.ts` toggles `petal-night` on `<html>` (60s re-check); `index.css` `html.petal-night` re-points only the palette tokens → whole UI flips via `var()` (no component edits), 600ms dusk fade, print stays white; `PetalFall` gains a `night` prop → chunky cartoon power stars (`makeCartoonStar`, Mario/Kirby-style, 5 candy colors) mixed ~70/30 with small twinkle sparkles, gentle spin + shallow shimmer, effect re-inits on flip; App: `useNightMode()``<PetalFall night={night}/>`. tsc + vite clean, companion vitest 45/45; verified with real-browser Playwright screenshots (clock mocked to 23:30) — day petals/cream vs night stars/dark-plum, both pretty. Bedtime window is `BEDTIME_FROM`/`BEDTIME_TO` (local clock) for easy retune.
- 2026-06-26: **Phase 11 complete** (writer power-ups, batch requested as "do it all"). Seven features: (1) in-doc **Find & Replace**`SearchHighlight` decoration extension + `FindReplace` bar (Ctrl/Cmd+F, match-case, replace-all back-to-front, DOM scroll that doesn't trigger the selection bubble); (2) **read-aloud** Web Speech util + 🔊 in WordCard & selection bubble; (3) **keyboard/touch access** — Ctrl/Cmd+D caret lookup, Ctrl/Cmd+J rewrite, touch long-press (refactored `handleContextMenu` → shared `openWordLookup(pos)`); (4) **export-all** backup zip (`GET /api/docs/export-all`, `TestExportAll`, sidebar download links); (5) **smart typography** input-rules extension (curly quotes/em-dash/ellipsis, ASCII-only so CJK untouched); (6) **duplicate doc + sidebar sort + outline popover**; (7) **English phonetic** (pivoted from pinyin — IPA is what an English learner needs; pinyin annotates Chinese she already reads) via `scripts/build_phonetic.py` + embedded `phonetic.json.gz` + `Result.Phonetic` + WordCard `/ˈrɪvər/` line — **full 46,579-word dataset built from ECDICT** (the csv re-download worked; `--seed` mode kept as a csv-free fallback). Also folded in this session: the **selection-bubble vs copy/paste fix** (bubble deferred to pointer-up + container `pointer-events:none` so it never sits where you click). go build/vet/test + tsc + vite all clean; live smoke verified word-phonetic (incl. de-inflection) + export-all zip (de-duped CJK names, route priority). Next: deferred bucket (auth/Copyleaks/deploy), still on hold per user.
+66
View File
@@ -0,0 +1,66 @@
# Petal — multi-stage build producing the single self-contained binary.
#
# Stage 1 builds the frontend; stage 2 compiles the Go server with web/dist
# embedded (go:embed), so the runtime image carries one executable and no
# assets. modernc's SQLite is pure Go, so CGO stays off and the binary is
# static — the runtime layer exists only for ffmpeg (read-aloud transcodes
# Piper's WAV to mp3) and CA certificates.
# ---------- stage 1: frontend ----------
FROM node:22-alpine AS web
WORKDIR /src/web
# Install deps against the lockfile alone so this layer caches across source
# edits. The Hunspell dictionaries come from a devDependency, so a plain
# `npm ci` (not --omit=dev) is required for the spell checker to ship.
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
# ---------- stage 2: server ----------
FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# The build context's web/dist is gitignored and excluded by .dockerignore;
# take the freshly built one from stage 1 so go:embed picks it up.
COPY --from=web /src/web/dist ./web/dist
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/petal ./cmd/server
# ---------- stage 3: runtime ----------
FROM alpine:3.21
# ffmpeg: read-aloud pipes Piper's WAV through it to mp3/opus. tzdata: the
# companion's bedtime nag and night mode read the local clock, so the container
# needs a real timezone rather than bare UTC.
RUN apk add --no-cache ca-certificates ffmpeg tzdata \
&& adduser -D -u 10001 petal
WORKDIR /app
COPY --from=build /out/petal /app/petal
# Mount point for petal.db (+ -wal/-shm), the image store and the TTS cache.
RUN mkdir -p /data && chown -R petal:petal /data
VOLUME ["/data"]
USER petal
EXPOSE 8080
ENV PORT=8080 \
DATABASE_PATH=/data/petal.db \
IMAGE_DIR=/data/images \
TTS_CACHE_DIR=/data/tts
# Same endpoint Traefik and the uptime probe use; needs no session by design.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/api/health || exit 1
ENTRYPOINT ["/app/petal"]
+374
View File
@@ -0,0 +1,374 @@
# Petal multi-user plan
**Status:** Phase 0 (identity plumbing) landed 2026-07-26 in `6901cdb`.
**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
work is in `SUGGESTIONS.md`.
Settled context that postdates the original draft: Petal will be hosted on the
**parodia.dev VPS**, reaching vLLM on millenia over **headscale VPN**; Piper
TTS is already installed on parodia (VPS-local, no VPN hop). The LLM is the
only cross-VPN dependency, and per the LLM-minimalism principle
(SUGGESTIONS.md §6) it must never gate essential functionality.
---
## 1. Where Petal is today
Single user, by construction. `db.Open` seeds one row (`users.id = 'local'`) and,
until this week, every query named that constant directly.
What was already right: **the schema has been multi-user-shaped from day one.**
`documents`, `tags`, and `vocab_words` all carry `user_id`; `document_versions`
and `suggestions` scope through their parent document. No migration is needed to
support a second user — only a way to know which user is asking.
### What Phase 0 changed
- New `internal/auth`: `Middleware(Resolver)` resolves the caller once per API
request and stores the id in the request context. Handlers read
`auth.UserID(r.Context())`.
- `Resolver` is a one-method interface — `Resolve(*http.Request) (string, error)`
— and is the only thing a real identity provider has to implement.
- `StaticResolver(db.LocalUserID)` supplies today's single user, so behavior is
unchanged.
- `/api` is split into a public group (`/health`, `/version`) and an
authenticated group (everything else).
- Two pre-existing access-control gaps fixed: `setStatus` (accept/dismiss) had
**no ownership check at all**, and `fetchPending` read suggestions by `doc_id`
alone — which leaked the quoted source sentences.
- Two-user isolation test suites (`docs`, `suggestions`) mount the same routers
twice behind two resolvers over one database.
**The remaining work is not "make Petal multi-user."** It is "authenticate
someone, provision them, and clean up the three places where data is still
global."
---
## 2. Goals and non-goals
**Goals**
- Two or more people use one Petal instance without seeing each other's writing.
- The existing local user's data survives, attached to a real account.
- Adding a user is an operator action, not a code change.
**Non-goals (explicitly out, unless a reviewer argues otherwise)**
- Sharing, collaboration, or multi-author documents. Petal is a private writing
space; every feature to date assumes one reader. Sharing would change the
passport's meaning (authorship evidence) and is a product decision, not an
auth one.
- Roles, permissions, or an admin UI.
- Public signup. Accounts are provisioned deliberately.
---
## 3. Phase A — authentication
### OPEN #1: forward-auth vs. in-app OIDC — **SETTLED: Option B (in-app OIDC)**
Ratified 2026-07-26. The deciding fact arrived with the deployment plan: Petal
will live on the public parodia.dev VPS, which is exactly the environment where
Option A's "must never be reachable except through Traefik" invariant is a
footgun. Original analysis kept below for the record.
**Option A — Traefik forward-auth to an Authentik outpost.**
Traefik is already in the deferred deploy bucket. Authentik's outpost terminates
the login, and Petal receives a trusted header (`X-authentik-uid`, plus email and
name). The `Resolver` becomes ~20 lines: read the header, map to a user id.
- *For:* no OIDC library, no session store, no cookie handling, no redirect
plumbing, no token refresh, no logout endpoint. Petal keeps zero auth code.
Login/MFA/password reset are entirely Authentik's problem.
- *Against:* Petal is only secure if it is **never reachable except through
Traefik**. Anyone who can hit the container directly can forge the header and
become any user. That's a deployment invariant enforced by network config, not
by code — and it is exactly the kind of invariant that quietly breaks. It also
makes local development awkward (no proxy → no identity), though
`StaticResolver` covers that.
**Option B — Petal is an OIDC client itself.**
`github.com/coreos/go-oidc` + `golang.org/x/oauth2`, a `/auth/callback` route,
and a signed session cookie. The config fields already exist
(`AUTHENTIK_URL`, `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET`,
`SESSION_SECRET`).
- *For:* self-contained and safe to expose directly. No trust-the-proxy
invariant. Works the same in dev and prod.
- *Against:* meaningfully more code — a session table or signed-cookie scheme,
CSRF on the callback, token expiry, logout. Two new dependencies in a project
that currently has exactly two.
**My recommendation: Option B**, but not confidently. The deciding factor for me
is that "must never be reachable directly" is a footgun that survives long after
whoever set it up has forgotten, and Petal already holds someone's private
journals. But if the deployment is definitively a single Traefik-fronted box on a
LAN and will stay that way, Option A is *much* less code and I'd not object.
A reviewer should weigh: how likely is this instance ever exposed beyond the LAN?
Does the operator want Petal to be independently deployable?
### Session handling (assumes Option B)
- Server-side sessions in a `sessions` table (id, user_id, expires_at,
created_at, user_agent), cookie holds an opaque random id.
Preferred over signed stateless cookies because it makes logout and
revocation actually work — worth the one table.
- Cookie: `HttpOnly`, `SameSite=Lax`, `Secure` when `BASE_URL` is https.
- **OPEN #2 — SETTLED: 30-day sliding expiry** (ratified 2026-07-26). An
editor that logs you out mid-draft is hostile, and auto-save makes a
surprise 401 genuinely costly. Sliding: each authenticated request extends
the session.
### The 401 problem
Every frontend fetch currently assumes success. Once a session can expire,
**any** call can return 401 mid-session — including the 1.5s auto-save, which is
the one that must not fail silently.
Proposal: a single interceptor in `web/src/api/client.ts` that, on 401, halts
auto-save, surfaces a warm bilingual "请重新登录 / Please sign in again" state
rather than a raw error, and preserves unsaved editor content across the
re-login (localStorage draft keyed by doc id). This is small but easy to forget,
and getting it wrong means lost writing.
---
## 4. Phase B — user provisioning and migration
### Provisioning
On first successful login, upsert a `users` row from the OIDC claims
(`sub``users.id`, plus email and name). No signup flow; whoever Authentik lets
in gets an account.
**OPEN #3 — SETTLED: yes, allowlist** (ratified 2026-07-26). Authentik may
host other applications with a broader user set than Petal should have. Gate
via a `PETAL_ALLOWED_SUBS` env var (or an Authentik group claim check —
implementer's choice, env var is simpler); a valid login not on the list gets
a warm bilingual "this Petal isn't yours to write in" page, not a 500.
### Migrating the existing `local` user
The live database on millenia holds real writing under `user_id = 'local'`. That
data must end up owned by the wife's real account.
Recommended: a migration that **renames** rather than copies — update the
`users.id` and let `ON UPDATE CASCADE`… except SQLite FKs here are declared
without `ON UPDATE`, so this needs either a deliberate multi-table update inside
one transaction (`documents`, `tags`, `vocab_words` — versions and suggestions
follow their parents) with `PRAGMA foreign_keys=OFF` around it, or an explicit
one-off admin command.
I'd rather do this as a **documented one-off script run with the app stopped and
a copy of the DB taken first** than as an automatic startup migration, because it
depends on knowing the new OIDC subject id, which isn't available until that
person logs in once. Sequence: deploy auth → she logs in → new empty account is
created → stop app, back up, run script to move `local`'s rows onto her real id,
delete the empty row → restart.
**OPEN #4 — SETTLED: documented one-off script** (ratified 2026-07-26), run
with the app stopped and a DB backup taken first, per the existing `scripts/`
convention. No admin endpoint.
---
## 5. Phase C — the data that is still global
Found during the Phase 0 audit. None of these break with two users; all of them
leak or bleed.
### Image store — the real one
`internal/images` is a flat content-addressed directory. There is no per-user
association and no database row at all. Any authenticated user who knows a
sha256 can fetch any other user's image.
That is capability-URL security. Hashes aren't guessable, so this is not an
emergency — but "unguessable filename" is not access control, and images pasted
into a private journal are exactly the content that shouldn't rely on it.
Proposal: an `images` table (hash, user_id, content_type, created_at, size) with
the fetch handler joining on the caller. Content addressing is kept — the same
image uploaded by two users is stored once on disk and simply has two rows, so
deduplication survives. Deleting the last row referencing a hash removes the
file.
**OPEN #5 — SETTLED: fix it in the same phase as auth** (ratified
2026-07-26). The moment a second account exists the exposure is real, and the
fix requires a migration either way.
### Frontend `localStorage`
`petal.spell.personal` (personal dictionary), `petal.companion` (chosen mascot),
plus sound and petal-effect preferences are all per-browser. Two users on one
device share them — and the personal dictionary is the one that matters, since
it's built from someone's own writing.
Cheapest fix: namespace every key by user id once the client knows who it is.
The honest fix for the dictionary is to move it server-side into a table, which
also means it follows a user between devices — arguably a feature.
### Not affected
`export-all` is correctly scoped. The TTS cache is content-addressed audio of
text the requester supplied, no cross-user inference. The lexicon is a static
dataset identical for everyone.
---
## 6. Phase D — per-user language (and DreamDict)
Tracked here because it lands on the same `users` row and shouldn't be designed
twice.
**English is always the target language.** What varies is the user's *native*
language — the one glosses and explanations are written in. Mandarin ships today;
European Portuguese (pt-PT, explicitly not pt-BR) is wanted; French is possible.
### OPEN #6 is answered: DreamDict
The original worry here was data sourcing — Petal's gloss comes from ECDICT
(English↔Chinese), and a pt-PT equivalent of comparable quality and license
looked like the blocker.
`~/git/dreamdict` already solves it, and more completely than expected. It
supports **en, fr, pt-PT, and zh** (~136k/56k/136k/121k words), and its shape maps
almost 1:1 onto `lexicon.Result`:
| Petal field | DreamDict |
|---|---|
| `Gloss` | `Translate(word, "en", L1)` |
| `Phonetic` | pronunciation (CMU + IPA for en, Wiktionary IPA elsewhere) |
| `Definitions` | `Define(word, lang)` — curated sources ranked above Wiktionary |
| `Synonyms` | `Synonyms(word, lang)` |
It also carries data Petal has no equivalent for and could use: `Antonyms`,
`Frequency`, `Difficulty`, and `Etymology`.
So Phase D stops being gated on data and becomes an integration decision.
### OPEN #6a (new): how to integrate — **SETTLED: Option 3** (ratified 2026-07-26)
Import the package, open `dict.db` read-only. Prerequisite stands: DreamDict's
module path must be renamed (or `replace`-directed) first — **that change
lives in the dreamdict repo, not this one.** The migration caution below also
stands: pt-PT/fr wire to DreamDict first; zh stays on ECDICT until compared on
real lookups. Options kept below for the record.
**Option 1 — HTTP client.** Petal calls DreamDict on localhost:7777, exactly the
pattern already used for Piper TTS (including graceful degradation when it's
down).
- *For:* zero coupling, DreamDict updates independently, all endpoints available.
- *Against:* a second service Petal now depends on at runtime, and the gloss is a
350ms hover tooltip where "the dictionary service is down" is a visible
regression from today's always-there embedded data.
**Option 2 — build-time extraction.** A script (sibling to the existing
`scripts/build_gloss.py`) generates Petal's embedded `.json.gz` datasets per
language from DreamDict's `dict.db`.
- *For:* preserves the embedded/offline property exactly; no runtime dependency;
no architectural change at all.
- *Against:* every language multiplies the binary (the four current gz files are
already ~11.6 MB); updating the dictionary means rebuilding and redeploying
Petal; the richer fields are lost unless separately extracted.
**Option 3 — import the package, open `dict.db` read-only.** DreamDict's
`internal/dictionary` is a plain library with `NewReadOnly(dbPath)`, and its only
dependency is `modernc.org/sqlite` — the same CGO-free driver Petal already uses.
Petal opens `dict.db` as a second read-only handle beside `petal.db`.
- *For:* no service, no HTTP, no new dependency, lookups stay local-file fast,
all four languages at once, and it deletes ~11.6 MB of embedded gz plus the
ECDICT build scripts. One dictionary, maintained once, shared with GogoBee.
- *Against:* Petal stops being a self-contained binary in the "just run it" sense
`dict.db` has to be deployed alongside. In practice Petal already ships a
data directory (`petal.db`, images, TTS cache), so this is a smaller loss than
it first sounds.
**My recommendation: Option 3.** It is the only one that gets all four languages,
keeps lookups offline and instant, and *removes* code rather than adding a
subsystem. Option 1's runtime dependency buys flexibility Petal doesn't need for
a dictionary that changes a few times a year.
**Prerequisite:** DreamDict's module path is currently `module dreamdict`, which
isn't fetchable. Importing it needs the module renamed to something like
`gitea.parodia.dev/drwily/dreamdict` (or a local `replace` directive for
development). Small, but it must happen first.
### Migration caution
Whichever option wins, the zh path is **currently working and in daily use**. The
gloss quality difference between ECDICT and CC-CEDICT is unknown and matters more
than the architecture.
Proposal: introduce DreamDict behind Petal's existing lexicon interface as a
*provider*, wire pt-PT and fr to it first (nothing to regress — they don't exist
yet), and keep zh on ECDICT until the two have been compared on real lookups from
her actual documents. Converge only if quality holds. This also de-risks the whole
change: if DreamDict turns out to be a poor fit, only the unshipped languages are
affected.
### Still per-user regardless
Native language becomes a `users` column, and these become per-user lookups:
LLM prompt copy (`internal/llm/prompts.go`, currently Mandarin-first), companion
tips (`tips.ts`), the L1 Piper voice (Piper has pt-PT voices), and the CJK font
stacks (not needed for Latin-script L1). English-side machinery — nspell en-US,
the phonetic dataset, the EN voice — is unaffected and stays shared.
## 7. Suggested sequence
1. ~~Settle OPEN #1~~ **Settled: in-app OIDC.**
2. Deploy plumbing: Dockerfile, Traefik, real hostname on parodia.dev, HTTPS,
headscale route to vLLM on millenia (bound to the headscale interface
only), VPS-local Piper, off-VPS DB backup. Auth needs a stable `BASE_URL`
and a redirect URI.
3. Auth itself: OIDC `Resolver`, sessions table (30-day sliding), allowlist,
frontend 401 handling with draft preservation.
4. Image store table + migration (same phase, per OPEN #5).
5. Provision the second real account; migrate `local`'s data (script, app
stopped, backup first).
6. `localStorage` namespacing (key by user **and** language — see
SUGGESTIONS.md §8).
7. Per-user language pair. **No longer gated on data** — DreamDict covers all
four languages. Sequence within it: rename DreamDict's module path → wire
it in as a lexicon provider → pt-PT/fr first → compare zh quality →
converge if it holds. The pair model and langpack shape are specified in
SUGGESTIONS.md §1–§3.
These are expanded into checkboxed execution phases in `BUILD_PLAN.md`
(Phase 15 onward) — that file remains the source of truth for progress.
---
## 8. Risks
- **Silent unscoping.** Phase 0 hit this exactly once: `docs.fetch` took a
`userID` parameter and kept binding `db.LocalUserID` in the query. Unused
parameters are legal Go — it compiled, `vet` was silent, and every existing
test passed while the lookup stayed unscoped. Only the two-user isolation test
caught it. **Every new user-scoped endpoint should get an isolation case in the
same commit**; the existing suites are the template.
- **Migrating live data.** The wife's real writing is the thing being moved.
Back up first, run with the app stopped, verify counts before deleting
anything.
- **A 401 mid-draft losing work.** See Phase A.
- **Scope creep into sharing.** Multi-user and collaboration are different
products. Adding accounts should not quietly become adding sharing.
---
## 9. Questions for the reviewer — all answered 2026-07-26
1. ~~Forward-auth or in-app OIDC?~~ **In-app OIDC** (OPEN #1).
2. ~~Session lifetime?~~ **30-day sliding** (OPEN #2).
3. ~~Allowlist?~~ **Yes**, `PETAL_ALLOWED_SUBS` or group claim (OPEN #3).
4. ~~Script vs. admin endpoint?~~ **Script**, app stopped, backup first (OPEN #4).
5. ~~Image store timing?~~ **Same phase as auth** (OPEN #5).
6. ~~pt-PT dictionary data?~~ **DreamDict**, integrated per **Option 3**
(import package, read-only `dict.db`; module rename is the prerequisite)
(OPEN #6a).
7. ~~zh gloss regression risk?~~ **zh stays on ECDICT** until compared against
DreamDict on real lookups from her actual documents; converge only if
quality holds.
+364
View File
@@ -0,0 +1,364 @@
# Petal — product suggestions: becoming essential for language learners
**Status:** written 2026-07-26 against `feat/writing-passport`; **ratified by
the user 2026-07-26** (recommendations accepted — reviewer Q1Q3 settled
below; Q4Q6 remain genuinely open and don't block execution). This document
is the *why*; the checkboxed execution phases live in `BUILD_PLAN.md`
(Phase 15 onward).
**The brief:** make Petal essential for two audiences — ESL writers (native
Mandarin / pt-PT / French → English), and English natives learning Mandarin,
European Portuguese, French, maybe Spanish. Preserve privacy and warmth.
**The language model (settled by the user, 2026-07-26):** every user has
exactly one language **pair, with English always one half** — (en + X),
X ∈ {zh, pt-PT, fr, maybe es}. This is a deliberate scope decision: never an
X↔Y pair without English, never more than one pair per user. The UI is
bilingual in the pair everywhere (tips, pet responses, cards), and the user
may **type in either language of the pair**; Petal infers direction from the
text rather than asking.
---
## 1. What the pair model implies
The wife's zh setup is already exactly this — she writes Mandarin and English
mixed, the UI is zh+en bilingual, and Petal adapts per span (CJK is never
spellchecked, English words gloss to Chinese). So the pair model isn't a new
design; it's a *promotion of today's behavior to the spec*. Three consequences:
- **Schema:** one column, `users.pair_lang` (the X half; default `'zh'`).
No per-document language, no target/native split. Add it in whatever
migration Phase B's provisioning touches — one column now vs. a real
migration later, the same logic that put `user_id` in the schema on day one.
- **Direction is inferred, not declared.** The zh pair gets inference for free
(script boundaries separate the languages). Latin pairs don't — see §3a,
which is the one genuinely new problem the pair model creates.
- **Every bilingual surface stays two-language**, just parameterized: the
`中文 · English` pattern becomes `X · English`. Nothing about the UI's shape
changes, which is why the copy extraction in §2 is safe to do early.
## 2. Languages as data, not code ("langpacks")
Adding pt-PT today means editing code in many places. A quick census: **29
frontend files** carry hardcoded zh-first bilingual strings (`tips.ts`,
`GardenPanel`, `StatusBar`, `WordCard`, every popover…), plus the
Mandarin-first prompt copy in `internal/llm/prompts.go`. Adding each new
language by hunting through those files doesn't scale to four pairs and would
slowly erode the bilingual-copy quality that makes Petal feel cared-for.
Because English is always one half, a **langpack is keyed by X alone** — one
pack per pair, holding everything that varies:
- UI copy pairs — extract the existing `中文 · English` strings into a copy
module; the current strings become the `zh` pack verbatim, so nothing
visible changes. This is the biggest single chore in the whole effort;
better done once than per-language.
- Companion tip/cheer/bedtime lines (`tips.ts` is already data-shaped —
closest to done).
- LLM prompt copy: bilingual explanation phrasing, "natives usually say…"
example pairs, and the both-directions framing (the text may be English, X,
or mixed — respond appropriately).
- Hunspell dictionary for X where one exists (`pt-PT`, `fr`, `es` upstream;
zh has none — see §4). The en-US dictionary is shared by every pair.
- DreamDict wiring: gloss both directions (`en→X` and `X→en`), phonetics.
- Piper voices for X (both for reading X text aloud and as the L1 voice);
font stack (CJK stacks only for zh).
Shared across all pairs, untouched: nspell en-US, the English IPA dataset, the
EN Piper voice, and all of the editor machinery.
This is refactoring, not product, so it's tempting to skip. Don't: it's the
difference between "Spanish is a data drop" and "Spanish is a month."
## 3. Sequencing: Latin-script targets first, and in this order
**pt-PT → fr → es.** Everything needed for these exists already: Hunspell
dictionaries, Piper voices, DreamDict data (en/fr/pt-PT/zh), and — critically —
the entire decoration/anchoring machinery (`wordAt`, spell tokenizer, suggestion
re-anchoring) already works, because these languages are space-delimited and
Latin-script like English.
Caveats worth writing down now:
- **DreamDict has no Spanish.** "Maybe Spanish" is gated on adding es to
DreamDict first, or a separate dataset. Cheap to note, expensive to discover
later.
- **pt-BR drift is the main quality risk.** Qwen will default to Brazilian
Portuguese in both explanations and "natives say…" examples. Prompts must pin
European Portuguese explicitly, and the pt-PT pack should be reviewed by a
pt-PT speaker before it's trusted — same standard the zh copy got by being
written for a real reader. The multi-user plan's ECDICT-vs-DreamDict
compare-on-real-lookups discipline applies here too.
### 3a. The Latin+Latin wrinkle: inferring direction without a script boundary
The zh pair gets "which language is this word?" for free — the script answers
it, and all of today's behavior (CJK never spellchecked, English words gloss
to Chinese) hangs off that. In an en+fr or en+pt pair, both halves are Latin
script, so the two per-word decisions need a real answer:
- **Spellcheck:** load both Hunspell dictionaries and pass a token if *either*
accepts it; flag only words wrong in both. This never falsely squiggles
correct writing in either language — the failure mode is missing a French
word that happens to be a valid English word, which is the gentle direction
to fail in. Correction pills can offer both dictionaries' suggestions.
- **Gloss/WordCard:** look the word up in both directions via DreamDict; if it
exists in only one language, done. For collisions (*chat*, *pain*, *sale*
are all real words in both English and French), show both compactly — a
two-line card ("🇫🇷 chat → cat · 🇬🇧 chat → bavarder") is honest, needs no
detector, and is arguably *delightful* for a learner. A sentence-level
language guess can order the lines, but shouldn't hide either.
No trained language detector, no heuristics that can be wrong about someone's
writing — both-dictionaries membership plus show-both-on-collision covers it.
The LLM passes need nothing: the prompt already sees the mixed text whole.
- The Hunspell tokenizer's current rule "CJK is never tokenized" stays correct
for the zh pair unchanged.
## 4. The zh pair's *other* direction is a separate epic — say so explicitly
The en+zh pair already exists, but only one direction of it is built: today
Petal deliberately ignores typed hanzi (never tokenized, never flagged, never
glossed) — exactly right for a zh-native writer practicing English, and
exactly insufficient for an English native *learning* Chinese, for whom the
hanzi side is the whole point. Supporting that direction breaks assumptions
that are load-bearing everywhere:
- No spaces → `wordAt`, the spell tokenizer, and word-boundary lookups need
real word segmentation (a jieba-style segmenter, client- or server-side).
- Hunspell has no concept of Chinese; "spellcheck" becomes wrong-character
(错别字) detection — a different problem, probably LLM-assisted.
- Smart-typography input rules and the IME interact; input rules are currently
ASCII-gated, which is correct, but selection/caret behavior mid-IME
composition needs testing.
- The learning aids that matter are different: pinyin annotation (useful here,
unlike for the current user who reads hanzi), tone-mark help, HSK-level word
difficulty, hanzi stroke/handwriting practice.
None of this is unbuildable, but it is its **own phase with its own spec**, not
part of the langpack drop. Recommendation: ship the pt-PT/fr pairs first to
prove the pair model, and treat learner-facing Chinese writing as Petal's next
big product bet after that — it's also the most differentiated one (very few
warm, private tools exist for writing practice in Chinese).
## 5. Deepening the learning loop (all local, all gentle)
Petal's suggestion pipeline currently *corrects and forgets*. The vocabulary
garden proved that capturing what the user already does (lookups) creates a
learning surface for free. The same move is available twice more:
### 5a. Growth journal (patterns from accepted suggestions)
Accepted grammar/collocation suggestions are a record of what the writer is
learning. Aggregate them **locally** into gentle patterns: "this month you've
mostly stopped mixing 在/at" / "make a decision has stuck — you've used it
right 4 times since." Two framing rules that keep it warm: it reports *growth*,
never an error tally, and it only ever compares the writer to her own past
self. Feeds the companion's cheer pool with genuinely personal material
("上次你还问过这个词,这次自己用对了! 🌱"). Data is already in the
`suggestions` table (status + type + original/replacement); this is a read-side
feature, no new capture needed.
### 5b. Plant accepted collocations in the garden
An accepted collocation ("do a decision" → "make a decision") is a learnable
chunk, exactly like a looked-up word. Auto-capture it into the vocabulary
garden as a phrase card (the SM-2-lite scheduler doesn't care that it's two
words). The garden then reflects *both* halves of learning: words she sought
out, and phrasing she was gently given.
### 5c. Companion as tutor-lite: a daily invitation to write
The companion nudges about breaks and bedtime but never *invites writing*. A
once-a-day bilingual prompt ("写 50 个字:今天让你微笑的一件小事 · Write 50
words: one small thing that made you smile today"), offered when a session
starts with no doc open. Explicitly **no streaks, no guilt** — the existing
no-streak-shaming ethos in the SR scheduler is the right precedent; a declined
prompt just gets a sleepy "好吧,我继续睡 😴". Prompt lists live in the
native-language pack.
### 5d. Use DreamDict's richer fields
The multi-user plan notes DreamDict carries `Frequency`, `Difficulty`,
`Antonyms`, `Etymology` with "no equivalent" in Petal. Three cheap, high-value
surfaces:
- A **frequency/difficulty chip** in the WordCard ("common word" / "advanced")
— helps a learner decide whether a word is worth gardening.
- **Etymology for the en-native audience**: Romance-language learners live on
cognates; a one-line "from Latin *decidere*, like English *decide*" is the
single best memory hook for pt/fr/es vocabulary.
- **False friends**: a small curated list per pair (en↔pt: *embarrassed*
*embaraçada*-adjacent traps, *actually*/*atualmente*; en↔fr likewise),
surfaced as a warm heads-up in the WordCard and as a collocation-style
gentle flag when one is used suspiciously. Tiny data, disproportionate
trust-building — this is the mistake that makes learners feel foolish, and
catching it kindly is very Petal.
### 5e. Read-aloud, slower
Piper voices exist per target language; wire the target-language voice into the
existing 🔊 surfaces, and add a **slow toggle** (Piper's `length_scale`) —
learners replaying a sentence at 0.75× is one of the oldest, most-loved
listening aids, and it's a query parameter away.
## 6. LLM-minimalism: essential help in plain code, the model as garnish
**Stated by the user (2026-07-26):** with the LLM on the far side of a VPN,
preserve as much essential functionality as possible in ordinary code inside
Petal, and rely on the LLM as little as possible. This deserves to be a
standing design principle, not just a deployment reaction — it's also what
keeps Petal instant (no 38-second checkpoints for things a lookup can answer)
and private by construction.
Where Petal stands today, by dependency:
| Already pure code (survives VPN-down) | LLM-only today |
|---|---|
| Spellcheck (Hunspell), gloss/definitions/synonyms/phonetics (embedded lexicon → DreamDict), thesaurus, vocabulary garden + SR review, search, tags, versions + writing passport, export, find/replace, typography, TTS (Piper, VPS-local) | Grammar checkpoint, collocation coach, voice pass, Ask Petal, tone rewrite |
Everything in §5 lands in the left column by design (growth journal, garden
planting, daily prompts, DreamDict fields, false friends — all lookups and
local aggregation). The right column splits into two groups:
**Worth a code-first layer (the essential two):**
- **Grammar lite** — a rule-pack of high-precision, data-driven checks for
the classic ESL patterns: a/an before vowel sounds, uncountables
("informations", "advices", "furnitures"), subjectverb agreement in simple
clauses, doubled comparatives, common preposition pairs ("depend of" →
"depend on"), per-pair L1-interference rules (zh: dropped articles, he/she
slips; pt/fr: "have X years" for age). These run instantly on every edit —
no debounce, no 30s rate limit — as a fourth suggestion family through the
existing rail. The bar is **precision over recall**: an offline rule must be
near-certain before it flags, because a wrong correction is colder than a
missed one. LanguageTool's open rule corpus is a mineable source for
vetted patterns (extract data, not the Java).
- **Collocation data** — the same curated-list move as false friends: the
do/make, say/tell, strong-tea/heavy-rain families that fill every ESL
collocation workbook are a few hundred entries of data, not a model. A
small embedded miscollocation list catches the top offenders offline; the
LLM pass, when reachable, adds the long tail. Same family, same rail, same
warm phrasing — the writer never needs to know which engine spoke.
**Inherently LLM (degrade warmly, don't imitate):** Ask Petal, tone rewrite,
and the voice pass are open-ended language generation — a code fake would be
worse than the existing honest "小助手在休息" state. Leave them as the
garnish they are.
The framing that falls out: **the LLM never holds essential functionality
hostage.** Every daily-writing need — spell, define, gloss, pronounce, catch
the common mistakes, review vocabulary, prove authorship — works on a
disconnected VPS. The model adds depth and conversation when the tunnel is up.
## 7. The writing passport is an ESL flagship — treat it as one
The passport exists because AI detectors misfire on non-native English (the
commit message cites the Stanford TOEFL finding). That's not a side feature —
for the ESL audience it may be *the* reason to adopt Petal over any other
editor: **the tool that protects you from being wrongly accused, instead of
scoring you.** No product change needed beyond making sure it works identically
for any target language (it should — it's language-agnostic snapshot history).
Worth a prominent place in the README/landing copy when Petal gets one.
## 8. Ties into MULTIUSER_PLAN.md
For the open questions there, this document's brief implies:
- **OPEN #1 (auth):** Option B (in-app OIDC), and the planned deployment
settles it. The user's stated topology (2026-07-26) is: **Petal hosted on
the parodia.dev VPS, reaching vLLM on millenia over headscale VPN.** A
public-internet app is exactly the case where "must never be reachable
except through Traefik" is a footgun — one proxy misconfiguration on a VPS
and forged identity headers reach the app. In-app OIDC is safe to expose
directly.
- **Deployment topology consequences** worth writing into the plan's Phase 2
(deploy plumbing):
- The LLM becomes the only cross-VPN runtime dependency (Piper is already
installed on parodia.dev, so TTS stays VPS-local). The warm
"小助手在休息" degradation path was built for a flaky co-tenant Ollama; a
VPN link-down hits the same path, so the architecture already fails
gently — but checkpoint latency now includes a WAN+VPN round trip, worth
a look at the 60s LLM timeout.
- Everything offline-by-design (DreamDict lookups, spellcheck, gloss,
garden, search, the whole editor) keeps working when the VPN is down —
another argument for MULTIUSER_PLAN Option 3 over an HTTP dictionary
service, which would otherwise add a second cross-machine dependency.
- vLLM and Piper on millenia should bind to the headscale interface only,
never 0.0.0.0 on the LAN-facing side.
- The writing moves onto rented VPS disk. "The writing never leaves the
box" (§8) becomes "the box is a VPS" — at-rest encryption and an
off-VPS backup of `petal.db` (e.g. nightly to millenia over the same
VPN) deserve a line in the deploy phase.
- **OPEN #6a (DreamDict):** Option 3 (import, read-only `dict.db`), agreed —
it's the only option where four languages stay offline and instant, which
§6 and §9 treat as non-negotiable.
- **Phase B provisioning** should set `users.pair_lang` from the operator's
provisioning step or a first-run picker — add the column in the same
migration. (The plan's Phase D "native language becomes a `users` column"
becomes this: one column, the X half of the pair.)
- **localStorage namespacing** matters slightly more than the plan says once a
household mixes pairs: the personal spell dictionary is per-*language* as
well as per-user (a user's en words and pt-PT words must not merge into one
Hunspell overlay). Key by `user + lang`.
## 9. Privacy & warmth guardrails (the checklist for every item above)
Everything suggested here passes these; future ideas should too.
1. **Offline-first, always — and code-first (§6).** Every essential surface
must work with the LLM unreachable and the network unplugged
(DreamDict-as-local-file preserves this; an HTTP dictionary service would
not). The LLM only ever adds depth to something that already works.
Cloud APIs are off the table even when they'd be easier.
2. **The writing never leaves the box.** No telemetry, no "anonymous usage
stats," ever. The growth journal (§5a) is computed locally from local rows.
3. **No scores, no percentages, no red.** Petal already refuses AI-detection
scores and classic red squiggles; the growth journal and false-friend flags
must hold the same line — evidence and gentle phrasing, never grades.
4. **No streaks, no guilt.** The SR scheduler set the precedent (gentle
"again", no wipe). Daily prompts (§5c) are invitations, not obligations.
5. **Both languages of the pair, always visible** — every explanation, tip,
and pet response renders bilingual in (en + X). That's the warmth: being
helped in the language you think in, next to the one you're learning.
6. **The kitten stays asleep.** Every new companion behavior routes through
the existing mood/cooldown engine; 瞌睡猫 keeps mumbling helpful things
without waking up. (A reactive-animation puppy is on the wishlist — low
priority per the user; the `companions.ts` roster + mood engine is already
the drop-in point, richer per-mood Lottie segments are the only new work.)
## 10. Suggested sequence (interleaved with the multi-user plan's)
1. Add the `users.pair_lang` column (with the Phase B migration or sooner).
2. Extract the bilingual UI copy into the langpack (zh pack = today's strings
verbatim; pure refactor, no visible change).
3. DreamDict integration per MULTIUSER_PLAN Option 3 (module rename → lexicon
provider → pt-PT/fr wired first, zh compared before converging).
4. pt-PT as the first full second pair: Hunspell pt-PT, Piper pt-PT voices,
pinned-pt-PT prompts, native-speaker copy review, and the both-dictionaries
spellcheck + show-both-gloss behavior from §3a. French follows the same
groove; Spanish gated on DreamDict es data.
5. Learning-loop features (§5) — each is small and independent; growth journal
and garden-planting of collocations first, since they're read-side over
existing data.
6. Code-first layers (§6): the embedded miscollocation list first (same shape
as false friends, drops into the existing collocation family), then
grammar lite as its own suggestion family. Both are per-pair data, so
they slot naturally into the langpacks from step 2.
7. Learner-facing Chinese writing (the zh pair's second direction): spec it as
its own phase (§4) only after the pair model is proven on pt-PT/fr.
## 11. Questions for the reviewer (13 settled 2026-07-26)
1. ~~§3a's no-detector stance~~ **Settled: yes** — pass if either dictionary
accepts it, show both glosses on collision, no language detector.
2. ~~UI-copy extraction first?~~ **Settled: yes** — the extraction (§2) is a
prerequisite chore, done before pt-PT is wired.
3. ~~Growth journal framing~~ **Settled: build it** with the two framing rules
as hard constraints (growth only, self-comparison only).
4. When (not whether) to build the learner-facing hanzi direction of the zh
pair — after pt-PT/fr, or is it wanted sooner?
5. Spanish: worth asking DreamDict to grow an es dataset now, or park it?
6. Grammar lite (§6): hand-curate the rule pack from ESL teaching materials
(small, fully understood), or mine LanguageTool's open rule corpus for
vetted patterns (bigger head start, needs licensing + quality triage)?
+71 -35
View File
@@ -4,6 +4,7 @@ import (
"crypto/sha256"
"encoding/hex"
"errors"
"flag"
"io/fs"
"log"
"net/http"
@@ -12,6 +13,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/config"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/docs"
@@ -25,8 +27,25 @@ import (
)
func main() {
backupTo := flag.String("backup", "",
"write a consistent copy of the database to this path and exit (no server)")
flag.Parse()
cfg := config.Load()
// Backup mode short-circuits before anything else starts: no migrations, no
// seed, no listener. It runs against the live database safely (VACUUM INTO
// takes only a read transaction), so the nightly job is
// docker compose exec petal /app/petal -backup /data/backups/<name>.db
// against the running container rather than a copy of three WAL files.
if *backupTo != "" {
if err := db.Backup(cfg.DatabasePath, *backupTo); err != nil {
log.Fatalf("backup: %v", err)
}
log.Printf("backup written to %s", *backupTo)
return
}
database, err := db.Open(cfg.DatabasePath)
if err != nil {
log.Fatalf("database: %v", err)
@@ -65,48 +84,65 @@ func main() {
_, _ = w.Write([]byte(`{"version":"` + version + `"}`))
})
llmClient := llm.NewLLMClient(cfg)
sug := suggestions.New(database, llmClient)
// Everything below serves or mutates a particular user's data, so it sits
// behind the auth middleware. /health and /version deliberately stay
// outside it: they carry no user data, and a monitoring probe (or the
// 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.
api.Group(func(pr chi.Router) {
pr.Use(auth.Middleware(auth.StaticResolver(db.LocalUserID)))
// Document CRUD plus the doc-scoped checkpoint/list suggestion routes,
// both under /api/docs.
docsHandler := docs.New(database)
docsRouter := docsHandler.Routes()
sug.RegisterDocRoutes(docsRouter)
api.Mount("/docs", docsRouter)
llmClient := llm.NewLLMClient(cfg)
sug := suggestions.New(database, llmClient)
// Tag management (the roster) and cross-document full-text search.
api.Mount("/tags", docsHandler.TagRoutes())
api.Mount("/search", docsHandler.SearchRoutes())
// Document CRUD plus the doc-scoped checkpoint/list suggestion routes,
// both under /api/docs.
docsHandler := docs.New(database)
docsRouter := docsHandler.Routes()
sug.RegisterDocRoutes(docsRouter)
pr.Mount("/docs", docsRouter)
// Per-suggestion actions (accept/dismiss) under /api/suggestions.
api.Mount("/suggestions", sug.Routes())
// Tag management (the roster) and cross-document full-text search.
pr.Mount("/tags", docsHandler.TagRoutes())
pr.Mount("/search", docsHandler.SearchRoutes())
// Offline lexicon: full word lookups (gloss + definition + synonyms) for
// the right-click popover, and the lightweight Chinese-only gloss for the
// inline hover/select tooltip. One handler so the datasets load once.
lex := lexicon.NewHandler()
api.Mount("/word", lex.Routes())
api.Mount("/gloss", lex.GlossRoutes())
// Per-suggestion actions (accept/dismiss) under /api/suggestions.
pr.Mount("/suggestions", sug.Routes())
// Vocabulary garden: words the writer looks up are captured here and
// surfaced for gentle spaced-repetition review.
api.Mount("/vocab", vocab.New(database).Routes())
// Offline lexicon: full word lookups (gloss + definition + synonyms) for
// the right-click popover, and the lightweight Chinese-only gloss for the
// inline hover/select tooltip. One handler so the datasets load once.
// The dataset is static and identical for everyone, but it stays behind
// auth so the API surface has no unauthenticated read holes.
lex := lexicon.NewHandler()
pr.Mount("/word", lex.Routes())
pr.Mount("/gloss", lex.GlossRoutes())
// Editor image uploads, stored on disk and served back by content hash.
imgHandler, err := images.New(cfg.ImageDir)
if err != nil {
log.Fatalf("image store: %v", err)
}
api.Mount("/images", imgHandler.Routes())
// Vocabulary garden: words the writer looks up are captured here and
// surfaced for gentle spaced-repetition review.
pr.Mount("/vocab", vocab.New(database).Routes())
// Read-aloud: proxy short passages to a local Piper TTS server. Only
// mounted when TTS_ENDPOINT is configured; otherwise the frontend falls
// back to the browser's Web Speech API on its own.
if ttsHandler, ok := tts.New(cfg); ok {
api.Mount("/tts", ttsHandler.Routes())
log.Printf("read-aloud enabled (TTS endpoint=%s)", cfg.TTSEndpoint)
}
// Editor image uploads, stored on disk and served back by content hash.
imgHandler, err := images.New(cfg.ImageDir)
if err != nil {
log.Fatalf("image store: %v", err)
}
pr.Mount("/images", imgHandler.Routes())
// Read-aloud: proxy short passages to a local Piper TTS server. Only
// mounted when TTS_ENDPOINT is configured; otherwise the frontend falls
// back to the browser's Web Speech API on its own.
if ttsHandler, ok := tts.New(cfg); ok {
pr.Mount("/tts", ttsHandler.Routes())
log.Printf("read-aloud enabled (TTS endpoint=%s)", cfg.TTSEndpoint)
}
})
})
// Everything else: serve the embedded SPA (with index.html fallback for client routing).
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Nightly off-VPS backup of Petal's database.
#
# ./backup-petal.sh # snapshot, compress, push off-box, prune
# ./backup-petal.sh --local-only # snapshot + prune, skip the remote push
#
# Run it from cron on the VPS (see deploy/README.md). The snapshot itself goes
# through `petal -backup`, which uses SQLite's VACUUM INTO: one coherent file
# including anything still in the WAL, taken without a write lock, so it is
# safe against the live running app. That is why this script never touches
# petal.db / -wal / -shm directly — copying those three separately can capture
# a torn mid-checkpoint state.
#
# Everything below is overridable from the environment.
set -euo pipefail
# Stack directory (holds docker-compose.yml and ./data).
STACK_DIR="${STACK_DIR:-$HOME/petal}"
# Where snapshots land on the VPS before being pushed off-box. Inside ./data so
# the container can write it through the existing bind mount.
LOCAL_DIR="${LOCAL_DIR:-$STACK_DIR/data/backups}"
# Off-VPS destination: millenia over headscale. Empty disables the push.
REMOTE_HOST="${REMOTE_HOST:-100.64.0.2}"
REMOTE_USER="${REMOTE_USER:-}"
REMOTE_DIR="${REMOTE_DIR:-petal-backups}"
# Retention, in days, on each side.
KEEP_LOCAL_DAYS="${KEEP_LOCAL_DAYS:-7}"
KEEP_REMOTE_DAYS="${KEEP_REMOTE_DAYS:-30}"
local_only=0
[ "${1:-}" = "--local-only" ] && local_only=1
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
name="petal-${stamp}.db"
cd "$STACK_DIR"
echo ">> snapshotting to data/backups/${name}"
# The container writes to its own /data mount; ./data/backups is the same
# directory seen from the host.
docker compose exec -T petal /app/petal -backup "/data/backups/${name}"
snapshot="${LOCAL_DIR}/${name}"
[ -s "$snapshot" ] || { echo "snapshot missing or empty: $snapshot" >&2; exit 1; }
echo ">> compressing"
gzip -9 "$snapshot"
archive="${snapshot}.gz"
echo " $(du -h "$archive" | cut -f1) ${archive}"
if [ "$local_only" -eq 0 ] && [ -n "$REMOTE_HOST" ]; then
target="${REMOTE_HOST}"
[ -n "$REMOTE_USER" ] && target="${REMOTE_USER}@${REMOTE_HOST}"
echo ">> pushing to ${target}:${REMOTE_DIR}/"
ssh -o BatchMode=yes "$target" "mkdir -p '${REMOTE_DIR}'"
scp -q -o BatchMode=yes "$archive" "${target}:${REMOTE_DIR}/"
# Verify by size rather than trusting scp's exit code alone — a truncated
# transfer that still exits 0 would leave a backup that only looks fine.
local_size="$(stat -c%s "$archive")"
remote_size="$(ssh -o BatchMode=yes "$target" "stat -c%s '${REMOTE_DIR}/$(basename "$archive")'")"
if [ "$local_size" != "$remote_size" ]; then
echo "size mismatch after transfer: local ${local_size}, remote ${remote_size}" >&2
exit 1
fi
echo " verified ${remote_size} bytes"
echo ">> pruning remote copies older than ${KEEP_REMOTE_DAYS} days"
ssh -o BatchMode=yes "$target" \
"find '${REMOTE_DIR}' -name 'petal-*.db.gz' -type f -mtime +${KEEP_REMOTE_DAYS} -delete"
elif [ "$local_only" -eq 1 ]; then
echo ">> --local-only: skipping the remote push"
else
echo ">> REMOTE_HOST is empty: skipping the remote push" >&2
fi
echo ">> pruning local copies older than ${KEEP_LOCAL_DAYS} days"
find "$LOCAL_DIR" -name 'petal-*.db.gz' -type f -mtime "+${KEEP_LOCAL_DAYS}" -delete
echo ">> done"
+49
View File
@@ -0,0 +1,49 @@
# Petal — production environment for the parodia.dev VPS.
# Copy to the stack directory as `.env` (docker-compose.yml reads it via
# env_file) and fill in the model names. Values the image already fixes
# (PORT, DATABASE_PATH, IMAGE_DIR, TTS_CACHE_DIR, TTS endpoints) are set in
# docker-compose.yml, not here.
# --- Routing -----------------------------------------------------------------
# Must match the DNS A record and the Traefik Host() rule.
PETAL_HOST=petal.parodia.dev
# Absolute origin the app knows itself by. Phase 16's OIDC redirect URI is
# built from this, so it has to be the real public HTTPS origin.
BASE_URL=https://petal.parodia.dev
# The companion's bedtime nag and the night theme read the container clock.
TZ=Europe/Lisbon
# --- LLM (millenia, over headscale) ------------------------------------------
# The only cross-VPN dependency. Petal degrades warmly when it's unreachable:
# spell check, gloss, garden, search, export and read-aloud all keep working and
# the status bar shows 小助手在休息 · Petal's helper is resting.
#
# 100.64.0.2 is millenia on the headscale network. vLLM must be bound to that
# interface (NOT 0.0.0.0 — this host is public); see deploy/README.md.
LLM_BACKEND=vllm
LLM_ENDPOINT=http://100.64.0.2:8000
LLM_MODEL=
LLM_CHAT_MODEL=
# 30s is the local-network default. Over WAN + VPN, with the voice and
# collocation passes sending a whole document, that truncates real work — the
# request is a hard deadline on Complete, and a timeout surfaces as the same
# warm 502 as an unreachable model. 90s leaves headroom without letting a
# genuinely wedged backend hang the pass forever.
LLM_TIMEOUT=90s
# --- Read-aloud (Piper sidecars) ---------------------------------------------
# Endpoints are wired in docker-compose.yml; these pick the voice each sidecar
# loads. Changing one means recreating that container so it downloads the model.
TTS_VOICE_EN=en_US-amy-medium
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
# AUTHENTIK_CLIENT_ID=petal
# AUTHENTIK_CLIENT_SECRET=
# PETAL_ALLOWED_SUBS=
+37
View File
@@ -0,0 +1,37 @@
# Piper neural-TTS HTTP server — the read-aloud backend Petal proxies to.
#
# One image, any voice: the model is named by PIPER_VOICE at runtime and
# downloaded into the shared /voices volume on first start. Each Piper server
# loads exactly one voice, so a new language is a new service in
# docker-compose.yml, not a new image (English and Chinese today; pt-PT lands
# with the Portuguese pair).
#
# python:3.12 rather than 3.13 — piper-tts pulls onnxruntime, whose wheel
# coverage for 3.13 still lags.
FROM python:3.12-slim
RUN pip install --no-cache-dir "piper-tts[http]" \
&& useradd -m -u 10002 piper
ENV PIPER_VOICE=en_US-amy-medium \
PIPER_DATA_DIR=/voices \
PIPER_PORT=5000
RUN mkdir -p /voices && chown piper:piper /voices
VOLUME ["/voices"]
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
USER piper
EXPOSE 5000
# The server has no dedicated health route, so synthesizing a single word is
# the honest check: it proves the model loaded, not just that a port is open.
HEALTHCHECK --interval=60s --timeout=20s --start-period=180s --retries=3 \
CMD python -c "import os,urllib.request,json; \
urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:'+os.environ['PIPER_PORT']+'/', \
data=json.dumps({'text':'ok','voice':os.environ['PIPER_VOICE']}).encode(), \
headers={'Content-Type':'application/json'}), timeout=15).read(1)"
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Fetch the configured voice if the shared volume doesn't have it yet, then
# serve it. The download is the only step that needs the internet, and it runs
# once per voice for the life of the volume — Petal itself stays offline-first.
set -euo pipefail
voice="${PIPER_VOICE:?PIPER_VOICE must be set}"
data_dir="${PIPER_DATA_DIR:-/voices}"
port="${PIPER_PORT:-5000}"
if [ ! -f "${data_dir}/${voice}.onnx" ]; then
echo ">> downloading voice ${voice} into ${data_dir}"
python -m piper.download_voices "${voice}" --data-dir "${data_dir}"
fi
echo ">> serving ${voice} on :${port}"
# 0.0.0.0 is safe here: the container sits on Petal's internal compose network
# with no published ports, so only Petal can reach it.
exec python -m piper.http_server \
-m "${voice}" \
--data-dir "${data_dir}" \
--host 0.0.0.0 --port "${port}"
+103
View File
@@ -0,0 +1,103 @@
# Petal on the parodia.dev VPS.
#
# docker compose up -d --build
#
# Fronted by the host's existing Traefik (external `traefik` network, the
# `web-secure` entrypoint and the `default` cert resolver — same convention the
# other services on this box use). Petal itself never binds a host port; the
# only way in is through Traefik over HTTPS.
#
# Read-aloud runs as two sibling containers rather than host systemd services:
# each Piper HTTP server loads exactly one voice, the host has no lingering
# user session to keep systemd units alive, and keeping them on the internal
# network means the TTS ports are unreachable from anywhere but Petal.
#
# Copy deploy/petal.env.example to .env before the first `up`.
name: petal
services:
petal:
build:
context: .
dockerfile: Dockerfile
image: petal:local
container_name: petal
restart: unless-stopped
env_file: .env
environment:
# Fixed by the image layout; kept here so they're visible at a glance.
PORT: "8080"
DATABASE_PATH: /data/petal.db
IMAGE_DIR: /data/images
TTS_CACHE_DIR: /data/tts
# Piper sidecars. Each server loads one voice, so English and Chinese are
# separate containers; the handler maps language → instance from config.
TTS_ENDPOINT: http://piper-en:5000
TTS_ENDPOINT_ZH: http://piper-zh:5000
# The companion's bedtime nag and night mode read the local clock.
TZ: ${TZ:-Europe/Lisbon}
volumes:
# A bind mount, not a named volume: petal.db must be trivially reachable
# from the host for the nightly backup and for a restore.
- ./data:/data
networks:
- traefik
- internal
depends_on:
- piper-en
- piper-zh
labels:
traefik.enable: "true"
traefik.docker.network: traefik
traefik.http.routers.petal.rule: Host(`${PETAL_HOST:-petal.parodia.dev}`)
traefik.http.routers.petal.entrypoints: web-secure
traefik.http.routers.petal.tls: "true"
traefik.http.routers.petal.tls.certResolver: default
traefik.http.routers.petal.service: petal
traefik.http.routers.petal.middlewares: compression@file,petal-headers
traefik.http.services.petal.loadbalancer.server.port: "8080"
# Petal is a private writing space: no framing, no sniffing, HSTS on.
traefik.http.middlewares.petal-headers.headers.customresponseheaders.Content-Security-Policy: frame-ancestors 'self'
traefik.http.middlewares.petal-headers.headers.customresponseheaders.Strict-Transport-Security: max-age=31536000; includeSubDomains
traefik.http.middlewares.petal-headers.headers.customresponseheaders.X-Content-Type-Options: nosniff
traefik.http.middlewares.petal-headers.headers.customresponseheaders.Referrer-Policy: same-origin
piper-en:
build:
context: deploy/piper
image: petal-piper:local
container_name: petal-piper-en
restart: unless-stopped
environment:
PIPER_VOICE: ${TTS_VOICE_EN:-en_US-amy-medium}
volumes:
- piper-voices:/voices
networks:
- internal
piper-zh:
build:
context: deploy/piper
image: petal-piper:local
container_name: petal-piper-zh
restart: unless-stopped
environment:
PIPER_VOICE: ${TTS_VOICE_ZH:-zh_CN-huayan-medium}
volumes:
- piper-voices:/voices
networks:
- internal
networks:
# Created and owned by the host's Traefik stack.
traefik:
external: true
# Petal ↔ Piper only. Not reachable from the internet or the other stacks.
internal:
driver: bridge
volumes:
# Downloaded voice models, shared read-mostly by both Piper instances so the
# same model is never fetched twice.
piper-voices:
+77
View File
@@ -0,0 +1,77 @@
// Package auth answers one question for every API request: who is asking?
//
// Until now Petal ran as a single hardcoded user and every query passed
// db.LocalUserID directly. That made the identity of the caller a compile-time
// constant scattered across ~35 call sites — nothing a real login could ever
// replace without touching all of them. This package moves that identity into
// the request context, resolved once by [Middleware], so handlers read the
// current user instead of naming one.
//
// The identity itself still comes from [StaticResolver] today, which returns
// the same local user for everyone. Swapping in Authentik later means writing
// one Resolver (validate the session cookie → user id) and changing the single
// line in main.go that constructs it. No handler changes.
package auth
import (
"context"
"net/http"
"gitea.parodia.dev/drwily/petal/internal/httputil"
)
// ctxKey is unexported so no other package can plant a user id in the context
// without going through [WithUser].
type ctxKey struct{}
// WithUser returns a copy of ctx carrying userID as the authenticated caller.
// Handlers never call this; [Middleware] does, and tests use it to build a
// request that looks authenticated.
func WithUser(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, ctxKey{}, userID)
}
// UserID returns the authenticated user id carried by ctx, or "" if the request
// never passed through [Middleware].
//
// Returning "" rather than panicking keeps an unauthenticated request failing
// *closed*: every query in Petal is scoped `WHERE user_id = ?`, so an empty id
// matches no rows — a missing middleware leaks nothing, it just returns empty
// results. Handlers may therefore use the value directly without checking it.
func UserID(ctx context.Context) string {
id, _ := ctx.Value(ctxKey{}).(string)
return id
}
// Resolver maps an inbound request to the id of the user making it. Returning
// an error, or an empty id, rejects the request with a 401.
//
// This is the seam a real identity provider drops into: an Authentik resolver
// validates the session cookie and returns the user id it maps to.
type Resolver interface {
Resolve(r *http.Request) (string, error)
}
// StaticResolver resolves every request to the same user id, ignoring the
// request entirely. It is how Petal runs today — a single-user app whose one
// user now arrives through the same path a logged-in user eventually will.
type StaticResolver string
// Resolve implements [Resolver].
func (s StaticResolver) Resolve(*http.Request) (string, error) { return string(s), nil }
// Middleware resolves the caller with res and stores the result in the request
// context for [UserID]. Requests the resolver rejects — or resolves to an empty
// id — never reach the handler; they get a 401 instead.
func Middleware(res Resolver) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID, err := res.Resolve(r)
if err != nil || userID == "" {
httputil.ErrorJSON(w, http.StatusUnauthorized, "not signed in")
return
}
next.ServeHTTP(w, r.WithContext(WithUser(r.Context(), userID)))
})
}
}
+75
View File
@@ -0,0 +1,75 @@
package auth
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
// errResolver rejects every request, standing in for a real resolver that finds
// no valid session.
type errResolver struct{ err error }
func (e errResolver) Resolve(*http.Request) (string, error) { return "", e.err }
func TestUserIDRoundTrip(t *testing.T) {
ctx := WithUser(context.Background(), "alice")
if got := UserID(ctx); got != "alice" {
t.Fatalf("UserID = %q, want alice", got)
}
}
// A request that never passed through the middleware must report no user rather
// than panicking — every query is `WHERE user_id = ?`, so an empty id fails
// closed (matches nothing) instead of falling back to some default account.
func TestUserIDAbsentIsEmpty(t *testing.T) {
if got := UserID(context.Background()); got != "" {
t.Fatalf("UserID on bare context = %q, want empty", got)
}
}
func TestMiddlewareInjectsResolvedUser(t *testing.T) {
var seen string
h := Middleware(StaticResolver("local"))(http.HandlerFunc(
func(_ http.ResponseWriter, r *http.Request) { seen = UserID(r.Context()) },
))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if seen != "local" {
t.Fatalf("handler saw user %q, want local", seen)
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
}
// Both rejection paths — an explicit error and a silent empty id — must 401
// without ever entering the handler. The empty case matters most: a resolver
// that returns ("", nil) by mistake would otherwise hand handlers an empty user
// id, and while that fails closed at the SQL layer, it should never get there.
func TestMiddlewareRejectsUnresolved(t *testing.T) {
for name, res := range map[string]Resolver{
"resolver error": errResolver{err: http.ErrNoCookie},
"empty user id": StaticResolver(""),
} {
t.Run(name, func(t *testing.T) {
called := false
h := Middleware(res)(http.HandlerFunc(
func(http.ResponseWriter, *http.Request) { called = true },
))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if called {
t.Fatal("handler ran for an unauthenticated request")
}
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
})
}
}
+79
View File
@@ -0,0 +1,79 @@
package db
import (
"database/sql"
"fmt"
"os"
"path/filepath"
)
// Backup writes a consistent copy of the database at srcPath to destPath using
// SQLite's `VACUUM INTO`.
//
// Why not copy the file: Petal runs in WAL mode, so at any instant the newest
// committed pages may live in petal.db-wal rather than petal.db. Copying the
// three files separately can capture them mid-checkpoint and produce a backup
// that is subtly torn. `VACUUM INTO` runs inside a read transaction, so it sees
// one coherent snapshot including the WAL, and emits a single defragmented file
// with no -wal/-shm companions — exactly what you want to ship off-box.
//
// It takes no write lock, so this is safe to run against the live database
// while someone is writing.
//
// destPath must not already exist: SQLite refuses to overwrite, which keeps a
// failed run from destroying the previous good backup.
func Backup(srcPath, destPath string) error {
if _, err := os.Stat(srcPath); err != nil {
return fmt.Errorf("source database: %w", err)
}
if _, err := os.Stat(destPath); err == nil {
return fmt.Errorf("destination %s already exists", destPath)
}
if dir := filepath.Dir(destPath); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create backup dir: %w", err)
}
}
// Opened directly rather than through Open: a backup must never migrate or
// seed the database it is copying.
conn, err := sql.Open("sqlite", dsn(srcPath))
if err != nil {
return fmt.Errorf("open source: %w", err)
}
defer conn.Close()
conn.SetMaxOpenConns(1)
if err := conn.Ping(); err != nil {
return fmt.Errorf("ping source: %w", err)
}
// The path is interpolated because VACUUM INTO takes a literal, not a bound
// parameter. Quotes are doubled so a path containing one can't break out.
quoted := "'" + escapeSQLiteString(destPath) + "'"
if _, err := conn.Exec("VACUUM INTO " + quoted); err != nil {
return fmt.Errorf("vacuum into %s: %w", destPath, err)
}
// A zero-byte result would mean the vacuum silently produced nothing; catch
// it here rather than discovering it during a restore.
info, err := os.Stat(destPath)
if err != nil {
return fmt.Errorf("stat backup: %w", err)
}
if info.Size() == 0 {
return fmt.Errorf("backup %s is empty", destPath)
}
return nil
}
func escapeSQLiteString(s string) string {
out := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
if s[i] == '\'' {
out = append(out, '\'')
}
out = append(out, s[i])
}
return string(out)
}
+108
View File
@@ -0,0 +1,108 @@
package db
import (
"database/sql"
"os"
"path/filepath"
"testing"
)
// The point of VACUUM INTO over a file copy is that it captures rows still
// sitting in the WAL. This writes with the source connection open (so the WAL
// is hot and unlikely to have been checkpointed) and asserts the backup has
// them.
func TestBackupCapturesLiveWrites(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "petal.db")
dest := filepath.Join(dir, "backups", "petal-backup.db")
d, err := Open(src)
if err != nil {
t.Fatalf("open source: %v", err)
}
defer d.Close()
if _, err := d.Exec(
`INSERT INTO documents (id, user_id, title, content_text) VALUES ('d1', ?, '春天', 'hello 春天')`,
LocalUserID,
); err != nil {
t.Fatalf("insert: %v", err)
}
if err := Backup(src, dest); err != nil {
t.Fatalf("backup: %v", err)
}
// VACUUM INTO emits a single self-contained file — no -wal/-shm to ship
// alongside it.
for _, suffix := range []string{"-wal", "-shm"} {
if _, err := os.Stat(dest + suffix); err == nil {
t.Errorf("backup left a %s companion file behind", suffix)
}
}
copyConn, err := sql.Open("sqlite", dsn(dest))
if err != nil {
t.Fatalf("open backup: %v", err)
}
defer copyConn.Close()
var title string
if err := copyConn.QueryRow(`SELECT title FROM documents WHERE id = 'd1'`).Scan(&title); err != nil {
t.Fatalf("row missing from backup: %v", err)
}
if title != "春天" {
t.Errorf("title = %q, want 春天", title)
}
// The seeded user has to come across too, or a restore would orphan every
// document's foreign key.
var users int
if err := copyConn.QueryRow(`SELECT COUNT(*) FROM users WHERE id = ?`, LocalUserID).Scan(&users); err != nil {
t.Fatalf("count users: %v", err)
}
if users != 1 {
t.Errorf("users in backup = %d, want 1", users)
}
}
// A second run to the same path must fail loudly rather than clobber or
// half-write the previous good backup.
func TestBackupRefusesExistingDestination(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "petal.db")
dest := filepath.Join(dir, "petal-backup.db")
d, err := Open(src)
if err != nil {
t.Fatalf("open source: %v", err)
}
defer d.Close()
if err := Backup(src, dest); err != nil {
t.Fatalf("first backup: %v", err)
}
before, err := os.ReadFile(dest)
if err != nil {
t.Fatalf("read backup: %v", err)
}
if err := Backup(src, dest); err == nil {
t.Fatal("second backup to the same path succeeded; want an error")
}
after, err := os.ReadFile(dest)
if err != nil {
t.Fatalf("re-read backup: %v", err)
}
if len(before) != len(after) {
t.Errorf("existing backup was modified: %d bytes → %d", len(before), len(after))
}
}
func TestBackupMissingSource(t *testing.T) {
dir := t.TempDir()
if err := Backup(filepath.Join(dir, "nope.db"), filepath.Join(dir, "out.db")); err == nil {
t.Fatal("backup of a nonexistent database succeeded; want an error")
}
}
+25
View File
@@ -365,6 +365,31 @@ SELECT id, doc_id, from_pos, to_pos, original, replacement, explanation, type, s
DROP TABLE suggestions;
ALTER TABLE suggestions_new RENAME TO suggestions;
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
`,
},
{
// Writing passport: evidence that a document was written, not pasted.
//
// `preserve_history` opts a document out of auto-snapshot pruning. The
// 40-snapshot cap is right for recovery (you want recent states) but
// wrong for provenance (you want the *whole* span, oldest included), so
// a writer who may need to defend authorship flags the doc and keeps
// every snapshot.
//
// `content_hash`/`prev_hash` chain each snapshot to the one before it:
// hash = sha256(prev_hash | doc_id | created_at | word_count | text).
// This proves the local history is internally consistent — no snapshot
// was edited, reordered, or removed after the fact without breaking
// every link downstream. It is NOT third-party attestation: anyone with
// the DB and the algorithm could forge a fresh chain. It raises the cost
// of a doctored history from "edit one row" to "rebuild all of them".
// Pre-existing snapshots keep empty hashes and are reported as
// unverifiable rather than as failures.
name: "0009_writing_passport",
stmt: `
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 '';
`,
},
}
+11
View File
@@ -25,6 +25,10 @@ type Document struct {
WordCount int `json:"word_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// PreserveHistory opts this document out of auto-snapshot pruning so its
// full writing trail survives as authorship evidence (see the passport).
PreserveHistory bool `json:"preserve_history"`
}
// DocumentVersion is a point-in-time snapshot of a document's body, captured so
@@ -42,6 +46,13 @@ type DocumentVersion struct {
WordCount int `json:"word_count"`
Kind string `json:"kind"` // auto | manual | pre_restore
CreatedAt time.Time `json:"created_at"`
// ContentHash chains this snapshot to the previous one (PrevHash), so a
// history that was edited or thinned after the fact fails verification.
// Both are empty for snapshots taken before the chain existed. Omitted from
// list responses; the passport loads them explicitly.
ContentHash string `json:"content_hash,omitempty"`
PrevHash string `json:"prev_hash,omitempty"`
}
// Document version kinds, mirrored from the schema CHECK constraint.
+3 -2
View File
@@ -13,6 +13,7 @@ import (
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
)
@@ -46,7 +47,7 @@ func (h *Handler) exportAll(w http.ResponseWriter, r *http.Request) {
FROM documents
WHERE user_id = ?
ORDER BY updated_at DESC`,
db.LocalUserID,
auth.UserID(r.Context()),
)
if err != nil {
httputil.ServerError(w, err)
@@ -143,7 +144,7 @@ func (h *Handler) export(w http.ResponseWriter, r *http.Request) {
return
}
doc, err := h.fetch(chi.URLParam(r, "id"))
doc, err := h.fetch(auth.UserID(r.Context()), chi.URLParam(r, "id"))
if errors.Is(err, sql.ErrNoRows) {
notFound(w)
return
+28 -14
View File
@@ -1,6 +1,7 @@
// Package docs implements the document CRUD HTTP handlers — the create / list /
// read / update / delete surface that backs the editor and its 1.5s auto-save.
// All access is scoped to the single hardcoded local user while auth is deferred.
// Every query is scoped to the caller resolved by the auth middleware, so a
// document is only ever reachable by the user who owns it.
package docs
import (
@@ -12,6 +13,7 @@ import (
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
)
@@ -52,15 +54,16 @@ type docSummary struct {
Tags []db.Tag `json:"tags"`
}
// list returns the local user's documents, most-recently-updated first, each
// list returns the caller's documents, most-recently-updated first, each
// decorated with its tags.
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
userID := auth.UserID(r.Context())
rows, err := h.DB.Query(
`SELECT id, title, word_count, updated_at
FROM documents
WHERE user_id = ?
ORDER BY updated_at DESC`,
db.LocalUserID,
userID,
)
if err != nil {
httputil.ServerError(w, err)
@@ -84,7 +87,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
return
}
byDoc, err := h.tagsByDoc(ids)
byDoc, err := h.tagsByDoc(userID, ids)
if err != nil {
httputil.ServerError(w, err)
return
@@ -104,7 +107,7 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
err := h.DB.QueryRow(
`INSERT INTO documents (user_id) VALUES (?)
RETURNING id, user_id, title, content, content_text, tone, word_count, created_at, updated_at`,
db.LocalUserID,
auth.UserID(r.Context()),
).Scan(
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
@@ -118,7 +121,7 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
// get returns a single full document by id.
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
doc, err := h.fetch(chi.URLParam(r, "id"))
doc, err := h.fetch(auth.UserID(r.Context()), chi.URLParam(r, "id"))
if errors.Is(err, sql.ErrNoRows) {
notFound(w)
return
@@ -139,12 +142,17 @@ type updateRequest struct {
ContentText *string `json:"content_text"`
Tone *string `json:"tone"`
WordCount *int `json:"word_count"`
// PreserveHistory toggles the passport's keep-everything mode. Sent alone
// by the History panel's toggle, never by the auto-save path.
PreserveHistory *bool `json:"preserve_history"`
}
// update applies the provided fields to a document and returns the saved row.
// content and content_text are kept in sync by the client and written together.
func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
userID := auth.UserID(r.Context())
var req updateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -159,9 +167,11 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
content_text = COALESCE(?, content_text),
tone = COALESCE(?, tone),
word_count = COALESCE(?, word_count),
preserve_history = COALESCE(?, preserve_history),
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?`,
req.Title, req.Content, req.ContentText, req.Tone, req.WordCount, id, db.LocalUserID,
req.Title, req.Content, req.ContentText, req.Tone, req.WordCount,
req.PreserveHistory, id, userID,
)
if err != nil {
httputil.ServerError(w, err)
@@ -172,7 +182,7 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
return
}
doc, err := h.fetch(id)
doc, err := h.fetch(userID, id)
if err != nil {
httputil.ServerError(w, err)
return
@@ -194,7 +204,7 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
res, err := h.DB.Exec(
`DELETE FROM documents WHERE id = ? AND user_id = ?`,
chi.URLParam(r, "id"), db.LocalUserID,
chi.URLParam(r, "id"), auth.UserID(r.Context()),
)
if err != nil {
httputil.ServerError(w, err)
@@ -207,17 +217,21 @@ func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// fetch loads one full document scoped to the local user.
func (h *Handler) fetch(id string) (db.Document, error) {
// fetch loads one full document, scoped to its owner. Callers pass the id from
// [auth.UserID]; a document belonging to anyone else comes back as
// sql.ErrNoRows, which handlers surface as a 404 rather than a 403 (a stranger's
// document should be indistinguishable from one that doesn't exist).
func (h *Handler) fetch(userID, id string) (db.Document, error) {
var doc db.Document
err := h.DB.QueryRow(
`SELECT id, user_id, title, content, content_text, tone, word_count, created_at, updated_at
`SELECT id, user_id, title, content, content_text, tone, word_count,
created_at, updated_at, preserve_history
FROM documents
WHERE id = ? AND user_id = ?`,
id, db.LocalUserID,
id, userID,
).Scan(
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt, &doc.PreserveHistory,
)
return doc, err
}
+12 -2
View File
@@ -8,10 +8,14 @@ import (
"path/filepath"
"testing"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// newTestServer spins up an isolated on-disk database and the docs router.
// newTestServer spins up an isolated on-disk database and the docs router,
// behind the same auth middleware main.go installs. Tests must go through it:
// handlers read the caller from the request context, so a router mounted bare
// would see an empty user id and match no rows.
func newTestServer(t *testing.T) http.Handler {
t.Helper()
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
@@ -19,7 +23,13 @@ func newTestServer(t *testing.T) http.Handler {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { database.Close() })
return New(database).Routes()
return withAuth(New(database).Routes())
}
// withAuth wraps a router so every test request arrives authenticated as the
// seeded local user — the stand-in for a real session until Authentik lands.
func withAuth(h http.Handler) http.Handler {
return auth.Middleware(auth.StaticResolver(db.LocalUserID))(h)
}
func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder {
+222
View File
@@ -0,0 +1,222 @@
package docs
import (
"encoding/json"
"net/http"
"path/filepath"
"testing"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// This file is the point of the auth plumbing: it proves that swapping the
// hardcoded user for a request-scoped one actually isolates accounts. Every
// handler resolves its user from the request, so mounting the same routers twice
// behind two different resolvers gives us two "logged-in" users over one
// database — which is exactly the situation a real login will create.
// newTwoUserServer opens one database holding two users and returns a router for
// each, identical but for who the auth middleware says is calling.
func newTwoUserServer(t *testing.T) (alice, bob http.Handler) {
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() })
// db.Open seeds the local user; add a second so both sides have a valid FK.
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)
}
mount := func(userID string) http.Handler {
h := New(database)
r := chi.NewRouter()
r.Mount("/docs", h.Routes())
r.Mount("/tags", h.TagRoutes())
r.Mount("/search", h.SearchRoutes())
return auth.Middleware(auth.StaticResolver(userID))(r)
}
return mount(db.LocalUserID), mount("bob")
}
// TestDocumentIsolation walks every read and write path that takes a document id
// and asserts Bob cannot reach Alice's document through any of them. A stranger's
// document must be indistinguishable from a nonexistent one — 404, never 403.
func TestDocumentIsolation(t *testing.T) {
alice, bob := newTwoUserServer(t)
docID := createDoc(t, alice, "Alice's diary", "a private sentence about my day")
t.Run("not in list", func(t *testing.T) {
rec := do(t, bob, http.MethodGet, "/docs", "")
var out []docSummary
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode list: %v", err)
}
if len(out) != 0 {
t.Fatalf("bob sees %d of alice's documents, want 0", len(out))
}
})
t.Run("not in search", func(t *testing.T) {
rec := do(t, bob, http.MethodGet, "/search?q=private", "")
var out []searchResult
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode search: %v", err)
}
if len(out) != 0 {
t.Fatalf("search leaked %d of alice's documents", len(out))
}
})
// The FTS index is a separate table joined back to documents; a missing
// user_id filter there would leak content even though the list query is
// scoped, so assert the owner still finds her own document.
t.Run("owner still finds it", func(t *testing.T) {
rec := do(t, alice, http.MethodGet, "/search?q=private", "")
var out []searchResult
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode search: %v", err)
}
if len(out) != 1 {
t.Fatalf("alice found %d results for her own document, want 1", len(out))
}
})
for _, tc := range []struct {
name, method, path, body string
}{
{"get", http.MethodGet, "/docs/" + docID, ""},
{"update", http.MethodPut, "/docs/" + docID, `{"title":"defaced"}`},
{"delete", http.MethodDelete, "/docs/" + docID, ""},
{"export", http.MethodGet, "/docs/" + docID + "/export?format=md", ""},
{"passport", http.MethodGet, "/docs/" + docID + "/passport", ""},
{"snapshot", http.MethodPost, "/docs/" + docID + "/versions", ""},
} {
t.Run(tc.name, func(t *testing.T) {
rec := do(t, bob, tc.method, tc.path, tc.body)
if rec.Code != http.StatusNotFound {
t.Fatalf("%s %s as bob = %d, want 404 (body: %s)",
tc.method, tc.path, rec.Code, rec.Body)
}
})
}
// The document must have survived every attempt above unchanged.
rec := do(t, alice, http.MethodGet, "/docs/"+docID, "")
if rec.Code != http.StatusOK {
t.Fatalf("alice lost access to her own document: %d %s", rec.Code, rec.Body)
}
var doc db.Document
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
t.Fatalf("decode doc: %v", err)
}
if doc.Title != "Alice's diary" {
t.Fatalf("title = %q, want %q — bob's update went through", doc.Title, "Alice's diary")
}
}
// TestVersionIsolation covers the history endpoints, which scope through a join
// to documents rather than a direct user_id column — an easy place to forget the
// filter, and one where the leak would be the full text of every draft.
func TestVersionIsolation(t *testing.T) {
alice, bob := newTwoUserServer(t)
docID := createDoc(t, alice, "Draft", "the first version of my essay")
rec := do(t, alice, http.MethodPost, "/docs/"+docID+"/versions", "")
if rec.Code != http.StatusCreated {
t.Fatalf("snapshot: %d %s", rec.Code, rec.Body)
}
var v db.DocumentVersion
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
t.Fatalf("decode version: %v", err)
}
t.Run("list is empty for stranger", func(t *testing.T) {
rec := do(t, bob, http.MethodGet, "/docs/"+docID+"/versions", "")
var out []db.DocumentVersion
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out) != 0 {
t.Fatalf("bob sees %d of alice's snapshots, want 0", len(out))
}
})
for _, tc := range []struct{ name, method, path string }{
{"preview", http.MethodGet, "/docs/" + docID + "/versions/" + v.ID},
{"restore", http.MethodPost, "/docs/" + docID + "/versions/" + v.ID + "/restore"},
} {
t.Run(tc.name, func(t *testing.T) {
rec := do(t, bob, tc.method, tc.path, "")
if rec.Code != http.StatusNotFound {
t.Fatalf("%s as bob = %d, want 404 (body: %s)", tc.name, rec.Code, rec.Body)
}
})
}
}
// TestTagIsolation checks the tag roster and, more importantly, that a document
// and a tag can't be cross-linked across accounts — the assignment endpoint takes
// two ids from different tables and must own-check both.
func TestTagIsolation(t *testing.T) {
alice, bob := newTwoUserServer(t)
docID := createDoc(t, alice, "Essay", "some words")
rec := do(t, alice, http.MethodPost, "/tags", `{"name":"school","color":"mint"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("create tag: %d %s", rec.Code, rec.Body)
}
var aliceTag db.Tag
if err := json.Unmarshal(rec.Body.Bytes(), &aliceTag); err != nil {
t.Fatalf("decode tag: %v", err)
}
rec = do(t, bob, http.MethodPost, "/tags", `{"name":"bobs","color":"sky"}`)
var bobTag db.Tag
if err := json.Unmarshal(rec.Body.Bytes(), &bobTag); err != nil {
t.Fatalf("decode bob tag: %v", err)
}
t.Run("roster is per user", func(t *testing.T) {
rec := do(t, bob, http.MethodGet, "/tags", "")
var out []db.Tag
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out) != 1 || out[0].Name != "bobs" {
t.Fatalf("bob's roster = %+v, want just his own tag", out)
}
})
t.Run("cannot tag a stranger's document", func(t *testing.T) {
body, _ := json.Marshal(map[string]string{"tag_id": bobTag.ID})
rec := do(t, bob, http.MethodPost, "/docs/"+docID+"/tags", string(body))
if rec.Code != http.StatusNotFound {
t.Fatalf("bob tagging alice's doc = %d, want 404", rec.Code)
}
})
t.Run("cannot rename a stranger's tag", func(t *testing.T) {
rec := do(t, bob, http.MethodPatch, "/tags/"+aliceTag.ID, `{"name":"stolen"}`)
if rec.Code != http.StatusNotFound {
t.Fatalf("bob renaming alice's tag = %d, want 404", rec.Code)
}
})
t.Run("cannot delete a stranger's tag", func(t *testing.T) {
rec := do(t, bob, http.MethodDelete, "/tags/"+aliceTag.ID, "")
if rec.Code != http.StatusNotFound {
t.Fatalf("bob deleting alice's tag = %d, want 404", rec.Code)
}
})
}
+283
View File
@@ -0,0 +1,283 @@
package docs
// Writing passport: a standalone, printable report showing *how* a document was
// written — when each snapshot landed, how the word count grew, how the work
// broke into sessions. It exists because automated "AI detector" verdicts are
// unreliable and skew against non-native English writers, so the useful thing to
// hand someone who doubts your authorship is not a score but a record.
//
// The report is deliberately modest about what it proves (see passportLimits):
// it evidences a plausible writing process, it does not certify one.
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
)
// Passport tuning.
const (
// sessionGap is the idle time that separates one writing session from the
// next. Auto-snapshots fire at most every 3 minutes while typing, so any
// gap far above that means the writer stepped away. 45 minutes keeps a
// coffee break inside one session but splits morning from evening work.
sessionGap = 45 * time.Minute
// jumpNoteThreshold is the share of the final word count a single
// snapshot-to-snapshot increase must exceed before the report calls it out.
// A large jump is the first thing a skeptical reader will ask about, so the
// report raises it rather than leaving it to be discovered.
jumpNoteThreshold = 0.25
)
// chainHash links a snapshot to its predecessor. Covering prev_hash makes each
// hash depend on the entire history before it, so altering any earlier snapshot
// invalidates every later one; covering created_at means a row cannot be
// silently backdated.
//
// This detects tampering with the local database. It is not third-party
// attestation — someone with the database and this function could regenerate a
// consistent chain from scratch.
func chainHash(prevHash, docID string, createdAt time.Time, wordCount int, text string) string {
h := sha256.New()
fmt.Fprintf(h, "%s\x00%s\x00%d\x00%d\x00%s",
prevHash, docID, createdAt.UTC().UnixNano(), wordCount, text)
return hex.EncodeToString(h.Sum(nil))
}
// --- report model -----------------------------------------------------------
// passportSession is one continuous stretch of work — snapshots with no
// sessionGap-sized pause between them.
type passportSession struct {
Start, End time.Time
Snapshots int
WordsAdded int // net change across the session; negative when trimming
}
// Duration is the observed length of the session: first snapshot to last. A
// single-snapshot session reports zero, which is why total active time is
// described as a lower bound.
func (s passportSession) Duration() time.Duration { return s.End.Sub(s.Start) }
// chain verification outcomes, in the order the report prefers to report them.
const (
chainVerified = "verified" // every hash recomputes and every link holds
chainGaps = "gaps" // hashes valid, links broken — consistent with pruning
chainPartial = "partial" // some snapshots predate the hash chain
chainUnverifiable = "unverifiable" // no snapshot carries a hash
chainBroken = "broken" // a hash does not match its own contents
)
// passportData is everything the template renders.
type passportData struct {
Doc db.Document
Versions []db.DocumentVersion // ascending by time
Sessions []passportSession
FirstAt time.Time
LastAt time.Time
Span time.Duration // wall-clock first snapshot → last
ActiveTime time.Duration // summed session durations; a lower bound
LargestJump int // biggest single snapshot-to-snapshot word increase
LargestJumpAt time.Time
LargestJumpIdx int // index into Versions, so the chart can mark it
NoteJump bool // jump is large enough to be worth pre-empting
ChainStatus string
UnhashedCount int
GeneratedAt time.Time
}
// buildPassport derives the report from a document and its snapshots, which must
// be ordered oldest-first. It assumes nothing about snapshot spacing.
func buildPassport(doc db.Document, versions []db.DocumentVersion) passportData {
d := passportData{
Doc: doc,
Versions: versions,
GeneratedAt: time.Now(),
}
if len(versions) == 0 {
d.ChainStatus = chainUnverifiable
return d
}
d.FirstAt = versions[0].CreatedAt
d.LastAt = versions[len(versions)-1].CreatedAt
d.Span = d.LastAt.Sub(d.FirstAt)
cur := passportSession{Start: versions[0].CreatedAt, End: versions[0].CreatedAt, Snapshots: 1}
// Baseline for the running session's net-words figure. Later sessions
// measure from the *previous* session's final count, not from their own
// first snapshot, because that first snapshot already contains the few
// minutes of typing that preceded it — measuring from it would drop that
// work. The first session is the exception: it measures from its own first
// snapshot rather than from zero, so a history whose early snapshots were
// pruned understates session one instead of reporting the words it never
// saw as a sudden addition.
startWords := versions[0].WordCount
for i := 1; i < len(versions); i++ {
v, prev := versions[i], versions[i-1]
if delta := v.WordCount - prev.WordCount; delta > d.LargestJump {
d.LargestJump, d.LargestJumpAt, d.LargestJumpIdx = delta, v.CreatedAt, i
}
if v.CreatedAt.Sub(prev.CreatedAt) > sessionGap {
cur.WordsAdded = prev.WordCount - startWords
d.Sessions = append(d.Sessions, cur)
cur = passportSession{Start: v.CreatedAt, End: v.CreatedAt, Snapshots: 1}
startWords = prev.WordCount
continue
}
cur.End = v.CreatedAt
cur.Snapshots++
}
cur.WordsAdded = versions[len(versions)-1].WordCount - startWords
d.Sessions = append(d.Sessions, cur)
for _, s := range d.Sessions {
d.ActiveTime += s.Duration()
}
final := versions[len(versions)-1].WordCount
d.NoteJump = final > 0 && float64(d.LargestJump)/float64(final) > jumpNoteThreshold
d.ChainStatus, d.UnhashedCount = verifyChain(doc, versions)
return d
}
// verifyChain recomputes every snapshot's hash and checks that each links to the
// one before it. Returns the outcome and how many snapshots predate the chain.
//
// Broken *links* are not evidence of tampering on their own: auto-snapshot
// pruning legitimately removes rows from the middle of the history, which severs
// the links across the hole. So a link break is reported as a gap unless the
// document is in preserve-history mode, where nothing should ever be removed. A
// hash that fails to match its *own* contents is unambiguous, and always broken.
func verifyChain(doc db.Document, versions []db.DocumentVersion) (status string, unhashed int) {
var (
hashed int
linkBreak bool
prevHash string
havePrev bool
)
for _, v := range versions {
if v.ContentHash == "" {
unhashed++
havePrev = false // can't vouch for what follows an unhashed row
continue
}
hashed++
want := chainHash(v.PrevHash, v.DocID, v.CreatedAt, v.WordCount, v.ContentText)
if want != v.ContentHash {
return chainBroken, unhashed
}
if havePrev && v.PrevHash != prevHash {
linkBreak = true
}
prevHash, havePrev = v.ContentHash, true
}
switch {
case hashed == 0:
return chainUnverifiable, unhashed
case linkBreak && doc.PreserveHistory:
// Nothing should have been removed from a preserved history.
return chainBroken, unhashed
case linkBreak:
return chainGaps, unhashed
case unhashed > 0:
return chainPartial, unhashed
default:
return chainVerified, unhashed
}
}
// --- HTTP -------------------------------------------------------------------
// passport renders the report for one document as a standalone HTML download.
// HTML rather than PDF for the same reason as the other exports: a CJK-safe PDF
// needs an embedded Unicode font or a headless browser. The page is styled for
// printing, so "Save as PDF" in the browser produces the handoff artifact.
func (h *Handler) passport(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
userID := auth.UserID(r.Context())
doc, err := h.fetch(userID, docID)
if errors.Is(err, sql.ErrNoRows) {
notFound(w)
return
}
if err != nil {
httputil.ServerError(w, err)
return
}
versions, err := h.passportVersions(userID, docID)
if err != nil {
httputil.ServerError(w, err)
return
}
body := renderPassport(buildPassport(doc, versions))
filename := sanitizeFilename(doc.Title)
if filename == "" {
filename = "untitled"
}
filename += " - writing passport.html"
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Disposition",
fmt.Sprintf("attachment; filename*=UTF-8''%s", urlEscapeFilename(filename)))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
_, _ = w.Write(body)
}
// passportVersions loads every snapshot oldest-first with the fields the report
// and the chain check need — including content_text, which the list endpoint
// omits as too heavy but verification cannot do without.
func (h *Handler) passportVersions(userID, docID string) ([]db.DocumentVersion, error) {
rows, err := h.DB.Query(
`SELECT v.id, v.doc_id, v.title, v.content_text, v.word_count, v.kind,
v.created_at, v.content_hash, v.prev_hash
FROM document_versions v
JOIN documents d ON d.id = v.doc_id
WHERE v.doc_id = ? AND d.user_id = ?
ORDER BY v.created_at ASC, v.rowid ASC`,
docID, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []db.DocumentVersion
for rows.Next() {
var v db.DocumentVersion
if err := rows.Scan(
&v.ID, &v.DocID, &v.Title, &v.ContentText, &v.WordCount, &v.Kind,
&v.CreatedAt, &v.ContentHash, &v.PrevHash,
); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
+387
View File
@@ -0,0 +1,387 @@
package docs
// HTML rendering for the writing passport. Self-contained (no external assets)
// and styled for print, so the browser's "Save as PDF" turns it into the file a
// writer actually hands over.
import (
"fmt"
"math"
"strings"
"time"
)
// Chart geometry. The plot is wide and short on purpose: the report's question
// is "what shape did this document grow in", and a wide aspect makes a steady
// climb read as steady rather than dramatic.
const (
chartW, chartH = 760, 260
padL, padR, padT, padB = 52, 20, 18, 34
plotW, plotH = chartW - padL - padR, chartH - padT - padB
minBandW = 2.0 // so a single-snapshot session still shows
// gutterSlots is the space between sessions, in snapshot-slot widths. Wide
// enough to read as a break and to seat its duration label.
gutterSlots = 2.5
)
// Palette — the export stylesheet's tokens, reused so a passport looks like it
// came from the same application as the document it describes.
const (
rose = "#b04a6a"
roseLight = "#f6d6e0"
roseWash = "#fdeef3"
surface = "#fffafb"
)
func renderPassport(d passportData) []byte {
var b strings.Builder
fmt.Fprintf(&b, passportHead, htmlEscape(d.Doc.Title))
fmt.Fprintf(&b, `<header>
<p class="eyebrow">Writing passport</p>
<h1>%s</h1>
<p class="sub">Generated %s</p>
</header>
`, htmlEscape(d.Doc.Title), htmlEscape(formatWhen(d.GeneratedAt)))
if len(d.Versions) == 0 {
b.WriteString(`<p class="empty">This document has no saved history yet, so there is
nothing to report. History builds up automatically as you write.</p>
</body></html>`)
return []byte(b.String())
}
b.WriteString(renderStats(d))
b.WriteString(renderChart(d))
b.WriteString(renderSessions(d))
b.WriteString(renderIntegrity(d))
b.WriteString(passportLimits)
b.WriteString("</body></html>\n")
return []byte(b.String())
}
// renderStats is the headline row — the numbers a reader wants before deciding
// whether to study the chart.
func renderStats(d passportData) string {
final := d.Versions[len(d.Versions)-1].WordCount
tiles := []struct{ value, label string }{
{fmt.Sprintf("%d", len(d.Versions)), "snapshots saved"},
{humanDuration(d.Span), "from first to last edit"},
{fmt.Sprintf("%d", len(d.Sessions)), pluralize(len(d.Sessions), "writing session", "writing sessions")},
{humanDuration(d.ActiveTime), "spent actively editing"},
{fmt.Sprintf("%d", final), "words in the final draft"},
}
var b strings.Builder
b.WriteString(`<section class="stats">`)
for _, t := range tiles {
fmt.Fprintf(&b, `<div class="tile"><span class="v">%s</span><span class="l">%s</span></div>`,
htmlEscape(t.value), htmlEscape(t.label))
}
b.WriteString("</section>\n")
return b.String()
}
// renderChart draws word count as a step line — the count is only known at
// snapshot moments, and a step says that honestly where a smooth curve would
// invent values in between. Shaded bands mark writing sessions.
//
// The x axis is snapshot order, not wall-clock time, and this is deliberate. On
// a linear time axis an essay written in three half-hour sittings across three
// days renders as three vertical cliffs separated by empty space: all the actual
// writing is crushed into one percent of the width, and the result looks exactly
// like text pasted in three chunks — the opposite of what happened. Because
// auto-snapshots are throttled to roughly one per few minutes of *active*
// editing, snapshot order is already close to proportional to time spent
// writing. So the plot gives its width to the writing and compresses the breaks,
// which are drawn as explicit labelled gaps rather than silently removed.
//
// One series, so no legend: the heading names it.
func renderChart(d passportData) string {
maxW := 0
for _, v := range d.Versions {
if v.WordCount > maxW {
maxW = v.WordCount
}
}
yTop := niceCeil(maxW)
y := func(words int) float64 {
if yTop <= 0 {
return padT + plotH
}
return padT + plotH - float64(words)/float64(yTop)*plotH
}
// Lay snapshots out in slots: one per snapshot, plus a gutter between
// sessions for the break marker.
slots := float64(len(d.Versions)) + gutterSlots*float64(len(d.Sessions)-1)
sw := plotW / slots
xs := make([]float64, len(d.Versions))
bandStart := make([]float64, len(d.Sessions))
bandEnd := make([]float64, len(d.Sessions))
cursor, vi := 0.0, 0
for si, s := range d.Sessions {
if si > 0 {
cursor += gutterSlots
}
bandStart[si] = padL + cursor*sw
for k := 0; k < s.Snapshots; k++ {
xs[vi] = padL + (cursor+0.5)*sw
cursor++
vi++
}
bandEnd[si] = padL + cursor*sw
}
var b strings.Builder
fmt.Fprintf(&b, `<section class="chart">
<h2>How the draft grew</h2>
<svg viewBox="0 0 %d %d" role="img" aria-label="Word count at each saved snapshot, grouped into writing sessions">
`, chartW, chartH)
// Session bands sit behind everything; each carries a native tooltip.
for i, s := range d.Sessions {
w := bandEnd[i] - bandStart[i]
if w < minBandW {
w = minBandW
}
fmt.Fprintf(&b, `<rect x="%.1f" y="%d" width="%.1f" height="%d" fill="%s" rx="3"><title>Session %d: %s, %s, %d snapshots</title></rect>
`, bandStart[i], padT, w, plotH, roseWash, i+1,
htmlEscape(formatWhen(s.Start)), htmlEscape(humanDuration(s.Duration())), s.Snapshots)
}
// Recessive gridlines with y labels at 0 / half / top.
for _, gv := range []int{0, yTop / 2, yTop} {
gy := y(gv)
fmt.Fprintf(&b, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="%s" stroke-width="1"/>
<text x="%d" y="%.1f" class="axis" text-anchor="end">%d</text>
`, padL, gy, chartW-padR, gy, roseLight, padL-8, gy+4, gv)
}
// Break markers in the gutters, so compressed time is stated, not hidden.
for i := 1; i < len(d.Sessions); i++ {
mid := (bandEnd[i-1] + bandStart[i]) / 2
gap := d.Sessions[i].Start.Sub(d.Sessions[i-1].End)
fmt.Fprintf(&b, `<line x1="%.1f" y1="%d" x2="%.1f" y2="%d" stroke="%s" stroke-width="1" stroke-dasharray="3 3"/>
<text x="%.1f" y="%d" class="gap" text-anchor="middle">%s</text>
`, mid, padT, mid, padT+plotH, roseLight, mid, padT+plotH+13, htmlEscape(humanDuration(gap)+" away"))
}
// Step path: hold the previous value until the next snapshot lands.
var path strings.Builder
fmt.Fprintf(&path, "M %.1f %.1f", xs[0], y(d.Versions[0].WordCount))
for i := 1; i < len(d.Versions); i++ {
fmt.Fprintf(&path, " L %.1f %.1f L %.1f %.1f",
xs[i], y(d.Versions[i-1].WordCount), xs[i], y(d.Versions[i].WordCount))
}
fmt.Fprintf(&b, `<path d="%s" fill="none" stroke="%s" stroke-width="2" stroke-linejoin="round"/>
`, path.String(), rose)
// Pre-empt the obvious question: label the largest single jump when it is a
// big share of the finished draft, rather than letting a reader find it.
if d.NoteJump && d.LargestJumpIdx < len(xs) {
jx, jy := xs[d.LargestJumpIdx], y(d.Versions[d.LargestJumpIdx].WordCount)
// Flip the label inboard near the right edge so it can't overflow, and
// push it below the point when the point sits near the top.
anchor, dx := "start", 9.0
if jx > float64(chartW)*0.6 {
anchor, dx = "end", -9.0
}
ly := jy - 10
if ly < padT+12 {
ly = jy + 18
}
fmt.Fprintf(&b, `<circle cx="%.1f" cy="%.1f" r="4" fill="%s" stroke="%s" stroke-width="2"/>
<text x="%.1f" y="%.1f" class="note" text-anchor="%s">largest single addition: +%d words</text>
`, jx, jy, rose, surface, jx+dx, ly, anchor, d.LargestJump)
}
fmt.Fprintf(&b, `</svg>
<p class="caption">Each shaded band is one writing session, %s to %s. Width follows
snapshots saved, so time spent writing gets the space and breaks are compressed to
the labelled gaps.</p>
</section>
`, htmlEscape(formatDay(d.FirstAt)), htmlEscape(formatDay(d.LastAt)))
return b.String()
}
func renderSessions(d passportData) string {
var b strings.Builder
b.WriteString(`<section>
<h2>Writing sessions</h2>
<table>
<thead><tr><th>#</th><th>Started</th><th>Length</th><th>Snapshots</th><th>Net words</th></tr></thead>
<tbody>
`)
for i, s := range d.Sessions {
fmt.Fprintf(&b, `<tr><td>%d</td><td>%s</td><td>%s</td><td>%d</td><td>%+d</td></tr>
`, i+1, htmlEscape(formatWhen(s.Start)), htmlEscape(humanDuration(s.Duration())),
s.Snapshots, s.WordsAdded)
}
b.WriteString("</tbody></table>\n</section>\n")
return b.String()
}
// renderIntegrity explains the hash chain in plain language, including when it
// cannot vouch for something.
func renderIntegrity(d passportData) string {
var headline, detail string
switch d.ChainStatus {
case chainVerified:
headline = "History intact"
detail = "Every snapshot matches its own contents and links correctly to the one before it. Nothing in this history has been altered or removed since it was recorded."
case chainGaps:
headline = "History intact, with gaps"
detail = "Every snapshot matches its own contents, but some older automatic snapshots have been cleared to save space, so the record is not continuous. Turn on “keep full history” for this document to stop that happening."
case chainPartial:
headline = "Partly verifiable"
detail = fmt.Sprintf("%d snapshot(s) were recorded before this document started tracking integrity, so they cannot be checked. Everything recorded since then matches.", d.UnhashedCount)
case chainUnverifiable:
headline = "Not verifiable"
detail = "These snapshots were recorded before integrity tracking existed. The timeline above is still the record that was saved as you wrote; it simply cannot be checked for later alteration."
case chainBroken:
headline = "Integrity check failed"
detail = "At least one snapshot does not match what was recorded for it. This can mean the history was edited after the fact, or that the database was restored from a backup or copied between machines."
}
return fmt.Sprintf(`<section class="integrity %s">
<h2>%s</h2>
<p>%s</p>
</section>
`, htmlEscape(d.ChainStatus), htmlEscape(headline), htmlEscape(detail))
}
// passportLimits states plainly what the report does and does not establish.
// Overclaiming would be worse than useless: a reader who catches the report
// overstating its case discounts the whole thing.
const passportLimits = `<section class="limits">
<h2>How to read this</h2>
<p>A document written over time leaves a trail: many snapshots, uneven growth,
words added and cut and added again across separate sittings. A document that was
pasted in from elsewhere tends to arrive nearly whole, in one or two snapshots,
with little revision after.</p>
<p>What this report shows is the record Petal saved automatically while the
document was open, roughly every few minutes of active editing.</p>
<p><strong>What it does not show.</strong> It cannot prove who was at the
keyboard, and it cannot tell whether text typed into the editor was composed
there or copied from another window. It is evidence of a writing process, not a
certificate of authorship. It is most useful read alongside the drafts
themselves.</p>
</section>
`
// --- formatting helpers -----------------------------------------------------
func formatWhen(t time.Time) string { return t.Local().Format("2 Jan 2006, 3:04 PM") }
func formatDay(t time.Time) string { return t.Local().Format("2 Jan 2006") }
// humanDuration renders a span at the coarsest useful precision — a reader cares
// that a session ran "2h 40m", never that it ran 2h40m12s.
func humanDuration(d time.Duration) string {
if d < time.Minute {
return "under a minute"
}
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
mins := int(d.Minutes()) % 60
switch {
case days > 0 && hours > 0:
return fmt.Sprintf("%dd %dh", days, hours)
case days > 0:
return fmt.Sprintf("%dd", days)
case hours > 0 && mins > 0:
return fmt.Sprintf("%dh %dm", hours, mins)
case hours > 0:
return fmt.Sprintf("%dh", hours)
default:
return fmt.Sprintf("%dm", mins)
}
}
func pluralize(n int, one, many string) string {
if n == 1 {
return one
}
return many
}
// niceCeil rounds a maximum up to a round number so gridlines land on values a
// reader can hold in their head.
func niceCeil(n int) int {
if n <= 0 {
return 0
}
mag := math.Pow(10, math.Floor(math.Log10(float64(n))))
return int(math.Ceil(float64(n)/(mag/2)) * (mag / 2))
}
// passportHead is the page shell: one %s for the title. Print rules keep the
// chart and the caveats on the page rather than letting them break across sheets.
const passportHead = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Writing passport — %s</title>
<style>
:root { color-scheme: light; }
body {
font-family: "Georgia", "Songti SC", "Noto Serif CJK SC", "Source Han Serif SC", serif;
line-height: 1.7; color: #463a3f; background: #fffafb;
max-width: 48rem; margin: 3rem auto; padding: 0 1.5rem;
}
header { border-bottom: 2px solid #f6d6e0; padding-bottom: 1rem; margin-bottom: 2rem; }
.eyebrow { text-transform: uppercase; letter-spacing: .12em; font-size: .72rem;
color: #b04a6a; margin: 0 0 .3rem; }
h1 { font-size: 1.8rem; color: #b04a6a; margin: 0; line-height: 1.3; }
h2 { font-size: 1.05rem; color: #b04a6a; margin: 0 0 .75rem; }
.sub, .axis, .l { color: #6b5860; }
.sub { margin: .4rem 0 0; font-size: .9rem; }
section { margin: 2.25rem 0; }
.stats { display: flex; flex-wrap: wrap; gap: 1.25rem 2rem; margin: 2rem 0; }
.tile { display: flex; flex-direction: column; min-width: 7rem; }
.tile .v { font-size: 1.6rem; color: #b04a6a; line-height: 1.1; }
.tile .l { font-size: .8rem; margin-top: .15rem; }
.chart svg { width: 100%%; height: auto; }
.axis { font-size: 11px; fill: #6b5860; font-family: system-ui, sans-serif; }
.note { font-size: 11px; fill: #463a3f; font-family: system-ui, sans-serif; }
.gap { font-size: 10px; fill: #6b5860; font-family: system-ui, sans-serif; }
.caption { font-size: .8rem; color: #6b5860; margin: .5rem 0 0; }
table { border-collapse: collapse; width: 100%%; font-size: .9rem; }
th, td { text-align: left; padding: .45rem .6rem; border-bottom: 1px solid #f3cdd9; }
th { color: #6b5860; font-weight: normal; font-size: .78rem;
text-transform: uppercase; letter-spacing: .06em; }
.integrity { background: #fff2f6; border-left: 3px solid #f3b6c8;
padding: 1rem 1.25rem; border-radius: .4rem; }
.integrity.broken { border-left-color: #c2410c; }
.integrity p { margin: 0; font-size: .92rem; }
.limits { font-size: .88rem; color: #6b5860; border-top: 1px solid #f3cdd9;
padding-top: 1.25rem; }
.limits strong { color: #463a3f; }
.empty { color: #6b5860; }
@media print {
body { margin: 0; max-width: none; }
section, .chart svg, table { break-inside: avoid; }
}
</style>
</head>
<body>
`
+376
View File
@@ -0,0 +1,376 @@
package docs
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"regexp"
"strconv"
"strings"
"testing"
"time"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// chained builds a valid hash-chained snapshot run from (minutes-offset, words)
// pairs, so tests can describe a writing history in terms a reader recognises
// and get correct hashes for free.
func chained(docID string, base time.Time, points ...[2]int) []db.DocumentVersion {
var (
out []db.DocumentVersion
prev string
)
for i, p := range points {
at := base.Add(time.Duration(p[0]) * time.Minute)
text := strings.Repeat("word ", p[1])
v := db.DocumentVersion{
ID: fmt.Sprintf("v%d", i),
DocID: docID,
ContentText: text,
WordCount: p[1],
Kind: db.VersionKindAuto,
CreatedAt: at,
PrevHash: prev,
}
v.ContentHash = chainHash(prev, docID, at, p[1], text)
prev = v.ContentHash
out = append(out, v)
}
return out
}
func TestBuildPassportSessions(t *testing.T) {
base := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC)
doc := db.Document{ID: "d1", Title: "Essay"}
// Two sittings: 09:0009:30, then a three-hour break, then 12:3013:00.
vs := chained("d1", base,
[2]int{0, 40}, [2]int{15, 120}, [2]int{30, 210},
[2]int{210, 260}, [2]int{240, 330},
)
d := buildPassport(doc, vs)
if len(d.Sessions) != 2 {
t.Fatalf("sessions = %d, want 2", len(d.Sessions))
}
if got := d.Sessions[0].Duration(); got != 30*time.Minute {
t.Errorf("session 1 duration = %v, want 30m", got)
}
if got := d.Sessions[1].Snapshots; got != 2 {
t.Errorf("session 2 snapshots = %d, want 2", got)
}
if got := d.Span; got != 4*time.Hour {
t.Errorf("span = %v, want 4h", got)
}
// Active time counts only time inside sessions, never the break.
if got := d.ActiveTime; got != 60*time.Minute {
t.Errorf("active time = %v, want 60m", got)
}
// Session 2 measures from session 1's final count (210 → 330).
if got := d.Sessions[1].WordsAdded; got != 120 {
t.Errorf("session 2 words = %d, want 120", got)
}
if d.ChainStatus != chainVerified {
t.Errorf("chain = %q, want %q", d.ChainStatus, chainVerified)
}
}
func TestBuildPassportFlagsLargeJump(t *testing.T) {
base := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC)
doc := db.Document{ID: "d1"}
t.Run("steady growth is not flagged", func(t *testing.T) {
vs := chained("d1", base, [2]int{0, 100}, [2]int{5, 200}, [2]int{10, 300}, [2]int{15, 400})
if d := buildPassport(doc, vs); d.NoteJump {
t.Errorf("even growth flagged a jump of %d", d.LargestJump)
}
})
t.Run("a paste-shaped jump is flagged", func(t *testing.T) {
vs := chained("d1", base, [2]int{0, 20}, [2]int{5, 40}, [2]int{10, 900})
d := buildPassport(doc, vs)
if !d.NoteJump {
t.Fatal("large jump not flagged")
}
if d.LargestJump != 860 {
t.Errorf("largest jump = %d, want 860", d.LargestJump)
}
if !d.LargestJumpAt.Equal(base.Add(10 * time.Minute)) {
t.Errorf("jump at %v, want +10m", d.LargestJumpAt)
}
})
}
func TestVerifyChain(t *testing.T) {
base := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC)
good := func() []db.DocumentVersion {
return chained("d1", base, [2]int{0, 50}, [2]int{5, 90}, [2]int{10, 160})
}
t.Run("intact chain verifies", func(t *testing.T) {
got, _ := verifyChain(db.Document{ID: "d1"}, good())
if got != chainVerified {
t.Errorf("got %q, want %q", got, chainVerified)
}
})
t.Run("edited content breaks it", func(t *testing.T) {
vs := good()
vs[1].ContentText = "something else entirely"
if got, _ := verifyChain(db.Document{ID: "d1"}, vs); got != chainBroken {
t.Errorf("got %q, want %q", got, chainBroken)
}
})
t.Run("backdating breaks it", func(t *testing.T) {
vs := good()
vs[2].CreatedAt = base.Add(-time.Hour)
if got, _ := verifyChain(db.Document{ID: "d1"}, vs); got != chainBroken {
t.Errorf("got %q, want %q", got, chainBroken)
}
})
t.Run("a removed snapshot reads as a gap when pruning is allowed", func(t *testing.T) {
vs := good()
pruned := []db.DocumentVersion{vs[0], vs[2]} // middle snapshot gone
got, _ := verifyChain(db.Document{ID: "d1"}, pruned)
if got != chainGaps {
t.Errorf("got %q, want %q", got, chainGaps)
}
})
t.Run("a removed snapshot is tampering when history is preserved", func(t *testing.T) {
vs := good()
pruned := []db.DocumentVersion{vs[0], vs[2]}
got, _ := verifyChain(db.Document{ID: "d1", PreserveHistory: true}, pruned)
if got != chainBroken {
t.Errorf("got %q, want %q", got, chainBroken)
}
})
t.Run("pre-chain snapshots are partial, not failures", func(t *testing.T) {
vs := good()
vs[0].ContentHash, vs[0].PrevHash = "", ""
got, unhashed := verifyChain(db.Document{ID: "d1"}, vs)
if got != chainPartial {
t.Errorf("got %q, want %q", got, chainPartial)
}
if unhashed != 1 {
t.Errorf("unhashed = %d, want 1", unhashed)
}
})
t.Run("no hashes at all is unverifiable", func(t *testing.T) {
vs := good()
for i := range vs {
vs[i].ContentHash, vs[i].PrevHash = "", ""
}
if got, _ := verifyChain(db.Document{ID: "d1"}, vs); got != chainUnverifiable {
t.Errorf("got %q, want %q", got, chainUnverifiable)
}
})
}
// The report must survive the degenerate histories — one snapshot, an empty
// document — rather than dividing by a zero span or a zero maximum.
func TestRenderPassportEdgeCases(t *testing.T) {
base := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC)
t.Run("no history", func(t *testing.T) {
out := string(renderPassport(buildPassport(db.Document{Title: "Empty"}, nil)))
if !strings.Contains(out, "no saved history") {
t.Errorf("missing empty-state copy:\n%s", out)
}
})
t.Run("single snapshot", func(t *testing.T) {
vs := chained("d1", base, [2]int{0, 12})
out := string(renderPassport(buildPassport(db.Document{ID: "d1", Title: "One"}, vs)))
if strings.Contains(out, "NaN") || strings.Contains(out, "+Inf") {
t.Errorf("degenerate geometry leaked into output:\n%s", out)
}
})
t.Run("empty document", func(t *testing.T) {
vs := chained("d1", base, [2]int{0, 0}, [2]int{5, 0})
out := string(renderPassport(buildPassport(db.Document{ID: "d1"}, vs)))
if strings.Contains(out, "NaN") {
t.Errorf("zero word count produced NaN:\n%s", out)
}
})
t.Run("title is escaped", func(t *testing.T) {
doc := db.Document{ID: "d1", Title: `<script>alert(1)</script>`}
out := string(renderPassport(buildPassport(doc, chained("d1", base, [2]int{0, 5}))))
if strings.Contains(out, "<script>") {
t.Error("title was not escaped")
}
})
}
// The chart must give its width to the writing, not to the gaps between
// sittings. Three short sessions spread over three days is the case that a
// wall-clock x axis renders as three vertical cliffs — visually identical to
// pasted text, and wrong.
func TestChartGivesWidthToWriting(t *testing.T) {
base := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC)
// ~30 minutes of work on each of three consecutive days.
var pts [][2]int
words := 0
for day := 0; day < 3; day++ {
for k := 0; k < 6; k++ {
words += 50
pts = append(pts, [2]int{day*1440 + k*5, words})
}
}
d := buildPassport(db.Document{ID: "d1"}, chained("d1", base, pts...))
if len(d.Sessions) != 3 {
t.Fatalf("sessions = %d, want 3", len(d.Sessions))
}
out := renderChart(d)
// Every session band should be a substantial share of the plot, not a sliver.
widths := regexp.MustCompile(`<rect [^>]*width="([0-9.]+)"`).FindAllStringSubmatch(out, -1)
if len(widths) != 3 {
t.Fatalf("session bands = %d, want 3", len(widths))
}
for i, m := range widths {
w, err := strconv.ParseFloat(m[1], 64)
if err != nil {
t.Fatal(err)
}
if w < plotW*0.15 {
t.Errorf("session %d band is %.1fpx of %dpx plot — writing got crushed", i+1, w, plotW)
}
}
// The compressed breaks must be stated, not silently removed.
if got := strings.Count(out, `class="gap"`); got != 2 {
t.Errorf("break labels = %d, want 2", got)
}
if !strings.Contains(out, "away") {
t.Error("break labels do not name their duration")
}
}
// Chart coordinates must stay inside the viewBox whatever the history looks like.
func TestChartStaysInBounds(t *testing.T) {
base := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC)
histories := map[string][][2]int{
"single snapshot": {{0, 30}},
"two sessions": {{0, 30}, {5, 90}, {600, 140}, {605, 210}},
"words removed": {{0, 400}, {5, 380}, {10, 120}},
"all zero": {{0, 0}, {5, 0}},
"many snapshots": func() (p [][2]int) {
for i := 0; i < 60; i++ {
p = append(p, [2]int{i * 4, i * 20})
}
return
}(),
}
num := regexp.MustCompile(`(?:x|cx)="([0-9.-]+)"`)
for name, pts := range histories {
t.Run(name, func(t *testing.T) {
out := renderChart(buildPassport(db.Document{ID: "d1"}, chained("d1", base, pts...)))
if strings.Contains(out, "NaN") || strings.Contains(out, "Inf") {
t.Fatalf("degenerate geometry:\n%s", out)
}
for _, m := range num.FindAllStringSubmatch(out, -1) {
v, err := strconv.ParseFloat(m[1], 64)
if err != nil {
t.Fatal(err)
}
if v < 0 || v > chartW {
t.Errorf("x coordinate %.1f outside 0..%d", v, chartW)
}
}
})
}
}
func TestHumanDuration(t *testing.T) {
cases := []struct {
in time.Duration
want string
}{
{0, "under a minute"},
{30 * time.Second, "under a minute"},
{18 * time.Minute, "18m"},
{2 * time.Hour, "2h"},
{2*time.Hour + 40*time.Minute, "2h 40m"},
{50 * time.Hour, "2d 2h"},
}
for _, c := range cases {
if got := humanDuration(c.in); got != c.want {
t.Errorf("humanDuration(%v) = %q, want %q", c.in, got, c.want)
}
}
}
// --- endpoint / persistence -------------------------------------------------
func TestPassportEndpoint(t *testing.T) {
srv := newTestServer(t)
id := newDoc(t, srv)
do(t, srv, http.MethodPut, "/"+id,
`{"content":"{}","content_text":"the first draft","word_count":3}`)
rec := do(t, srv, http.MethodGet, "/"+id+"/passport", "")
if rec.Code != http.StatusOK {
t.Fatalf("passport: code=%d body=%s", rec.Code, rec.Body)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("content-type = %q, want text/html", ct)
}
body := rec.Body.String()
if !strings.Contains(body, "Writing passport") {
t.Errorf("report body missing heading:\n%s", body)
}
// Snapshots written through the real insert path must verify.
if !strings.Contains(body, "History intact") {
t.Errorf("live-written history did not verify:\n%s", body)
}
if rec := do(t, srv, http.MethodGet, "/does-not-exist/passport", ""); rec.Code != http.StatusNotFound {
t.Errorf("missing doc: code = %d, want 404", rec.Code)
}
}
func TestPreserveHistoryExemptsFromPruning(t *testing.T) {
srv := newTestServer(t)
id := newDoc(t, srv)
rec := do(t, srv, http.MethodPut, "/"+id, `{"preserve_history":true}`)
if rec.Code != http.StatusOK {
t.Fatalf("set preserve_history: code=%d body=%s", rec.Code, rec.Body)
}
decodeDoc := func(rec *httptest.ResponseRecorder) db.Document {
t.Helper()
var doc db.Document
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
t.Fatalf("decode doc: %v", err)
}
return doc
}
if !decodeDoc(rec).PreserveHistory {
t.Fatal("preserve_history did not persist")
}
// An ordinary body save must not clear the flag.
rec = do(t, srv, http.MethodPut, "/"+id,
`{"content":"{}","content_text":"hello there","word_count":2}`)
if !decodeDoc(rec).PreserveHistory {
t.Error("a normal save cleared preserve_history")
}
}
+6 -4
View File
@@ -7,6 +7,7 @@ import (
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
)
@@ -50,12 +51,13 @@ func (h *Handler) SearchRoutes() chi.Router {
return r
}
// search runs a cross-document full-text search for the local user. Queries of
// search runs a cross-document full-text search for the caller. Queries of
// three or more runes use the trigram FTS index (fast, ranked); shorter queries
// fall back to a LIKE scan so 2-character Chinese words still resolve. Either way
// the snippet is built in Go from the original text, for clean word boundaries
// and a uniform highlight format.
func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
userID := auth.UserID(r.Context())
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
httputil.WriteJSON(w, http.StatusOK, []searchResult{})
@@ -80,7 +82,7 @@ func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
WHERE documents_fts MATCH ? AND d.user_id = ?
ORDER BY rank
LIMIT ?`,
phrase, db.LocalUserID, maxSearchResults,
phrase, userID, maxSearchResults,
)
if err != nil {
httputil.ServerError(w, err)
@@ -110,7 +112,7 @@ func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
AND (title LIKE ? ESCAPE '\' OR content_text LIKE ? ESCAPE '\')
ORDER BY updated_at DESC
LIMIT ?`,
db.LocalUserID, like, like, maxSearchResults,
userID, like, like, maxSearchResults,
)
if err != nil {
httputil.ServerError(w, err)
@@ -144,7 +146,7 @@ func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
ids = append(ids, rw.id)
}
byDoc, err := h.tagsByDoc(ids)
byDoc, err := h.tagsByDoc(userID, ids)
if err != nil {
httputil.ServerError(w, err)
return
+20 -17
View File
@@ -7,6 +7,7 @@ import (
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
)
@@ -55,7 +56,7 @@ func (h *Handler) listTags(w http.ResponseWriter, r *http.Request) {
WHERE t.user_id = ?
GROUP BY t.id
ORDER BY t.name COLLATE NOCASE`,
db.LocalUserID,
auth.UserID(r.Context()),
)
if err != nil {
httputil.ServerError(w, err)
@@ -104,7 +105,7 @@ func (h *Handler) createTag(w http.ResponseWriter, r *http.Request) {
`INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)
ON CONFLICT(user_id, name) DO UPDATE SET name = excluded.name
RETURNING id, name, color`,
db.LocalUserID, name, normalizeColor(req.Color),
auth.UserID(r.Context()), name, normalizeColor(req.Color),
).Scan(&t.ID, &t.Name, &t.Color)
if err != nil {
httputil.ServerError(w, err)
@@ -117,6 +118,7 @@ func (h *Handler) createTag(w http.ResponseWriter, r *http.Request) {
// a recolor needn't resend the name.
func (h *Handler) updateTag(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
userID := auth.UserID(r.Context())
var req struct {
Name *string `json:"name"`
@@ -145,7 +147,7 @@ func (h *Handler) updateTag(w http.ResponseWriter, r *http.Request) {
SET name = COALESCE(?, name),
color = COALESCE(?, color)
WHERE id = ? AND user_id = ?`,
namePtr, colorPtr, id, db.LocalUserID,
namePtr, colorPtr, id, userID,
)
if err != nil {
httputil.ServerError(w, err)
@@ -159,7 +161,7 @@ func (h *Handler) updateTag(w http.ResponseWriter, r *http.Request) {
var t db.Tag
if err := h.DB.QueryRow(
`SELECT id, name, color FROM tags WHERE id = ? AND user_id = ?`,
id, db.LocalUserID,
id, userID,
).Scan(&t.ID, &t.Name, &t.Color); err != nil {
httputil.ServerError(w, err)
return
@@ -171,7 +173,7 @@ func (h *Handler) updateTag(w http.ResponseWriter, r *http.Request) {
func (h *Handler) deleteTag(w http.ResponseWriter, r *http.Request) {
res, err := h.DB.Exec(
`DELETE FROM tags WHERE id = ? AND user_id = ?`,
chi.URLParam(r, "id"), db.LocalUserID,
chi.URLParam(r, "id"), auth.UserID(r.Context()),
)
if err != nil {
httputil.ServerError(w, err)
@@ -184,10 +186,11 @@ func (h *Handler) deleteTag(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// assignTag attaches a tag to a document. Both must belong to the local user;
// assignTag attaches a tag to a document. Both must belong to the caller;
// the assignment is idempotent (re-assigning is a no-op, not an error).
func (h *Handler) assignTag(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
userID := auth.UserID(r.Context())
var req struct {
TagID string `json:"tag_id"`
@@ -203,11 +206,11 @@ func (h *Handler) assignTag(w http.ResponseWriter, r *http.Request) {
// Verify both the doc and the tag belong to the user before linking, so a
// stray id can't cross-link another account's rows.
if !h.ownsDoc(docID) {
if !h.ownsDoc(userID, docID) {
notFound(w)
return
}
if !h.ownsTag(req.TagID) {
if !h.ownsTag(userID, req.TagID) {
notFoundMsg(w, "tag not found")
return
}
@@ -228,7 +231,7 @@ func (h *Handler) unassignTag(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
tagID := chi.URLParam(r, "tagId")
if !h.ownsDoc(docID) {
if !h.ownsDoc(auth.UserID(r.Context()), docID) {
notFound(w)
return
}
@@ -242,22 +245,22 @@ func (h *Handler) unassignTag(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// ownsDoc reports whether a document belongs to the local user.
func (h *Handler) ownsDoc(docID string) bool {
// ownsDoc reports whether a document belongs to the given user.
func (h *Handler) ownsDoc(userID, docID string) bool {
var exists bool
_ = h.DB.QueryRow(
`SELECT EXISTS(SELECT 1 FROM documents WHERE id = ? AND user_id = ?)`,
docID, db.LocalUserID,
docID, userID,
).Scan(&exists)
return exists
}
// ownsTag reports whether a tag belongs to the local user.
func (h *Handler) ownsTag(tagID string) bool {
// ownsTag reports whether a tag belongs to the given user.
func (h *Handler) ownsTag(userID, tagID string) bool {
var exists bool
_ = h.DB.QueryRow(
`SELECT EXISTS(SELECT 1 FROM tags WHERE id = ? AND user_id = ?)`,
tagID, db.LocalUserID,
tagID, userID,
).Scan(&exists)
return exists
}
@@ -265,7 +268,7 @@ func (h *Handler) ownsTag(tagID string) bool {
// tagsByDoc loads the tags for a set of documents in one query and groups them
// by doc id. Used to decorate the document list and search results without an
// N+1 of per-doc queries. Returns an empty (non-nil) map when ids is empty.
func (h *Handler) tagsByDoc(ids []string) (map[string][]db.Tag, error) {
func (h *Handler) tagsByDoc(userID string, ids []string) (map[string][]db.Tag, error) {
out := map[string][]db.Tag{}
if len(ids) == 0 {
return out, nil
@@ -277,7 +280,7 @@ func (h *Handler) tagsByDoc(ids []string) (map[string][]db.Tag, error) {
for _, id := range ids {
args = append(args, id)
}
args = append(args, db.LocalUserID)
args = append(args, userID)
rows, err := h.DB.Query(
`SELECT dt.doc_id, t.id, t.name, t.color
+1 -1
View File
@@ -27,7 +27,7 @@ func newFullServer(t *testing.T) http.Handler {
r.Mount("/docs", h.Routes())
r.Mount("/tags", h.TagRoutes())
r.Mount("/search", h.SearchRoutes())
return r
return withAuth(r)
}
// createDoc makes a document with the given title/body and returns its id.
+71 -15
View File
@@ -8,6 +8,7 @@ import (
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
)
@@ -33,6 +34,7 @@ func (h *Handler) versionRoutes(r chi.Router) {
r.Post("/{id}/versions", h.createVersion) // explicit "save a restore point"
r.Get("/{id}/versions/{vid}", h.getVersion) // full body for preview
r.Post("/{id}/versions/{vid}/restore", h.restoreVersion)
r.Get("/{id}/passport", h.passport) // authorship report over that history
}
// listVersions returns the document's snapshots, newest first, without the heavy
@@ -48,7 +50,7 @@ func (h *Handler) listVersions(w http.ResponseWriter, r *http.Request) {
JOIN documents d ON d.id = v.doc_id
WHERE v.doc_id = ? AND d.user_id = ?
ORDER BY v.created_at DESC`,
docID, db.LocalUserID,
docID, auth.UserID(r.Context()),
)
if err != nil {
httputil.ServerError(w, err)
@@ -74,7 +76,7 @@ func (h *Handler) listVersions(w http.ResponseWriter, r *http.Request) {
// getVersion returns one snapshot in full (including content) for preview.
func (h *Handler) getVersion(w http.ResponseWriter, r *http.Request) {
v, err := h.fetchVersion(chi.URLParam(r, "id"), chi.URLParam(r, "vid"))
v, err := h.fetchVersion(auth.UserID(r.Context()), chi.URLParam(r, "id"), chi.URLParam(r, "vid"))
if errors.Is(err, sql.ErrNoRows) {
notFoundMsg(w, "version not found")
return
@@ -91,7 +93,7 @@ func (h *Handler) getVersion(w http.ResponseWriter, r *http.Request) {
func (h *Handler) createVersion(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
doc, err := h.fetch(docID)
doc, err := h.fetch(auth.UserID(r.Context()), docID)
if errors.Is(err, sql.ErrNoRows) {
notFound(w)
return
@@ -115,8 +117,9 @@ func (h *Handler) createVersion(w http.ResponseWriter, r *http.Request) {
func (h *Handler) restoreVersion(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
vid := chi.URLParam(r, "vid")
userID := auth.UserID(r.Context())
v, err := h.fetchVersion(docID, vid)
v, err := h.fetchVersion(userID, docID, vid)
if errors.Is(err, sql.ErrNoRows) {
notFoundMsg(w, "version not found")
return
@@ -126,7 +129,7 @@ func (h *Handler) restoreVersion(w http.ResponseWriter, r *http.Request) {
return
}
current, err := h.fetch(docID)
current, err := h.fetch(userID, docID)
if err != nil {
httputil.ServerError(w, err)
return
@@ -141,7 +144,7 @@ func (h *Handler) restoreVersion(w http.ResponseWriter, r *http.Request) {
SET title = ?, content = ?, content_text = ?, word_count = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?`,
v.Title, v.Content, v.ContentText, v.WordCount, docID, db.LocalUserID,
v.Title, v.Content, v.ContentText, v.WordCount, docID, userID,
)
if err != nil {
httputil.ServerError(w, err)
@@ -152,7 +155,7 @@ func (h *Handler) restoreVersion(w http.ResponseWriter, r *http.Request) {
return
}
doc, err := h.fetch(docID)
doc, err := h.fetch(userID, docID)
if err != nil {
httputil.ServerError(w, err)
return
@@ -202,20 +205,73 @@ func (h *Handler) maybeAutoSnapshot(doc db.Document) error {
// insertVersion writes a snapshot row of the given kind and returns it (without
// the heavy content fields, matching the list shape).
//
// The row is linked into the document's hash chain: it carries the previous
// snapshot's hash, and its own hash covers that link plus its content. The hash
// can only be computed once the database has assigned created_at, so the insert
// and the hash write share a transaction — a snapshot is never visible with a
// hash that doesn't cover its own timestamp.
func (h *Handler) insertVersion(doc db.Document, kind string) (db.DocumentVersion, error) {
tx, err := h.DB.Begin()
if err != nil {
return db.DocumentVersion{}, err
}
defer tx.Rollback() //nolint:errcheck // no-op once committed
// Chain onto the newest existing snapshot. created_at has second
// granularity, so rowid breaks ties in true insertion order; verification
// walks the same ordering in reverse.
var prevHash string
err = tx.QueryRow(
`SELECT content_hash FROM document_versions
WHERE doc_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1`,
doc.ID,
).Scan(&prevHash)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return db.DocumentVersion{}, err
}
var v db.DocumentVersion
err := h.DB.QueryRow(
`INSERT INTO document_versions (doc_id, title, content, content_text, word_count, kind)
VALUES (?, ?, ?, ?, ?, ?)
if err := tx.QueryRow(
`INSERT INTO document_versions (doc_id, title, content, content_text, word_count, kind, prev_hash)
VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING id, doc_id, title, word_count, kind, created_at`,
doc.ID, doc.Title, doc.Content, doc.ContentText, doc.WordCount, kind,
).Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt)
return v, err
doc.ID, doc.Title, doc.Content, doc.ContentText, doc.WordCount, kind, prevHash,
).Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt); err != nil {
return db.DocumentVersion{}, err
}
v.PrevHash = prevHash
v.ContentHash = chainHash(prevHash, v.DocID, v.CreatedAt, v.WordCount, doc.ContentText)
if _, err := tx.Exec(
`UPDATE document_versions SET content_hash = ? WHERE id = ?`, v.ContentHash, v.ID,
); err != nil {
return db.DocumentVersion{}, err
}
if err := tx.Commit(); err != nil {
return db.DocumentVersion{}, err
}
return v, nil
}
// pruneAutoVersions trims a document's 'auto' snapshots to the newest
// maxAutoVersions, leaving 'manual' and 'pre_restore' restore points intact.
//
// Documents flagged preserve_history are exempt entirely: their history is
// authorship evidence, and evidence with the oldest entries dropped is exactly
// the part a reader would want — the early, sparse, figuring-it-out edits that
// distinguish writing from pasting.
func (h *Handler) pruneAutoVersions(docID string) error {
var preserve bool
if err := h.DB.QueryRow(
`SELECT preserve_history FROM documents WHERE id = ?`, docID,
).Scan(&preserve); err != nil {
return err
}
if preserve {
return nil
}
_, err := h.DB.Exec(
`DELETE FROM document_versions
WHERE doc_id = ? AND kind = 'auto'
@@ -230,14 +286,14 @@ func (h *Handler) pruneAutoVersions(docID string) error {
}
// fetchVersion loads one full snapshot, scoped to its owner via the parent doc.
func (h *Handler) fetchVersion(docID, vid string) (db.DocumentVersion, error) {
func (h *Handler) fetchVersion(userID, docID, vid string) (db.DocumentVersion, error) {
var v db.DocumentVersion
err := h.DB.QueryRow(
`SELECT v.id, v.doc_id, v.title, v.content, v.content_text, v.word_count, v.kind, v.created_at
FROM document_versions v
JOIN documents d ON d.id = v.doc_id
WHERE v.id = ? AND v.doc_id = ? AND d.user_id = ?`,
vid, docID, db.LocalUserID,
vid, docID, userID,
).Scan(
&v.ID, &v.DocID, &v.Title, &v.Content, &v.ContentText,
&v.WordCount, &v.Kind, &v.CreatedAt,
+8
View File
@@ -28,6 +28,12 @@ type vllmRequest struct {
TopP float64 `json:"top_p"`
Stop []string `json:"stop,omitempty"`
Stream bool `json:"stream"`
// ChatTemplateKwargs is a vLLM extension. Qwen3-family models reason by
// default and prepend a plain-text preamble ("Here's a thinking process:")
// ahead of the answer — not a <think> block, so it cannot be stripped after
// the fact. Every Petal pass parses a JSON object out of the completion, so
// an unsuppressed preamble fails the parse outright.
ChatTemplateKwargs map[string]any `json:"chat_template_kwargs,omitempty"`
}
func (c *VLLMClient) body(req CompletionRequest) vllmRequest {
@@ -40,6 +46,8 @@ func (c *VLLMClient) body(req CompletionRequest) vllmRequest {
TopP: req.TopP,
Stop: req.Stop,
Stream: req.Stream,
ChatTemplateKwargs: map[string]any{"enable_thinking": false},
}
}
+2 -2
View File
@@ -9,7 +9,7 @@ import (
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/httputil"
"gitea.parodia.dev/drwily/petal/internal/llm"
)
@@ -46,7 +46,7 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
FROM suggestions s
JOIN documents d ON d.id = s.doc_id
WHERE s.id = ? AND d.user_id = ?`,
sugID, db.LocalUserID,
sugID, auth.UserID(r.Context()),
).Scan(&original, &replacement, &explanation, &typ, &fromPos, &contentText)
if errors.Is(err, sql.ErrNoRows) {
httputil.ErrorJSON(w, http.StatusNotFound, "suggestion not found")
+29 -14
View File
@@ -16,6 +16,7 @@ import (
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
"gitea.parodia.dev/drwily/petal/internal/llm"
@@ -98,11 +99,11 @@ const maxMechanicsFindings = 500
func (h *Handler) mechanics(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
// Confirm the document exists (and is the local user's) for clean 404s.
// Confirm the document exists (and belongs to the caller) for clean 404s.
var exists bool
err := h.DB.QueryRow(
`SELECT EXISTS(SELECT 1 FROM documents WHERE id = ? AND user_id = ?)`,
docID, db.LocalUserID,
docID, auth.UserID(r.Context()),
).Scan(&exists)
if err != nil {
httputil.ServerError(w, err)
@@ -129,7 +130,7 @@ func (h *Handler) mechanics(w http.ResponseWriter, r *http.Request) {
return
}
out, err := h.fetchPending(docID)
out, err := h.fetchPending(auth.UserID(r.Context()), docID)
if err != nil {
httputil.ServerError(w, err)
return
@@ -203,11 +204,12 @@ type pass func(ctx context.Context, client llm.LLMClient, contentText, tone stri
// (both families) so the client always renders a unified picture.
func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.RateLimiter, run pass, scope pendingScope) {
docID := chi.URLParam(r, "id")
userID := auth.UserID(r.Context())
var contentText, tone string
err := h.DB.QueryRow(
`SELECT content_text, tone FROM documents WHERE id = ? AND user_id = ?`,
docID, db.LocalUserID,
docID, userID,
).Scan(&contentText, &tone)
if errors.Is(err, sql.ErrNoRows) {
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
@@ -228,7 +230,7 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
if !ok {
// Throttled: return the existing pending set unchanged rather than an
// error, so the frontend keeps showing current suggestions.
existing, err := h.fetchPending(docID)
existing, err := h.fetchPending(userID, docID)
if err != nil {
httputil.ServerError(w, err)
return
@@ -255,7 +257,7 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
// Return the unified pending set (grammar + voice), not just this batch, so
// a grammar check never drops the voice highlights from the client and the
// throttle path above stays consistent with the success path.
out, err := h.fetchPending(docID)
out, err := h.fetchPending(userID, docID)
if err != nil {
httputil.ServerError(w, err)
return
@@ -465,7 +467,7 @@ func buildSuppressor(tx *sql.Tx, docID string) (suppressor, error) {
// listForDoc returns the document's current pending suggestions (used when the
// editor loads a document, before any new checkpoint fires).
func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) {
out, err := h.fetchPending(chi.URLParam(r, "id"))
out, err := h.fetchPending(auth.UserID(r.Context()), chi.URLParam(r, "id"))
if err != nil {
httputil.ServerError(w, err)
return
@@ -473,13 +475,19 @@ func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) {
httputil.WriteJSON(w, http.StatusOK, out)
}
func (h *Handler) fetchPending(docID string) ([]db.Suggestion, error) {
// fetchPending loads a document's pending suggestions, joined through documents
// so the rows are only reachable by the document's owner. A suggestion quotes the
// sentence it corrects, so an unscoped read here would leak document text to
// anyone holding a doc id.
func (h *Handler) fetchPending(userID, docID string) ([]db.Suggestion, error) {
rows, err := h.DB.Query(
`SELECT id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at
FROM suggestions
WHERE doc_id = ? AND status = ?
ORDER BY from_pos ASC, created_at ASC`,
docID, db.SuggestionStatusPending,
`SELECT s.id, s.doc_id, s.from_pos, s.to_pos, s.original, s.replacement,
s.explanation, s.type, s.status, s.created_at
FROM suggestions s
JOIN documents d ON d.id = s.doc_id
WHERE s.doc_id = ? AND d.user_id = ? AND s.status = ?
ORDER BY s.from_pos ASC, s.created_at ASC`,
docID, userID, db.SuggestionStatusPending,
)
if err != nil {
return nil, err
@@ -554,10 +562,17 @@ func (h *Handler) dismiss(w http.ResponseWriter, r *http.Request) {
h.setStatus(w, r, db.SuggestionStatusRejected)
}
// setStatus accepts or dismisses one suggestion. The doc_id subquery scopes the
// write to the caller's own documents, so a stray (or guessed) suggestion id
// can't action a row belonging to another account; an unowned id simply affects
// no rows and surfaces as a 404.
func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status string) {
res, err := h.DB.Exec(
`UPDATE suggestions SET status = ? WHERE id = ? AND status = ?`,
`UPDATE suggestions SET status = ?
WHERE id = ? AND status = ?
AND doc_id IN (SELECT id FROM documents WHERE user_id = ?)`,
status, chi.URLParam(r, "id"), db.SuggestionStatusPending,
auth.UserID(r.Context()),
)
if err != nil {
httputil.ServerError(w, err)
+6 -1
View File
@@ -11,6 +11,7 @@ import (
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/llm"
)
@@ -56,7 +57,11 @@ func newTestServer(t *testing.T, client llm.LLMClient) (http.Handler, string, *H
r := chi.NewRouter()
r.Route("/docs", func(dr chi.Router) { h.RegisterDocRoutes(dr) })
r.Mount("/suggestions", h.Routes())
return r, docID, h
// Behind the same auth middleware main.go installs: handlers resolve the
// caller from the request context, so a bare router would see no user.
authed := auth.Middleware(auth.StaticResolver(db.LocalUserID))(r)
return authed, docID, h
}
func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder {
+119
View File
@@ -0,0 +1,119 @@
package suggestions
import (
"encoding/json"
"net/http"
"path/filepath"
"testing"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/llm"
)
// Suggestions are scoped indirectly: the table has no user_id of its own, only a
// doc_id, so every access has to reach the owner through the parent document. A
// forgotten join here is worse than it sounds — a suggestion quotes the sentence
// it corrects, so listing another account's suggestions leaks their prose.
// newTwoUserSuggestionServer seeds one document owned by the local user and
// returns routers for its owner and for a second, unrelated user.
func newTwoUserSuggestionServer(t *testing.T, client llm.LLMClient) (owner, stranger http.Handler, docID string) {
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)
}
if err := database.QueryRow(
`INSERT INTO documents (user_id, content_text) VALUES (?, ?) RETURNING id`,
db.LocalUserID, "I has two apple.",
).Scan(&docID); err != nil {
t.Fatalf("seed doc: %v", err)
}
mount := func(userID string) http.Handler {
h := New(database, client)
r := chi.NewRouter()
r.Route("/docs", func(dr chi.Router) { h.RegisterDocRoutes(dr) })
r.Mount("/suggestions", h.Routes())
return auth.Middleware(auth.StaticResolver(userID))(r)
}
return mount(db.LocalUserID), mount("bob"), docID
}
func TestSuggestionIsolation(t *testing.T) {
client := &stubClient{response: `{"suggestions":[
{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"}
]}`}
owner, stranger, docID := newTwoUserSuggestionServer(t, client)
// The owner runs a checkpoint so there is a real pending suggestion to guard.
rec := do(t, owner, http.MethodPost, "/docs/"+docID+"/check", "")
if rec.Code != http.StatusOK {
t.Fatalf("check: %d %s", rec.Code, rec.Body)
}
var pending []db.Suggestion
if err := json.Unmarshal(rec.Body.Bytes(), &pending); err != nil {
t.Fatalf("decode: %v", err)
}
if len(pending) != 1 {
t.Fatalf("owner has %d suggestions, want 1", len(pending))
}
sugID := pending[0].ID
t.Run("cannot list a stranger's suggestions", func(t *testing.T) {
rec := do(t, stranger, http.MethodGet, "/docs/"+docID+"/suggestions", "")
var out []db.Suggestion
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out) != 0 {
t.Fatalf("stranger read %d suggestions (leaking %q)", len(out), out[0].Original)
}
})
t.Run("cannot run a pass on a stranger's document", func(t *testing.T) {
rec := do(t, stranger, http.MethodPost, "/docs/"+docID+"/check", "")
if rec.Code != http.StatusNotFound {
t.Fatalf("stranger check = %d, want 404", rec.Code)
}
})
// accept/dismiss take a bare suggestion id with no document in the path, so
// the write has to scope itself through doc_id → documents.user_id.
for _, action := range []string{"accept", "dismiss"} {
t.Run("cannot "+action+" a stranger's suggestion", func(t *testing.T) {
rec := do(t, stranger, http.MethodPost, "/suggestions/"+sugID+"/"+action, "")
if rec.Code != http.StatusNotFound {
t.Fatalf("stranger %s = %d, want 404 (body: %s)", action, rec.Code, rec.Body)
}
})
}
// After every attempt the suggestion must still be pending for its owner.
rec = do(t, owner, http.MethodGet, "/docs/"+docID+"/suggestions", "")
var after []db.Suggestion
if err := json.Unmarshal(rec.Body.Bytes(), &after); err != nil {
t.Fatalf("decode: %v", err)
}
if len(after) != 1 || after[0].Status != db.SuggestionStatusPending {
t.Fatalf("owner's suggestion was altered by the stranger: %+v", after)
}
// And the owner can still action it — the scoping guards, it doesn't block.
rec = do(t, owner, http.MethodPost, "/suggestions/"+sugID+"/accept", "")
if rec.Code != http.StatusNoContent {
t.Fatalf("owner accept = %d, want 204 (body: %s)", rec.Code, rec.Body)
}
}
+2 -2
View File
@@ -9,7 +9,7 @@ import (
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/httputil"
"gitea.parodia.dev/drwily/petal/internal/llm"
)
@@ -56,7 +56,7 @@ func (h *Handler) rewrite(w http.ResponseWriter, r *http.Request) {
var exists int
err := h.DB.QueryRow(
`SELECT 1 FROM documents WHERE id = ? AND user_id = ?`,
docID, db.LocalUserID,
docID, auth.UserID(r.Context()),
).Scan(&exists)
if errors.Is(err, sql.ErrNoRows) {
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
+2 -2
View File
@@ -8,7 +8,7 @@ import (
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/httputil"
"gitea.parodia.dev/drwily/petal/internal/llm"
)
@@ -31,7 +31,7 @@ func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
FROM suggestions s
JOIN documents d ON d.id = s.doc_id
WHERE s.id = ? AND d.user_id = ?`,
sugID, db.LocalUserID,
sugID, auth.UserID(r.Context()),
).Scan(&explanation)
if errors.Is(err, sql.ErrNoRows) {
httputil.ErrorJSON(w, http.StatusNotFound, "suggestion not found")
+17 -13
View File
@@ -11,6 +11,7 @@ import (
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
)
@@ -68,15 +69,15 @@ func scanWord(s interface {
}
// list returns the full garden, newest blossoms first.
func (h *Handler) list(w http.ResponseWriter, _ *http.Request) {
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
h.queryList(w, `SELECT `+vocabColumns+` FROM vocab_words
WHERE user_id = ? ORDER BY created_at DESC`, db.LocalUserID)
WHERE user_id = ? ORDER BY created_at DESC`, auth.UserID(r.Context()))
}
// due returns only the cards whose review time has arrived, soonest first.
func (h *Handler) due(w http.ResponseWriter, _ *http.Request) {
func (h *Handler) due(w http.ResponseWriter, r *http.Request) {
h.queryList(w, `SELECT `+vocabColumns+` FROM vocab_words
WHERE user_id = ? AND due_at <= datetime('now') ORDER BY due_at ASC`, db.LocalUserID)
WHERE user_id = ? AND due_at <= datetime('now') ORDER BY due_at ASC`, auth.UserID(r.Context()))
}
func (h *Handler) queryList(w http.ResponseWriter, query string, args ...any) {
@@ -138,6 +139,8 @@ func clamp(s string, max int) string {
// schedule untouched but refreshes its gloss/phonetic/example/doc_id so the most
// recent context wins. Looking words up IS the data source — no extra effort.
func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
userID := auth.UserID(r.Context())
var req captureRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httputil.ErrorJSON(w, http.StatusBadRequest, "invalid body")
@@ -169,7 +172,7 @@ func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
var ok int
err := h.DB.QueryRow(
`SELECT 1 FROM documents WHERE id = ? AND user_id = ?`,
*req.DocID, db.LocalUserID,
*req.DocID, userID,
).Scan(&ok)
if errors.Is(err, sql.ErrNoRows) {
httputil.ErrorJSON(w, http.StatusBadRequest, "unknown doc_id")
@@ -194,14 +197,14 @@ func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
phonetic = excluded.phonetic,
example = CASE WHEN excluded.example != '' THEN excluded.example ELSE vocab_words.example END,
doc_id = COALESCE(excluded.doc_id, vocab_words.doc_id)`,
db.LocalUserID, word, req.Gloss, req.Definition, req.Phonetic, req.Example, req.DocID,
userID, word, req.Gloss, req.Definition, req.Phonetic, req.Example, req.DocID,
)
if err != nil {
httputil.ServerError(w, err)
return
}
out, err := h.fetch(word)
out, err := h.fetch(userID, word)
if err != nil {
httputil.ServerError(w, err)
return
@@ -210,10 +213,10 @@ func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
}
// fetch loads one word row by its (user, word) key.
func (h *Handler) fetch(word string) (Word, error) {
func (h *Handler) fetch(userID, word string) (Word, error) {
return scanWord(h.DB.QueryRow(
`SELECT `+vocabColumns+` FROM vocab_words WHERE user_id = ? AND word = ?`,
db.LocalUserID, word,
userID, word,
))
}
@@ -225,6 +228,7 @@ type reviewRequest struct {
// scheduler; the new interval is applied as `due_at = now + interval days`.
func (h *Handler) review(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
userID := auth.UserID(r.Context())
var req reviewRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httputil.ErrorJSON(w, http.StatusBadRequest, "invalid body")
@@ -249,7 +253,7 @@ func (h *Handler) review(w http.ResponseWriter, r *http.Request) {
var cur State
err = tx.QueryRow(
`SELECT reps, interval_days, ease, lapses FROM vocab_words WHERE id = ? AND user_id = ?`,
id, db.LocalUserID,
id, userID,
).Scan(&cur.Reps, &cur.Interval, &cur.Ease, &cur.Lapses)
if errors.Is(err, sql.ErrNoRows) {
httputil.ErrorJSON(w, http.StatusNotFound, "word not found")
@@ -269,14 +273,14 @@ func (h *Handler) review(w http.ResponseWriter, r *http.Request) {
reps = ?, interval_days = ?, ease = ?, lapses = ?,
last_reviewed = datetime('now'), due_at = datetime('now', ?)
WHERE id = ? AND user_id = ?`,
nxt.Reps, nxt.Interval, nxt.Ease, nxt.Lapses, offset, id, db.LocalUserID,
nxt.Reps, nxt.Interval, nxt.Ease, nxt.Lapses, offset, id, userID,
); err != nil {
httputil.ServerError(w, err)
return
}
out, err := scanWord(tx.QueryRow(
`SELECT `+vocabColumns+` FROM vocab_words WHERE id = ? AND user_id = ?`, id, db.LocalUserID,
`SELECT `+vocabColumns+` FROM vocab_words WHERE id = ? AND user_id = ?`, id, userID,
))
if err != nil {
httputil.ServerError(w, err)
@@ -293,7 +297,7 @@ func (h *Handler) review(w http.ResponseWriter, r *http.Request) {
func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
res, err := h.DB.Exec(
`DELETE FROM vocab_words WHERE id = ? AND user_id = ?`,
chi.URLParam(r, "id"), db.LocalUserID,
chi.URLParam(r, "id"), auth.UserID(r.Context()),
)
if err != nil {
httputil.ServerError(w, err)
+7 -1
View File
@@ -11,6 +11,7 @@ import (
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/db"
)
@@ -23,7 +24,12 @@ func newTestServer(t *testing.T) (http.Handler, *db.DB) {
t.Cleanup(func() { database.Close() })
r := chi.NewRouter()
r.Mount("/vocab", New(database).Routes())
return r, database
// Behind the same auth middleware main.go installs: handlers resolve the
// caller from the request context, so a bare router would see no user and
// every user-scoped query would match nothing.
authed := auth.Middleware(auth.StaticResolver(db.LocalUserID))(r)
return authed, database
}
func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder {
+9
View File
@@ -41,6 +41,9 @@ export interface Document {
word_count: number
created_at: string
updated_at: string
// When true, this document's automatic snapshots are never pruned, so its
// full writing trail survives as authorship evidence (see the passport).
preserve_history: boolean
}
// Fields the editor sends on auto-save. All optional so a rename can send title
@@ -51,6 +54,7 @@ export interface DocUpdate {
content_text?: string
tone?: string
word_count?: number
preserve_history?: boolean
}
// One sense of a word from the offline dictionary.
@@ -219,6 +223,11 @@ export const api = {
// the given format. A one-click "download all my writing" safety net.
exportAllUrl: (format: ExportFormat) => `/api/docs/export-all?format=${format}`,
// Download URL for the writing passport: a standalone HTML report of how this
// document was written (timeline, growth, sessions), for showing someone who
// questions its authorship. Print to PDF from the browser to hand it over.
passportUrl: (id: string) => `/api/docs/${id}/passport`,
// Offline word lookup (gloss + definition + synonyms) for the right-click popover.
lookupWord: (word: string) => req<WordInfo>(`/word/${encodeURIComponent(word)}`),
// Lightweight Chinese-only gloss for the inline hover/select tooltip — instant
@@ -42,6 +42,8 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
const [selected, setSelected] = useState<DocumentVersion | null>(null)
const [preview, setPreview] = useState<DocumentVersion | null>(null)
const [busy, setBusy] = useState(false)
// Null until the document loads; the passport controls stay inert until then.
const [preserve, setPreserve] = useState<boolean | null>(null)
const panelRef = useFocusTrap<HTMLElement>()
const load = useCallback(async () => {
@@ -57,6 +59,25 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
void load()
}, [load])
// The keep-full-history flag lives on the document, not on its snapshots.
useEffect(() => {
void api
.getDoc(docId)
.then((d) => setPreserve(d.preserve_history))
.catch(() => setPreserve(null))
}, [docId])
const togglePreserve = useCallback(async () => {
if (preserve === null) return
const next = !preserve
setPreserve(next) // optimistic; revert if the save fails
try {
await api.updateDoc(docId, { preserve_history: next })
} catch {
setPreserve(!next)
}
}, [docId, preserve])
// Escape closes the drawer.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -231,6 +252,51 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
</div>
</div>
)}
{/* Writing passport: turn this history into something you can show
someone who doubts you wrote this yourself. */}
<div
className="shrink-0 px-4 py-3"
style={{ borderTop: '1px solid var(--color-border)' }}
>
<a
href={api.passportUrl(docId)}
download
className="flex w-full items-center justify-center gap-1.5 rounded-full py-2.5 text-sm font-extrabold"
style={{
border: '1.5px solid var(--color-accent)',
color: 'var(--color-accent)',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
📜 · Writing passport
</a>
<div className="mt-1.5 text-center text-[11px]" style={{ color: 'var(--color-muted)' }}>
A report showing how this draft grew, session by session.
</div>
<label
className="mt-3 flex cursor-pointer items-start gap-2 text-[11px]"
style={{ color: 'var(--color-muted)' }}
>
<input
type="checkbox"
checked={preserve ?? false}
disabled={preserve === null}
onChange={togglePreserve}
className="mt-0.5 shrink-0"
style={{ accentColor: 'var(--color-accent)' }}
/>
<span>
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
· Keep full history
</span>
<br />
Never delete old snapshots of this document, so the record stays complete.
</span>
</label>
</div>
</aside>
</div>
)