Compare commits
7
Commits
49e84278d5
...
8410b6315b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8410b6315b | ||
|
|
dae1213c68 | ||
|
|
316b6b305d | ||
|
|
023882a722 | ||
|
|
6901cdbbe4 | ||
|
|
61b3c6cd62 | ||
|
|
78ed1dd281 |
@@ -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
@@ -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 15–17 below** (decisions ratified 2026-07-26; see `MULTIUSER_PLAN.md`)
|
||||
|
||||
## Execution phases 15–22 (added 2026-07-26)
|
||||
|
||||
Decisions behind these are ratified in `MULTIUSER_PLAN.md` (all OPENs settled) and `SUGGESTIONS.md` (the *why*; Q1–Q3 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 19–21 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 15–22** 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
@@ -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"]
|
||||
@@ -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
@@ -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 Q1–Q3 settled
|
||||
below; Q4–Q6 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"), subject–verb 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 (1–3 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
@@ -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).
|
||||
|
||||
Executable
+81
@@ -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"
|
||||
@@ -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=
|
||||
@@ -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"]
|
||||
Executable
+22
@@ -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}"
|
||||
@@ -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:
|
||||
@@ -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)))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 '';
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
@@ -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:00–09:30, then a three-hour break, then 12:30–13: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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user