diff --git a/Justfile b/Justfile index 984fa7bdc9..5c5511eca6 100644 --- a/Justfile +++ b/Justfile @@ -519,7 +519,8 @@ test-unit: # exact name for the same reason: the rest of handlers::req needs a DB. # The third clause adds the NIP-FI HTTP ingress and its router/config # neighbours: nip_fi_http, nip_fi_config, router, api::parse_query_tests, - # and the Git transport off_mode_precedence_tests. All are infra-free + # the profile search request bounds (api::profiles), and the Git + # transport off_mode_precedence_tests. All are infra-free # and finish in well under a second with no DATABASE_URL, so none wait # out the sqlx acquire timeout. `--bin buzz-relay` adds main.rs's # `tests::` module (JWKS refresh cadence) and `composition_tests::` @@ -531,7 +532,7 @@ test-unit: # are infra-free; config parses env into a struct and never connects. # `^config::` is anchored so it does not also select nip_fi_config. cargo nextest run -p buzz-relay --lib --bin buzz-relay \ - -E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^handlers::system_channels::tests::/) + test(/^nip11::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + test(=handlers::req::tests::group_state_t_and_creator_tags_reach_sql_only_for_group_state_kinds) + test(=api::bridge::postgres_tests::bridge_search_mode_extension_accepts_prefix_snake_or_camel_case) + test(=api::bridge::postgres_tests::bridge_search_sort_extension_selects_newest_only_when_named) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))' + -E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^handlers::system_channels::tests::/) + test(/^nip11::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::profiles::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + test(=handlers::req::tests::group_state_t_and_creator_tags_reach_sql_only_for_group_state_kinds) + test(=api::bridge::postgres_tests::bridge_search_mode_extension_accepts_prefix_snake_or_camel_case) + test(=api::bridge::postgres_tests::bridge_search_sort_extension_selects_newest_only_when_named) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))' # Note on audio::join::tests scope: the full suite is infra-free (no DB, # no Redis). The infra-free audio/FI regression witnesses — bootstrap # ordering barrier, CommitConfirmed arm, pending-close invisibility, diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index e63a1143d9..74d6560e19 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -709,7 +709,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 63); + assert_eq!(migrations.len(), 64); assert_eq!(migrations[58].version, 59); assert!(migrations[58].sql.as_str().contains("buzz.push_enabled")); assert_eq!(migrations[59].version, 60); @@ -724,6 +724,11 @@ mod postgres_tests { .sql .as_str() .contains("ALTER TYPE channel_type ADD VALUE IF NOT EXISTS 'system'")); + assert_eq!(migrations[63].version, 64); + assert!(migrations[63] + .sql + .as_str() + .contains("CREATE FUNCTION profile_name_fields")); assert_eq!(migrations[55].version, 56); assert!(migrations[55] .sql diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index bc4b1d1a78..c4cb8f7a51 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -27,6 +27,65 @@ use super::{api_error, db_read_error, internal_error, not_found, parse_query_or_ mod thread_roots; mod thread_window; +/// Authenticate a relay-owned JSON `POST` endpoint for a community member. +/// +/// Binds the community from the request host, runs NIP-FI admission over a +/// NIP-98 proof with a required payload hash, then applies the shared API rate +/// limit, replay guard and relay membership check, in that order. +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers +pub(crate) async fn authenticate_member_post( + state: &Arc, + headers: &HeaderMap, + path: &str, + body: &[u8], +) -> Result<(TenantContext, nostr::PublicKey), Response> { + let tenant = crate::nip_fi_shadow::bind_tenant(state, headers) + .await + .ok_or_else(|| { + api_error( + StatusCode::NOT_FOUND, + "relay: no community is configured for this host", + ) + .into_response() + })?; + + let expected_url = nip98_expected_url(&state.config.relay_url, &tenant, path); + + // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] + let admission = admit_nip_fi_http_on_state( + state, + headers, + make_nip98_closure_for_admission( + headers.clone(), + "POST", + expected_url, + Some(body.to_vec()), + true, + true, + ), + )?; + let pubkey = *admission.proven_pubkey(); + let (event_id_bytes, signed_created_at) = admission.into_extra(); + + enforce_http_admission(state, &tenant, &pubkey) + .await + .map_err(|e| e.into_response())?; + check_nip98_replay(state, &tenant, event_id_bytes) + .await + .map_err(|e| e.into_response())?; + super::relay_members::enforce_relay_membership( + state, + tenant.community(), + &pubkey.to_bytes(), + super::relay_members::extract_auth_tag_header(headers), + signed_created_at, + ) + .await + .map_err(|e| e.into_response())?; + + Ok((tenant, pubkey)) +} + pub(crate) async fn enforce_http_admission( state: &AppState, tenant: &TenantContext, @@ -5872,7 +5931,7 @@ pub(crate) mod postgres_tests { // 401 `authentication required\n`. // // Note: removing only the outer guard does NOT change this test — the - // handler's own `admit_nip_fi_http_on_state` in `gifs::authenticate` also + // handler's own `admit_nip_fi_http_on_state` in `authenticate_member_post` also // fires 401 on missing assertion. This test witnesses the outer guard fires // first and its error path is exercised; it does not claim the outer guard is // the sole denial point. The same-key positive (below) is the complement witness. @@ -6079,6 +6138,72 @@ pub(crate) mod postgres_tests { ); } + // ── Profile name search — Enforce mode, end to end ───────────────────── + // + // An admitted member gets signed kind:0 events whose `name` or + // `display_name` matches. A profile that carries the query only in its + // avatar data URL, `about` or `nip05` is not returned. + #[test] + #[ignore = "requires Postgres"] + fn profile_search_same_key_returns_only_name_matches() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_enforce_test_state_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-profile-search-{}.local", + uuid::Uuid::new_v4().simple() + ); + let community = rt + .block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community") + .id; + + let store = |content: &str| { + let event = nostr::EventBuilder::new(nostr::Kind::Metadata, content) + .sign_with_keys(&Keys::generate()) + .expect("sign profile"); + rt.block_on(state.db.replace_addressable_event(community, &event, None)) + .expect("store profile"); + event + }; + let named = store(r#"{"name":"wes","display_name":"Wes Anderson"}"#); + store(r#"{"name":"pollen","picture":"data:image/png;base64,P7+Wes+dYL"}"#); + store(r#"{"name":"pollen","about":"Wes' bestie","nip05":"wes@example.com"}"#); + + let keys = Keys::generate(); + let body = br#"{"query":"WES"}"#; + let url = format!("https://{host}{}", crate::api::profiles::SEARCH_PATH); + let headers = same_key_nip98_and_assertion_headers(&keys, &url, "POST", body); + + let (status, _resp_headers, resp) = rt.block_on(oneshot_request_full( + state, + "POST", + crate::api::profiles::SEARCH_PATH, + &host, + headers, + body, + )); + + assert_eq!( + status, + axum::http::StatusCode::OK, + "body: {:?}", + String::from_utf8_lossy(&resp) + ); + let events: Vec = + serde_json::from_slice(&resp).expect("response is a JSON array of events"); + assert_eq!(events, vec![named]); + assert!( + events[0].verify().is_ok(), + "returned event keeps its signature" + ); + } + // ── Moderation reports — Enforce mode, same-key admission → reaches handler ─ // // Positive control: a valid NIP-FI assertion + same-key NIP-98 on the diff --git a/crates/buzz-relay/src/api/gifs.rs b/crates/buzz-relay/src/api/gifs.rs index c4c4560178..8dd8bdf768 100644 --- a/crates/buzz-relay/src/api/gifs.rs +++ b/crates/buzz-relay/src/api/gifs.rs @@ -25,7 +25,7 @@ use crate::state::AppState; use buzz_auth::LimitType; -use super::{api_error, bridge, internal_error, relay_members}; +use super::{api_error, bridge, internal_error}; const KLIPY_API_ROOT: &str = "https://api.klipy.com/api/v1/"; pub(crate) const SEARCH_PATH: &str = "/gifs/search"; @@ -118,60 +118,6 @@ fn klipy_share_request( .json(&serde_json::json!({ "customer_id": request.customer_id }))) } -#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers -async fn authenticate( - state: &Arc, - headers: &HeaderMap, - path: &str, - body: &[u8], -) -> Result<(buzz_core::TenantContext, nostr::PublicKey), Response> { - let tenant = crate::nip_fi_shadow::bind_tenant(state, headers) - .await - .ok_or_else(|| { - api_error( - StatusCode::NOT_FOUND, - "relay: no community is configured for this host", - ) - .into_response() - })?; - - let expected_url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); - - // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] - let admission = crate::nip_fi_http::admit_nip_fi_http_on_state( - state, - headers, - bridge::make_nip98_closure_for_admission( - headers.clone(), - "POST", - expected_url, - Some(body.to_vec()), - true, - true, - ), - )?; - let pubkey = *admission.proven_pubkey(); - let (event_id_bytes, signed_created_at) = admission.into_extra(); - - bridge::enforce_http_admission(state, &tenant, &pubkey) - .await - .map_err(|e| e.into_response())?; - bridge::check_nip98_replay(state, &tenant, event_id_bytes) - .await - .map_err(|e| e.into_response())?; - relay_members::enforce_relay_membership( - state, - tenant.community(), - &pubkey.to_bytes(), - relay_members::extract_auth_tag_header(headers), - signed_created_at, - ) - .await - .map_err(|e| e.into_response())?; - - Ok((tenant, pubkey)) -} - async fn send_upstream( request: reqwest::RequestBuilder, ) -> Result)> { @@ -285,7 +231,8 @@ async fn search_inner( // Admission first: NIP-98 + NIP-FI must fire before any application-level // check (including provider availability) so the denial contract wins over // config or request-validation errors. [FI-TRACE-HTTP-INGRESS] - let (tenant, pubkey) = authenticate(&state, &headers, SEARCH_PATH, &body).await?; + let (tenant, pubkey) = + bridge::authenticate_member_post(&state, &headers, SEARCH_PATH, &body).await?; let Some(config) = state.config.klipy.as_ref() else { return Err( @@ -359,7 +306,7 @@ async fn share_inner( ) -> Result { // Admission first: NIP-98 + NIP-FI must fire before provider availability // check. [FI-TRACE-HTTP-INGRESS] - authenticate(&state, &headers, SHARE_PATH, &body).await?; + bridge::authenticate_member_post(&state, &headers, SHARE_PATH, &body).await?; let Some(config) = state.config.klipy.as_ref() else { return Err( diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index f41712de4f..89ff79f7e4 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -12,6 +12,7 @@ pub mod mesh_demo; pub mod nip05; pub mod nip_fi; pub mod operator; +pub mod profiles; pub mod workflows; // Re-export imeta helpers used by ingest pipeline. diff --git a/crates/buzz-relay/src/api/profiles.rs b/crates/buzz-relay/src/api/profiles.rs new file mode 100644 index 0000000000..a1c467a445 --- /dev/null +++ b/crates/buzz-relay/src/api/profiles.rs @@ -0,0 +1,163 @@ +//! Relay-owned profile name search. +//! +//! NIP-50 search over kind:0 matches the full profile JSON, so text inside +//! avatar data URLs, `about` and `nip05` competes with real names. This +//! endpoint keeps NIP-50 prefix matching and paging but applies them only to +//! the `name` and `display_name` fields of each current profile, and returns +//! the signed kind:0 events. + +use std::{collections::HashMap, sync::Arc}; + +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Json, Response}, +}; +use buzz_search::PROFILE_NAMES_PER_PAGE_MAX; +use serde::Deserialize; +use serde_json::Value; + +use crate::state::AppState; + +use super::{api_error, bridge, db_read_error, internal_error}; + +/// Route path for profile name search. +pub(crate) const SEARCH_PATH: &str = "/profiles/search"; +/// Longest accepted search text, in characters. +const QUERY_MAX_CHARS: usize = 100; +/// Page size when the request omits `limit`. +const DEFAULT_LIMIT: u32 = 20; +/// Highest accepted page, matching the NIP-50 search page ceiling. +const PAGE_MAX: u32 = 1000; + +/// `POST /profiles/search` body. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SearchRequest { + /// Text to find in `name` or `display_name`. + query: String, + /// Results per page, 1 through [`PROFILE_NAMES_PER_PAGE_MAX`]. + limit: Option, + /// 1-based page of `limit` results, 1 through [`PAGE_MAX`]. + page: Option, +} + +/// A validated search: text, page size and page. +type Search = (String, u32, u32); + +fn parse_request(body: &[u8]) -> Result)> { + let request: SearchRequest = serde_json::from_slice(body) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid profile search JSON"))?; + let query = request.query.trim(); + if query.is_empty() || query.chars().count() > QUERY_MAX_CHARS { + return Err(api_error( + StatusCode::BAD_REQUEST, + &format!("query must be 1 through {QUERY_MAX_CHARS} characters"), + )); + } + let limit = request.limit.unwrap_or(DEFAULT_LIMIT); + if !(1..=PROFILE_NAMES_PER_PAGE_MAX).contains(&limit) { + return Err(api_error( + StatusCode::BAD_REQUEST, + &format!("limit must be 1 through {PROFILE_NAMES_PER_PAGE_MAX}"), + )); + } + let page = request.page.unwrap_or(1); + if !(1..=PAGE_MAX).contains(&page) { + return Err(api_error( + StatusCode::BAD_REQUEST, + &format!("page must be 1 through {PAGE_MAX}"), + )); + } + Ok((query.to_owned(), limit, page)) +} + +/// Search current profiles by `name` and `display_name` for an authenticated +/// member. Responds with one page of signed kind:0 events, best match first. +pub async fn search( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Response { + search_inner(state, headers, body).await.into_response() +} + +async fn search_inner( + state: Arc, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, Response> { + // Admission first so the denial contract wins over request validation. + // [FI-TRACE-HTTP-INGRESS] + let (tenant, pubkey) = + bridge::authenticate_member_post(&state, &headers, SEARCH_PATH, &body).await?; + let (query, limit, page) = parse_request(&body).map_err(IntoResponse::into_response)?; + + let hits = state + .search + .search_profile_names(tenant.community(), &query, page, limit) + .await + .map_err(|e| internal_error(&format!("profile search error: {e}")).into_response())? + .hits; + if hits.is_empty() { + return Ok(Json(Value::Array(Vec::new()))); + } + let ids: Vec<&[u8]> = hits.iter().map(|hit| hit.event_id.as_slice()).collect(); + let stored = state + .db + .get_events_by_ids_routed("profile_search_hydrate", tenant.community(), &ids) + .await + .map_err(|e| db_read_error("profile search fetch error", &e).into_response())?; + let mut by_id: HashMap<[u8; 32], &buzz_core::StoredEvent> = stored + .iter() + .map(|stored| (stored.event.id.to_bytes(), stored)) + .collect(); + + // Keep search order. Search never widens visibility: apply the shared + // per-event read gate. + let reader = pubkey.to_bytes(); + let events = hits + .iter() + .filter_map(|hit| by_id.remove(&hit.event_id)) + .filter(|stored| crate::handlers::req::event_visible_to_reader(&stored.event, &reader)) + .filter_map(|stored| serde_json::to_value(&stored.event).ok()) + .collect(); + Ok(Json(Value::Array(events))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_validation_bounds_query_and_limit() { + assert_eq!( + parse_request(br#"{"query":" Wes "}"#).unwrap(), + ("Wes".to_owned(), DEFAULT_LIMIT, 1) + ); + assert_eq!( + parse_request(br#"{"query":"w","limit":100,"page":1000}"#).unwrap(), + ("w".to_owned(), 100, 1000) + ); + let long = "x".repeat(QUERY_MAX_CHARS + 1); + for body in [ + br#"{"query":" "}"#.to_vec(), + format!(r#"{{"query":"{long}"}}"#).into_bytes(), + br#"{"query":"w","limit":0}"#.to_vec(), + br#"{"query":"w","limit":101}"#.to_vec(), + br#"{"query":"w","page":0}"#.to_vec(), + br#"{"query":"w","page":1001}"#.to_vec(), + br#"{"query":"w","search_mode":"prefix"}"#.to_vec(), + br#"{"q":"w"}"#.to_vec(), + b"not json".to_vec(), + ] { + let (status, _) = parse_request(&body).unwrap_err(); + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "{}", + String::from_utf8_lossy(&body) + ); + } + } +} diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 6349f15c66..53f810b7f3 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -329,6 +329,8 @@ pub fn build_router(state: Arc) -> Router { // Relay-owned third-party GIF metadata proxy (NIP-98 auth). .route(api::gifs::SEARCH_PATH, post(api::gifs::search)) .route(api::gifs::SHARE_PATH, post(api::gifs::share)) + // Profile search over `name` and `display_name` only (NIP-98 auth). + .route(api::profiles::SEARCH_PATH, post(api::profiles::search)) .route( "/workflows/{workflow_id}/runs", get(api::workflows::workflow_runs), @@ -2893,6 +2895,11 @@ mod tests { br#"{"q":"cat"}"#.to_vec(), ), ("POST", "/gifs/share".to_owned(), br#"{"id":"x"}"#.to_vec()), + ( + "POST", + "/profiles/search".to_owned(), + br#"{"query":"wes"}"#.to_vec(), + ), ("POST", "/api/invites".to_owned(), b"{}".to_vec()), ( "GET", @@ -3282,6 +3289,10 @@ mod tests { !is_exempt("/gifs/share"), "POST /gifs/share must be protected" ); + assert!( + !is_exempt("/profiles/search"), + "POST /profiles/search must be protected" + ); assert!( !is_exempt("/workflows/abc/runs"), "GET /workflows must be protected" @@ -3835,7 +3846,7 @@ mod tests { // ── NIP-FI S4 deny witnesses ── // ES256 key pair — same as command.rs / api/nip_fi.rs test material. const DENY_TEST_PRIVATE_KEY_PEM: &str = - "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\nWZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\nzhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n-----END PRIVATE KEY-----\n"; + "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\nWZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\nzhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n-----END PRIVATE KEY-----\n"; // sadscan:disable np.pem.1 kingfisher.privkey.2 -- public ES256 test fixture const DENY_TEST_ISS: &str = "https://nip-fi-deny-test.example.com"; const DENY_TEST_AUD: &str = "https://relay.example"; @@ -4214,6 +4225,7 @@ mod tests { ("GET", git, Some("Nostr !!not-base64!!")), ("POST", "/gifs/search".to_owned(), Some(&signed)), ("POST", "/gifs/share".to_owned(), Some(&signed)), + ("POST", "/profiles/search".to_owned(), Some(&signed)), ("GET", branch.clone(), Some(&signed)), ("POST", branch, Some(&signed)), ("POST", "/api/invites".to_owned(), Some(&signed)), diff --git a/crates/buzz-search/src/lib.rs b/crates/buzz-search/src/lib.rs index 45ef48899d..4af282dacc 100644 --- a/crates/buzz-search/src/lib.rs +++ b/crates/buzz-search/src/lib.rs @@ -29,7 +29,8 @@ pub mod query; pub use buzz_core::CommunityId; pub use error::SearchError; pub use query::{ - search, ChannelScope, SearchHit, SearchMode, SearchQuery, SearchResult, SearchSort, + search, search_profile_names, ChannelScope, SearchHit, SearchMode, SearchQuery, SearchResult, + SearchSort, PROFILE_NAMES_PER_PAGE_MAX, }; use sqlx::PgPool; @@ -53,4 +54,16 @@ impl SearchService { pub async fn search(&self, query: &SearchQuery) -> Result { query::search(&self.pool, query).await } + + /// Find current profiles by `name` and `display_name`; see + /// [`search_profile_names`]. + pub async fn search_profile_names( + &self, + community: CommunityId, + q: &str, + page: u32, + per_page: u32, + ) -> Result { + query::search_profile_names(&self.pool, community, q, page, per_page).await + } } diff --git a/crates/buzz-search/src/query.rs b/crates/buzz-search/src/query.rs index c45fa2818e..ce75a7ea7a 100644 --- a/crates/buzz-search/src/query.rs +++ b/crates/buzz-search/src/query.rs @@ -356,6 +356,114 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result Result { + let page = page.clamp(1, PAGE_MAX); + let Some(search_text) = normalized_search_text(q) else { + return Ok(SearchResult { + hits: Vec::new(), + page, + }); + }; + let per_page = per_page.clamp(1, PROFILE_NAMES_PER_PAGE_MAX); + let offset = ((page - 1) as i64) * (per_page as i64); + // Tiers compare the query as typed, with whitespace runs collapsed, to + // each lower-cased name (spaces collapsed too). + let typed = search_text + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase(); + + let mut qb: QueryBuilder = QueryBuilder::new( + "SELECT id, kind, pubkey, channel_id, created_at_s, rank FROM (\ + SELECT e.id, e.kind, e.pubkey, e.channel_id, \ + EXTRACT(EPOCH FROM e.created_at)::bigint AS created_at_s, e.created_at, \ + ts_rank_cd(names.tsv, search_query.query) AS rank, \ + LEAST(", + ); + for field in ["names.name", "names.display_name"] { + qb.push(format!("CASE WHEN {field} = ")); + qb.push_bind(typed.clone()); + qb.push(format!(" THEN 0 WHEN starts_with({field}, ")); + qb.push_bind(typed.clone()); + qb.push(format!(") THEN 1 WHEN strpos({field}, ' ' || ")); + qb.push_bind(typed.clone()); + qb.push(") > 0 THEN 2 ELSE 3 END"); + if field == "names.name" { + qb.push(", "); + } + } + qb.push( + ") AS tier \ + FROM events e CROSS JOIN LATERAL (SELECT ", + ); + push_tsquery(&mut qb, SearchMode::Prefix, &search_text); + qb.push( + " AS query) AS search_query \ + CROSS JOIN LATERAL profile_name_fields(e.content) AS fields \ + CROSS JOIN LATERAL (SELECT \ + to_tsvector('simple', concat_ws(' ', fields.name, fields.display_name)) AS tsv, \ + regexp_replace(lower(btrim(fields.name)), '\\s+', ' ', 'g') AS name, \ + regexp_replace(lower(btrim(fields.display_name)), '\\s+', ' ', 'g') AS display_name) AS names \ + WHERE e.community_id = ", + ); + qb.push_bind(*community.as_uuid()); + qb.push( + " AND e.kind = 0 AND e.channel_id IS NULL AND e.deleted_at IS NULL \ + AND e.search_tsv @@ search_query.query \ + AND names.tsv @@ search_query.query\ + ) AS matched ORDER BY tier, rank DESC, created_at DESC, id LIMIT ", + ); + qb.push_bind(per_page as i64); + qb.push(" OFFSET "); + qb.push_bind(offset); + + let rows = qb.build().fetch_all(pool).await?; + let mut hits = Vec::with_capacity(rows.len()); + for row in rows { + let id_bytes: Vec = row.try_get("id")?; + let pk_bytes: Vec = row.try_get("pubkey")?; + let id: [u8; 32] = id_bytes.try_into().map_err(|v: Vec| { + sqlx::Error::Decode(format!("event id column is {} bytes, expected 32", v.len()).into()) + })?; + let pubkey: [u8; 32] = pk_bytes.try_into().map_err(|v: Vec| { + sqlx::Error::Decode(format!("pubkey column is {} bytes, expected 32", v.len()).into()) + })?; + hits.push(SearchHit { + event_id: id, + kind: row.try_get("kind")?, + pubkey, + channel_id: row.try_get("channel_id")?, + created_at: row.try_get("created_at_s")?, + rank: row.try_get("rank")?, + }); + } + Ok(SearchResult { hits, page }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-search/tests/postgres_fts_integration.rs b/crates/buzz-search/tests/postgres_fts_integration.rs index 9f3db1c5f0..cb859b445e 100644 --- a/crates/buzz-search/tests/postgres_fts_integration.rs +++ b/crates/buzz-search/tests/postgres_fts_integration.rs @@ -16,7 +16,7 @@ use buzz_search::{ChannelScope, SearchQuery, SearchService}; use sqlx::{postgres::PgPoolOptions, PgPool}; use uuid::Uuid; -const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; +const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials async fn setup() -> (PgPool, String) { let url = std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string()); // The PostgreSQL lane supplies an isolated desired-state database. Outside @@ -1612,3 +1612,157 @@ async fn artifact_search_uses_only_live_current_head() { assert!(result.hits.is_empty()); teardown(pool, &schema).await; } + +/// Insert a current kind:0 profile; returns its event id. +async fn put_profile(pool: &PgPool, community: CommunityId, content: &str, at: i64) -> [u8; 32] { + let id = rand_bytes32(); + insert_event(pool, community, id, rand_bytes32(), 0, content, None, at).await; + id +} + +async fn profile_names( + pool: &PgPool, + community: CommunityId, + q: &str, + page: u32, + per_page: u32, +) -> Vec<[u8; 32]> { + SearchService::new(pool.clone()) + .search_profile_names(community, q, page, per_page) + .await + .expect("profile name search") + .hits + .into_iter() + .map(|hit| hit.event_id) + .collect() +} + +/// Profile name search keeps the NIP-50 prefix rule (whole words in any +/// order, last word a prefix) but only `name` and `display_name` count. An +/// equal name ranks first, then a name prefix, then a later word prefix, then +/// other matches; ties stay newest first. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn profile_names_match_name_words_only() { + let (pool, schema) = setup().await; + let c = mk_community(&pool, "profile-names.example").await; + let other = mk_community(&pool, "profile-names-other.example").await; + + let exact = put_profile( + &pool, + c, + r#"{"display_name":"Wes","picture":"data:image/png;base64,P7+Wes+dYL"}"#, + 1_700_000_000, + ) + .await; + let westie_old = put_profile(&pool, c, r#"{"name":"Westie"}"#, 1_700_000_010).await; + let westie_new = put_profile(&pool, c, r#"{"display_name":"Westie"}"#, 1_700_000_020).await; + let word = put_profile( + &pool, + c, + r#"{"name":"carl","display_name":"Carl (borrowed from Wes)"}"#, + 1_700_000_030, + ) + .await; + let full = put_profile( + &pool, + c, + r#"{"name":"wjohnson","display_name":"Wes Johnson"}"#, + 1_700_000_040, + ) + .await; + // Words split across the two fields still match, as in NIP-50 search. + let split = put_profile( + &pool, + c, + r#"{"name":"johnson","display_name":"Wes"}"#, + 1_700_000_050, + ) + .await; + // A word that only contains the query is not a match: same as NIP-50. + put_profile(&pool, c, r#"{"name":"Dwes"}"#, 1_700_000_060).await; + + for (i, noise) in [ + r#"{"display_name":"Blueberry","picture":"data:image/png;base64,P7+Wes+dYL"}"#, + r#"{"display_name":"Pollen","about":"Wes Johnson's bestie"}"#, + r#"{"display_name":"Pollen","nip05":"wes@example.com"}"#, + r#"{"display_name":"Pollen","banner":"https://wes.example/b.png"}"#, + r#"{"name":["wes"],"display_name":{"text":"wes"}}"#, + r#"not json wes"#, + r#"["wes"]"#, + ] + .into_iter() + .enumerate() + { + put_profile(&pool, c, noise, 1_700_000_100 + i as i64).await; + } + // A replaced profile (soft-deleted) no longer matches. + let replaced = put_profile(&pool, c, r#"{"name":"wes"}"#, 1_699_000_000).await; + sqlx::query("UPDATE events SET deleted_at = now() WHERE community_id = $1 AND id = $2") + .bind(c.as_uuid()) + .bind(&replaced[..]) + .execute(&pool) + .await + .expect("replace profile"); + put_profile(&pool, other, r#"{"name":"Wes"}"#, 1_700_000_000).await; + + // Tier 0: exact (Wes; "Wes" display name of `split`), newest first. + // Tier 1: name prefix (Wes Johnson, Westie x2). Tier 2: later word (Carl). + let all = vec![split, exact, full, westie_new, westie_old, word]; + assert_eq!(profile_names(&pool, c, "WES", 1, 100).await, all); + // Pages split the same order without overlap or gaps. + assert_eq!(profile_names(&pool, c, "wes", 1, 4).await, all[..4]); + assert_eq!(profile_names(&pool, c, "wes", 2, 4).await, all[4..]); + assert!(profile_names(&pool, c, "wes", 3, 4).await.is_empty()); + + // Word order does not matter; completed words must be whole words. + assert_eq!( + profile_names(&pool, c, "johnson wes", 1, 100).await, + vec![split, full] + ); + assert_eq!( + profile_names(&pool, c, "wes jo", 1, 100).await, + vec![full, split] + ); + assert!(profile_names(&pool, c, "estie", 1, 100).await.is_empty()); + assert!(profile_names(&pool, c, "bestie", 1, 100).await.is_empty()); + assert!(profile_names(&pool, c, "example", 1, 100).await.is_empty()); + assert!(profile_names(&pool, c, "%", 1, 100).await.is_empty()); + assert!(profile_names(&pool, c, " ", 1, 100).await.is_empty()); + + teardown(pool, &schema).await; +} + +/// Stored kind:0 content is whatever the signer sent. Content that cannot +/// become jsonb must not fail the query for everyone else. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn profile_names_survive_unparsable_content() { + let (pool, schema) = setup().await; + let c = mk_community(&pool, "profile-names-unparsable.example").await; + + let plain = put_profile(&pool, c, r#"{"name":"Wes"}"#, 1_700_000_000).await; + + // Valid JSON objects that cannot become jsonb. Each one names Wes; it is + // not findable, but it must not break the search. + let deep = format!( + r#"{{"name":"Wes","nested":{}{}}}"#, + "[".repeat(60_000), + "]".repeat(60_000) + ); + for (i, poison) in [ + r#"{"name":"Wes","about":"\u0000"}"#, + r#"{"name":"Wes","about":"\ud800"}"#, + r#"{"name":"Wes","lud16":1e1000000}"#, + deep.as_str(), + ] + .into_iter() + .enumerate() + { + put_profile(&pool, c, poison, 1_700_000_100 + i as i64).await; + } + + assert_eq!(profile_names(&pool, c, "wes", 1, 100).await, vec![plain]); + + teardown(pool, &schema).await; +} diff --git a/docs/profile-search.md b/docs/profile-search.md new file mode 100644 index 0000000000..33f91ca029 --- /dev/null +++ b/docs/profile-search.md @@ -0,0 +1,50 @@ +# Profile name search + +`POST /profiles/search` finds community members by profile name. It matches +only the `name` and `display_name` string fields of each current global kind:0 +profile. It does not search `picture` (including data URLs), `about`, `nip05`, +`banner`, or any other profile field. NIP-50 search over kind:0 matches the +whole profile JSON, so it can return profiles whose avatar data or bio happens +to contain the query text. + +Apart from the fields it reads, the endpoint matches and pages like a NIP-50 +kind:0 search with `search_mode: "prefix"`. A client can switch from one to the +other without a visible change in which people a query finds. + +The endpoint requires NIP-98 authentication over the request body and relay +membership, like other relay-owned member endpoints. A relay without the +endpoint answers `404`; clients fall back to NIP-50 search for that request. + +Request: + +```json +{ "query": "wes", "limit": 20, "page": 1 } +``` + +- `query`: 1 through 100 characters after trimming. Required. +- `limit`: results per page, 1 through 100. Defaults to 20. +- `page`: 1-based page of `limit` results, 1 through 1000. Defaults to 1. +- Unknown fields are rejected with `400`. + +Response: a JSON array of signed kind:0 events for that page, best match +first. A page shorter than `limit` is the last page. + +Matching is the NIP-50 prefix rule, case-insensitive. The query is split on +whitespace. Every word except the last must equal a word of `name` or +`display_name`; the last word may be a word prefix. Words may appear in any +order and may be split across the two fields, so "johnson wes" finds +"Wes Johnson". Accents are not folded. + +Results are ordered: + +1. `name` or `display_name` equal to the query; +2. a name that starts with the query; +3. a name with a later word that starts with the query; +4. any other match. + +Ties keep the NIP-50 order (relevance, newest first, event id), so pages do +not overlap. Profile content that is not a JSON object, or a name field that +is not a string, never matches. Content that PostgreSQL cannot read as `jsonb` +(for example a `\u0000` escape or very deep nesting) has no names, so it +never matches and never makes the search fail +(`schema/functions/profile_name_fields.sql`). diff --git a/migrations/0064_profile_name_fields.sql b/migrations/0064_profile_name_fields.sql new file mode 100644 index 0000000000..ea416e8c9c --- /dev/null +++ b/migrations/0064_profile_name_fields.sql @@ -0,0 +1,21 @@ +-- Name-only profile search: extract names without letting one stored profile +-- fail the query. See schema/functions/profile_name_fields.sql. +CREATE FUNCTION profile_name_fields(content TEXT, OUT name TEXT, OUT display_name TEXT) +LANGUAGE plpgsql IMMUTABLE STRICT AS $$ +DECLARE + doc JSONB; +BEGIN + doc := content::jsonb; + IF jsonb_typeof(doc) = 'object' THEN + IF jsonb_typeof(doc -> 'name') = 'string' THEN + name := doc ->> 'name'; + END IF; + IF jsonb_typeof(doc -> 'display_name') = 'string' THEN + display_name := doc ->> 'display_name'; + END IF; + END IF; +EXCEPTION WHEN OTHERS THEN + name := NULL; + display_name := NULL; +END +$$; diff --git a/schema/functions/profile_name_fields.sql b/schema/functions/profile_name_fields.sql new file mode 100644 index 0000000000..5a4b6cd43f --- /dev/null +++ b/schema/functions/profile_name_fields.sql @@ -0,0 +1,24 @@ +-- Profile name search reads `name` and `display_name` from stored kind:0 +-- content. Content is any text the signer chose, and some valid JSON cannot +-- become jsonb (for example `\u0000`, a lone surrogate, a huge number or very +-- deep nesting). Such content has no searchable names; it must never make the +-- whole search query fail. Only string values count as names. +CREATE FUNCTION profile_name_fields(content TEXT, OUT name TEXT, OUT display_name TEXT) +LANGUAGE plpgsql IMMUTABLE STRICT AS $$ +DECLARE + doc JSONB; +BEGIN + doc := content::jsonb; + IF jsonb_typeof(doc) = 'object' THEN + IF jsonb_typeof(doc -> 'name') = 'string' THEN + name := doc ->> 'name'; + END IF; + IF jsonb_typeof(doc -> 'display_name') = 'string' THEN + display_name := doc ->> 'display_name'; + END IF; + END IF; +EXCEPTION WHEN OTHERS THEN + name := NULL; + display_name := NULL; +END +$$; diff --git a/schema/schema.sql b/schema/schema.sql index bb71025f2c..b6d355cff9 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -77,6 +77,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto; \i functions/enforce_community_write_fence.sql \i functions/enforce_community_tombstone.sql \i functions/attach_community_write_fence.sql +\i functions/profile_name_fields.sql -- tables \i tables/public/communities.sql diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 0dbd43195c..1a51c4e637 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -295,6 +295,9 @@ run_unit_tests() { run_test_step "buzz-relay API query parsing tests" \ cargo test -p buzz-relay --lib api::parse_query_tests:: -- --nocapture + run_test_step "buzz-relay profile search request tests" \ + cargo test -p buzz-relay --lib api::profiles:: -- --nocapture + run_test_step "buzz-relay Git transport Off-mode precedence tests" \ cargo test -p buzz-relay --lib api::git::transport::off_mode_precedence_tests:: -- --nocapture