diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index f80f928d31..45d31fec88 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -514,7 +514,7 @@ pub async fn cmd_search( /// Resolve an `--author` value to a 64-char hex pubkey. /// /// Accepts, in order of precedence: 64-char hex (validated), an `npub1…` -/// bech32 key, or a display name resolved via NIP-50 profile search. A name +/// bech32 key, or a display name resolved via profile name search. A name /// must match exactly one user (case-insensitive, on `display_name` or /// `name`) — ambiguity is an error listing the candidates rather than a /// silent mix of authors. @@ -529,13 +529,8 @@ async fn resolve_author(client: &BuzzClient, author: &str) -> Result = serde_json::from_str(&raw).unwrap_or_default(); let mut matches = match_profiles_by_name(&events, author); match matches.len() { diff --git a/crates/buzz-cli/src/commands/notes.rs b/crates/buzz-cli/src/commands/notes.rs index 08ef345be1..912af65af1 100644 --- a/crates/buzz-cli/src/commands/notes.rs +++ b/crates/buzz-cli/src/commands/notes.rs @@ -209,13 +209,8 @@ pub async fn resolve_author(client: &BuzzClient, author_flag: &str) -> Result = events diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index bb2d45dbf1..7053ee95b5 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -11,7 +11,7 @@ use crate::validate::validate_hex64; /// /// - 0 pubkeys, no name → query our own profile /// - 1+ pubkeys → query those users' profiles -/// - --name "foo" → NIP-50 search on kind:0, then client-side filter +/// - --name "foo" → profile name search, then client-side filter pub async fn cmd_get_users( client: &BuzzClient, pubkeys: &[String], @@ -283,7 +283,7 @@ fn owner_scoped_profiles( } /// Search for users by display name. Owner-scoped searches resolve managed-agent records -/// and verify their profiles; unscoped searches use NIP-50 and return [] if unsupported. +/// and verify their profiles; unscoped searches use the relay's profile name search. async fn search_by_name( client: &BuzzClient, query: &str, @@ -319,12 +319,7 @@ async fn search_by_name( }; owner_scoped_profiles(&events, &pubkeys, &owner, &effective_owner(client)) } else { - let filter = serde_json::json!({ - "kinds": [0], - "search": query, - "limit": 100 - }); - let raw = client.query(&filter).await?; + let raw = crate::profile_search::search_profiles(client, query, 100).await?; let events: Vec = serde_json::from_str(&raw) .map_err(|e| CliError::Other(format!("failed to parse response: {e}")))?; name_search_profiles(&events, query) diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index c2f6b7e089..84c410e3b3 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -4,6 +4,7 @@ mod commands; mod error; mod help_tree; mod links; +mod profile_search; mod validate; use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; diff --git a/crates/buzz-cli/src/profile_search.rs b/crates/buzz-cli/src/profile_search.rs new file mode 100644 index 0000000000..ae19f8d629 --- /dev/null +++ b/crates/buzz-cli/src/profile_search.rs @@ -0,0 +1,150 @@ +//! Finding people by profile name. +//! +//! NIP-50 search over kind:0 matches the whole profile JSON, so text inside +//! avatar data URLs, `about` or `nip05` competes with real names: a shared +//! default avatar can fill every result page and hide the person a name +//! lookup asked for. The relay's `POST /profiles/search` matches only `name` +//! and `display_name` and puts exact and prefix name matches first. + +use crate::client::BuzzClient; +use crate::error::CliError; + +/// The relay's name search path. +const SEARCH_PATH: &str = "/profiles/search"; +/// Longest query the name search accepts, in characters after trimming. +const QUERY_MAX_CHARS: usize = 100; +/// Largest page the name search returns. +const LIMIT_MAX: u32 = 100; + +/// Returns up to `limit` kind:0 events whose names match `query`, as the raw +/// JSON array the relay sent. +/// +/// Uses the relay's name search. A relay without it answers 404 or 405; the +/// search then uses the NIP-50 kind:0 search that callers used before. +/// Requests the name search cannot take (a query over 100 characters, or a +/// `limit` over 100) also use NIP-50, as before. Other errors are returned. +/// Callers still filter the events by name. +pub(crate) async fn search_profiles( + client: &BuzzClient, + query: &str, + limit: u32, +) -> Result { + let text = query.trim(); + if !text.is_empty() + && text.chars().count() <= QUERY_MAX_CHARS + && (1..=LIMIT_MAX).contains(&limit) + { + let body = serde_json::json!({ "query": text, "limit": limit }); + match client.post_json_authed(SEARCH_PATH, &body).await { + Ok(raw) => return Ok(raw), + Err(CliError::Relay { + status: 404 | 405, .. + }) => {} + Err(error) => return Err(error), + } + } + client + .query(&serde_json::json!({ + "kinds": [0], + "search": query, + "limit": limit, + })) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Bytes; + use axum::extract::State; + use axum::http::StatusCode; + use axum::routing::post; + use axum::Router; + use nostr::Keys; + use std::sync::{Arc, Mutex}; + use tokio::net::TcpListener; + + type Seen = Arc>>; + + /// A relay whose name search answers `status`; `/query` answers `[]`. + async fn relay(status: StatusCode) -> (BuzzClient, Seen) { + let seen: Seen = Arc::default(); + let app = Router::new() + .route( + SEARCH_PATH, + post(move |State(seen): State, body: Bytes| async move { + let body = serde_json::from_slice(&body).unwrap(); + seen.lock().unwrap().push((SEARCH_PATH.into(), body)); + (status, r#"[{"kind":0,"from":"names"}]"#) + }), + ) + .route( + "/query", + post(|State(seen): State, body: Bytes| async move { + let body = serde_json::from_slice(&body).unwrap(); + seen.lock().unwrap().push(("/query".into(), body)); + "[]" + }), + ) + .with_state(seen.clone()); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async { axum::serve(listener, app).await.unwrap() }); + let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + (client, seen) + } + + fn nip50(query: &str, limit: u32) -> (String, serde_json::Value) { + ( + "/query".into(), + serde_json::json!([{ "kinds": [0], "search": query, "limit": limit }]), + ) + } + + #[tokio::test] + async fn uses_the_name_search() { + let (client, seen) = relay(StatusCode::OK).await; + let raw = search_profiles(&client, " Wes ", 100).await.unwrap(); + assert_eq!(raw, r#"[{"kind":0,"from":"names"}]"#); + assert_eq!( + *seen.lock().unwrap(), + [( + SEARCH_PATH.to_string(), + serde_json::json!({ "query": "Wes", "limit": 100 }) + )] + ); + } + + #[tokio::test] + async fn a_relay_without_the_name_search_uses_nip50() { + for status in [StatusCode::NOT_FOUND, StatusCode::METHOD_NOT_ALLOWED] { + let (client, seen) = relay(status).await; + assert_eq!(search_profiles(&client, "Wes", 100).await.unwrap(), "[]"); + let seen = seen.lock().unwrap(); + assert_eq!(seen.len(), 2); + assert_eq!(seen[1], nip50("Wes", 100)); + } + } + + #[tokio::test] + async fn other_name_search_errors_are_returned() { + let (client, seen) = relay(StatusCode::INTERNAL_SERVER_ERROR).await; + let error = search_profiles(&client, "Wes", 100).await.unwrap_err(); + assert!(matches!(error, CliError::Relay { status: 500, .. })); + assert!(seen + .lock() + .unwrap() + .iter() + .all(|(path, _)| path != "/query")); + } + + #[tokio::test] + async fn requests_the_name_search_cannot_take_use_nip50() { + let long = "w".repeat(QUERY_MAX_CHARS + 1); + for (query, limit) in [(long.as_str(), 100), ("Wes", 101), (" ", 100)] { + let (client, seen) = relay(StatusCode::OK).await; + search_profiles(&client, query, limit).await.unwrap(); + assert_eq!(*seen.lock().unwrap(), [nip50(query, limit)]); + } + } +}