- 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.
60 lines
1.6 KiB
Go
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})
|
|
}
|