Vellum/internal/search/search.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

84 lines
2.3 KiB
Go

// Package search queries the FTS5 index over media item titles and paths.
package search
import (
"context"
"database/sql"
"fmt"
"strings"
)
// Result is one matching item.
type Result struct {
ID int64 `json:"id"`
LibraryID int64 `json:"library_id"`
Kind string `json:"kind"`
Path string `json:"path"`
Title string `json:"title"`
}
// Query searches visible libraries for a free-text string.
//
// Each whitespace-separated term becomes a quoted FTS5 token joined by
// implicit AND. Quoting is not cosmetic: raw user input containing a quote, a
// hyphen, or the word OR would otherwise be parsed as FTS5 query syntax and
// raise an error, or silently change the meaning of the search.
func Query(ctx context.Context, db *sql.DB, q string, libraryIDs []int64, limit int) ([]Result, error) {
terms := ftsTerms(q)
if len(terms) == 0 {
return []Result{}, nil
}
if len(libraryIDs) == 0 {
return []Result{}, nil
}
if limit <= 0 || limit > 200 {
limit = 50
}
placeholders := make([]string, len(libraryIDs))
args := make([]any, 0, len(libraryIDs)+2)
args = append(args, strings.Join(terms, " "))
for i, id := range libraryIDs {
placeholders[i] = "?"
args = append(args, id)
}
args = append(args, limit)
query := `
SELECT i.id, i.library_id, i.kind, i.path, i.title
FROM media_search s
JOIN media_items i ON i.id = s.rowid
WHERE media_search MATCH ?
AND i.missing = 0
AND i.library_id IN (` + strings.Join(placeholders, ",") + `)
ORDER BY bm25(media_search)
LIMIT ?`
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("search: query: %w", err)
}
defer rows.Close()
out := []Result{}
for rows.Next() {
var r Result
if err := rows.Scan(&r.ID, &r.LibraryID, &r.Kind, &r.Path, &r.Title); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// ftsTerms turns free text into a safe FTS5 expression: every term quoted, so
// the result is always valid FTS5 and always means "all these words".
func ftsTerms(q string) []string {
var terms []string
for _, field := range strings.Fields(q) {
// A double quote inside a quoted FTS5 string is escaped by doubling it.
escaped := strings.ReplaceAll(field, `"`, `""`)
terms = append(terms, `"`+escaped+`"`)
}
return terms
}