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

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

View File

@@ -13,6 +13,7 @@ import (
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
)
// 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,
)
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
defer rows.Close()
@@ -72,20 +73,20 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
for rows.Next() {
var d docSummary
if err := rows.Scan(&d.ID, &d.Title, &d.WordCount, &d.UpdatedAt); err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
out = append(out, d)
ids = append(ids, d.ID)
}
if err := rows.Err(); err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
byDoc, err := h.tagsByDoc(ids)
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
for i := range out {
@@ -94,7 +95,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
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.
@@ -109,10 +110,10 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
)
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
writeJSON(w, http.StatusCreated, doc)
httputil.WriteJSON(w, http.StatusCreated, doc)
}
// get returns a single full document by id.
@@ -123,10 +124,10 @@ func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
return
}
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
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
@@ -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,
)
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
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)
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
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).
@@ -196,7 +197,7 @@ func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
chi.URLParam(r, "id"), db.LocalUserID,
)
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
@@ -222,21 +223,12 @@ func (h *Handler) fetch(id string) (db.Document, error) {
}
// --- 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) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
func badRequest(w http.ResponseWriter, msg string) { httputil.BadRequest(w, msg) }
func notFound(w http.ResponseWriter) {
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
}
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) }
func notFoundMsg(w http.ResponseWriter, msg string) { httputil.ErrorJSON(w, http.StatusNotFound, msg) }

View File

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

View File

