- 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.
84 lines
2.3 KiB
Go
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
|
|
}
|