diff --git a/cmd/server/main.go b/cmd/server/main.go index c4ff306..fcf75b5 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -48,6 +48,11 @@ func main() { log.Printf("frontend build version %s", version) 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) { w.Header().Set("Content-Type", "application/json") _, _ = 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 // 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 diff --git a/internal/docs/export.go b/internal/docs/export.go index a9c34e9..8eafdd9 100644 --- a/internal/docs/export.go +++ b/internal/docs/export.go @@ -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 } diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go index ab4bd81..a946460 100644 --- a/internal/docs/handlers.go +++ b/internal/docs/handlers.go @@ -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) } diff --git a/internal/docs/search.go b/internal/docs/search.go index 1663222..c928086 100644 --- a/internal/docs/search.go +++ b/internal/docs/search.go @@ -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 diff --git a/internal/docs/tags.go b/internal/docs/tags.go index 15df7ef..f87b4f8 100644 --- a/internal/docs/tags.go +++ b/internal/docs/tags.go @@ -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) diff --git a/internal/docs/versions.go b/internal/docs/versions.go index c0cc315..0290c0e 100644 --- a/internal/docs/versions.go +++ b/internal/docs/versions.go @@ -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 diff --git a/internal/httputil/json.go b/internal/httputil/json.go new file mode 100644 index 0000000..006154a --- /dev/null +++ b/internal/httputil/json.go @@ -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") +} diff --git a/internal/suggestions/chat.go b/internal/suggestions/chat.go index 1f0c5ba..c992b68 100644 --- a/internal/suggestions/chat.go +++ b/internal/suggestions/chat.go @@ -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 } diff --git a/internal/suggestions/handlers.go b/internal/suggestions/handlers.go index fd42139..5ad8c6e 100644 --- a/internal/suggestions/handlers.go +++ b/internal/suggestions/handlers.go @@ -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()) -} diff --git a/internal/suggestions/rewrite.go b/internal/suggestions/rewrite.go index 92da4de..55e2efa 100644 --- a/internal/suggestions/rewrite.go +++ b/internal/suggestions/rewrite.go @@ -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}) } diff --git a/internal/suggestions/translate.go b/internal/suggestions/translate.go index a7a7c74..5add0b0 100644 --- a/internal/suggestions/translate.go +++ b/internal/suggestions/translate.go @@ -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}) } diff --git a/internal/vocab/handlers.go b/internal/vocab/handlers.go index 663490d..6ff2a70 100644 --- a/internal/vocab/handlers.go +++ b/internal/vocab/handlers.go @@ -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()) -} diff --git a/internal/vocab/handlers_test.go b/internal/vocab/handlers_test.go index 288005a..7657841 100644 --- a/internal/vocab/handlers_test.go +++ b/internal/vocab/handlers_test.go @@ -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) { diff --git a/web/src/audio/speech.ts b/web/src/audio/speech.ts index 3d7cda8..79a63b8 100644 --- a/web/src/audio/speech.ts +++ b/web/src/audio/speech.ts @@ -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. 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) { current.audio.pause() URL.revokeObjectURL(current.url) @@ -77,7 +82,7 @@ export function detectLang(text: string): string { // with no configured voice). export function speak(text: string, lang = detectLang(text)): void { if (!text.trim()) return - stopCurrent() + stopSpeech() const seq = ++requestSeq fetch('/api/tts', { diff --git a/web/src/components/Garden/GardenPanel.tsx b/web/src/components/Garden/GardenPanel.tsx index d801129..ec4c879 100644 --- a/web/src/components/Garden/GardenPanel.tsx +++ b/web/src/components/Garden/GardenPanel.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' 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, // 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 [revealed, setRevealed] = useState(false) const [expanded, setExpanded] = useState(null) + const panelRef = useFocusTrap() + + // 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 () => { setError(false) @@ -127,6 +134,11 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) { />