//! Search handler, full-text fuzzy search returning HTMX fragments. use axum::{ extract::Query, response::{IntoResponse, Response}, }; use serde::Deserialize; use crate::AppState; use crate::routes::db_error; use crate::templates::{SearchResultViewRow, SearchResultsFragment}; #[derive(Deserialize)] pub(super) struct SearchQuery { pub(super) q: Option, pub(super) scope: Option, } /// GET /search?q=...&scope=..., returns HTML fragment for HTMX swap. /// /// Access posture: **intentionally public.** There is no members-only or private /// community, every community is public-read (the `state` machine only gates /// *writes*: restricted/frozen/archived, migration 025), so an anonymous request /// already sees all the content search can surface. A community *ban* restricts /// participation, not reading, so there is no additional content to withhold from /// a banned viewer that they couldn't reach anonymously. Content that must never /// surface is excluded in the query itself (`queries::search_threads` filters /// `deleted_at`, `suspended_at`, and `removed_at`), not per-viewer here. If a /// private-community concept is ever added, this handler must take `MaybeUser` /// and filter by membership/ban, until then, taking no session is correct. #[tracing::instrument(skip_all)] pub(super) async fn search_handler( axum::extract::State(state): axum::extract::State, Query(query): Query, ) -> Result { let q = query.q.as_deref().unwrap_or("").trim(); if q.is_empty() || q.len() < 2 { return Ok(SearchResultsFragment { results: vec![] }); } // Sanitize: limit length (find a char boundary to avoid UTF-8 panic) let q = if q.len() > 200 { let mut end = 200; while !q.is_char_boundary(end) { end -= 1; } &q[..end] } else { q }; let scope = query.scope.as_deref().filter(|s| !s.is_empty()); let db_results = mt_db::queries::search_threads(&state.db, q, scope, 20) .await .map_err(db_error)?; let results = db_results .into_iter() .map(|r| SearchResultViewRow { thread_id: r.thread_id.to_string(), thread_title: r.thread_title, author_username: r.author_username, community_name: r.community_name, community_slug: r.community_slug, category_name: r.category_name, category_slug: r.category_slug, snippet: r.snippet, last_activity: mt_core::time_format::relative_timestamp(r.last_activity_at), }) .collect(); Ok(SearchResultsFragment { results }) }