Code-review follow-ups: httputil, validation caps, a11y

Backend:
- Extract shared internal/httputil (WriteJSON/ErrorJSON/BadRequest/
  ServerError); drop the triple-duplicated helpers in docs, suggestions,
  vocab. ServerError now logs the real error and returns a generic 500 so
  raw DB/internal errors never reach the client.
- vocab capture: validate doc_id ownership (blank -> none, unknown -> 400
  instead of a leaked FK 500); rune-safe clamp word/gloss/definition/
  phonetic/example.
- vocab review(): wrap the read-modify-write in a transaction (TOCTOU).
- /api request-size cap via MaxBytesReader middleware (2 MiB), exempting
  /api/images (own 10 MiB limit).

Frontend:
- StatusBar: drive the checking/voicing/collocating indicators from one
  array; llmDown uses !anyBusy.
- Slide-overs: new useFocusTrap hook (focus-in, Tab trap, focus-restore)
  on GardenPanel + HistoryPanel, both role=dialog/aria-modal/aria-label.
- speech.ts: export stopSpeech(); GardenPanel cancels audio on unmount.

Tests: add doc_id-validation and field-clamp coverage; full suite green.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 16:59:23 -07:00
parent 4161830da6
commit 8c6bc1604b
18 changed files with 436 additions and 204 deletions

View File

@@ -48,6 +48,11 @@ func main() {
log.Printf("frontend build version %s", version) log.Printf("frontend build version %s", version)
r.Route("/api", func(api chi.Router) { r.Route("/api", func(api chi.Router) {
// Cap request bodies so a runaway or hostile client can't stream an
// unbounded payload into a JSON decoder. Image uploads carry their own
// (larger) limit inside the images handler, so they're exempt here.
api.Use(limitBody(maxAPIBodyBytes, "/api/images"))
api.Get("/health", func(w http.ResponseWriter, _ *http.Request) { api.Get("/health", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok"}`)) _, _ = w.Write([]byte(`{"status":"ok"}`))
@@ -114,6 +119,32 @@ func main() {
} }
} }
// maxAPIBodyBytes caps a JSON API request body at 2 MiB. That's far above any
// real document save (the body is text plus lightweight marks; images upload
// separately by reference) while still bounding abuse. Exceeding it makes the
// handler's json.Decode fail, which surfaces as a 400.
const maxAPIBodyBytes = 2 << 20
// limitBody wraps each request body in an http.MaxBytesReader so handlers can't
// be made to read an unbounded payload. Paths under any of exemptPrefixes are
// left alone (e.g. image uploads, which set their own, larger limit).
func limitBody(max int64, exemptPrefixes ...string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, p := range exemptPrefixes {
if strings.HasPrefix(r.URL.Path, p) {
next.ServeHTTP(w, r)
return
}
}
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, max)
}
next.ServeHTTP(w, r)
})
}
}
// buildVersion derives a short, stable identifier for the currently embedded // buildVersion derives a short, stable identifier for the currently embedded
// frontend by hashing dist/index.html. Vite stamps content-hashed asset names // frontend by hashing dist/index.html. Vite stamps content-hashed asset names
// into that file each build, so the digest is a reliable "did the deploy // into that file each build, so the digest is a reliable "did the deploy

View File

