diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index fdd72c3c32..1f4cff4074 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -1833,16 +1833,6 @@ impl Db { user::get_user_by_nip05(&self.pool, community_id, local_part, domain).await } - /// Search users by display name, NIP-05 handle, or pubkey prefix. - pub async fn search_users( - &self, - community_id: CommunityId, - query: &str, - limit: u32, - ) -> Result> { - user::search_users(&self.pool, community_id, query, limit).await - } - /// Atomically set agent owner — only if no owner is currently assigned. /// Returns Ok(true) if set, Ok(false) if an owner already exists. pub async fn set_agent_owner( diff --git a/crates/buzz-db/src/user.rs b/crates/buzz-db/src/user.rs index 066fb5f5c0..95dea2d742 100644 --- a/crates/buzz-db/src/user.rs +++ b/crates/buzz-db/src/user.rs @@ -20,19 +20,6 @@ pub struct UserProfile { pub nip05_handle: Option, } -/// Lightweight user record returned from search. -#[derive(Debug, Clone)] -pub struct UserSearchProfile { - /// Raw 32-byte compressed public key. - pub pubkey: Vec, - /// Human-readable display name chosen by the user. - pub display_name: Option, - /// URL of the user's avatar image. - pub avatar_url: Option, - /// NIP-05 identifier (user@domain). - pub nip05_handle: Option, -} - /// Ensure a user record exists for the given pubkey (upsert). /// Creates with minimal fields if not present; no-op if already exists. /// @@ -206,80 +193,6 @@ pub async fn get_user_by_nip05( )) } -/// Escape SQL LIKE metacharacters (`%`, `_`, `\`) so user input is treated -/// as literal text. Used with `ESCAPE '\'` in the query. -/// -/// Without this, a search query of `"%"` would match every row (full table -/// scan) and `"_"` would act as a single-character wildcard. -fn escape_like(input: &str) -> String { - input - .replace('\\', "\\\\") - .replace('%', "\\%") - .replace('_', "\\_") -} - -/// Search users by display name, NIP-05 handle, or pubkey prefix. -/// -/// Empty queries return an empty vec and do not hit the database. -pub async fn search_users( - pool: &PgPool, - community_id: CommunityId, - query: &str, - limit: u32, -) -> Result> { - let normalized = query.trim().to_lowercase(); - if normalized.is_empty() { - return Ok(Vec::new()); - } - - let escaped = escape_like(&normalized); - let contains_pattern = format!("%{escaped}%"); - let prefix_pattern = format!("{escaped}%"); - let limit = limit.clamp(1, 500) as i64; - - let rows = sqlx::query_as::<_, (Vec, Option, Option, Option)>( - r#" - SELECT pubkey, display_name, avatar_url, nip05_handle - FROM users - WHERE community_id = $1 - AND (LOWER(COALESCE(display_name, '')) LIKE $2 ESCAPE '\' - OR LOWER(COALESCE(nip05_handle, '')) LIKE $2 ESCAPE '\' - OR LOWER(encode(pubkey, 'hex')) LIKE $2 ESCAPE '\') - ORDER BY - CASE - WHEN LOWER(COALESCE(display_name, '')) = $3 THEN 0 - WHEN LOWER(COALESCE(nip05_handle, '')) = $3 THEN 1 - WHEN LOWER(encode(pubkey, 'hex')) = $3 THEN 2 - WHEN LOWER(COALESCE(display_name, '')) LIKE $4 ESCAPE '\' THEN 3 - WHEN LOWER(COALESCE(nip05_handle, '')) LIKE $4 ESCAPE '\' THEN 4 - WHEN LOWER(encode(pubkey, 'hex')) LIKE $4 ESCAPE '\' THEN 5 - ELSE 6 - END, - COALESCE(NULLIF(display_name, ''), NULLIF(nip05_handle, ''), LOWER(encode(pubkey, 'hex'))) - LIMIT $5 - "#, - ) - .bind(community_id.as_uuid()) - .bind(&contains_pattern) - .bind(&normalized) - .bind(&prefix_pattern) - .bind(limit) - .fetch_all(pool) - .await?; - - Ok(rows - .into_iter() - .map( - |(pubkey, display_name, avatar_url, nip05_handle)| UserSearchProfile { - pubkey, - display_name, - avatar_url, - nip05_handle, - }, - ) - .collect()) -} - /// Set the owner pubkey for an agent user. /// The owner pubkey must already exist in the users table (FK constraint). /// Returns an error if the agent pubkey is not found (rows_affected == 0). @@ -612,40 +525,6 @@ mod tests { assert!(result.is_err(), "should reject invalid policy value"); } - // Use the production `escape_like` function directly — no local mirror. - use super::escape_like; - - #[test] - fn like_escape_percent() { - assert_eq!(escape_like("%"), "\\%"); - assert_eq!(escape_like("100%match"), "100\\%match"); - } - - #[test] - fn like_escape_underscore() { - assert_eq!(escape_like("_"), "\\_"); - assert_eq!(escape_like("a_b"), "a\\_b"); - } - - #[test] - fn like_escape_backslash() { - assert_eq!(escape_like("\\"), "\\\\"); - assert_eq!(escape_like("a\\b"), "a\\\\b"); - } - - #[test] - fn like_escape_combined() { - // All three metacharacters in one string - assert_eq!(escape_like("%_\\"), "\\%\\_\\\\"); - } - - #[test] - fn like_escape_normal_input_unchanged() { - assert_eq!(escape_like("alice"), "alice"); - assert_eq!(escape_like("bob@example.com"), "bob@example.com"); - assert_eq!(escape_like(""), ""); - } - /// A user with "owner_only" policy but no agent_owner_pubkey set should /// return Some(("owner_only", None)). #[tokio::test] diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 8372e49a2a..9e0d5a1e81 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -341,10 +341,31 @@ fn extract_search_mode(raw: &Value) -> buzz_search::SearchMode { .and_then(Value::as_str) { Some("prefix") => buzz_search::SearchMode::Prefix, + Some("contains") => buzz_search::SearchMode::Contains, _ => buzz_search::SearchMode::FullText, } } +/// Upgrade profile-only prefix typeahead to substring matching. +/// +/// Shipped clients (desktop, mobile) send `search_mode: "prefix"` for their +/// user-picker typeahead over exactly `kinds:[0]` and expect any typed +/// fragment of a name to match — but names are single FTS lexemes, so prefix +/// matching misses mid-name fragments ("kurs" never finds "mattkursmark"). +/// Every prefix hit is a substring hit, so mapping these queries to +/// `Contains` fixes installed apps without a client update. Message +/// typeahead queries other kinds and keeps true prefix matching. +fn effective_search_mode( + mode: buzz_search::SearchMode, + kinds: Option<&[i32]>, +) -> buzz_search::SearchMode { + if mode == buzz_search::SearchMode::Prefix && kinds == Some(&[0]) { + buzz_search::SearchMode::Contains + } else { + mode + } +} + fn extract_search_page(raw: &Value) -> u32 { raw.get("page") .or_else(|| raw.get("search_page")) @@ -1683,6 +1704,7 @@ async fn handle_bridge_search( }); let since = filter.since.map(|s| s.as_secs() as i64); let until = filter.until.map(|u| u.as_secs() as i64); + let mode = effective_search_mode(search_mode, kinds.as_deref()); let search_query = buzz_search::SearchQuery { community: tenant.community(), @@ -1694,7 +1716,7 @@ async fn handle_bridge_search( until, page: search_page, per_page: limit, - mode: search_mode, + mode, }; let search_result = state @@ -2263,6 +2285,54 @@ mod tests { ); } + #[test] + fn profile_only_prefix_typeahead_upgrades_to_contains() { + use buzz_search::SearchMode; + + assert_eq!( + effective_search_mode(SearchMode::Prefix, Some(&[0])), + SearchMode::Contains, + "user-picker typeahead from shipped clients gets substring matching" + ); + assert_eq!( + effective_search_mode(SearchMode::Prefix, Some(&[9, 40002])), + SearchMode::Prefix, + "message typeahead keeps true prefix" + ); + assert_eq!( + effective_search_mode(SearchMode::Prefix, Some(&[0, 9])), + SearchMode::Prefix, + "mixed-kind searches keep true prefix" + ); + assert_eq!( + effective_search_mode(SearchMode::Prefix, None), + SearchMode::Prefix + ); + assert_eq!( + effective_search_mode(SearchMode::FullText, Some(&[0])), + SearchMode::FullText, + "whole-word search is never silently widened" + ); + assert_eq!( + effective_search_mode(SearchMode::Contains, Some(&[0])), + SearchMode::Contains + ); + } + + #[test] + fn bridge_search_mode_extension_accepts_contains() { + assert_eq!( + extract_search_mode( + &serde_json::json!({ "search": "kurs", "search_mode": "contains" }) + ), + buzz_search::SearchMode::Contains + ); + assert_eq!( + extract_search_mode(&serde_json::json!({ "search": "kurs", "searchMode": "contains" })), + buzz_search::SearchMode::Contains + ); + } + /// Attack 3 proof: two stateless relay pods sharing Redis must share one /// community-scoped NIP-98 seen-set. Pod A's first claim succeeds; pod B's /// replay of the same event id in the same community is rejected. The same diff --git a/crates/buzz-search/src/query.rs b/crates/buzz-search/src/query.rs index 7f33b660c4..5372ab8f50 100644 --- a/crates/buzz-search/src/query.rs +++ b/crates/buzz-search/src/query.rs @@ -63,6 +63,16 @@ pub enum SearchMode { /// relay still refetches and re-authorizes every hit; this mode changes only /// the candidate tsquery, not the access boundary. Prefix, + /// Case-insensitive substring match over the raw `content` (`kurs` matches + /// `mattkursmark`). + /// + /// Intended for user-picker typeahead over kind:0 profiles, where names are + /// single FTS lexemes that neither `FullText` nor `Prefix` can match + /// mid-token. Uses `ILIKE`, not tsquery, so hits carry no `ts_rank_cd` + /// relevance (rank is 0; ordering falls back to recency) — picker surfaces + /// re-rank client-side. Rows excluded from the search allowlist + /// (`search_tsv IS NULL`, e.g. gift wraps) stay excluded. + Contains, } /// A community-scoped FTS query. @@ -137,8 +147,49 @@ const SEARCH_TEXT_MAX_CHARS: usize = 4096; /// wire untrusted input into a multi-trillion-row OFFSET. const PAGE_MAX: u32 = 1000; -fn push_tsquery(qb: &mut QueryBuilder, mode: SearchMode, search_text: &str) { - match mode { +/// Escape SQL LIKE metacharacters (`%`, `_`, `\`) so user input is treated +/// as literal text. Used with `ESCAPE '\'` in the query. +/// +/// Without this, a search query of `"%"` would match every row and `"_"` +/// would act as a single-character wildcard. +fn escape_like(input: &str) -> String { + input + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_") +} + +/// Push the SELECT head, community fence, and mode-specific match predicate. +/// +/// The two tsquery modes share the `search_tsv @@ query` shape with a +/// `ts_rank_cd` relevance score. `Contains` has no tsquery: it matches the raw +/// `content` with a LIKE-escaped `ILIKE` pattern and a constant rank, keeping +/// `search_tsv IS NOT NULL` so kinds excluded from the search allowlist stay +/// invisible to it. +fn push_query_head(qb: &mut QueryBuilder, query: &SearchQuery, search_text: &str) { + if query.mode == SearchMode::Contains { + qb.push( + "SELECT id, kind, pubkey, channel_id, \ + EXTRACT(EPOCH FROM created_at)::bigint AS created_at_s, \ + 0::real AS rank \ + FROM events WHERE community_id = ", + ); + qb.push_bind(*query.community.as_uuid()); + qb.push(" AND deleted_at IS NULL AND search_tsv IS NOT NULL AND content ILIKE "); + qb.push_bind(format!("%{}%", escape_like(search_text))); + qb.push(" ESCAPE '\\'"); + return; + } + + qb.push( + "SELECT id, kind, pubkey, channel_id, \ + EXTRACT(EPOCH FROM created_at)::bigint AS created_at_s, \ + ts_rank_cd(search_tsv, search_query.query) AS rank \ + FROM events CROSS JOIN LATERAL (SELECT ", + ); + match query.mode { + // Returned early above; this arm exists only for match totality. + SearchMode::Contains => {} SearchMode::FullText => { qb.push("websearch_to_tsquery('simple', "); qb.push_bind(search_text); @@ -175,6 +226,9 @@ fn push_tsquery(qb: &mut QueryBuilder, mode: SearchMode, search_ ); } } + qb.push(" AS query) AS search_query WHERE community_id = "); + qb.push_bind(*query.community.as_uuid()); + qb.push(" AND deleted_at IS NULL AND search_tsv @@ search_query.query"); } fn normalized_search_text(q: &str) -> Option { let trimmed = q.trim(); @@ -197,7 +251,7 @@ fn normalized_search_text(q: &str) -> Option { /// Execute a community-scoped FTS query. /// -/// SQL shape (always): +/// SQL shape (`FullText` and `Prefix`): /// ```sql /// SELECT id, kind, pubkey, channel_id, EXTRACT(EPOCH FROM created_at)::bigint AS created_at_s, /// ts_rank_cd(search_tsv, query) AS rank @@ -211,6 +265,11 @@ fn normalized_search_text(q: &str) -> Option { /// LIMIT $per_page OFFSET (($page - 1) * $per_page) /// ``` /// +/// `Contains` swaps the match arm for `search_tsv IS NOT NULL AND content +/// ILIKE '%%'` with a constant `0` rank (ordering falls back to +/// recency); everything else — community fence, scopes, pagination — is the +/// same builder tail. +/// /// `community_id = $ctx` is the first predicate and is non-negotiable. There /// is no code path through this function that omits it. pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result { @@ -230,16 +289,8 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result = QueryBuilder::new( - "SELECT id, kind, pubkey, channel_id, \ - EXTRACT(EPOCH FROM created_at)::bigint AS created_at_s, \ - ts_rank_cd(search_tsv, search_query.query) AS rank \ - FROM events CROSS JOIN LATERAL (SELECT ", - ); - push_tsquery(&mut qb, query.mode, &search_text); - qb.push(" AS query) AS search_query WHERE community_id = "); - qb.push_bind(*query.community.as_uuid()); - qb.push(" AND deleted_at IS NULL AND search_tsv @@ search_query.query"); + let mut qb: QueryBuilder = QueryBuilder::new(""); + push_query_head(&mut qb, query, &search_text); // Channel scope — see `ChannelScope` doc for the four-case mapping. The // emitted SQL fragments are identical to the legacy 2x2 tuple for the @@ -349,4 +400,13 @@ mod tests { let cleaned = normalized_search_text(&long).expect("non-empty"); assert_eq!(cleaned.chars().count(), SEARCH_TEXT_MAX_CHARS); } + + #[test] + fn escape_like_treats_metacharacters_as_literals() { + assert_eq!(escape_like("%"), "\\%"); + assert_eq!(escape_like("a_b"), "a\\_b"); + assert_eq!(escape_like("a\\b"), "a\\\\b"); + assert_eq!(escape_like("%_\\"), "\\%\\_\\\\"); + assert_eq!(escape_like("kurs"), "kurs"); + } } diff --git a/crates/buzz-search/tests/fts_integration.rs b/crates/buzz-search/tests/fts_integration.rs index 675d15db8b..92ac4ffdf7 100644 --- a/crates/buzz-search/tests/fts_integration.rs +++ b/crates/buzz-search/tests/fts_integration.rs @@ -258,6 +258,135 @@ async fn search_does_not_return_other_community_events() { teardown(pool, &schema).await; } +#[tokio::test] +#[ignore = "requires Postgres"] +async fn contains_mode_matches_mid_token_substring() { + // Repro for "Member search should match substrings, not just prefixes": + // a member picker query for "kurs" must find the profile whose + // display_name is "mattkursmark". The name is a single `simple` lexeme, + // so neither FullText nor Prefix can match it mid-token — only Contains. + let (pool, schema) = setup().await; + + let c = mk_community(&pool, "sub.example").await; + let evt_id = rand_bytes32(); + insert_event( + &pool, + c, + evt_id, + rand_bytes32(), + 0, + r#"{"name":"mattkursmark","display_name":"mattkursmark","about":"hello"}"#, + None, + 1700000000, + ) + .await; + + let svc = SearchService::new(pool.clone()); + let query = |q: &str, mode: buzz_search::SearchMode| SearchQuery { + community: c, + q: q.into(), + channel_scope: ChannelScope::Any, + kinds: Some(vec![0]), + authors: None, + since: None, + until: None, + page: 1, + per_page: 10, + mode, + }; + let contains = buzz_search::SearchMode::Contains; + let prefix = buzz_search::SearchMode::Prefix; + + let by_prefix = svc.search(&query("kurs", prefix)).await.expect("search ok"); + assert_eq!( + by_prefix.hits.len(), + 0, + "prefix mode cannot match mid-token — the gap contains mode closes" + ); + + let by_substring = svc + .search(&query("kurs", contains)) + .await + .expect("search ok"); + assert_eq!(by_substring.hits.len(), 1, "mid-token substring matches"); + assert_eq!(by_substring.hits[0].event_id, evt_id); + + let case_insensitive = svc + .search(&query("KURS", contains)) + .await + .expect("search ok"); + assert_eq!(case_insensitive.hits.len(), 1, "ILIKE is case-insensitive"); + + let wildcard = svc.search(&query("%", contains)).await.expect("search ok"); + assert_eq!( + wildcard.hits.len(), + 0, + "LIKE metacharacters are escaped, not wildcards" + ); + + teardown(pool, &schema).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn contains_mode_stays_community_scoped_and_respects_search_allowlist() { + let (pool, schema) = setup().await; + + let c_a = mk_community(&pool, "contains-a.example").await; + let c_b = mk_community(&pool, "contains-b.example").await; + insert_event( + &pool, + c_a, + rand_bytes32(), + rand_bytes32(), + 0, + r#"{"display_name":"mattkursmark"}"#, + None, + 1700000000, + ) + .await; + // Kind 1059 (gift wrap) is excluded from the search allowlist: + // `search_tsv` is NULL and contains mode must not see its content. + insert_event( + &pool, + c_a, + rand_bytes32(), + rand_bytes32(), + 1059, + "wrapped-kursmark-payload", + None, + 1700000000, + ) + .await; + + let svc = SearchService::new(pool.clone()); + let query = |community: CommunityId, kinds: Option>| SearchQuery { + community, + q: "kurs".into(), + channel_scope: ChannelScope::Any, + kinds, + authors: None, + since: None, + until: None, + page: 1, + per_page: 10, + mode: buzz_search::SearchMode::Contains, + }; + + let in_a = svc.search(&query(c_a, None)).await.expect("search ok"); + assert_eq!( + in_a.hits.len(), + 1, + "profile matches; allowlist-excluded gift wrap does not" + ); + assert_eq!(in_a.hits[0].kind, 0); + + let in_b = svc.search(&query(c_b, None)).await.expect("search ok"); + assert_eq!(in_b.hits.len(), 0, "other community must see nothing"); + + teardown(pool, &schema).await; +} + #[tokio::test] #[ignore = "requires Postgres"] async fn kind0_search_by_display_name_works_without_flattening() { diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index ef67fac570..5fecdcd8c1 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -255,7 +255,7 @@ fn build_user_search_filter(query: &str, limit: usize, page: u32) -> serde_json: serde_json::json!({ "kinds": [0], "search": query, - "search_mode": "prefix", + "search_mode": "contains", "limit": limit, "page": page, }) @@ -319,12 +319,14 @@ pub async fn search_users( // than a substring hit in `about`. The caller can request later pages via the // cursor so the UI cap is only a page size, not a terminal directory ceiling. // - // `search_mode: "prefix"` matters: every caller of this command is a + // `search_mode: "contains"` matters: every caller of this command is a // typeahead surface (member picker, @mention popup, DM recipient search, - // topbar people results), so a partially typed name must match. Without it - // the relay runs whole-word `websearch_to_tsquery` matching and "tyl" - // returns zero results for "Tyler". Same bridge-only extension the topbar - // message search uses (see `build_search_messages_filter`). + // topbar people results), so any typed fragment of a name must match. + // Whole-word FTS returns zero results for "tyl" → "Tyler", and prefix + // mode returns zero results for "kurs" → "mattkursmark" because the name + // is a single lexeme. Contains mode is ILIKE substring matching on the + // relay (same bridge-only `search_mode` extension family the topbar + // message search uses via `build_search_messages_filter`). let events = query_relay(&state, &[build_user_search_filter(trimmed, max, page)]).await?; let mut response = nostr_convert::rank_user_search_results(&events, trimmed, max); @@ -469,15 +471,17 @@ mod tests { } #[test] - fn user_search_filter_requests_prefix_mode_for_typeahead() { + fn user_search_filter_requests_contains_mode_for_typeahead() { // Every caller of `search_users` is a typeahead surface. Whole-word // FTS matching returns zero results for a partially typed name - // ("tyl" for "Tyler"), which reads as "user doesn't exist" in the - // member picker and @mention popup. Pin the mode so it can't drift. - let filter = build_user_search_filter("tyl", 25, 1); - - assert_eq!(filter["search"], serde_json::json!("tyl")); - assert_eq!(filter["search_mode"], serde_json::json!("prefix")); + // ("tyl" for "Tyler"), and prefix matching returns zero results for + // a mid-name fragment ("kurs" for "mattkursmark"), which reads as + // "user doesn't exist" in the member picker and @mention popup. Pin + // the mode so it can't drift. + let filter = build_user_search_filter("kurs", 25, 1); + + assert_eq!(filter["search"], serde_json::json!("kurs")); + assert_eq!(filter["search_mode"], serde_json::json!("contains")); assert_eq!(filter["limit"], serde_json::json!(25)); assert_eq!(filter["page"], serde_json::json!(1)); } diff --git a/mobile/lib/shared/relay/nostr_filters.dart b/mobile/lib/shared/relay/nostr_filters.dart index 5cea037f9d..71aeef6726 100644 --- a/mobile/lib/shared/relay/nostr_filters.dart +++ b/mobile/lib/shared/relay/nostr_filters.dart @@ -134,16 +134,17 @@ abstract final class NostrFilters { /// Global user search over kind:0 profiles (NIP-50 via the HTTP bridge). /// - /// `search_mode: "prefix"` is a Buzz bridge-only extension: every caller is - /// a typeahead surface, so a partially typed name must match ("rac" → - /// "raccoon"). Mirrors desktop's `build_user_search_filter` - /// (desktop/src-tauri/src/commands/profile.rs). Bridge-only — send through - /// `queryRelay`, not a WebSocket REQ. + /// `search_mode: "contains"` is a Buzz bridge-only extension: every caller + /// is a typeahead surface, so any typed fragment of a name must match + /// ("kurs" → "mattkursmark" — prefix matching misses mid-name fragments + /// because names are single FTS lexemes). Mirrors desktop's + /// `build_user_search_filter` (desktop/src-tauri/src/commands/profile.rs). + /// Bridge-only — send through `queryRelay`, not a WebSocket REQ. static NostrFilter searchUsers(String query, {int limit = 50}) => NostrFilter( kinds: [0], search: query, limit: limit, - extensions: const {'search_mode': 'prefix'}, + extensions: const {'search_mode': 'contains'}, ); /// Deletions (kind:5) targeting event IDs.