Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions crates/buzz-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<user::UserSearchProfile>> {
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(
Expand Down
121 changes: 0 additions & 121 deletions crates/buzz-db/src/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,6 @@ pub struct UserProfile {
pub nip05_handle: Option<String>,
}

/// Lightweight user record returned from search.
#[derive(Debug, Clone)]
pub struct UserSearchProfile {
/// Raw 32-byte compressed public key.
pub pubkey: Vec<u8>,
/// Human-readable display name chosen by the user.
pub display_name: Option<String>,
/// URL of the user's avatar image.
pub avatar_url: Option<String>,
/// NIP-05 identifier (user@domain).
pub nip05_handle: Option<String>,
}

/// Ensure a user record exists for the given pubkey (upsert).
/// Creates with minimal fields if not present; no-op if already exists.
///
Expand Down Expand Up @@ -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<Vec<UserSearchProfile>> {
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<u8>, Option<String>, Option<String>, Option<String>)>(
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).
Expand Down Expand Up @@ -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]
Expand Down
72 changes: 71 additions & 1 deletion crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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(),
Expand All @@ -1694,7 +1716,7 @@ async fn handle_bridge_search(
until,
page: search_page,
per_page: limit,
mode: search_mode,
mode,
};

let search_result = state
Expand Down Expand Up @@ -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
Expand Down
86 changes: 73 additions & 13 deletions crates/buzz-search/src/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<sqlx::Postgres>, 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<sqlx::Postgres>, 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);
Expand Down Expand Up @@ -175,6 +226,9 @@ fn push_tsquery(qb: &mut QueryBuilder<sqlx::Postgres>, 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<String> {
let trimmed = q.trim();
Expand All @@ -197,7 +251,7 @@ fn normalized_search_text(q: &str) -> Option<String> {

/// 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
Expand All @@ -211,6 +265,11 @@ fn normalized_search_text(q: &str) -> Option<String> {
/// LIMIT $per_page OFFSET (($page - 1) * $per_page)
/// ```
///
/// `Contains` swaps the match arm for `search_tsv IS NOT NULL AND content
/// ILIKE '%<escaped>%'` 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<SearchResult, SearchError> {
Expand All @@ -230,16 +289,8 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result<SearchResult,
let page = query.page.clamp(1, PAGE_MAX);
let offset = ((page - 1) as i64) * (per_page_actual as i64);

let mut qb: QueryBuilder<sqlx::Postgres> = 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<sqlx::Postgres> = 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
Expand Down Expand Up @@ -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");
}
}
Loading
Loading