@@ -14,6 +14,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
) )
// exportRoutes registers the download endpoints: one document // exportRoutes registers the download endpoints: one document
@@ -48,7 +49,7 @@ func (h *Handler) exportAll(w http.ResponseWriter, r *http.Request) {
db.LocalUserID, db.LocalUserID,
) )
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
defer rows.Close() defer rows.Close()
@@ -62,12 +63,12 @@ func (h *Handler) exportAll(w http.ResponseWriter, r *http.Request) {
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText, &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,
); err != nil { ); err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
body, err := format.render(doc) body, err := format.render(doc)
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
base := sanitizeFilename(doc.Title) base := sanitizeFilename(doc.Title)
@@ -82,20 +83,20 @@ func (h *Handler) exportAll(w http.ResponseWriter, r *http.Request) {
seen[key]++ seen[key]++
f, err := zw.Create(name) f, err := zw.Create(name)
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
if _, err := f.Write(body); err != nil { if _, err := f.Write(body); err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
if err := zw.Close(); err != nil { if err := zw.Close(); err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
@@ -148,13 +149,13 @@ func (h *Handler) export(w http.ResponseWriter, r *http.Request) {
return return
} }
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
body, err := format.render(doc) body, err := format.render(doc)
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }

View File

@@ -13,6 +13,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
) )
// Handler holds the dependencies shared by every document route. // Handler holds the dependencies shared by every document route.
@@ -62,7 +63,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
db.LocalUserID, db.LocalUserID,
) )
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
defer rows.Close() defer rows.Close()
@@ -72,20 +73,20 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
for rows.Next() { for rows.Next() {
var d docSummary var d docSummary
if err := rows.Scan(&d.ID, &d.Title, &d.WordCount, &d.UpdatedAt); err != nil { if err := rows.Scan(&d.ID, &d.Title, &d.WordCount, &d.UpdatedAt); err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
out = append(out, d) out = append(out, d)
ids = append(ids, d.ID) ids = append(ids, d.ID)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
byDoc, err := h.tagsByDoc(ids) byDoc, err := h.tagsByDoc(ids)
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
for i := range out { for i := range out {
@@ -94,7 +95,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
out[i].Tags = []db.Tag{} out[i].Tags = []db.Tag{}
} }
} }
writeJSON(w, http.StatusOK, out) httputil.WriteJSON(w, http.StatusOK, out)
} }
// create inserts a fresh blank document and returns it in full. // create inserts a fresh blank document and returns it in full.
@@ -109,10 +110,10 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt, &doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
) )
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
writeJSON(w, http.StatusCreated, doc) httputil.WriteJSON(w, http.StatusCreated, doc)
} }
// get returns a single full document by id. // get returns a single full document by id.
@@ -123,10 +124,10 @@ func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
return return
} }
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
writeJSON(w, http.StatusOK, doc) httputil.WriteJSON(w, http.StatusOK, doc)
} }
// updateRequest is the auto-save payload. Every field is optional (a pointer) so // updateRequest is the auto-save payload. Every field is optional (a pointer) so
@@ -163,7 +164,7 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
req.Title, req.Content, req.ContentText, req.Tone, req.WordCount, id, db.LocalUserID, req.Title, req.Content, req.ContentText, req.Tone, req.WordCount, id, db.LocalUserID,
) )
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
if n, _ := res.RowsAffected(); n == 0 { if n, _ := res.RowsAffected(); n == 0 {
@@ -173,7 +174,7 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
doc, err := h.fetch(id) doc, err := h.fetch(id)
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
@@ -186,7 +187,7 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
} }
} }
writeJSON(w, http.StatusOK, doc) httputil.WriteJSON(w, http.StatusOK, doc)
} }
// delete removes a document (suggestions cascade via the FK). // delete removes a document (suggestions cascade via the FK).
@@ -196,7 +197,7 @@ func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
chi.URLParam(r, "id"), db.LocalUserID, chi.URLParam(r, "id"), db.LocalUserID,
) )
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
if n, _ := res.RowsAffected(); n == 0 { if n, _ := res.RowsAffected(); n == 0 {
@@ -222,21 +223,12 @@ func (h *Handler) fetch(id string) (db.Document, error) {
} }
// --- small response helpers ------------------------------------------------- // --- small response helpers -------------------------------------------------
//
// The generic JSON/error helpers live in internal/httputil; these are the
// document-specific shorthands that carry domain wording.
func writeJSON(w http.ResponseWriter, status int, v any) { func badRequest(w http.ResponseWriter, msg string) { httputil.BadRequest(w, msg) }
w.Header().Set("Content-Type", "application/json") func notFound(w http.ResponseWriter) {
w.WriteHeader(status) httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
_ = json.NewEncoder(w).Encode(v)
} }
func notFoundMsg(w http.ResponseWriter, msg string) { httputil.ErrorJSON(w, http.StatusNotFound, msg) }
func errorJSON(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func serverError(w http.ResponseWriter, err error) {
errorJSON(w, http.StatusInternalServerError, err.Error())
}
func badRequest(w http.ResponseWriter, msg string) { errorJSON(w, http.StatusBadRequest, msg) }
func notFound(w http.ResponseWriter) { errorJSON(w, http.StatusNotFound, "document not found") }
func notFoundMsg(w http.ResponseWriter, msg string) { errorJSON(w, http.StatusNotFound, msg) }

View File

@@ -8,6 +8,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
) )
// Search snippet shaping. // Search snippet shaping.
@@ -57,7 +58,7 @@ func (h *Handler) SearchRoutes() chi.Router {
func (h *Handler) search(w http.ResponseWriter, r *http.Request) { func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q")) q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" { if q == "" {
writeJSON(w, http.StatusOK, []searchResult{}) httputil.WriteJSON(w, http.StatusOK, []searchResult{})
return return
} }
@@ -82,20 +83,20 @@ func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
phrase, db.LocalUserID, maxSearchResults, phrase, db.LocalUserID, maxSearchResults,
) )
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
defer sqlRows.Close() defer sqlRows.Close()
for sqlRows.Next() { for sqlRows.Next() {
var rw row var rw row
if err := sqlRows.Scan(&rw.id, &rw.title, &rw.contentText, &rw.wordCount, &rw.updatedAt); err != nil { if err := sqlRows.Scan(&rw.id, &rw.title, &rw.contentText, &rw.wordCount, &rw.updatedAt); err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
rows = append(rows, rw) rows = append(rows, rw)
} }
if err := sqlRows.Err(); err != nil { if err := sqlRows.Err(); err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
} else { } else {
@@ -112,20 +113,20 @@ func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
db.LocalUserID, like, like, maxSearchResults, db.LocalUserID, like, like, maxSearchResults,
) )
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
defer sqlRows.Close() defer sqlRows.Close()
for sqlRows.Next() { for sqlRows.Next() {
var rw row var rw row
if err := sqlRows.Scan(&rw.id, &rw.title, &rw.contentText, &rw.wordCount, &rw.updatedAt); err != nil { if err := sqlRows.Scan(&rw.id, &rw.title, &rw.contentText, &rw.wordCount, &rw.updatedAt); err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
rows = append(rows, rw) rows = append(rows, rw)
} }
if err := sqlRows.Err(); err != nil { if err := sqlRows.Err(); err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
} }
@@ -145,7 +146,7 @@ func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
byDoc, err := h.tagsByDoc(ids) byDoc, err := h.tagsByDoc(ids)
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
for i := range out { for i := range out {
@@ -154,7 +155,7 @@ func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
out[i].Tags = []db.Tag{} out[i].Tags = []db.Tag{}
} }
} }
writeJSON(w, http.StatusOK, out) httputil.WriteJSON(w, http.StatusOK, out)
} }
// escapeLike escapes the LIKE metacharacters (% and _) and the escape character // escapeLike escapes the LIKE metacharacters (% and _) and the escape character

View File

