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

@@ -9,6 +9,7 @@ import (
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
)
// Version-history tuning.
@@ -29,8 +30,8 @@ const (
// resolve to /api/docs/{id}/versions...
func (h *Handler) versionRoutes(r chi.Router) {
r.Get("/{id}/versions", h.listVersions)
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", 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)
}
@@ -50,7 +51,7 @@ func (h *Handler) listVersions(w http.ResponseWriter, r *http.Request) {
docID, db.LocalUserID,
)
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
defer rows.Close()
@@ -59,16 +60,16 @@ func (h *Handler) listVersions(w http.ResponseWriter, r *http.Request) {
for rows.Next() {
var v db.DocumentVersion
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
}
out = append(out, v)
}
if err := rows.Err(); err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
httputil.WriteJSON(w, http.StatusOK, out)
}
// 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
}
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
writeJSON(w, http.StatusOK, v)
httputil.WriteJSON(w, http.StatusOK, v)
}
// 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
}
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
v, err := h.insertVersion(doc, db.VersionKindManual)
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
writeJSON(w, http.StatusCreated, v)
httputil.WriteJSON(w, http.StatusCreated, v)
}
// 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
}
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
current, err := h.fetch(docID)
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
if _, err := h.insertVersion(current, db.VersionKindPreRestore); err != nil {
serverError(w, err)
httputil.ServerError(w, err)
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,
)
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
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)
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
writeJSON(w, http.StatusOK, doc)
httputil.WriteJSON(w, http.StatusOK, doc)
}
// maybeAutoSnapshot records a throttled background snapshot of the just-saved