Skip to main content

yggdrasil/api/
feed.rs

1//! RSS 2.0 与 JSON Feed 1.1 订阅端点。
2//!
3//! 提供两个无中间件的公开读端点,挂载在 `static_routes` 上:
4//! - `GET /feed.xml` — RSS 2.0(`application/rss+xml`)
5//! - `GET /feed.json` — JSON Feed 1.1(`application/feed+json`)
6//!
7//! 输出最近 [`FEED_ITEM_LIMIT`] 篇已发布文章(含保存时已渲染的全文 `content_html`),
8//! 数据经 moka 单键缓存(`CacheKey::Feed`,TTL 600s),文章写路径统一失效。
9//! 渲染函数均为纯函数并接受 `now`/`base` 参数注入,便于单元测试固定输出。
10//!
11//! 仅在 `server` feature 启用时编译。
12
13#![cfg(feature = "server")]
14
15use axum::{
16    http::{header, HeaderMap, HeaderValue, StatusCode},
17    response::{IntoResponse, Response},
18};
19use chrono::{DateTime, Utc};
20use serde_json::{json, Map, Value};
21
22use crate::api::error::AppError;
23use crate::db::pool::get_conn;
24use crate::models::post::FeedItem;
25
26/// Feed channel 标题。站点无标题配置,与首页 `HomeInfo` 硬编码保持一致。
27const CHANNEL_TITLE: &str = "Yggdrasil";
28
29/// Feed channel 描述。与首页 `HomeInfo` 副标题保持一致。
30const CHANNEL_DESCRIPTION: &str = "极简、快速、现代。专注于文字本身的开源博客平台。";
31
32/// Feed 语言。
33const FEED_LANGUAGE: &str = "zh-CN";
34
35/// Feed 输出文章条数上限。
36const FEED_ITEM_LIMIT: i64 = 20;
37
38/// RSS Content-Type(标准注册类型,带 charset 便于阅读器正确解码中文)。
39const RSS_CONTENT_TYPE: &str = "application/rss+xml; charset=utf-8";
40
41/// JSON Feed Content-Type。
42const JSON_CONTENT_TYPE: &str = "application/feed+json; charset=utf-8";
43
44/// Feed 响应缓存头,与缓存层 TTL(600s)对齐。
45const FEED_CACHE_CONTROL: &str = "public, max-age=600";
46
47/// XML 文本转义:`& < > " '` 五个字符。
48///
49/// 全文 HTML 直接整体转义一次(而非 CDATA):`&` 先行替换避免二次转义,
50/// 阅读器解析后得到与页面一致的 HTML 源码。
51fn escape_xml(input: &str) -> String {
52    let mut out = String::with_capacity(input.len());
53    for c in input.chars() {
54        match c {
55            '&' => out.push_str("&amp;"),
56            '<' => out.push_str("&lt;"),
57            '>' => out.push_str("&gt;"),
58            '"' => out.push_str("&quot;"),
59            '\'' => out.push_str("&apos;"),
60            _ => out.push(c),
61        }
62    }
63    out
64}
65
66/// 推导站点绝对 URL 基址(无尾部斜杠)。
67///
68/// 回退链与 CSRF 的 `trusted_origin` 一致:`APP_BASE_URL` → `Host` 头(https 前缀)
69/// → 兜底 `http://localhost` 并告警。生产部署规范要求设置 `APP_BASE_URL`。
70fn site_base_url(headers: &HeaderMap) -> String {
71    if let Ok(base) = std::env::var("APP_BASE_URL") {
72        let base = base.trim();
73        if !base.is_empty() {
74            return base.trim_end_matches('/').to_string();
75        }
76    }
77    if let Some(host) = headers
78        .get(header::HOST)
79        .and_then(|h| h.to_str().ok())
80        .map(str::trim)
81        .filter(|h| !h.is_empty())
82    {
83        return format!("https://{}", host.trim_end_matches('/'));
84    }
85    tracing::warn!("feed: 未配置 APP_BASE_URL 且缺少 Host 头,回退 http://localhost");
86    "http://localhost".to_string()
87}
88
89/// 渲染 RSS 2.0 文档。`now` 注入以便测试固定 `lastBuildDate`。
90fn render_rss(base: &str, now: DateTime<Utc>, items: &[FeedItem]) -> String {
91    let mut xml = String::with_capacity(4096 + items.len() * 512);
92    xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
93    xml.push_str("<rss version=\"2.0\"><channel>");
94    xml.push_str("<title>");
95    xml.push_str(CHANNEL_TITLE);
96    xml.push_str("</title><link>");
97    xml.push_str(base);
98    xml.push_str("</link><description>");
99    xml.push_str(CHANNEL_DESCRIPTION);
100    xml.push_str("</description><language>");
101    xml.push_str(FEED_LANGUAGE);
102    xml.push_str("</language><lastBuildDate>");
103    xml.push_str(&now.to_rfc2822());
104    xml.push_str("</lastBuildDate>");
105    for item in items {
106        xml.push_str("<item><title>");
107        xml.push_str(&escape_xml(&item.title));
108        xml.push_str("</title><link>");
109        xml.push_str(base);
110        xml.push_str("/post/");
111        xml.push_str(&item.slug);
112        xml.push_str("</link><guid isPermaLink=\"true\">");
113        xml.push_str(base);
114        xml.push_str("/post/");
115        xml.push_str(&item.slug);
116        xml.push_str("</guid><pubDate>");
117        xml.push_str(&item.published_at.to_rfc2822());
118        xml.push_str("</pubDate>");
119        for tag in &item.tags {
120            xml.push_str("<category>");
121            xml.push_str(&escape_xml(tag));
122            xml.push_str("</category>");
123        }
124        if let Some(html) = &item.content_html {
125            xml.push_str("<description>");
126            xml.push_str(&escape_xml(html));
127            xml.push_str("</description>");
128        } else if let Some(summary) = &item.summary {
129            xml.push_str("<description>");
130            xml.push_str(&escape_xml(summary));
131            xml.push_str("</description>");
132        }
133        xml.push_str("</item>");
134    }
135    xml.push_str("</channel></rss>");
136    xml
137}
138
139/// 渲染 JSON Feed 1.1 文档(`serde_json` 直接序列化,零新依赖)。
140fn render_json(base: &str, items: &[FeedItem]) -> Result<String, serde_json::Error> {
141    let feed_items: Vec<Value> = items
142        .iter()
143        .map(|item| {
144            let url = format!("{base}/post/{}", item.slug);
145            let mut m = Map::new();
146            m.insert("id".to_string(), json!(url.clone()));
147            m.insert("url".to_string(), json!(url));
148            m.insert("title".to_string(), json!(item.title));
149            if let Some(html) = &item.content_html {
150                m.insert("content_html".to_string(), json!(html));
151            }
152            if let Some(summary) = &item.summary {
153                m.insert("summary".to_string(), json!(summary));
154            }
155            m.insert(
156                "date_published".to_string(),
157                json!(item.published_at.to_rfc3339()),
158            );
159            m.insert(
160                "date_modified".to_string(),
161                json!(item.updated_at.to_rfc3339()),
162            );
163            m.insert("tags".to_string(), json!(item.tags));
164            Value::Object(m)
165        })
166        .collect();
167    serde_json::to_string(&json!({
168        "version": "https://jsonfeed.org/version/1.1",
169        "title": CHANNEL_TITLE,
170        "home_page_url": base,
171        "feed_url": format!("{base}/feed.json"),
172        "language": FEED_LANGUAGE,
173        "items": feed_items,
174    }))
175}
176
177/// 将数据库行转换为 Feed 条目。
178///
179/// 与 `row_to_post_list_item` 同款聚合标签写法;`published_at` 对
180/// 空值回退 `updated_at`(published 状态理论上必有值)。
181fn row_to_feed_item(row: &tokio_postgres::Row) -> FeedItem {
182    let updated_at: DateTime<Utc> = row.get("updated_at");
183    let mut tags: Vec<String> = row.try_get::<_, Vec<String>>("tags").unwrap_or_default();
184    tags.retain(|t| !t.is_empty());
185    FeedItem {
186        title: row.get("title"),
187        slug: row.get("slug"),
188        summary: row.get("summary"),
189        content_html: row.get("content_html"),
190        published_at: row
191            .get::<_, Option<DateTime<Utc>>>("published_at")
192            .unwrap_or(updated_at),
193        updated_at,
194        tags,
195    }
196}
197
198/// 加载 Feed 条目:缓存命中直接返回,miss 则查询 DB 并回填缓存。
199async fn load_feed_items() -> Result<Vec<FeedItem>, AppError> {
200    if let Some(items) = crate::cache::get_feed().await {
201        return Ok(items);
202    }
203    let client = get_conn().await.map_err(AppError::db_conn)?;
204    let rows = client
205        .query(
206            "SELECT p.title, p.slug, p.summary, p.content_html, p.published_at, p.updated_at,
207                    COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') AS tags
208             FROM posts p
209             LEFT JOIN post_tags pt ON p.id = pt.post_id
210             LEFT JOIN tags t ON pt.tag_id = t.id
211             WHERE p.status = 'published' AND p.deleted_at IS NULL
212             GROUP BY p.id
213             ORDER BY p.published_at DESC
214             LIMIT $1",
215            &[&FEED_ITEM_LIMIT],
216        )
217        .await
218        .map_err(AppError::query)?;
219    let items: Vec<FeedItem> = rows.iter().map(row_to_feed_item).collect();
220    crate::cache::set_feed(items.clone()).await;
221    Ok(items)
222}
223
224/// `GET /feed.xml` — RSS 2.0 订阅源。
225pub async fn rss_feed(headers: HeaderMap) -> Response {
226    match load_feed_items().await {
227        Ok(items) => {
228            let base = site_base_url(&headers);
229            let body = render_rss(&base, Utc::now(), &items);
230            (
231                [
232                    (
233                        header::CONTENT_TYPE,
234                        HeaderValue::from_static(RSS_CONTENT_TYPE),
235                    ),
236                    (
237                        header::CACHE_CONTROL,
238                        HeaderValue::from_static(FEED_CACHE_CONTROL),
239                    ),
240                ],
241                body,
242            )
243                .into_response()
244        }
245        Err(_) => {
246            // DB 失败细节已由 AppError::db_conn/query 构造器记录完整链条。
247            tracing::error!("feed 生成失败");
248            (StatusCode::INTERNAL_SERVER_ERROR, "feed unavailable").into_response()
249        }
250    }
251}
252
253/// `GET /feed.json` — JSON Feed 1.1 订阅源。
254pub async fn json_feed(headers: HeaderMap) -> Response {
255    match load_feed_items().await {
256        Ok(items) => {
257            let base = site_base_url(&headers);
258            match render_json(&base, &items) {
259                Ok(body) => (
260                    [
261                        (
262                            header::CONTENT_TYPE,
263                            HeaderValue::from_static(JSON_CONTENT_TYPE),
264                        ),
265                        (
266                            header::CACHE_CONTROL,
267                            HeaderValue::from_static(FEED_CACHE_CONTROL),
268                        ),
269                    ],
270                    body,
271                )
272                    .into_response(),
273                Err(e) => {
274                    // serde_json 序列化失败属于代码 bug(结构固定),记录具体原因。
275                    tracing::error!("feed JSON 序列化失败: {e}");
276                    (StatusCode::INTERNAL_SERVER_ERROR, "feed unavailable").into_response()
277                }
278            }
279        }
280        Err(_) => {
281            // DB 失败细节已由 AppError::db_conn/query 构造器记录完整链条。
282            tracing::error!("feed 生成失败");
283            (StatusCode::INTERNAL_SERVER_ERROR, "feed unavailable").into_response()
284        }
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use serial_test::serial;
292
293    fn fixed_now() -> DateTime<Utc> {
294        DateTime::parse_from_rfc3339("2026-01-02T03:04:05Z")
295            .unwrap()
296            .with_timezone(&Utc)
297    }
298
299    fn fixture_item() -> FeedItem {
300        let ts = fixed_now();
301        FeedItem {
302            title: "A & B".to_string(),
303            slug: "my-post".to_string(),
304            summary: None,
305            content_html: Some("<p>hi</p>".to_string()),
306            published_at: ts,
307            updated_at: ts,
308            tags: vec!["Rust".to_string()],
309        }
310    }
311
312    #[test]
313    fn escape_xml_escapes_all_special_chars() {
314        assert_eq!(escape_xml("&<>\"'"), "&amp;&lt;&gt;&quot;&apos;");
315    }
316
317    #[test]
318    fn escape_xml_plain_text_unchanged() {
319        assert_eq!(escape_xml("plain text 123"), "plain text 123");
320    }
321
322    #[test]
323    fn render_rss_contains_escaped_fields() {
324        let xml = render_rss("https://example.com", fixed_now(), &[fixture_item()]);
325        assert!(
326            xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rss version=\"2.0\">")
327        );
328        assert!(xml.ends_with("</channel></rss>"));
329        assert!(xml.contains("<title>A &amp; B</title>"));
330        assert!(xml.contains("<link>https://example.com</link>"));
331        assert!(xml.contains("<guid isPermaLink=\"true\">https://example.com/post/my-post</guid>"));
332        assert!(xml.contains("<category>Rust</category>"));
333        assert!(xml.contains("<description>&lt;p&gt;hi&lt;/p&gt;</description>"));
334        assert!(xml.contains("<lastBuildDate>Fri, 2 Jan 2026 03:04:05 +0000</lastBuildDate>"));
335    }
336
337    #[test]
338    fn render_rss_falls_back_to_summary_and_omits_item_description() {
339        let mut item = fixture_item();
340        item.content_html = None;
341        item.summary = Some("摘要 & 简介".to_string());
342        let xml = render_rss("https://example.com", fixed_now(), &[item]);
343        assert!(xml.contains("<description>摘要 &amp; 简介</description>"));
344
345        let mut item2 = fixture_item();
346        item2.content_html = None;
347        item2.summary = None;
348        let xml2 = render_rss("https://example.com", fixed_now(), &[item2]);
349        // 仅 channel 级 description(站点简介)保留,item 级省略。
350        assert_eq!(xml2.matches("<description>").count(), 1);
351    }
352
353    #[test]
354    fn render_json_roundtrips() {
355        let out = render_json("https://example.com", &[fixture_item()]).unwrap();
356        let v: Value = serde_json::from_str(&out).unwrap();
357        assert_eq!(v["version"], "https://jsonfeed.org/version/1.1");
358        assert_eq!(v["title"], "Yggdrasil");
359        assert_eq!(v["home_page_url"], "https://example.com");
360        assert_eq!(v["feed_url"], "https://example.com/feed.json");
361        assert_eq!(v["language"], "zh-CN");
362        assert_eq!(v["items"][0]["id"], "https://example.com/post/my-post");
363        assert_eq!(v["items"][0]["url"], "https://example.com/post/my-post");
364        assert_eq!(v["items"][0]["title"], "A & B");
365        assert_eq!(v["items"][0]["content_html"], "<p>hi</p>");
366        assert_eq!(v["items"][0]["tags"][0], "Rust");
367        assert_eq!(v["items"][0]["date_published"], "2026-01-02T03:04:05+00:00");
368        assert!(v["items"][0].get("summary").is_none());
369    }
370
371    /// 保存/恢复 `APP_BASE_URL`,避免污染进程级环境(serial 保护并发测试)。
372    fn with_env_removed<R>(f: impl FnOnce() -> R) -> R {
373        let saved = std::env::var("APP_BASE_URL").ok();
374        std::env::remove_var("APP_BASE_URL");
375        let r = f();
376        match saved {
377            Some(v) => std::env::set_var("APP_BASE_URL", v),
378            None => std::env::remove_var("APP_BASE_URL"),
379        }
380        r
381    }
382
383    #[test]
384    #[serial]
385    fn site_base_url_prefers_env() {
386        let saved = std::env::var("APP_BASE_URL").ok();
387        std::env::set_var("APP_BASE_URL", "https://blog.example.com/");
388        let r = site_base_url(&HeaderMap::new());
389        match saved {
390            Some(v) => std::env::set_var("APP_BASE_URL", v),
391            None => std::env::remove_var("APP_BASE_URL"),
392        }
393        assert_eq!(r, "https://blog.example.com");
394    }
395
396    #[test]
397    #[serial]
398    fn site_base_url_falls_back_to_host() {
399        let mut headers = HeaderMap::new();
400        headers.insert(header::HOST, HeaderValue::from_static("blog.example.com"));
401        assert_eq!(
402            with_env_removed(|| site_base_url(&headers)),
403            "https://blog.example.com"
404        );
405    }
406}