@@ -8,6 +8,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
) )
// validTagColors is the palette a tag may use, mirrored from the design tokens. // validTagColors is the palette a tag may use, mirrored from the design tokens.
@@ -57,7 +58,7 @@ func (h *Handler) listTags(w http.ResponseWriter, r *http.Request) {
db.LocalUserID, db.LocalUserID,
) )
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
defer rows.Close() defer rows.Close()
@@ -66,16 +67,16 @@ func (h *Handler) listTags(w http.ResponseWriter, r *http.Request) {
for rows.Next() { for rows.Next() {
var t db.Tag var t db.Tag
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.DocCount); err != nil { if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.DocCount); err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
out = append(out, t) out = append(out, t)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
writeJSON(w, http.StatusOK, out) httputil.WriteJSON(w, http.StatusOK, out)
} }
type tagRequest struct { type tagRequest struct {
@@ -106,10 +107,10 @@ func (h *Handler) createTag(w http.ResponseWriter, r *http.Request) {
db.LocalUserID, name, normalizeColor(req.Color), db.LocalUserID, name, normalizeColor(req.Color),
).Scan(&t.ID, &t.Name, &t.Color) ).Scan(&t.ID, &t.Name, &t.Color)
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
writeJSON(w, http.StatusCreated, t) httputil.WriteJSON(w, http.StatusCreated, t)
} }
// updateTag renames and/or recolors a tag. Both fields optional via pointers so // updateTag renames and/or recolors a tag. Both fields optional via pointers so
@@ -147,7 +148,7 @@ func (h *Handler) updateTag(w http.ResponseWriter, r *http.Request) {
namePtr, colorPtr, id, db.LocalUserID, namePtr, colorPtr, id, db.LocalUserID,
) )
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
if n, _ := res.RowsAffected(); n == 0 { if n, _ := res.RowsAffected(); n == 0 {
@@ -160,10 +161,10 @@ func (h *Handler) updateTag(w http.ResponseWriter, r *http.Request) {
`SELECT id, name, color FROM tags WHERE id = ? AND user_id = ?`, `SELECT id, name, color FROM tags WHERE id = ? AND user_id = ?`,
id, db.LocalUserID, id, db.LocalUserID,
).Scan(&t.ID, &t.Name, &t.Color); err != nil { ).Scan(&t.ID, &t.Name, &t.Color); err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
writeJSON(w, http.StatusOK, t) httputil.WriteJSON(w, http.StatusOK, t)
} }
// deleteTag removes a tag; its document assignments cascade away via the FK. // deleteTag removes a tag; its document assignments cascade away via the FK.
@@ -173,7 +174,7 @@ func (h *Handler) deleteTag(w http.ResponseWriter, r *http.Request) {
chi.URLParam(r, "id"), db.LocalUserID, chi.URLParam(r, "id"), db.LocalUserID,
) )
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
if n, _ := res.RowsAffected(); n == 0 { if n, _ := res.RowsAffected(); n == 0 {
@@ -216,7 +217,7 @@ func (h *Handler) assignTag(w http.ResponseWriter, r *http.Request) {
ON CONFLICT(doc_id, tag_id) DO NOTHING`, ON CONFLICT(doc_id, tag_id) DO NOTHING`,
docID, req.TagID, docID, req.TagID,
); err != nil { ); err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -235,7 +236,7 @@ func (h *Handler) unassignTag(w http.ResponseWriter, r *http.Request) {
`DELETE FROM document_tags WHERE doc_id = ? AND tag_id = ?`, `DELETE FROM document_tags WHERE doc_id = ? AND tag_id = ?`,
docID, tagID, docID, tagID,
); err != nil { ); err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)

View File

@@ -9,6 +9,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
) )
// Version-history tuning. // Version-history tuning.
@@ -50,7 +51,7 @@ func (h *Handler) listVersions(w http.ResponseWriter, r *http.Request) {
docID, db.LocalUserID, docID, db.LocalUserID,
) )
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
defer rows.Close() defer rows.Close()
@@ -59,16 +60,16 @@ func (h *Handler) listVersions(w http.ResponseWriter, r *http.Request) {
for rows.Next() { for rows.Next() {
var v db.DocumentVersion var v db.DocumentVersion
if err := rows.Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt); err != nil { if err := rows.Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt); err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
out = append(out, v) out = append(out, v)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
writeJSON(w, http.StatusOK, out) httputil.WriteJSON(w, http.StatusOK, out)
} }
// getVersion returns one snapshot in full (including content) for preview. // getVersion returns one snapshot in full (including content) for preview.
@@ -79,10 +80,10 @@ func (h *Handler) getVersion(w http.ResponseWriter, r *http.Request) {
return return
} }
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
writeJSON(w, http.StatusOK, v) httputil.WriteJSON(w, http.StatusOK, v)
} }
// createVersion takes an explicit, user-requested ('manual') restore point from // createVersion takes an explicit, user-requested ('manual') restore point from
@@ -96,16 +97,16 @@ func (h *Handler) createVersion(w http.ResponseWriter, r *http.Request) {
return return
} }
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
v, err := h.insertVersion(doc, db.VersionKindManual) v, err := h.insertVersion(doc, db.VersionKindManual)
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
writeJSON(w, http.StatusCreated, v) httputil.WriteJSON(w, http.StatusCreated, v)
} }
// restoreVersion copies a snapshot back onto the live document. Before // restoreVersion copies a snapshot back onto the live document. Before
@@ -121,17 +122,17 @@ func (h *Handler) restoreVersion(w http.ResponseWriter, r *http.Request) {
return return
} }
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
current, err := h.fetch(docID) current, err := h.fetch(docID)
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
if _, err := h.insertVersion(current, db.VersionKindPreRestore); err != nil { if _, err := h.insertVersion(current, db.VersionKindPreRestore); err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
@@ -143,7 +144,7 @@ func (h *Handler) restoreVersion(w http.ResponseWriter, r *http.Request) {
v.Title, v.Content, v.ContentText, v.WordCount, docID, db.LocalUserID, v.Title, v.Content, v.ContentText, v.WordCount, docID, db.LocalUserID,
) )
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
if n, _ := res.RowsAffected(); n == 0 { if n, _ := res.RowsAffected(); n == 0 {
@@ -153,10 +154,10 @@ func (h *Handler) restoreVersion(w http.ResponseWriter, r *http.Request) {
doc, err := h.fetch(docID) doc, err := h.fetch(docID)
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
writeJSON(w, http.StatusOK, doc) httputil.WriteJSON(w, http.StatusOK, doc)
} }
// maybeAutoSnapshot records a throttled background snapshot of the just-saved // maybeAutoSnapshot records a throttled background snapshot of the just-saved

37
internal/httputil/json.go Normal file
View File

@@ -0,0 +1,37 @@
// Package httputil holds the small HTTP response helpers shared by every API
// handler package (docs, suggestions, vocab, …). Keeping them in one place means
// JSON encoding and — crucially — error handling behave identically everywhere:
// internal errors are logged in full but never leaked to the client.
package httputil
import (
"encoding/json"
"log"
"net/http"
)
// WriteJSON encodes v as the response body with the given status code.
func WriteJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// ErrorJSON sends a {"error": msg} body with the given status. The message is
// caller-chosen and safe to show the client.
func ErrorJSON(w http.ResponseWriter, status int, msg string) {
WriteJSON(w, status, map[string]string{"error": msg})
}
// BadRequest is the common 400 shorthand.
func BadRequest(w http.ResponseWriter, msg string) {
ErrorJSON(w, http.StatusBadRequest, msg)
}
// ServerError logs the real error (with full detail, for the operator) and
// returns a generic 500 to the client — raw database/internal errors must never
// reach the browser, where they leak schema and implementation details.
func ServerError(w http.ResponseWriter, err error) {
log.Printf("internal error: %v", err)
ErrorJSON(w, http.StatusInternalServerError, "something went wrong")
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
"gitea.parodia.dev/drwily/petal/internal/llm" "gitea.parodia.dev/drwily/petal/internal/llm"
) )
@@ -29,7 +30,7 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
var body chatRequest var body chatRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
errorJSON(w, http.StatusBadRequest, "invalid request body") httputil.ErrorJSON(w, http.StatusBadRequest, "invalid request body")
return return
} }
@@ -48,11 +49,11 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
sugID, db.LocalUserID, sugID, db.LocalUserID,
).Scan(&original, &replacement, &explanation, &typ, &fromPos, &contentText) ).Scan(&original, &replacement, &explanation, &typ, &fromPos, &contentText)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
errorJSON(w, http.StatusNotFound, "suggestion not found") httputil.ErrorJSON(w, http.StatusNotFound, "suggestion not found")
return return
} }
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
@@ -63,7 +64,7 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
// Flush through; bail with a plain error if somehow they don't. // Flush through; bail with a plain error if somehow they don't.
flusher, ok := w.(http.Flusher) flusher, ok := w.(http.Flusher)
if !ok { if !ok {
serverError(w, errors.New("streaming unsupported")) httputil.ServerError(w, errors.New("streaming unsupported"))
return return
} }
@@ -71,7 +72,7 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
// The stream never opened (e.g. LLM unreachable) — a normal JSON error is // The stream never opened (e.g. LLM unreachable) — a normal JSON error is
// still appropriate since we haven't written SSE headers yet. // still appropriate since we haven't written SSE headers yet.
errorJSON(w, http.StatusBadGateway, "chat failed: "+err.Error()) httputil.ErrorJSON(w, http.StatusBadGateway, "chat failed: "+err.Error())
return return
} }

