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