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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
37
internal/httputil/json.go
Normal 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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user