View File

@@ -8,7 +8,6 @@ package suggestions
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"net/http" "net/http"
"strings" "strings"
@@ -16,6 +15,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
"gitea.parodia.dev/drwily/petal/internal/llm" "gitea.parodia.dev/drwily/petal/internal/llm"
) )
@@ -99,17 +99,17 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
docID, db.LocalUserID, docID, db.LocalUserID,
).Scan(&contentText, &tone) ).Scan(&contentText, &tone)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
errorJSON(w, http.StatusNotFound, "document not found") httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
return return
} }
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
// Nothing to analyze on an empty document — skip the LLM round-trip. // Nothing to analyze on an empty document — skip the LLM round-trip.
if strings.TrimSpace(contentText) == "" { if strings.TrimSpace(contentText) == "" {
writeJSON(w, http.StatusOK, []db.Suggestion{}) httputil.WriteJSON(w, http.StatusOK, []db.Suggestion{})
return return
} }
@@ -119,10 +119,10 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
// error, so the frontend keeps showing current suggestions. // error, so the frontend keeps showing current suggestions.
existing, err := h.fetchPending(docID) existing, err := h.fetchPending(docID)
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
writeJSON(w, http.StatusOK, existing) httputil.WriteJSON(w, http.StatusOK, existing)
return return
} }
@@ -132,12 +132,12 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
// the per-document slot for the full interval — stranding the frontend's // the per-document slot for the full interval — stranding the frontend's
// auto-retry on the throttle path. Release it so a retry can re-run. // auto-retry on the throttle path. Release it so a retry can re-run.
limiter.Release(docID, slotAt) limiter.Release(docID, slotAt)
errorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error()) httputil.ErrorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
return return
} }
if err := h.replacePending(docID, contentText, raw, scope); err != nil { if err := h.replacePending(docID, contentText, raw, scope); err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
@@ -146,10 +146,10 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
// throttle path above stays consistent with the success path. // throttle path above stays consistent with the success path.
out, err := h.fetchPending(docID) out, err := h.fetchPending(docID)
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
writeJSON(w, http.StatusOK, out) httputil.WriteJSON(w, http.StatusOK, out)
} }
// pendingScope describes how one LLM pass touches the shared suggestions table: // pendingScope describes how one LLM pass touches the shared suggestions table:
@@ -261,10 +261,10 @@ func suggestionKey(original, replacement string) string {
func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) { func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) {
out, err := h.fetchPending(chi.URLParam(r, "id")) out, err := h.fetchPending(chi.URLParam(r, "id"))
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
writeJSON(w, http.StatusOK, out) httputil.WriteJSON(w, http.StatusOK, out)
} }
func (h *Handler) fetchPending(docID string) ([]db.Suggestion, error) { func (h *Handler) fetchPending(docID string) ([]db.Suggestion, error) {
@@ -310,11 +310,11 @@ func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status strin
status, chi.URLParam(r, "id"), db.SuggestionStatusPending, status, chi.URLParam(r, "id"), db.SuggestionStatusPending,
) )
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
if n, _ := res.RowsAffected(); n == 0 { if n, _ := res.RowsAffected(); n == 0 {
errorJSON(w, http.StatusNotFound, "pending suggestion not found") httputil.ErrorJSON(w, http.StatusNotFound, "pending suggestion not found")
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -341,19 +341,3 @@ func normalizeType(t string) string {
return db.SuggestionTypeGrammar return db.SuggestionTypeGrammar
} }
} }
// --- response helpers -------------------------------------------------------
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func errorJSON(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func serverError(w http.ResponseWriter, err error) {
errorJSON(w, http.StatusInternalServerError, err.Error())
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
"gitea.parodia.dev/drwily/petal/internal/llm" "gitea.parodia.dev/drwily/petal/internal/llm"
) )
@@ -36,17 +37,17 @@ func (h *Handler) rewrite(w http.ResponseWriter, r *http.Request) {
var body rewriteRequest var body rewriteRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
errorJSON(w, http.StatusBadRequest, "invalid request body") httputil.ErrorJSON(w, http.StatusBadRequest, "invalid request body")
return return
} }
text := strings.TrimSpace(body.Text) text := strings.TrimSpace(body.Text)
if text == "" { if text == "" {
errorJSON(w, http.StatusBadRequest, "no text to rewrite") httputil.ErrorJSON(w, http.StatusBadRequest, "no text to rewrite")
return return
} }
if len([]rune(text)) > llm.RewriteMaxRunes { if len([]rune(text)) > llm.RewriteMaxRunes {
errorJSON(w, http.StatusBadRequest, "selection too long to rewrite") httputil.ErrorJSON(w, http.StatusBadRequest, "selection too long to rewrite")
return return
} }
@@ -58,19 +59,19 @@ func (h *Handler) rewrite(w http.ResponseWriter, r *http.Request) {
docID, db.LocalUserID, docID, db.LocalUserID,
).Scan(&exists) ).Scan(&exists)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
errorJSON(w, http.StatusNotFound, "document not found") httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
return return
} }
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
out, err := llm.RunRewrite(r.Context(), h.Client, text, body.Style) out, err := llm.RunRewrite(r.Context(), h.Client, text, body.Style)
if err != nil { if err != nil {
errorJSON(w, http.StatusBadGateway, "rewrite failed: "+err.Error()) httputil.ErrorJSON(w, http.StatusBadGateway, "rewrite failed: "+err.Error())
return return
} }
writeJSON(w, http.StatusOK, rewriteResponse{Rewrite: out}) httputil.WriteJSON(w, http.StatusOK, rewriteResponse{Rewrite: out})
} }

