yggdrasil/api/posts/
search.rs1use dioxus::prelude::*;
11
12#[cfg(feature = "server")]
13use super::helpers::row_to_post_list_item;
14use super::types::PostListResponse;
15#[cfg(feature = "server")]
16use crate::api::error::AppError;
17#[cfg(feature = "server")]
18use crate::cache;
19#[cfg(feature = "server")]
20use crate::db::pool::get_conn;
21
22#[server(SearchPosts, "/api")]
27pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnError> {
28 #[cfg(feature = "server")]
29 {
30 use crate::api::rate_limit;
31
32 if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
34 let parts = ctx.parts_mut();
35 let ip = rate_limit::get_client_ip(&parts.headers);
36 if let Err(_msg) = rate_limit::check_strict_limit(&ip) {
37 return Ok(PostListResponse {
38 posts: Vec::new(),
39 total: 0,
40 });
41 }
42 }
43
44 let client = get_conn().await.map_err(AppError::db_conn)?;
45
46 let q = query.trim();
47 if q.is_empty() || q.chars().count() > 200 {
48 return Ok(PostListResponse {
49 posts: Vec::new(),
50 total: 0,
51 });
52 }
53
54 let cache_key = cache::normalize_search_key(q);
56 if let Some((posts, total)) = cache::get_search_results(&cache_key).await {
57 return Ok(PostListResponse { posts, total });
58 }
59
60 let escaped = crate::utils::server::escape_like_pattern(q);
62
63 let rows = client
66 .query(
67 "SELECT
68 p.id, p.author_id, p.title, p.slug, p.summary, p.status,
69 p.published_at, p.created_at, p.updated_at, p.cover_image,
70 p.word_count, p.reading_time,
71 COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags,
72 word_similarity(p.search_text, $2) AS sml
73 FROM posts p
74 LEFT JOIN post_tags pt ON p.id = pt.post_id
75 LEFT JOIN tags t ON pt.tag_id = t.id
76 WHERE p.status = 'published' AND p.deleted_at IS NULL
77 AND p.search_text ILIKE '%' || $1 || '%' ESCAPE '\\'
78 GROUP BY p.id, p.search_text
79 ORDER BY sml DESC, p.published_at DESC
80 LIMIT 50",
81 &[&escaped, &q],
82 )
83 .await
84 .map_err(AppError::query)?;
85
86 let posts: Vec<_> = rows.iter().map(row_to_post_list_item).collect();
87
88 let total = posts.len() as i64;
89 cache::set_search_results(&cache_key, posts.clone(), total).await;
90 Ok(PostListResponse { posts, total })
91 }
92
93 #[cfg(not(feature = "server"))]
94 {
95 Ok(PostListResponse {
96 posts: Vec::new(),
97 total: 0,
98 })
99 }
100}