mirror of
https://github.com/block/buzz.git
synced 2026-11-13 12:51:34 +00:00
🤖 > **Review as a pair with block/buzz-app#1084** (the app change that uses this endpoint). Either can merge first: the app falls back to the old search on relays without this endpoint. ## Summary Searching for a person by name over the relay's general search returns profiles that only *mention* the text somewhere else: in an avatar image embedded as text, in the bio, or in the verification address. A search for "wes" could fill every result page with profiles whose avatar data happens to contain "Wes", and never return the person actually named Wes. This adds a relay endpoint, `POST /profiles/search`, that looks only at a profile's **name** and **display name**. Every other profile field is ignored. **Matching and paging are the same as the relay's existing full-text profile search** (NIP-50 search over kind:0 profiles with `search_mode: "prefix"`). A client can switch to this endpoint without a visible change in which people a query finds: - Each query word must match a whole word of the name; the last word can be a prefix. Words can be in any order, so "johnson wes" finds "Wes Johnson". Case is ignored. - Pages work the same way: up to 100 results per page, up to page 1000. - The results are exactly the old results minus profiles that match only outside their names. The order is better for people search: exact name first, then names that start with the query, then names with a later word that starts with it, then other matches. Ties are ordered by `ts_rank_cd` over the two name fields only (not the whole profile, as in NIP-50), then newest first, then event id. The response is a JSON array of the original signed profile events (Nostr kind:0), so clients can verify them as they do today. The buzz-app client change is block/buzz-app#1084. ## Details - Request: `{"query": "wes", "limit": 20, "page": 1}`. `query` is 1–100 characters after trimming; `limit` is 1–100 (default 20); `page` is 1–1000 (default 1); unknown fields are rejected with 400. - The search reuses the full-text search's own query builder (`buzz-search`), so the word rule cannot drift from it. The existing full-text index on events stays the access path; the name check runs only on rows the index already matched. There is no new index. One small migration (`0061`) adds the function `profile_name_fields()`; it reads no table data, so it runs instantly. - Only current profiles count: a replaced profile stops matching. Profiles that are not a JSON object, or whose name fields are not strings, never match. Content that PostgreSQL cannot read as `jsonb` (for example a `\^@` escape or very deep nesting) also never matches, and it cannot make the search fail for everyone else. - The handler loads the matched events through the normal routed event read and applies the same reader visibility check as other reads, in search order. - Access uses the same checks as the relay's GIF search endpoint: a signed HTTP request (NIP-98), identity admission (NIP-FI), rate limit, replay protection, and relay membership. That code moved out of `gifs.rs` into one shared helper in `bridge.rs`; GIF behavior is unchanged. - The route is added to the existing protected-route and NIP-FI shadow/enforce route tables. Docs: `docs/profile-search.md`. - Two test-only lines get `sadscan:disable` comments, as other fixtures already do: an existing public ES256 test key in `router.rs`, and the local Postgres test URL in the search integration test. ### Related issue None found. ### Testing - Postgres test for the search query (`profile_names_match_name_words_only`): only name fields match (avatar data URL, `about`, `nip05`, `banner`, non-string names and non-object content are excluded); words in any order and across both name fields; whole words only for completed words; ranking order; pages split without overlap or gaps; replaced profile; other community; `%` literal. - Postgres end-to-end test through the router: an admitted member gets only the name-matching signed event, and the returned event still verifies. - Request validation unit tests; existing GIF admission tests and router route-table tests pass. - Local end-to-end run with buzz-app#1084 against a relay built from this head, with about 195 seeded profiles. For every query tried, the endpoint's results were a subset of the full-text search's results; the only profiles missing were avatar-only matches. --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
70 lines
2.5 KiB
Rust
70 lines
2.5 KiB
Rust
#![deny(unsafe_code)]
|
|
#![warn(missing_docs)]
|
|
//! Buzz search — community-scoped Postgres full-text search over Buzz events.
|
|
//!
|
|
//! The index lives in the `events` table: `search_tsv TSVECTOR GENERATED
|
|
//! ALWAYS AS (to_tsvector('simple', content)) STORED`, with `GIN
|
|
//! (search_tsv)` as the access path. Because the column is `GENERATED ALWAYS`,
|
|
//! every row write *is* the index update — there is no separate indexer, no
|
|
//! mpsc queue, no reindex job, no consistency window to reason about. A
|
|
//! client cannot forge the tsvector out of sync with the content it signed.
|
|
//!
|
|
//! This crate is the **query** side. Indexing is the SQL row insert — owned
|
|
//! by `buzz-db`. The relay refetches canonical events through `buzz-db`'s
|
|
//! scoped fetcher and runs access checks per hit; search is never the access
|
|
//! boundary (conformance row 50).
|
|
//!
|
|
//! ## Multi-tenant fence
|
|
//!
|
|
//! Every [`SearchQuery`] carries a [`CommunityId`]. There is no construction
|
|
//! path through this crate that omits it, and every SQL execution binds
|
|
//! `community_id = $ctx` as the first predicate. A query bound to community
|
|
//! A cannot return events stored under community B, by construction.
|
|
|
|
/// Search error types.
|
|
pub mod error;
|
|
/// Search query execution.
|
|
pub mod query;
|
|
|
|
pub use buzz_core::CommunityId;
|
|
pub use error::SearchError;
|
|
pub use query::{
|
|
search, search_profile_names, ChannelScope, SearchHit, SearchMode, SearchQuery, SearchResult,
|
|
SearchSort, PROFILE_NAMES_PER_PAGE_MAX,
|
|
};
|
|
|
|
use sqlx::PgPool;
|
|
|
|
/// Thin handle around a `PgPool` for community-scoped FTS.
|
|
///
|
|
/// Holds nothing the pool itself doesn't already own. The whole purpose of
|
|
/// this type is a stable injection point for the relay's `AppState`.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SearchService {
|
|
pool: PgPool,
|
|
}
|
|
|
|
impl SearchService {
|
|
/// Build a search service over an existing Postgres pool.
|
|
pub fn new(pool: PgPool) -> Self {
|
|
Self { pool }
|
|
}
|
|
|
|
/// Execute a community-scoped FTS query.
|
|
pub async fn search(&self, query: &SearchQuery) -> Result<SearchResult, SearchError> {
|
|
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<SearchResult, SearchError> {
|
|
query::search_profile_names(&self.pool, community, q, page, per_page).await
|
|
}
|
|
}
|