Skip to main content

yggdrasil/api/posts/
list.rs

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