View File

@@ -9,6 +9,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
"gitea.parodia.dev/drwily/petal/internal/llm" "gitea.parodia.dev/drwily/petal/internal/llm"
) )
@@ -33,25 +34,25 @@ func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
sugID, db.LocalUserID, sugID, db.LocalUserID,
).Scan(&explanation) ).Scan(&explanation)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
errorJSON(w, http.StatusNotFound, "suggestion not found") httputil.ErrorJSON(w, http.StatusNotFound, "suggestion not found")
return return
} }
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
explanation = strings.TrimSpace(explanation) explanation = strings.TrimSpace(explanation)
if explanation == "" { if explanation == "" {
writeJSON(w, http.StatusOK, translateResponse{Translation: ""}) httputil.WriteJSON(w, http.StatusOK, translateResponse{Translation: ""})
return return
} }
out, err := llm.RunTranslate(r.Context(), h.Client, explanation) out, err := llm.RunTranslate(r.Context(), h.Client, explanation)
if err != nil { if err != nil {
errorJSON(w, http.StatusBadGateway, "translate failed: "+err.Error()) httputil.ErrorJSON(w, http.StatusBadGateway, "translate failed: "+err.Error())
return return
} }
writeJSON(w, http.StatusOK, translateResponse{Translation: out}) httputil.WriteJSON(w, http.StatusOK, translateResponse{Translation: out})
} }

View File

