diff --git a/internal/store/documents.go b/internal/store/documents.go index 5f7399fa..e6beb2da 100644 --- a/internal/store/documents.go +++ b/internal/store/documents.go @@ -97,7 +97,15 @@ func (s *Store) ListDocuments(workspaceID string, params models.DocumentListPara } if params.Query != "" { - query += fmt.Sprintf(" ORDER BY rank, d.%s %s", sortCol, order) + if s.dialect.Driver() == DriverPostgres { + // PostgreSQL ts_rank(): higher = more relevant → DESC + ftsRank := s.dialect.FTSRank("d", "search_vector") + query += fmt.Sprintf(" ORDER BY %s DESC, d.%s %s", ftsRank, sortCol, order) + args = append(args, params.Query) // extra placeholder for ts_rank + } else { + // SQLite FTS5: rank is a hidden column on the FTS JOIN (ascending = better) + query += fmt.Sprintf(" ORDER BY rank, d.%s %s", sortCol, order) + } } else { query += fmt.Sprintf(" ORDER BY pinned DESC, %s %s", sortCol, order) } diff --git a/internal/store/items.go b/internal/store/items.go index 58bcf99c..d922d453 100644 --- a/internal/store/items.go +++ b/internal/store/items.go @@ -421,6 +421,11 @@ func (s *Store) ListItems(workspaceID string, params models.ItemListParams) ([]m // Field filters — supports comma-separated values as OR for key, value := range params.Fields { + // Sanitize the key to prevent SQL injection — field names must be + // alphanumeric/underscore only (user-controlled from query params). + if !isValidFieldKey(key) { + continue + } jsonExpr := s.dialect.JSONExtractText("i.fields", key) if strings.Contains(value, ",") { values := strings.Split(value, ",") @@ -513,7 +518,13 @@ func (s *Store) listItemsFTS(workspaceID string, params models.ItemListParams) ( args = append(args, params.CollectionSlug) } - query += " ORDER BY " + ftsRank + // SQLite bm25(): more negative = more relevant → ASC (default). + // PostgreSQL ts_rank(): higher = more relevant → DESC. + if s.dialect.Driver() == DriverPostgres { + query += " ORDER BY " + ftsRank + " DESC" + } else { + query += " ORDER BY " + ftsRank + } if params.Limit > 0 { query += " LIMIT ?" @@ -761,7 +772,11 @@ func (s *Store) SearchItems(workspaceID, query string) ([]ItemSearchResult, erro args = append(args, workspaceID) } - sqlQuery += " ORDER BY rank_score LIMIT 50" + if s.dialect.Driver() == DriverPostgres { + sqlQuery += " ORDER BY rank_score DESC LIMIT 50" + } else { + sqlQuery += " ORDER BY rank_score LIMIT 50" + } rows, err := s.db.Query(s.q(sqlQuery), args...) if err != nil { diff --git a/internal/store/store.go b/internal/store/store.go index 80d14c00..52a21c95 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -332,6 +332,21 @@ func isAlpha(b byte) bool { return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_' } +// isValidFieldKey checks that a field name contains only safe characters +// (alphanumeric, underscore, hyphen). This prevents SQL injection when +// field keys from user input are interpolated into JSON path expressions. +func isValidFieldKey(key string) bool { + if key == "" { + return false + } + for _, c := range key { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-') { + return false + } + } + return true +} + // q rebinds a query to the store's dialect (converts "?" to "$1", "$2", etc. for PostgreSQL). func (s *Store) q(query string) string { return s.dialect.Rebind(query)