@@ -8,6 +8,7 @@ import (
"github.com/go-chi/chi/v5"
"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.
@@ -57,7 +58,7 @@ func (h *Handler) listTags(w http.ResponseWriter, r *http.Request) {
db.LocalUserID,
)
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
defer rows.Close()
@@ -66,16 +67,16 @@ func (h *Handler) listTags(w http.ResponseWriter, r *http.Request) {
for rows.Next() {
var t db.Tag
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.DocCount); err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
out = append(out, t)
}
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)
}
type tagRequest struct {
@@ -106,10 +107,10 @@ func (h *Handler) createTag(w http.ResponseWriter, r *http.Request) {
db.LocalUserID, name, normalizeColor(req.Color),
).Scan(&t.ID, &t.Name, &t.Color)
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
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
@@ -147,7 +148,7 @@ func (h *Handler) updateTag(w http.ResponseWriter, r *http.Request) {
namePtr, colorPtr, id, db.LocalUserID,
)
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
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 = ?`,
id, db.LocalUserID,
).Scan(&t.ID, &t.Name, &t.Color); err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
writeJSON(w, http.StatusOK, t)
httputil.WriteJSON(w, http.StatusOK, t)
}
// 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,
)
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
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`,
docID, req.TagID,
); err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
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 = ?`,
docID, tagID,
); err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)

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

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"
"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
}

View File

@@ -8,7 +8,6 @@ package suggestions
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"strings"
@@ -16,6 +15,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"
)
@@ -99,17 +99,17 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
docID, db.LocalUserID,
).Scan(&contentText, &tone)
if errors.Is(err, sql.ErrNoRows) {
errorJSON(w, http.StatusNotFound, "document not found")
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
return
}
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
// Nothing to analyze on an empty document — skip the LLM round-trip.
if strings.TrimSpace(contentText) == "" {
writeJSON(w, http.StatusOK, []db.Suggestion{})
httputil.WriteJSON(w, http.StatusOK, []db.Suggestion{})
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.
existing, err := h.fetchPending(docID)
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
writeJSON(w, http.StatusOK, existing)
httputil.WriteJSON(w, http.StatusOK, existing)
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
// auto-retry on the throttle path. Release it so a retry can re-run.
limiter.Release(docID, slotAt)
errorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
httputil.ErrorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
return
}
if err := h.replacePending(docID, contentText, raw, scope); err != nil {
serverError(w, err)
httputil.ServerError(w, err)
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.
out, err := h.fetchPending(docID)
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
httputil.WriteJSON(w, http.StatusOK, out)
}
// 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) {
out, err := h.fetchPending(chi.URLParam(r, "id"))
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
httputil.WriteJSON(w, http.StatusOK, out)
}
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,
)
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
errorJSON(w, http.StatusNotFound, "pending suggestion not found")
httputil.ErrorJSON(w, http.StatusNotFound, "pending suggestion not found")
return
}
w.WriteHeader(http.StatusNoContent)
@@ -341,19 +341,3 @@ func normalizeType(t string) string {
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"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
"gitea.parodia.dev/drwily/petal/internal/llm"
)
@@ -36,17 +37,17 @@ func (h *Handler) rewrite(w http.ResponseWriter, r *http.Request) {
var body rewriteRequest
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
}
text := strings.TrimSpace(body.Text)
if text == "" {
errorJSON(w, http.StatusBadRequest, "no text to rewrite")
httputil.ErrorJSON(w, http.StatusBadRequest, "no text to rewrite")
return
}
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
}
@@ -58,19 +59,19 @@ func (h *Handler) rewrite(w http.ResponseWriter, r *http.Request) {
docID, db.LocalUserID,
).Scan(&exists)
if errors.Is(err, sql.ErrNoRows) {
errorJSON(w, http.StatusNotFound, "document not found")
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
return
}
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
out, err := llm.RunRewrite(r.Context(), h.Client, text, body.Style)
if err != nil {
errorJSON(w, http.StatusBadGateway, "rewrite failed: "+err.Error())
httputil.ErrorJSON(w, http.StatusBadGateway, "rewrite failed: "+err.Error())
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"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
"gitea.parodia.dev/drwily/petal/internal/llm"
)
@@ -33,25 +34,25 @@ func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
sugID, db.LocalUserID,
).Scan(&explanation)
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
}
explanation = strings.TrimSpace(explanation)
if explanation == "" {
writeJSON(w, http.StatusOK, translateResponse{Translation: ""})
httputil.WriteJSON(w, http.StatusOK, translateResponse{Translation: ""})
return
}
out, err := llm.RunTranslate(r.Context(), h.Client, explanation)
if err != nil {
errorJSON(w, http.StatusBadGateway, "translate failed: "+err.Error())
httputil.ErrorJSON(w, http.StatusBadGateway, "translate failed: "+err.Error())
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"
"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,
@@ -81,7 +82,7 @@ func (h *Handler) due(w http.ResponseWriter, _ *http.Request) {
func (h *Handler) queryList(w http.ResponseWriter, query string, args ...any) {
rows, err := h.DB.Query(query, args...)
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
defer rows.Close()
@@ -89,16 +90,16 @@ func (h *Handler) queryList(w http.ResponseWriter, query string, args ...any) {
for rows.Next() {
word, err := scanWord(rows)
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
out = append(out, word)
}
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)
}
type captureRequest struct {
@@ -110,6 +111,28 @@ type captureRequest struct {
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:
// 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
@@ -117,18 +140,47 @@ type captureRequest struct {
func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
var req captureRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errorJSON(w, http.StatusBadRequest, "invalid body")
httputil.ErrorJSON(w, http.StatusBadRequest, "invalid body")
return
}
// Normalize the same way the lexicon does (internal/lexicon: lower+trim) so
// the UNIQUE(user_id, word) upsert is genuinely idempotent — otherwise
// "Apple" at a sentence start and "apple" mid-line would create two separate
// cards on independent schedules.
word := strings.ToLower(strings.TrimSpace(req.Word))
word := clamp(strings.ToLower(strings.TrimSpace(req.Word)), maxWordLen)
if word == "" {
errorJSON(w, http.StatusBadRequest, "word is required")
httputil.ErrorJSON(w, http.StatusBadRequest, "word is required")
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
// 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,
)
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
out, err := h.fetch(word)
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
httputil.WriteJSON(w, http.StatusCreated, out)
}
// 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")
var req reviewRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errorJSON(w, http.StatusBadRequest, "invalid body")
httputil.ErrorJSON(w, http.StatusBadRequest, "invalid body")
return
}
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
}
// 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
err := h.DB.QueryRow(
err = tx.QueryRow(
`SELECT reps, interval_days, ease, lapses FROM vocab_words WHERE id = ? AND user_id = ?`,
id, db.LocalUserID,
).Scan(&cur.Reps, &cur.Interval, &cur.Ease, &cur.Lapses)
if errors.Is(err, sql.ErrNoRows) {
errorJSON(w, http.StatusNotFound, "word not found")
httputil.ErrorJSON(w, http.StatusNotFound, "word not found")
return
}
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
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
// text format, matching CURRENT_TIMESTAMP and the due query's comparison.
offset := "+" + strconv.Itoa(nxt.Interval) + " days"
if _, err := h.DB.Exec(
if _, err := tx.Exec(
`UPDATE vocab_words SET
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,
); err != nil {
serverError(w, err)
httputil.ServerError(w, err)
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,
))
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
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).
@@ -229,28 +296,12 @@ func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
chi.URLParam(r, "id"), db.LocalUserID,
)
if err != nil {
serverError(w, err)
httputil.ServerError(w, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
errorJSON(w, http.StatusNotFound, "word not found")
httputil.ErrorJSON(w, http.StatusNotFound, "word not found")
return
}
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/httptest"
"path/filepath"
"strings"
"testing"
"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
// garden when its source document is removed.
func TestDocLinkSurvivesDocDelete(t *testing.T) {