@@ -12,6 +12,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
) )
// Word is one entry in the vocabulary garden: the looked-up word with its gloss, // Word is one entry in the vocabulary garden: the looked-up word with its gloss,
@@ -81,7 +82,7 @@ func (h *Handler) due(w http.ResponseWriter, _ *http.Request) {
func (h *Handler) queryList(w http.ResponseWriter, query string, args ...any) { func (h *Handler) queryList(w http.ResponseWriter, query string, args ...any) {
rows, err := h.DB.Query(query, args...) rows, err := h.DB.Query(query, args...)
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
defer rows.Close() defer rows.Close()
@@ -89,16 +90,16 @@ func (h *Handler) queryList(w http.ResponseWriter, query string, args ...any) {
for rows.Next() { for rows.Next() {
word, err := scanWord(rows) word, err := scanWord(rows)
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
out = append(out, word) out = append(out, word)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
writeJSON(w, http.StatusOK, out) httputil.WriteJSON(w, http.StatusOK, out)
} }
type captureRequest struct { type captureRequest struct {
@@ -110,6 +111,28 @@ type captureRequest struct {
DocID *string `json:"doc_id"` DocID *string `json:"doc_id"`
} }
// Field-length caps. The captured fields come from the offline lexicon and the
// editor selection, not free-typed prose, so we clamp rather than reject — a
// lookup should never fail because a definition ran long. Counts are runes so a
// Chinese gloss isn't cut mid-character. The word is the upsert key, so an absurd
// "word" is bounded too.
const (
maxWordLen = 128
maxGlossLen = 512
maxDefinitionLen = 4096
maxPhoneticLen = 256
maxExampleLen = 4096
)
// clamp trims s to at most max runes (rune-safe so multibyte glosses survive).
func clamp(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max])
}
// capture records a looked-up word. It's an idempotent upsert keyed on the word: // capture records a looked-up word. It's an idempotent upsert keyed on the word:
// a new word lands due tomorrow (interval 1 day); an existing word keeps its // a new word lands due tomorrow (interval 1 day); an existing word keeps its
// schedule untouched but refreshes its gloss/phonetic/example/doc_id so the most // schedule untouched but refreshes its gloss/phonetic/example/doc_id so the most
@@ -117,18 +140,47 @@ type captureRequest struct {
func (h *Handler) capture(w http.ResponseWriter, r *http.Request) { func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
var req captureRequest var req captureRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errorJSON(w, http.StatusBadRequest, "invalid body") httputil.ErrorJSON(w, http.StatusBadRequest, "invalid body")
return return
} }
// Normalize the same way the lexicon does (internal/lexicon: lower+trim) so // Normalize the same way the lexicon does (internal/lexicon: lower+trim) so
// the UNIQUE(user_id, word) upsert is genuinely idempotent — otherwise // the UNIQUE(user_id, word) upsert is genuinely idempotent — otherwise
// "Apple" at a sentence start and "apple" mid-line would create two separate // "Apple" at a sentence start and "apple" mid-line would create two separate
// cards on independent schedules. // cards on independent schedules.
word := strings.ToLower(strings.TrimSpace(req.Word)) word := clamp(strings.ToLower(strings.TrimSpace(req.Word)), maxWordLen)
if word == "" { if word == "" {
errorJSON(w, http.StatusBadRequest, "word is required") httputil.ErrorJSON(w, http.StatusBadRequest, "word is required")
return return
} }
req.Gloss = clamp(req.Gloss, maxGlossLen)
req.Definition = clamp(req.Definition, maxDefinitionLen)
req.Phonetic = clamp(req.Phonetic, maxPhoneticLen)
req.Example = clamp(req.Example, maxExampleLen)
// A blank doc_id is the same as none; otherwise the word must belong to a
// document the user actually owns. Without this check a stale or forged id
// would hit the foreign key and leak a raw "FOREIGN KEY constraint" 500
// instead of a clean 400 (and, once auth lands, would let a word be attached
// to another user's document).
if req.DocID != nil {
if strings.TrimSpace(*req.DocID) == "" {
req.DocID = nil
} else {
var ok int
err := h.DB.QueryRow(
`SELECT 1 FROM documents WHERE id = ? AND user_id = ?`,
*req.DocID, db.LocalUserID,
).Scan(&ok)
if errors.Is(err, sql.ErrNoRows) {
httputil.ErrorJSON(w, http.StatusBadRequest, "unknown doc_id")
return
}
if err != nil {
httputil.ServerError(w, err)
return
}
}
}
// New rows start due tomorrow; ON CONFLICT refreshes context but leaves the // New rows start due tomorrow; ON CONFLICT refreshes context but leaves the
// schedule (due_at/reps/interval/ease) alone so re-looking-up a word never // schedule (due_at/reps/interval/ease) alone so re-looking-up a word never
@@ -145,16 +197,16 @@ func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
db.LocalUserID, word, req.Gloss, req.Definition, req.Phonetic, req.Example, req.DocID, db.LocalUserID, word, req.Gloss, req.Definition, req.Phonetic, req.Example, req.DocID,
) )
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
out, err := h.fetch(word) out, err := h.fetch(word)
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
writeJSON(w, http.StatusCreated, out) httputil.WriteJSON(w, http.StatusCreated, out)
} }
// fetch loads one word row by its (user, word) key. // fetch loads one word row by its (user, word) key.
@@ -175,25 +227,36 @@ func (h *Handler) review(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
var req reviewRequest var req reviewRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errorJSON(w, http.StatusBadRequest, "invalid body") httputil.ErrorJSON(w, http.StatusBadRequest, "invalid body")
return return
} }
if req.Grade != GradeAgain && req.Grade != GradeGood && req.Grade != GradeEasy { if req.Grade != GradeAgain && req.Grade != GradeGood && req.Grade != GradeEasy {
errorJSON(w, http.StatusBadRequest, "grade must be again, good, or easy") httputil.ErrorJSON(w, http.StatusBadRequest, "grade must be again, good, or easy")
return return
} }
// The grade is computed in Go (the SM-2-lite scheduler), so the read and the
// write must be one atomic unit: a bare SELECT-then-UPDATE could interleave
// with a concurrent review of the same card and lose an update. Wrap both in a
// transaction.
tx, err := h.DB.Begin()
if err != nil {
httputil.ServerError(w, err)
return
}
defer tx.Rollback() // no-op once committed
var cur State var cur State
err := h.DB.QueryRow( err = tx.QueryRow(
`SELECT reps, interval_days, ease, lapses FROM vocab_words WHERE id = ? AND user_id = ?`, `SELECT reps, interval_days, ease, lapses FROM vocab_words WHERE id = ? AND user_id = ?`,
id, db.LocalUserID, id, db.LocalUserID,
).Scan(&cur.Reps, &cur.Interval, &cur.Ease, &cur.Lapses) ).Scan(&cur.Reps, &cur.Interval, &cur.Ease, &cur.Lapses)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
errorJSON(w, http.StatusNotFound, "word not found") httputil.ErrorJSON(w, http.StatusNotFound, "word not found")
return return
} }
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
@@ -201,25 +264,29 @@ func (h *Handler) review(w http.ResponseWriter, r *http.Request) {
// `datetime('now', '+N days')` keeps the stored value in SQLite's canonical // `datetime('now', '+N days')` keeps the stored value in SQLite's canonical
// text format, matching CURRENT_TIMESTAMP and the due query's comparison. // text format, matching CURRENT_TIMESTAMP and the due query's comparison.
offset := "+" + strconv.Itoa(nxt.Interval) + " days" offset := "+" + strconv.Itoa(nxt.Interval) + " days"
if _, err := h.DB.Exec( if _, err := tx.Exec(
`UPDATE vocab_words SET `UPDATE vocab_words SET
reps = ?, interval_days = ?, ease = ?, lapses = ?, reps = ?, interval_days = ?, ease = ?, lapses = ?,
last_reviewed = datetime('now'), due_at = datetime('now', ?) last_reviewed = datetime('now'), due_at = datetime('now', ?)
WHERE id = ? AND user_id = ?`, 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, db.LocalUserID,
); err != nil { ); err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
out, err := scanWord(h.DB.QueryRow( 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, db.LocalUserID,
)) ))
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
writeJSON(w, http.StatusOK, out) if err := tx.Commit(); err != nil {
httputil.ServerError(w, err)
return
}
httputil.WriteJSON(w, http.StatusOK, out)
} }
// remove deletes a word from the garden (e.g. the writer already knows it). // remove deletes a word from the garden (e.g. the writer already knows it).
@@ -229,28 +296,12 @@ func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
chi.URLParam(r, "id"), db.LocalUserID, chi.URLParam(r, "id"), db.LocalUserID,
) )
if err != nil { if err != nil {
serverError(w, err) httputil.ServerError(w, err)
return return
} }
if n, _ := res.RowsAffected(); n == 0 { if n, _ := res.RowsAffected(); n == 0 {
errorJSON(w, http.StatusNotFound, "word not found") httputil.ErrorJSON(w, http.StatusNotFound, "word not found")
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// --- response helpers -------------------------------------------------------
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func errorJSON(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func serverError(w http.ResponseWriter, err error) {
errorJSON(w, http.StatusInternalServerError, err.Error())
}

View File

@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -183,6 +184,42 @@ func TestCaptureCaseInsensitive(t *testing.T) {
} }
} }
// TestCaptureUnknownDocID rejects an unowned/stale doc_id with a clean 400
// rather than letting it hit the foreign key and leak a raw 500.
func TestCaptureUnknownDocID(t *testing.T) {
srv, _ := newTestServer(t)
rec := do(t, srv, http.MethodPost, "/vocab",
`{"word":"quixotic","gloss":"不切实际的","doc_id":"does-not-exist"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("unknown doc_id: want 400, got %d body=%s", rec.Code, rec.Body)
}
// A blank doc_id is treated as none, not an error.
if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"limpid","gloss":"清澈的","doc_id":""}`); rec.Code != http.StatusCreated {
t.Fatalf("blank doc_id should be accepted as none: code=%d body=%s", rec.Code, rec.Body)
}
}
// TestCaptureClampsLongFields proves over-long fields are clamped (not rejected)
// so a lookup never fails on length, and the word key stays bounded.
func TestCaptureClampsLongFields(t *testing.T) {
srv, _ := newTestServer(t)
longWord := strings.Repeat("a", maxWordLen+50)
longDef := strings.Repeat("x", maxDefinitionLen+50)
rec := do(t, srv, http.MethodPost, "/vocab",
`{"word":"`+longWord+`","definition":"`+longDef+`"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
}
var w Word
_ = json.Unmarshal(rec.Body.Bytes(), &w)
if len([]rune(w.Word)) != maxWordLen {
t.Fatalf("word should clamp to %d runes, got %d", maxWordLen, len([]rune(w.Word)))
}
if len([]rune(w.Definition)) != maxDefinitionLen {
t.Fatalf("definition should clamp to %d runes, got %d", maxDefinitionLen, len([]rune(w.Definition)))
}
}
// TestDocLinkSurvivesDocDelete proves the ON DELETE SET NULL keeps a word in the // TestDocLinkSurvivesDocDelete proves the ON DELETE SET NULL keeps a word in the
// garden when its source document is removed. // garden when its source document is removed.
func TestDocLinkSurvivesDocDelete(t *testing.T) { func TestDocLinkSurvivesDocDelete(t *testing.T) {

View File

@@ -28,7 +28,12 @@ let current: { audio: HTMLAudioElement; url: string } | null = null
// a newer tap can detect it's stale and bow out instead of double-playing. // a newer tap can detect it's stale and bow out instead of double-playing.
let requestSeq = 0 let requestSeq = 0
function stopCurrent(): void { // stopSpeech halts any read-aloud in flight — both the server-audio element and
// the Web Speech fallback — and bumps requestSeq so a fetch still in flight bows
// out instead of playing late. Exported so a panel can cancel audio on unmount,
// keeping a word's pronunciation from outliving the panel that started it.
export function stopSpeech(): void {
requestSeq++
if (current) { if (current) {
current.audio.pause() current.audio.pause()
URL.revokeObjectURL(current.url) URL.revokeObjectURL(current.url)
@@ -77,7 +82,7 @@ export function detectLang(text: string): string {
// with no configured voice). // with no configured voice).
export function speak(text: string, lang = detectLang(text)): void { export function speak(text: string, lang = detectLang(text)): void {
if (!text.trim()) return if (!text.trim()) return
stopCurrent() stopSpeech()
const seq = ++requestSeq const seq = ++requestSeq
fetch('/api/tts', { fetch('/api/tts', {

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { api, type VocabGrade, type VocabWord } from '../../api/client' import { api, type VocabGrade, type VocabWord } from '../../api/client'
import { speak, speechSupported } from '../../audio/speech' import { speak, speechSupported, stopSpeech } from '../../audio/speech'
import { useFocusTrap } from '../../hooks/useFocusTrap'
// GardenPanel is the vocabulary garden: every word the writer has looked up, // GardenPanel is the vocabulary garden: every word the writer has looked up,
// grown into a blossom that opens further the more she remembers it, plus a // grown into a blossom that opens further the more she remembers it, plus a
@@ -48,6 +49,12 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) {
const [cursor, setCursor] = useState(0) const [cursor, setCursor] = useState(0)
const [revealed, setRevealed] = useState(false) const [revealed, setRevealed] = useState(false)
const [expanded, setExpanded] = useState<string | null>(null) const [expanded, setExpanded] = useState<string | null>(null)
const panelRef = useFocusTrap<HTMLElement>()
// Read-aloud is fire-and-forget, so a word she tapped could still be speaking
// when the panel closes. Cancel any in-flight audio on unmount so it can't
// outlive the garden.
useEffect(() => stopSpeech, [])
const load = useCallback(async () => { const load = useCallback(async () => {
setError(false) setError(false)
@@ -127,6 +134,11 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) {
/> />
<aside <aside
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label="词汇花园 · Vocabulary Garden"
tabIndex={-1}
className="relative flex h-full w-full max-w-[420px] flex-col" className="relative flex h-full w-full max-w-[420px] flex-col"
style={{ style={{
background: 'var(--color-surface)', background: 'var(--color-surface)',

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { api, type Document, type DocumentVersion } from '../../api/client' import { api, type Document, type DocumentVersion } from '../../api/client'
import { useFocusTrap } from '../../hooks/useFocusTrap'
// HistoryPanel is the "time machine" drawer: every snapshot Petal kept of this // HistoryPanel is the "time machine" drawer: every snapshot Petal kept of this
// document, newest first, with a one-click preview and restore. It's the safety // document, newest first, with a one-click preview and restore. It's the safety
@@ -41,6 +42,7 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
const [selected, setSelected] = useState<DocumentVersion | null>(null) const [selected, setSelected] = useState<DocumentVersion | null>(null)
const [preview, setPreview] = useState<DocumentVersion | null>(null) const [preview, setPreview] = useState<DocumentVersion | null>(null)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const panelRef = useFocusTrap<HTMLElement>()
const load = useCallback(async () => { const load = useCallback(async () => {
setError(false) setError(false)
@@ -99,6 +101,11 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
/> />
<aside <aside
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label="历史 · History"
tabIndex={-1}
className="relative flex h-full w-full max-w-[380px] flex-col" className="relative flex h-full w-full max-w-[380px] flex-col"
style={{ style={{
background: 'var(--color-surface)', background: 'var(--color-surface)',

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react' import { Fragment, useEffect, useRef, useState } from 'react'
import type { SaveStatus } from '../../hooks/useAutoSave' import type { SaveStatus } from '../../hooks/useAutoSave'
import { StatsPanel } from './StatsPanel' import { StatsPanel } from './StatsPanel'
import { SoundToggle } from './SoundToggle' import { SoundToggle } from './SoundToggle'
@@ -30,8 +30,40 @@ const SAVE_LABEL: Record<SaveStatus, string> = {
// StatusBar is the slim footer: word count on the left, save state and the // StatusBar is the slim footer: word count on the left, save state and the
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose // grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
// circle that breathes while a check is in flight (spec → Signature animations). // circle that breathes while a check is in flight (spec → Signature animations).
// The live "Petal is working" indicators. Each is a breathing dot + label shown
// while its pass is in flight; driving them from one array keeps the markup (and
// the "nothing in flight" check below) in lockstep as passes are added.
interface Indicator {
active: boolean
color: string
title: string
label: string
}
export function StatusBar({ wordCount, text, saveStatus, checking, voicing, collocating, llmDown }: Props) { export function StatusBar({ wordCount, text, saveStatus, checking, voicing, collocating, llmDown }: Props) {
const label = SAVE_LABEL[saveStatus] const label = SAVE_LABEL[saveStatus]
const indicators: Indicator[] = [
{
active: checking,
color: 'var(--color-accent)',
title: 'Petal is reading your writing…',
label: 'Checking…',
},
{
active: voicing,
color: 'var(--color-honey)',
title: 'Petal is reading your voice…',
label: 'Reading your voice…',
},
{
active: collocating,
color: 'var(--color-blossom)',
title: 'Petal is looking for more natural word pairings…',
label: 'Finding natural phrasing…',
},
]
const anyBusy = indicators.some((i) => i.active)
// The expanded stats panel toggles open when the word count is clicked. // The expanded stats panel toggles open when the word count is clicked.
const [statsOpen, setStatsOpen] = useState(false) const [statsOpen, setStatsOpen] = useState(false)
const statsRef = useRef<HTMLDivElement>(null) const statsRef = useRef<HTMLDivElement>(null)
@@ -68,43 +100,21 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing, coll
</button> </button>
{statsOpen && <StatsPanel text={text} wordCount={wordCount} />} {statsOpen && <StatsPanel text={text} wordCount={wordCount} />}
</div> </div>
{checking && ( {indicators
<> .filter((i) => i.active)
.map((i) => (
<Fragment key={i.label}>
<span aria-hidden>·</span> <span aria-hidden>·</span>
<span className="inline-flex items-center gap-1.5" title="Petal is reading your writing…"> <span className="inline-flex items-center gap-1.5" title={i.title}>
<span <span
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full" className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
style={{ background: 'var(--color-accent)' }} style={{ background: i.color }}
/> />
Checking {i.label}
</span> </span>
</> </Fragment>
)} ))}
{voicing && ( {llmDown && !anyBusy && (
<>
<span aria-hidden>·</span>
<span className="inline-flex items-center gap-1.5" title="Petal is reading your voice…">
<span
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
style={{ background: 'var(--color-honey)' }}
/>
Reading your voice
</span>
</>
)}
{collocating && (
<>
<span aria-hidden>·</span>
<span className="inline-flex items-center gap-1.5" title="Petal is looking for more natural word pairings…">
<span
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
style={{ background: 'var(--color-blossom)' }}
/>
Finding natural phrasing
</span>
</>
)}
{llmDown && !checking && !voicing && !collocating && (
<> <>
<span aria-hidden>·</span> <span aria-hidden>·</span>
<span <span

View File

@@ -0,0 +1,59 @@
import { useEffect, useRef } from 'react'
// useFocusTrap makes a slide-over / modal keyboard-accessible. Attach the
// returned ref to the dialog container and, while it's mounted, it:
// • moves focus into the panel on open (so Tab/Escape work without a click),
// • keeps Tab/Shift+Tab cycling within the panel instead of escaping to the
// page behind the backdrop, and
// • restores focus to whatever was focused before it opened on unmount.
// Escape handling stays with each panel, which has its own close semantics.
export function useFocusTrap<T extends HTMLElement>() {
const ref = useRef<T>(null)
useEffect(() => {
const node = ref.current
if (!node) return
// Remember where focus was so we can hand it back when the panel closes.
const previouslyFocused = document.activeElement as HTMLElement | null
const focusables = () =>
Array.from(
node.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])',
),
).filter((el) => el.offsetParent !== null)
// Focus the first interactive element, or the container itself as a fallback.
const first = focusables()[0]
if (first) first.focus()
else node.focus()
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return
const items = focusables()
if (items.length === 0) {
e.preventDefault()
return
}
const firstEl = items[0]
const lastEl = items[items.length - 1]
const active = document.activeElement
if (e.shiftKey && active === firstEl) {
e.preventDefault()
lastEl.focus()
} else if (!e.shiftKey && active === lastEl) {
e.preventDefault()
firstEl.focus()
}
}
node.addEventListener('keydown', onKeyDown)
return () => {
node.removeEventListener('keydown', onKeyDown)
previouslyFocused?.focus?.()
}
}, [])
return ref
}