Skip to main content

yggdrasil/api/posts/
list.rs

1//! 文章列表查询接口。
2//!
3//! 提供已发布文章分页、管理员全量列表、以及按标签筛选三种查询能力,
4//! 均通过缓存层减少重复数据库访问。
5//! Dioxus server function,注册在 `/api` 路径下。
6//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。
7
8use dioxus::prelude::*;
9
10#[cfg(feature = "server")]
11use super::helpers::{get_current_admin_user, row_to_post_list_item};
12use super::types::PostListResponse;
13#[cfg(feature = "server")]
14use crate::api::error::AppError;
15#[cfg(feature = "server")]
16use crate::db::pool::get_conn;
17
18/// 单页允许的最大文章数。
19///
20/// 公开的 `list_published_posts` 接口无需认证,若不对 `per_page` 设上限,
21/// 攻击者可传入巨大值迫使数据库扫描并实例化超大 Vec,造成内存放大与拒绝服务。
22#[cfg(feature = "server")]
23const MAX_PER_PAGE: i32 = 50;
24
25/// 允许的最大页码。
26///
27/// `page` 无上限时,攻击者可用海量不同 `page` 值撑大缓存键空间(缓存污染),
28/// 并触发无意义的超大 `OFFSET` 扫描。10_000 对任何实际博客都足够宽裕
29/// (配合 `MAX_PER_PAGE` 最多覆盖 50 万篇文章),同时把缓存键空间限制在有限范围。
30#[cfg(feature = "server")]
31const MAX_PAGE: i32 = 10_000;
32
33/// 将分页参数钳制到安全范围:页码 1–`MAX_PAGE`,每页 1–`MAX_PER_PAGE`。
34///
35/// 注意:返回值必须同时用于缓存键与 SQL 查询,避免同一逻辑页落入不同缓存条目。
36#[cfg(feature = "server")]
37fn clamp_pagination(page: i32, per_page: i32) -> (i32, i32) {
38    (page.clamp(1, MAX_PAGE), per_page.clamp(1, MAX_PER_PAGE))
39}
40
41/// 获取已发布文章分页列表。
42///
43/// 优先命中缓存;未命中时查询总数与分页记录,并按 published_at 降序排列。
44#[server(ListPublishedPosts, "/api")]
45pub async fn list_published_posts(
46    page: i32,
47    per_page: i32,
48) -> Result<PostListResponse, ServerFnError> {
49    // 钳制分页参数,防止无认证调用方请求超大每页数量导致内存放大 / DoS。
50    let (page, per_page) = clamp_pagination(page, per_page);
51
52    #[cfg(feature = "server")]
53    {
54        let cache_key = crate::cache::CacheKey::PublishedPosts { page, per_page };
55        if let Some((cached_posts, cached_total)) = crate::cache::get_post_list(&cache_key).await {
56            return Ok(PostListResponse {
57                posts: cached_posts,
58                total: cached_total,
59            });
60        }
61
62        let client = get_conn().await.map_err(AppError::db_conn)?;
63
64        // 优先读取缓存中的已发布文章总数,否则查询数据库并回填缓存。
65        let total = if let Some(cached_total) = crate::cache::get_total_published_posts().await {
66            cached_total
67        } else {
68            let count_row = client
69                .query_one(
70                    "SELECT COUNT(*) FROM posts WHERE status = 'published' AND deleted_at IS NULL",
71                    &[],
72                )
73                .await
74                .map_err(AppError::query)?;
75            let total: i64 = count_row.get(0);
76            crate::cache::set_total_published_posts(total).await;
77            total
78        };
79
80        let offset = ((page - 1).max(0) as i64) * (per_page as i64);
81        let limit = per_page as i64;
82        let rows = client
83            .query(
84                "SELECT
85                    p.id, p.author_id, p.title, p.slug, p.summary, p.status,
86                    p.published_at, p.created_at, p.updated_at, p.cover_image,
87                    p.word_count, p.reading_time,
88                    COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
89                 FROM posts p
90                 LEFT JOIN post_tags pt ON p.id = pt.post_id
91                 LEFT JOIN tags t ON pt.tag_id = t.id
92                 WHERE p.status = 'published' AND p.deleted_at IS NULL
93                 GROUP BY p.id
94                 ORDER BY p.published_at DESC
95                 LIMIT $1 OFFSET $2",
96                &[&limit, &offset],
97            )
98            .await
99            .map_err(AppError::query)?;
100
101        let posts: Vec<_> = rows.iter().map(row_to_post_list_item).collect();
102
103        crate::cache::set_post_list(&cache_key, posts.clone(), total).await;
104        Ok(PostListResponse { posts, total })
105    }
106
107    #[cfg(not(feature = "server"))]
108    {
109        Ok(PostListResponse {
110            posts: Vec::new(),
111            total: 0,
112        })
113    }
114}
115
116/// 获取管理员视角的全部文章列表(含草稿与已发布)。
117///
118/// 需要 admin 权限;结果按创建时间降序,不走缓存。
119#[server(ListPosts, "/api")]
120pub async fn list_posts(
121    page: i32,
122    per_page: i32,
123    search: Option<String>,
124) -> Result<PostListResponse, ServerFnError> {
125    // 与公开接口保持一致的分页钳制,避免单次请求拉取过多记录。
126    let (page, per_page) = clamp_pagination(page, per_page);
127    let _user = get_current_admin_user().await?;
128
129    #[cfg(feature = "server")]
130    {
131        let client = get_conn().await.map_err(AppError::db_conn)?;
132
133        // 归一化标题搜索词:trim 后为空则视为不搜索;限长 200 字符并转义 SQL
134        // LIKE 通配符(% / _ / \),避免用户输入导致模式错配或全表误匹配。
135        // 与 search.rs 的全文检索不同:管理后台需覆盖草稿,且仅按标题匹配。
136        let title_filter: Option<String> = search
137            .as_deref()
138            .map(str::trim)
139            .filter(|s| !s.is_empty())
140            .map(|s| {
141                let truncated: String = s.chars().take(200).collect();
142                crate::utils::server::escape_like_pattern(&truncated)
143            });
144
145        let offset = ((page - 1).max(0) as i64) * (per_page as i64);
146        let limit = per_page as i64;
147
148        // 有搜索词时按 title ILIKE 子串匹配过滤(双侧 %),否则走原全量列表。
149        // posts 表对管理后台属低频访问、行数有限,ILIKE 全表扫可接受,靠 LIMIT 兜底。
150        let (total, rows) = if let Some(esc) = title_filter {
151            let pattern = format!("%{esc}%");
152            let total: i64 = client
153                .query_one(
154                    "SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL AND title ILIKE $1 ESCAPE '\\'",
155                    &[&pattern],
156                )
157                .await
158                .map_err(AppError::query)?
159                .get(0);
160            let rows = client
161                .query(
162                    "SELECT
163                        p.id, p.author_id, p.title, p.slug, p.summary, p.status,
164                        p.published_at, p.created_at, p.updated_at, p.cover_image,
165                        p.word_count, p.reading_time,
166                        COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
167                     FROM posts p
168                     LEFT JOIN post_tags pt ON p.id = pt.post_id
169                     LEFT JOIN tags t ON pt.tag_id = t.id
170                     WHERE p.deleted_at IS NULL AND p.title ILIKE $3 ESCAPE '\\'
171                     GROUP BY p.id
172                     ORDER BY p.created_at DESC
173                     LIMIT $1 OFFSET $2",
174                    &[&limit, &offset, &pattern],
175                )
176                .await
177                .map_err(AppError::query)?;
178            (total, rows)
179        } else {
180            let total: i64 = client
181                .query_one("SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL", &[])
182                .await
183                .map_err(AppError::query)?
184                .get(0);
185            let rows = client
186                .query(
187                    "SELECT
188                        p.id, p.author_id, p.title, p.slug, p.summary, p.status,
189                        p.published_at, p.created_at, p.updated_at, p.cover_image,
190                        p.word_count, p.reading_time,
191                        COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
192                     FROM posts p
193                     LEFT JOIN post_tags pt ON p.id = pt.post_id
194                     LEFT JOIN tags t ON pt.tag_id = t.id
195                     WHERE p.deleted_at IS NULL
196                     GROUP BY p.id
197                     ORDER BY p.created_at DESC
198                     LIMIT $1 OFFSET $2",
199                    &[&limit, &offset],
200                )
201                .await
202                .map_err(AppError::query)?;
203            (total, rows)
204        };
205
206        let posts: Vec<_> = rows.iter().map(row_to_post_list_item).collect();
207
208        Ok(PostListResponse { posts, total })
209    }
210
211    #[cfg(not(feature = "server"))]
212    {
213        Ok(PostListResponse {
214            posts: Vec::new(),
215            total: 0,
216        })
217    }
218}
219
220/// 获取回收站中已软删除的文章列表。
221///
222/// 需要 admin 权限;按删除时间降序,不走缓存。
223#[server(ListDeletedPosts, "/api")]
224pub async fn list_deleted_posts(
225    page: i32,
226    per_page: i32,
227) -> Result<PostListResponse, ServerFnError> {
228    // 与 list_posts 一致的分页钳制。
229    let (page, per_page) = clamp_pagination(page, per_page);
230    let _user = get_current_admin_user().await?;
231
232    #[cfg(feature = "server")]
233    {
234        let client = get_conn().await.map_err(AppError::db_conn)?;
235
236        let count_row = client
237            .query_one(
238                "SELECT COUNT(*) FROM posts WHERE deleted_at IS NOT NULL",
239                &[],
240            )
241            .await
242            .map_err(AppError::query)?;
243        let total: i64 = count_row.get(0);
244
245        let offset = ((page - 1).max(0) as i64) * (per_page as i64);
246        let limit = per_page as i64;
247        let rows = client
248            .query(
249                "SELECT
250                    p.id, p.author_id, p.title, p.slug, p.summary, p.status,
251                    p.published_at, p.created_at, p.updated_at, p.cover_image, p.deleted_at,
252                    p.word_count, p.reading_time,
253                    COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
254                 FROM posts p
255                 LEFT JOIN post_tags pt ON p.id = pt.post_id
256                 LEFT JOIN tags t ON pt.tag_id = t.id
257                 WHERE p.deleted_at IS NOT NULL
258                 GROUP BY p.id
259                 ORDER BY p.deleted_at DESC
260                 LIMIT $1 OFFSET $2",
261                &[&limit, &offset],
262            )
263            .await
264            .map_err(AppError::query)?;
265
266        let posts: Vec<_> = rows.iter().map(row_to_post_list_item).collect();
267
268        Ok(PostListResponse { posts, total })
269    }
270
271    #[cfg(not(feature = "server"))]
272    {
273        Ok(PostListResponse {
274            posts: Vec::new(),
275            total: 0,
276        })
277    }
278}
279
280/// 获取指定标签下的全部已发布文章(上限 200),用于无分页 UI 的标签详情页。
281/// 结果缓存于按标签的键空间。
282#[server(GetPostsByTag, "/api")]
283pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, ServerFnError> {
284    #[cfg(feature = "server")]
285    {
286        let client = get_conn().await.map_err(AppError::db_conn)?;
287
288        if let Some((cached_posts, cached_total)) = crate::cache::get_posts_by_tag(&tag_name).await
289        {
290            return Ok(PostListResponse {
291                posts: cached_posts,
292                total: cached_total,
293            });
294        }
295
296        // 真实总数(即使被 LIMIT 截断也返回完整计数)。
297        let total: i64 = client
298            .query_one(
299                "SELECT COUNT(*) FROM posts p
300                 JOIN post_tags pt ON p.id = pt.post_id
301                 JOIN tags t ON pt.tag_id = t.id
302                 WHERE t.name = $1 AND p.status = 'published' AND p.deleted_at IS NULL",
303                &[&tag_name],
304            )
305            .await
306            .map_err(AppError::query)?
307            .get(0);
308
309        let rows = client
310            .query(
311                "SELECT
312                    p.id, p.author_id, p.title, p.slug, p.summary, p.status,
313                    p.published_at, p.created_at, p.updated_at, p.cover_image,
314                    p.word_count, p.reading_time,
315                    COALESCE(array_agg(t2.name) FILTER (WHERE t2.name IS NOT NULL), '{}') as tags
316                 FROM posts p
317                 JOIN post_tags pt ON p.id = pt.post_id
318                 JOIN tags t ON pt.tag_id = t.id
319                 LEFT JOIN post_tags pt2 ON p.id = pt2.post_id
320                 LEFT JOIN tags t2 ON pt2.tag_id = t2.id
321                 WHERE t.name = $1 AND p.status = 'published' AND p.deleted_at IS NULL
322                 GROUP BY p.id
323                 ORDER BY p.published_at DESC
324                 LIMIT 200",
325                &[&tag_name],
326            )
327            .await
328            .map_err(AppError::query)?;
329
330        let posts: Vec<_> = rows.iter().map(row_to_post_list_item).collect();
331
332        // total 为真实 COUNT(*),不再用 posts.len()。
333        crate::cache::set_posts_by_tag(&tag_name, posts.clone(), total).await;
334        Ok(PostListResponse { posts, total })
335    }
336
337    #[cfg(not(feature = "server"))]
338    {
339        Ok(PostListResponse {
340            posts: Vec::new(),
341            total: 0,
342        })
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn clamp_pagination_keeps_valid_values() {
352        assert_eq!(clamp_pagination(1, 10), (1, 10));
353        assert_eq!(clamp_pagination(3, 20), (3, 20));
354    }
355
356    #[test]
357    fn clamp_pagination_clamps_oversized_per_page() {
358        // 攻击者传入超大 per_page 必须被压回上限,避免内存放大 / DoS。
359        assert_eq!(clamp_pagination(1, 1_000_000_000), (1, MAX_PER_PAGE));
360        assert_eq!(clamp_pagination(2, 51), (2, MAX_PER_PAGE));
361    }
362
363    #[test]
364    fn clamp_pagination_clamps_non_positive() {
365        assert_eq!(clamp_pagination(0, 10), (1, 10));
366        assert_eq!(clamp_pagination(-5, 10), (1, 10));
367        assert_eq!(clamp_pagination(1, 0), (1, 1));
368        assert_eq!(clamp_pagination(1, -100), (1, 1));
369    }
370
371    #[test]
372    fn clamp_pagination_clamps_oversized_page() {
373        // 巨大 page 必须被压回上限,避免无界 OFFSET 扫描与缓存键扇出。
374        assert_eq!(clamp_pagination(i32::MAX, 10), (MAX_PAGE, 10));
375        assert_eq!(clamp_pagination(MAX_PAGE + 1, 10), (MAX_PAGE, 10));
376    }
377
378    #[test]
379    fn clamp_pagination_max_page_boundary() {
380        assert_eq!(clamp_pagination(MAX_PAGE, 10), (MAX_PAGE, 10));
381        assert_eq!(clamp_pagination(MAX_PAGE - 1, 10), (MAX_PAGE - 1, 10));
382    }
383
384    #[test]
385    fn clamp_pagination_max_per_page_boundary() {
386        assert_eq!(clamp_pagination(1, MAX_PER_PAGE), (1, MAX_PER_PAGE));
387        assert_eq!(clamp_pagination(1, MAX_PER_PAGE - 1), (1, MAX_PER_PAGE - 1));
388    }
389}