Vellum/internal/search/handlers.go
mithral a2c2f7a7c1 Phase 2b: FTS5 search
- migration 0003: an FTS5 index over media_items as an external-content table,
  so the index stores no copy of the text. Triggers keep it in sync for every
  write path, including the scanner's bulk inserts.
- migrate: splitStatements now tracks BEGIN...END depth, so CREATE TRIGGER
  bodies survive as one statement. Without this, the trigger's internal
  semicolons would tear it apart.
- search: every whitespace term is quoted before it reaches FTS5. Unquoted
  input like a bare quote, a hyphen, or the word OR is valid FTS5 syntax and
  would either error or silently change the query's meaning. Tested against a
  list of hostile inputs, including a DROP TABLE attempt.
- scoped through the same VisibleLibraryIDs chokepoint; an empty library set
  returns nothing, never everything.
- web: search box in the topbar and a results view.

Verified live: finds items across libraries, hostile input returns 200 with no
results, index stays in sync on insert/update/delete.
2026-10-09 21:10:44 -04:00

60 lines
1.6 KiB
Go

package search
import (
"database/sql"
"log/slog"
"net/http"
"strconv"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/httpx"
)
// Handlers serves the search endpoint.
type Handlers struct {
db *sql.DB
log *slog.Logger
auth *auth.Authenticator
}
// NewHandlers builds the search HTTP layer.
func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers {
return &Handlers{db: db, log: log, auth: a}
}
// Register mounts the route.
func (h *Handlers) Register(mux *http.ServeMux) {
mux.Handle("GET /api/v1/search", h.auth.Require(h.handleSearch))
}
func (h *Handlers) handleSearch(w http.ResponseWriter, r *http.Request, u auth.User) {
q := r.URL.Query().Get("q")
if q == "" {
httpx.WriteJSON(w, http.StatusOK, map[string]any{"results": []Result{}, "query": ""})
return
}
limit := 50
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
limit = n
}
}
// Scope the search to libraries the caller may read. This is the same
// chokepoint every other read path uses.
libs, err := auth.VisibleLibraryIDs(r.Context(), h.db, u)
if err != nil {
h.log.Error("visible libraries", "err", err)
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
return
}
results, err := Query(r.Context(), h.db, q, libs, limit)
if err != nil {
h.log.Error("search", "err", err)
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"results": results, "query": q})
}