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

@@ -10,6 +10,7 @@ import (
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
"gitea.parodia.dev/drwily/petal/internal/llm"
)
@@ -29,7 +30,7 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
var body chatRequest
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
}
@@ -37,8 +38,8 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
// scoped to the local user so a stray id can't read another user's doc.
var (
original, replacement, explanation, typ string
fromPos int
contentText string
fromPos int
contentText string
)
err := h.DB.QueryRow(
`SELECT s.original, s.replacement, s.explanation, s.type, s.from_pos, d.content_text
@@ -48,11 +49,11 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
sugID, db.LocalUserID,
).Scan(&original, &replacement, &explanation, &typ, &fromPos, &contentText)
if errors.Is(err, sql.ErrNoRows) {
errorJSON(w, http.StatusNotFound, "suggestion not found")
httputil.ErrorJSON(w, http.StatusNotFound, "suggestion not found")
return
}
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
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.
flusher, ok := w.(http.Flusher)
if !ok {
serverError(w, errors.New("streaming unsupported"))
httputil.ServerError(w, errors.New("streaming unsupported"))
return
}
@@ -71,7 +72,7 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
if err != nil {
// The stream never opened (e.g. LLM unreachable) — a normal JSON error is
// 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
}