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/// 仅信任「站点配置 → 安全」面板中的 APP_BASE_URL;绝不使用请求 Host
69/// 生成公开 Feed 链接,避免 Host header poisoning。未配置时仅回退到
70/// `http://localhost`,生产环境必须配置固定的 APP_BASE_URL。
71async fn site_base_url(_headers: &HeaderMap) -> String {
72    let base = crate::api::settings::runtime_security_settings()
73        .await
74        .app_base_url;
75    let base = base.trim();
76    if !base.is_empty() {
77        return base.trim_end_matches('/').to_string();
78    }
79    tracing::warn!("APP_BASE_URL 未配置,RSS/Feed 链接回退到 localhost");
80    "http://localhost".to_string()
81}
82
83/// 渲染 RSS 2.0 文档。`now` 注入以便测试固定 `lastBuildDate`。
84fn render_rss(base: &str, now: DateTime<Utc>, items: &[FeedItem]) -> String {
85    let mut xml = String::with_capacity(4096 + items.len() * 512);
86    xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
87    xml.push_str("<rss version=\"2.0\"><channel>");
88    xml.push_str("<title>");
89    xml.push_str(CHANNEL_TITLE);
90    xml.push_str("</title><link>");
91    xml.push_str(base);
92    xml.push_str("</link><description>");
93    xml.push_str(CHANNEL_DESCRIPTION);
94    xml.push_str("</description><language>");
95    xml.push_str(FEED_LANGUAGE);
96    xml.push_str("</language><lastBuildDate>");
97    xml.push_str(&now.to_rfc2822());
98    xml.push_str("</lastBuildDate>");
99    for item in items {
100        xml.push_str("<item><title>");
101        xml.push_str(&escape_xml(&item.title));
102        xml.push_str("</title><link>");
103        xml.push_str(base);
104        xml.push_str("/post/");
105        xml.push_str(&item.slug);
106        xml.push_str("</link><guid isPermaLink=\"true\">");
107        xml.push_str(base);
108        xml.push_str("/post/");
109        xml.push_str(&item.slug);
110        xml.push_str("</guid><pubDate>");
111        xml.push_str(&item.published_at.to_rfc2822());
112        xml.push_str("</pubDate>");
113        for tag in &item.tags {
114            xml.push_str("<category>");
115            xml.push_str(&escape_xml(tag));
116            xml.push_str("</category>");
117        }
118        if let Some(html) = &item.content_html {
119            xml.push_str("<description>");
120            xml.push_str(&escape_xml(html));
121            xml.push_str("</description>");
122        } else if let Some(summary) = &item.summary {
123            xml.push_str("<description>");
124            xml.push_str(&escape_xml(summary));
125            xml.push_str("</description>");
126        }
127        xml.push_str("</item>");
128    }
129    xml.push_str("</channel></rss>");
130    xml
131}
132
133/// 渲染 JSON Feed 1.1 文档(`serde_json` 直接序列化,零新依赖)。
134fn render_json(base: &str, items: &[FeedItem]) -> Result<String, serde_json::Error> {
135    let feed_items: Vec<Value> = items
136        .iter()
137        .map(|item| {
138            let url = format!("{base}/post/{}", item.slug);
139            let mut m = Map::new();
140            m.insert("id".to_string(), json!(url.clone()));
141            m.insert("url".to_string(), json!(url));
142            m.insert("title".to_string(), json!(item.title));
143            if let Some(html) = &item.content_html {
144                m.insert("content_html".to_string(), json!(html));
145            }
146            if let Some(summary) = &item.summary {
147                m.insert("summary".to_string(), json!(summary));
148            }
149            m.insert(
150                "date_published".to_string(),
151                json!(item.published_at.to_rfc3339()),
152            );
153            m.insert(
154                "date_modified".to_string(),
155                json!(item.updated_at.to_rfc3339()),
156            );
157            m.insert("tags".to_string(), json!(item.tags));
158            Value::Object(m)
159        })
160        .collect();
161    serde_json::to_string(&json!({
162        "version": "https://jsonfeed.org/version/1.1",
163        "title": CHANNEL_TITLE,
164        "home_page_url": base,
165        "feed_url": format!("{base}/feed.json"),
166        "language": FEED_LANGUAGE,
167        "items": feed_items,
168    }))
169}
170
171/// 将数据库行转换为 Feed 条目。
172///
173/// 与 `row_to_post_list_item` 同款聚合标签写法;`published_at` 对
174/// 空值回退 `updated_at`(published 状态理论上必有值)。
175fn row_to_feed_item(row: &tokio_postgres::Row) -> FeedItem {
176    let updated_at: DateTime<Utc> = row.get("updated_at");
177    let mut tags: Vec<String> = row.try_get::<_, Vec<String>>("tags").unwrap_or_default();
178    tags.retain(|t| !t.is_empty());
179    FeedItem {
180        title: row.get("title"),
181        slug: row.get("slug"),
182        summary: row.get("summary"),
183        content_html: row.get("content_html"),
184        published_at: row
185            .get::<_, Option<DateTime<Utc>>>("published_at")
186            .unwrap_or(updated_at),
187        updated_at,
188        tags,
189    }
190}
191
192/// 加载 Feed 条目:缓存命中直接返回,miss 则查询 DB 并回填缓存。
193async fn load_feed_items() -> Result<Vec<FeedItem>, AppError> {
194    if let Some(items) = crate::cache::get_feed().await {
195        return Ok(items);
196    }
197    let client = get_conn().await.map_err(AppError::db_conn)?;
198    let rows = client
199        .query(
200            "SELECT p.title, p.slug, p.summary, p.content_html, p.published_at, p.updated_at,
201                    COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') AS tags
202             FROM posts p
203             LEFT JOIN post_tags pt ON p.id = pt.post_id
204             LEFT JOIN tags t ON pt.tag_id = t.id
205             WHERE p.status = 'published' AND p.deleted_at IS NULL
206             GROUP BY p.id
207             ORDER BY p.published_at DESC
208             LIMIT $1",
209            &[&FEED_ITEM_LIMIT],
210        )
211        .await
212        .map_err(AppError::query)?;
213    let items: Vec<FeedItem> = rows.iter().map(row_to_feed_item).collect();
214    crate::cache::set_feed(items.clone()).await;
215    Ok(items)
216}
217
218/// `GET /feed.xml` — RSS 2.0 订阅源。
219pub async fn rss_feed(headers: HeaderMap) -> Response {
220    match load_feed_items().await {
221        Ok(items) => {
222            let base = site_base_url(&headers).await;
223            let body = render_rss(&base, Utc::now(), &items);
224            (
225                [
226                    (
227                        header::CONTENT_TYPE,
228                        HeaderValue::from_static(RSS_CONTENT_TYPE),
229                    ),
230                    (
231                        header::CACHE_CONTROL,
232                        HeaderValue::from_static(FEED_CACHE_CONTROL),
233                    ),
234                ],
235                body,
236            )
237                .into_response()
238        }
239        Err(_) => {
240            // DB 失败细节已由 AppError::db_conn/query 构造器记录完整链条。
241            tracing::error!("feed 生成失败");
242            (StatusCode::INTERNAL_SERVER_ERROR, "feed unavailable").into_response()
243        }
244    }
245}
246
247/// `GET /feed.json` — JSON Feed 1.1 订阅源。
248pub async fn json_feed(headers: HeaderMap) -> Response {
249    match load_feed_items().await {
250        Ok(items) => {
251            let base = site_base_url(&headers).await;
252            match render_json(&base, &items) {
253                Ok(body) => (
254                    [
255                        (
256                            header::CONTENT_TYPE,
257                            HeaderValue::from_static(JSON_CONTENT_TYPE),
258                        ),
259                        (
260                            header::CACHE_CONTROL,
261                            HeaderValue::from_static(FEED_CACHE_CONTROL),
262                        ),
263                    ],
264                    body,
265                )
266                    .into_response(),
267                Err(e) => {
268                    // serde_json 序列化失败属于代码 bug(结构固定),记录具体原因。
269                    tracing::error!("feed JSON 序列化失败: {e}");
270                    (StatusCode::INTERNAL_SERVER_ERROR, "feed unavailable").into_response()
271                }
272            }
273        }
274        Err(_) => {
275            // DB 失败细节已由 AppError::db_conn/query 构造器记录完整链条。
276            tracing::error!("feed 生成失败");
277            (StatusCode::INTERNAL_SERVER_ERROR, "feed unavailable").into_response()
278        }
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use serial_test::serial;
286
287    fn fixed_now() -> DateTime<Utc> {
288        DateTime::parse_from_rfc3339("2026-01-02T03:04:05Z")
289            .unwrap()
290            .with_timezone(&Utc)
291    }
292
293    fn fixture_item() -> FeedItem {
294        let ts = fixed_now();
295        FeedItem {
296            title: "A & B".to_string(),
297            slug: "my-post".to_string(),
298            summary: None,
299            content_html: Some("<p>hi</p>".to_string()),
300            published_at: ts,
301            updated_at: ts,
302            tags: vec!["Rust".to_string()],
303        }
304    }
305
306    #[test]
307    fn escape_xml_escapes_all_special_chars() {
308        assert_eq!(escape_xml("&<>\"'"), "&amp;&lt;&gt;&quot;&apos;");
309    }
310
311    #[test]
312    fn escape_xml_plain_text_unchanged() {
313        assert_eq!(escape_xml("plain text 123"), "plain text 123");
314    }
315
316    #[test]
317    fn render_rss_contains_escaped_fields() {
318        let xml = render_rss("https://example.com", fixed_now(), &[fixture_item()]);
319        assert!(
320            xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rss version=\"2.0\">")
321        );
322        assert!(xml.ends_with("</channel></rss>"));
323        assert!(xml.contains("<title>A &amp; B</title>"));
324        assert!(xml.contains("<link>https://example.com</link>"));
325        assert!(xml.contains("<guid isPermaLink=\"true\">https://example.com/post/my-post</guid>"));
326        assert!(xml.contains("<category>Rust</category>"));
327        assert!(xml.contains("<description>&lt;p&gt;hi&lt;/p&gt;</description>"));
328        assert!(xml.contains("<lastBuildDate>Fri, 2 Jan 2026 03:04:05 +0000</lastBuildDate>"));
329    }
330
331    #[test]
332    fn render_rss_falls_back_to_summary_and_omits_item_description() {
333        let mut item = fixture_item();
334        item.content_html = None;
335        item.summary = Some("摘要 & 简介".to_string());
336        let xml = render_rss("https://example.com", fixed_now(), &[item]);
337        assert!(xml.contains("<description>摘要 &amp; 简介</description>"));
338
339        let mut item2 = fixture_item();
340        item2.content_html = None;
341        item2.summary = None;
342        let xml2 = render_rss("https://example.com", fixed_now(), &[item2]);
343        // 仅 channel 级 description(站点简介)保留,item 级省略。
344        assert_eq!(xml2.matches("<description>").count(), 1);
345    }
346
347    #[test]
348    fn render_json_roundtrips() {
349        let out = render_json("https://example.com", &[fixture_item()]).unwrap();
350        let v: Value = serde_json::from_str(&out).unwrap();
351        assert_eq!(v["version"], "https://jsonfeed.org/version/1.1");
352        assert_eq!(v["title"], "Yggdrasil");
353        assert_eq!(v["home_page_url"], "https://example.com");
354        assert_eq!(v["feed_url"], "https://example.com/feed.json");
355        assert_eq!(v["language"], "zh-CN");
356        assert_eq!(v["items"][0]["id"], "https://example.com/post/my-post");
357        assert_eq!(v["items"][0]["url"], "https://example.com/post/my-post");
358        assert_eq!(v["items"][0]["title"], "A & B");
359        assert_eq!(v["items"][0]["content_html"], "<p>hi</p>");
360        assert_eq!(v["items"][0]["tags"][0], "Rust");
361        assert_eq!(v["items"][0]["date_published"], "2026-01-02T03:04:05+00:00");
362        assert!(v["items"][0].get("summary").is_none());
363    }
364
365    #[tokio::test]
366    #[serial]
367    async fn site_base_url_ignores_untrusted_host_when_no_settings() {
368        // 未配置固定 APP_BASE_URL 时,不能使用请求 Host 生成公开链接。
369        let mut headers = HeaderMap::new();
370        headers.insert(header::HOST, HeaderValue::from_static("attacker.example"));
371        let r = site_base_url(&headers).await;
372        assert_eq!(r, "http://localhost");
373    }
374
375    #[tokio::test]
376    #[serial]
377    async fn site_base_url_falls_back_to_localhost_when_nothing() {
378        // 既无配置也无 Host 头 → 兜底 localhost 并告警。
379        let r = site_base_url(&HeaderMap::new()).await;
380        assert_eq!(r, "http://localhost");
381    }
382}