Skip to main content

max / makenotwork

2.7 KB · 76 lines History Blame Raw
1 //! Search handler, full-text fuzzy search returning HTMX fragments.
2
3 use axum::{
4 extract::Query,
5 response::{IntoResponse, Response},
6 };
7 use serde::Deserialize;
8
9 use crate::AppState;
10 use crate::routes::db_error;
11 use crate::templates::{SearchResultViewRow, SearchResultsFragment};
12
13 #[derive(Deserialize)]
14 pub(super) struct SearchQuery {
15 pub(super) q: Option<String>,
16 pub(super) scope: Option<String>,
17 }
18
19 /// GET /search?q=...&scope=..., returns HTML fragment for HTMX swap.
20 ///
21 /// Access posture: **intentionally public.** There is no members-only or private
22 /// community, every community is public-read (the `state` machine only gates
23 /// *writes*: restricted/frozen/archived, migration 025), so an anonymous request
24 /// already sees all the content search can surface. A community *ban* restricts
25 /// participation, not reading, so there is no additional content to withhold from
26 /// a banned viewer that they couldn't reach anonymously. Content that must never
27 /// surface is excluded in the query itself (`queries::search_threads` filters
28 /// `deleted_at`, `suspended_at`, and `removed_at`), not per-viewer here. If a
29 /// private-community concept is ever added, this handler must take `MaybeUser`
30 /// and filter by membership/ban, until then, taking no session is correct.
31 #[tracing::instrument(skip_all)]
32 pub(super) async fn search_handler(
33 axum::extract::State(state): axum::extract::State<AppState>,
34 Query(query): Query<SearchQuery>,
35 ) -> Result<impl IntoResponse, Response> {
36 let q = query.q.as_deref().unwrap_or("").trim();
37
38 if q.is_empty() || q.len() < 2 {
39 return Ok(SearchResultsFragment { results: vec![] });
40 }
41
42 // Sanitize: limit length (find a char boundary to avoid UTF-8 panic)
43 let q = if q.len() > 200 {
44 let mut end = 200;
45 while !q.is_char_boundary(end) {
46 end -= 1;
47 }
48 &q[..end]
49 } else {
50 q
51 };
52
53 let scope = query.scope.as_deref().filter(|s| !s.is_empty());
54
55 let db_results = mt_db::queries::search_threads(&state.db, q, scope, 20)
56 .await
57 .map_err(db_error)?;
58
59 let results = db_results
60 .into_iter()
61 .map(|r| SearchResultViewRow {
62 thread_id: r.thread_id.to_string(),
63 thread_title: r.thread_title,
64 author_username: r.author_username,
65 community_name: r.community_name,
66 community_slug: r.community_slug,
67 category_name: r.category_name,
68 category_slug: r.category_slug,
69 snippet: r.snippet,
70 last_activity: mt_core::time_format::relative_timestamp(r.last_activity_at),
71 })
72 .collect();
73
74 Ok(SearchResultsFragment { results })
75 }
76