Skip to main content

yggdrasil/api/
feed.rs

1//! RSS 2.0 与 JSON Feed 1.1 订阅端点。
2//!
3//! 提供两个公开读端点,挂载在 `feed_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//! 代码高亮标记)。20 篇全文有数 MB,渲染加压缩要两百多毫秒,所以缓存的是成品:两种格式的正文
9//! 连同各启用算法的压缩结果(`CacheKey::Feed`,TTL 600s,文章写路径统一失效),命中时按
10//! `Accept-Encoding` 直接返回,并支持 `If-None-Match`。
11//! 渲染函数均为纯函数并接受 `now`/`base` 参数注入,便于单元测试固定输出。
12//!
13//! 仅在 `server` feature 启用时编译。
14
15#![cfg(feature = "server")]
16
17use std::borrow::Cow;
18use std::sync::Arc;
19
20use axum::{
21    body::Body,
22    http::{header, HeaderMap, HeaderValue, StatusCode},
23    response::{IntoResponse, Response},
24};
25use bytes::Bytes;
26use chrono::{DateTime, Utc};
27use serde_json::{json, Map, Value};
28use sha2::{Digest, Sha256};
29
30use crate::api::error::AppError;
31use crate::api::markdown::mermaid::strip_dark_variants;
32use crate::compression::{self, CompressionAlgorithms, Effort, Encoding};
33use crate::db::pool::get_conn;
34use crate::models::post::FeedItem;
35use crate::utils::html::escape_html;
36
37/// Feed channel 标题。站点无标题配置,与首页 `HomeInfo` 硬编码保持一致。
38const CHANNEL_TITLE: &str = "Yggdrasil";
39
40/// Feed channel 描述。与首页 `HomeInfo` 副标题保持一致。
41const CHANNEL_DESCRIPTION: &str = "极简、快速、现代。专注于文字本身的开源博客平台。";
42
43/// Feed 语言。
44const FEED_LANGUAGE: &str = "zh-CN";
45
46/// Feed 输出文章条数上限。
47const FEED_ITEM_LIMIT: i64 = 20;
48
49/// RSS Content-Type(标准注册类型,带 charset 便于阅读器正确解码中文)。
50const RSS_CONTENT_TYPE: &str = "application/rss+xml; charset=utf-8";
51
52/// JSON Feed Content-Type。
53const JSON_CONTENT_TYPE: &str = "application/feed+json; charset=utf-8";
54
55/// Feed 响应缓存头,与缓存层 TTL(600s)对齐。
56const FEED_CACHE_CONTROL: &str = "public, max-age=600";
57
58/// 推导站点绝对 URL 基址(无尾部斜杠)。
59///
60/// 仅信任「站点配置 → 安全」面板中的 APP_BASE_URL;绝不使用请求 Host
61/// 生成公开 Feed 链接,避免 Host header poisoning。未配置时仅回退到
62/// `http://localhost`,生产环境必须配置固定的 APP_BASE_URL。
63async fn site_base_url(_headers: &HeaderMap) -> String {
64    let base = crate::api::settings::runtime_security_settings()
65        .await
66        .app_base_url;
67    let base = base.trim();
68    if !base.is_empty() {
69        return base.trim_end_matches('/').to_string();
70    }
71    tracing::warn!("APP_BASE_URL 未配置,RSS/Feed 链接回退到 localhost");
72    "http://localhost".to_string()
73}
74
75/// 渲染 RSS 2.0 文档。`now` 注入以便测试固定 `lastBuildDate`。
76///
77/// 文本字段经 [`escape_html`] 转义(`& < > " '`;`'` 输出数字字符引用 `&#x27;`,
78/// 在 XML 中与 `&apos;` 等价)。全文 HTML 直接整体转义一次(而非 CDATA),
79/// 阅读器解析后得到与页面一致的 HTML 源码。
80fn render_rss(base: &str, now: DateTime<Utc>, items: &[FeedItem]) -> String {
81    let mut xml = String::with_capacity(4096 + items.len() * 512);
82    xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
83    xml.push_str("<rss version=\"2.0\"><channel>");
84    xml.push_str("<title>");
85    xml.push_str(CHANNEL_TITLE);
86    xml.push_str("</title><link>");
87    xml.push_str(base);
88    xml.push_str("</link><description>");
89    xml.push_str(CHANNEL_DESCRIPTION);
90    xml.push_str("</description><language>");
91    xml.push_str(FEED_LANGUAGE);
92    xml.push_str("</language><lastBuildDate>");
93    xml.push_str(&now.to_rfc2822());
94    xml.push_str("</lastBuildDate>");
95    for item in items {
96        xml.push_str("<item><title>");
97        xml.push_str(&escape_html(&item.title));
98        xml.push_str("</title><link>");
99        xml.push_str(base);
100        xml.push_str("/post/");
101        xml.push_str(&item.slug);
102        xml.push_str("</link><guid isPermaLink=\"true\">");
103        xml.push_str(base);
104        xml.push_str("/post/");
105        xml.push_str(&item.slug);
106        xml.push_str("</guid><pubDate>");
107        xml.push_str(&item.published_at.to_rfc2822());
108        xml.push_str("</pubDate>");
109        for tag in &item.tags {
110            xml.push_str("<category>");
111            xml.push_str(&escape_html(tag));
112            xml.push_str("</category>");
113        }
114        if let Some(html) = &item.content_html {
115            xml.push_str("<description>");
116            xml.push_str(&escape_html(html));
117            xml.push_str("</description>");
118        } else if let Some(summary) = &item.summary {
119            xml.push_str("<description>");
120            xml.push_str(&escape_html(summary));
121            xml.push_str("</description>");
122        }
123        xml.push_str("</item>");
124    }
125    xml.push_str("</channel></rss>");
126    xml
127}
128
129/// 渲染 JSON Feed 1.1 文档(`serde_json` 直接序列化,零新依赖)。
130fn render_json(base: &str, items: &[FeedItem]) -> Result<String, serde_json::Error> {
131    let feed_items: Vec<Value> = items
132        .iter()
133        .map(|item| {
134            let url = format!("{base}/post/{}", item.slug);
135            let mut m = Map::new();
136            m.insert("id".to_string(), json!(url.clone()));
137            m.insert("url".to_string(), json!(url));
138            m.insert("title".to_string(), json!(item.title));
139            if let Some(html) = &item.content_html {
140                m.insert("content_html".to_string(), json!(html));
141            }
142            if let Some(summary) = &item.summary {
143                m.insert("summary".to_string(), json!(summary));
144            }
145            m.insert(
146                "date_published".to_string(),
147                json!(item.published_at.to_rfc3339()),
148            );
149            m.insert(
150                "date_modified".to_string(),
151                json!(item.updated_at.to_rfc3339()),
152            );
153            m.insert("tags".to_string(), json!(item.tags));
154            Value::Object(m)
155        })
156        .collect();
157    serde_json::to_string(&json!({
158        "version": "https://jsonfeed.org/version/1.1",
159        "title": CHANNEL_TITLE,
160        "home_page_url": base,
161        "feed_url": format!("{base}/feed.json"),
162        "language": FEED_LANGUAGE,
163        "items": feed_items,
164    }))
165}
166
167/// 将数据库行转换为 Feed 条目。
168///
169/// 与 `row_to_post_list_item` 同款聚合标签写法;`published_at` 对
170/// 空值回退 `updated_at`(published 状态理论上必有值)。
171fn row_to_feed_item(row: &tokio_postgres::Row) -> FeedItem {
172    let updated_at: DateTime<Utc> = row.get("updated_at");
173    let mut tags: Vec<String> = row.try_get::<_, Vec<String>>("tags").unwrap_or_default();
174    tags.retain(|t| !t.is_empty());
175    FeedItem {
176        title: row.get("title"),
177        slug: row.get("slug"),
178        summary: row.get("summary"),
179        // 阅读器没有站点 CSS:图表只留浅色一份,代码去掉高亮标记。
180        content_html: row.get::<_, Option<String>>("content_html").map(|html| {
181            let html = match strip_dark_variants(&html) {
182                Cow::Owned(stripped) => stripped,
183                Cow::Borrowed(_) => html,
184            };
185            match strip_code_highlight(&html) {
186                Cow::Owned(stripped) => stripped,
187                Cow::Borrowed(_) => html,
188            }
189        }),
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/// 删掉 `<pre>` 里的 `<span …>` / `</span>`,只留代码文本。高亮靠站点的 highlight.css 着色,
199/// 阅读器里这些标记不起作用,却占了 Feed 约三成体积(单个代码块可达数百 KB)。
200fn strip_code_highlight(html: &str) -> Cow<'_, str> {
201    const CLOSE: &str = "</pre>";
202    let is_tag = |rest: &str, name: &str| {
203        rest.strip_prefix(name)
204            .and_then(|after| after.bytes().next())
205            .is_some_and(|b| b == b'>' || b.is_ascii_whitespace())
206    };
207    let mut out = String::new();
208    let mut rest = html;
209    let mut changed = false;
210    while let Some(start) = rest.find("<pre") {
211        if !is_tag(&rest[start..], "<pre") {
212            out.push_str(&rest[..start + 4]);
213            rest = &rest[start + 4..];
214            continue;
215        }
216        let Some(len) = rest[start..].find(CLOSE) else {
217            break;
218        };
219        out.push_str(&rest[..start]);
220        let mut block = &rest[start..start + len];
221        while let Some(at) = block.find('<') {
222            out.push_str(&block[..at]);
223            let tag = &block[at..];
224            let skip = if tag.starts_with("</span>") {
225                Some("</span>".len())
226            } else if is_tag(tag, "<span") {
227                tag.find('>').map(|end| end + 1)
228            } else {
229                None
230            };
231            match skip {
232                Some(skip) => {
233                    changed = true;
234                    block = &tag[skip..];
235                }
236                None => {
237                    out.push('<');
238                    block = &tag[1..];
239                }
240            }
241        }
242        out.push_str(block);
243        rest = &rest[start + len..];
244    }
245    if !changed {
246        return Cow::Borrowed(html);
247    }
248    out.push_str(rest);
249    Cow::Owned(out)
250}
251
252/// 一种格式的成品:正文、各启用算法的压缩结果与 ETag。
253struct FeedDocument {
254    body: Bytes,
255    encoded: Vec<(Encoding, Bytes)>,
256    etag: HeaderValue,
257}
258
259impl FeedDocument {
260    /// 压缩取动态档(brotli 7):成品每 10 分钟或每次文章写入后重建一次,brotli 11 压数 MB
261    /// 要好几秒,只小约 8%,不值。
262    fn new(body: String, algorithms: Option<CompressionAlgorithms>) -> Self {
263        let encoded = algorithms
264            .into_iter()
265            .flat_map(CompressionAlgorithms::encodings)
266            .filter_map(|encoding| {
267                match compression::encode(encoding, Effort::Dynamic, body.as_bytes())? {
268                    Ok(bytes) => Some((encoding, Bytes::from(bytes))),
269                    Err(error) => {
270                        tracing::warn!(%error, "feed 预压缩失败,该编码改由压缩层现压");
271                        None
272                    }
273                }
274            })
275            .collect();
276        let etag = HeaderValue::from_str(&format!("\"{}\"", hex::encode(Sha256::digest(&body))))
277            .expect("SHA256 is an ASCII entity tag");
278        Self {
279            body: Bytes::from(body),
280            encoded,
281            etag,
282        }
283    }
284
285    /// 协商出的编码有预压缩结果时直接返回;否则给出原文,由外层压缩层现压或回 406。
286    /// 启用压缩时一律带 `Vary: Accept-Encoding`(含 304)。
287    fn response(
288        &self,
289        content_type: &'static str,
290        request: &HeaderMap,
291        algorithms: Option<CompressionAlgorithms>,
292    ) -> Response {
293        let unchanged = request
294            .get(header::IF_NONE_MATCH)
295            .and_then(|h| h.to_str().ok())
296            .is_some_and(|value| {
297                value.split(',').any(|tag| {
298                    let tag = tag.trim().strip_prefix("W/").unwrap_or(tag.trim());
299                    tag == "*" || tag == self.etag.to_str().unwrap_or("")
300                })
301            });
302        let variant = algorithms
303            .and_then(|algorithms| compression::negotiate(request, algorithms))
304            .and_then(|encoding| self.encoded.iter().find(|(e, _)| *e == encoding))
305            .filter(|_| !unchanged);
306        let mut response = Response::new(match (variant, unchanged) {
307            (_, true) => Body::empty(),
308            (Some((_, bytes)), false) => Body::from(bytes.clone()),
309            (None, false) => Body::from(self.body.clone()),
310        });
311        if unchanged {
312            *response.status_mut() = StatusCode::NOT_MODIFIED;
313        }
314        let headers = response.headers_mut();
315        headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
316        headers.insert(
317            header::CACHE_CONTROL,
318            HeaderValue::from_static(FEED_CACHE_CONTROL),
319        );
320        headers.insert(header::ETAG, self.etag.clone());
321        if let Some((encoding, _)) = variant {
322            headers.insert(header::CONTENT_ENCODING, encoding.header_value());
323        }
324        if algorithms.is_some() {
325            headers.insert(header::VARY, HeaderValue::from_static("accept-encoding"));
326        }
327        response
328    }
329}
330
331/// 渲染好的两份 Feed 文档,整体放进 `CacheKey::Feed`。
332pub struct RenderedFeed {
333    /// 渲染时用的站点基址;面板改了 APP_BASE_URL 后据此判定成品过期。
334    base: String,
335    rss: FeedDocument,
336    json: FeedDocument,
337}
338
339/// 渲染并压缩两种格式。`lastBuildDate` 取最近一篇的更新时间而不是当前时间:内容没变时
340/// 重建出的正文与 ETag 也不变,阅读器的条件请求能拿到 304。
341fn build_feed(
342    base: String,
343    items: &[FeedItem],
344    algorithms: Option<CompressionAlgorithms>,
345) -> Result<RenderedFeed, serde_json::Error> {
346    let built = items
347        .iter()
348        .map(|item| item.updated_at)
349        .max()
350        .unwrap_or_else(Utc::now);
351    let rss = FeedDocument::new(render_rss(&base, built, items), algorithms);
352    let json = FeedDocument::new(render_json(&base, items)?, algorithms);
353    Ok(RenderedFeed { base, rss, json })
354}
355
356/// 同一时刻只重建一份成品:缓存失效后并发到达的请求排队等第一个建完。
357static BUILD_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
358
359/// 取 Feed 成品:缓存命中直接返回,未命中则查库、在阻塞线程池渲染压缩并回填缓存。
360async fn load_feed(headers: &HeaderMap) -> Result<Arc<RenderedFeed>, AppError> {
361    let base = site_base_url(headers).await;
362    let cached = |feed: Option<Arc<RenderedFeed>>| feed.filter(|feed| feed.base == base);
363    if let Some(feed) = cached(crate::cache::get_feed().await) {
364        return Ok(feed);
365    }
366    let _guard = BUILD_LOCK.lock().await;
367    if let Some(feed) = cached(crate::cache::get_feed().await) {
368        return Ok(feed);
369    }
370    let client = get_conn().await.map_err(AppError::db_conn)?;
371    let rows = client
372        .query(
373            "SELECT p.title, p.slug, p.summary, p.content_html, p.published_at, p.updated_at,
374                    COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') AS tags
375             FROM posts p
376             LEFT JOIN post_tags pt ON p.id = pt.post_id
377             LEFT JOIN tags t ON pt.tag_id = t.id
378             WHERE p.status = 'published' AND p.deleted_at IS NULL
379             GROUP BY p.id
380             ORDER BY p.published_at DESC
381             LIMIT $1",
382            &[&FEED_ITEM_LIMIT],
383        )
384        .await
385        .map_err(AppError::query)?;
386    drop(client);
387    let algorithms = compression::algorithms();
388    let feed = tokio::task::spawn_blocking(move || {
389        let items: Vec<FeedItem> = rows.iter().map(row_to_feed_item).collect();
390        build_feed(base, &items, algorithms)
391    })
392    .await
393    .map_err(|error| {
394        tracing::error!(%error, "feed 渲染任务异常退出");
395        AppError::Internal("feed 生成失败")
396    })?
397    .map_err(|error| {
398        // serde_json 序列化失败属于代码 bug(结构固定),记录具体原因。
399        tracing::error!(%error, "feed JSON 序列化失败");
400        AppError::Internal("feed 生成失败")
401    })?;
402    let feed = Arc::new(feed);
403    crate::cache::set_feed(feed.clone()).await;
404    Ok(feed)
405}
406
407async fn serve(headers: HeaderMap, json: bool) -> Response {
408    match load_feed(&headers).await {
409        Ok(feed) => {
410            let (document, content_type) = if json {
411                (&feed.json, JSON_CONTENT_TYPE)
412            } else {
413                (&feed.rss, RSS_CONTENT_TYPE)
414            };
415            document.response(content_type, &headers, compression::algorithms())
416        }
417        Err(_) => {
418            // 失败细节已由 AppError 构造器或上面的日志记录完整链条。
419            tracing::error!("feed 生成失败");
420            (StatusCode::INTERNAL_SERVER_ERROR, "feed unavailable").into_response()
421        }
422    }
423}
424
425/// `GET /feed.xml` — RSS 2.0 订阅源。
426pub async fn rss_feed(headers: HeaderMap) -> Response {
427    serve(headers, false).await
428}
429
430/// `GET /feed.json` — JSON Feed 1.1 订阅源。
431pub async fn json_feed(headers: HeaderMap) -> Response {
432    serve(headers, true).await
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use serial_test::serial;
439
440    fn fixed_now() -> DateTime<Utc> {
441        DateTime::parse_from_rfc3339("2026-01-02T03:04:05Z")
442            .unwrap()
443            .with_timezone(&Utc)
444    }
445
446    fn fixture_item() -> FeedItem {
447        let ts = fixed_now();
448        FeedItem {
449            title: "A & B".to_string(),
450            slug: "my-post".to_string(),
451            summary: None,
452            content_html: Some("<p>hi</p>".to_string()),
453            published_at: ts,
454            updated_at: ts,
455            tags: vec!["Rust".to_string()],
456        }
457    }
458
459    #[test]
460    fn render_rss_escapes_apostrophe_as_numeric_reference() {
461        let mut item = fixture_item();
462        item.title = "It's <ok>".to_string();
463        let xml = render_rss("https://example.com", fixed_now(), &[item]);
464        assert!(xml.contains("<title>It&#x27;s &lt;ok&gt;</title>"));
465    }
466
467    #[test]
468    fn render_rss_contains_escaped_fields() {
469        let xml = render_rss("https://example.com", fixed_now(), &[fixture_item()]);
470        assert!(
471            xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rss version=\"2.0\">")
472        );
473        assert!(xml.ends_with("</channel></rss>"));
474        assert!(xml.contains("<title>A &amp; B</title>"));
475        assert!(xml.contains("<link>https://example.com</link>"));
476        assert!(xml.contains("<guid isPermaLink=\"true\">https://example.com/post/my-post</guid>"));
477        assert!(xml.contains("<category>Rust</category>"));
478        assert!(xml.contains("<description>&lt;p&gt;hi&lt;/p&gt;</description>"));
479        assert!(xml.contains("<lastBuildDate>Fri, 2 Jan 2026 03:04:05 +0000</lastBuildDate>"));
480    }
481
482    #[test]
483    fn render_rss_falls_back_to_summary_and_omits_item_description() {
484        let mut item = fixture_item();
485        item.content_html = None;
486        item.summary = Some("摘要 & 简介".to_string());
487        let xml = render_rss("https://example.com", fixed_now(), &[item]);
488        assert!(xml.contains("<description>摘要 &amp; 简介</description>"));
489
490        let mut item2 = fixture_item();
491        item2.content_html = None;
492        item2.summary = None;
493        let xml2 = render_rss("https://example.com", fixed_now(), &[item2]);
494        // 仅 channel 级 description(站点简介)保留,item 级省略。
495        assert_eq!(xml2.matches("<description>").count(), 1);
496    }
497
498    #[test]
499    fn render_json_roundtrips() {
500        let out = render_json("https://example.com", &[fixture_item()]).unwrap();
501        let v: Value = serde_json::from_str(&out).unwrap();
502        assert_eq!(v["version"], "https://jsonfeed.org/version/1.1");
503        assert_eq!(v["title"], "Yggdrasil");
504        assert_eq!(v["home_page_url"], "https://example.com");
505        assert_eq!(v["feed_url"], "https://example.com/feed.json");
506        assert_eq!(v["language"], "zh-CN");
507        assert_eq!(v["items"][0]["id"], "https://example.com/post/my-post");
508        assert_eq!(v["items"][0]["url"], "https://example.com/post/my-post");
509        assert_eq!(v["items"][0]["title"], "A & B");
510        assert_eq!(v["items"][0]["content_html"], "<p>hi</p>");
511        assert_eq!(v["items"][0]["tags"][0], "Rust");
512        assert_eq!(v["items"][0]["date_published"], "2026-01-02T03:04:05+00:00");
513        assert!(v["items"][0].get("summary").is_none());
514    }
515
516    #[test]
517    fn strip_code_highlight_keeps_code_text_and_other_markup() {
518        let html = concat!(
519            r#"<p>前文 <span class="katex">x</span></p>"#,
520            r#"<pre><code class="language-rust"><span class="source rust">"#,
521            r#"<span class="storage type">fn</span> a&lt;T&gt;() {}"#,
522            "\n</span></code></pre>",
523            r#"<p><span>后文</span></p><pre>plain</pre>"#,
524        );
525        assert_eq!(
526            strip_code_highlight(html),
527            concat!(
528                r#"<p>前文 <span class="katex">x</span></p>"#,
529                r#"<pre><code class="language-rust">fn a&lt;T&gt;() {}"#,
530                "\n</code></pre>",
531                r#"<p><span>后文</span></p><pre>plain</pre>"#,
532            )
533        );
534        // 没有可删的标记时不重新分配;`<prefix>` 之类不是代码块。
535        for untouched in [
536            "<p><span>无代码</span></p>",
537            "<pre>plain</pre>",
538            "<prefix><span>x</span></prefix>",
539            "<pre><span>未闭合",
540        ] {
541            assert!(
542                matches!(strip_code_highlight(untouched), Cow::Borrowed(_)),
543                "{untouched}"
544            );
545        }
546    }
547
548    fn accept(encoding: &'static str) -> HeaderMap {
549        let mut headers = HeaderMap::new();
550        headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static(encoding));
551        headers
552    }
553
554    #[tokio::test]
555    async fn built_feed_serves_precompressed_variants_and_304() {
556        let mut item = fixture_item();
557        item.content_html = Some("<p>正文</p>".repeat(200));
558        let algorithms = compression::parse_compression_algorithms("br, gzip");
559        assert!(algorithms.is_some());
560        let feed = build_feed("https://example.com".into(), &[item.clone()], algorithms).unwrap();
561        assert!(std::str::from_utf8(&feed.rss.body)
562            .unwrap()
563            .contains("<lastBuildDate>Fri, 2 Jan 2026 03:04:05 +0000</lastBuildDate>"));
564
565        for (document, content_type) in [
566            (&feed.rss, RSS_CONTENT_TYPE),
567            (&feed.json, JSON_CONTENT_TYPE),
568        ] {
569            for (name, encoding) in [("br", Encoding::Brotli), ("gzip", Encoding::Gzip)] {
570                let response = document.response(content_type, &accept(name), algorithms);
571                assert_eq!(response.status(), StatusCode::OK);
572                assert_eq!(response.headers()[header::CONTENT_TYPE], content_type);
573                assert_eq!(response.headers()[header::CONTENT_ENCODING], name);
574                assert_eq!(response.headers()[header::VARY], "accept-encoding");
575                assert_eq!(
576                    response.headers()[header::CACHE_CONTROL],
577                    FEED_CACHE_CONTROL
578                );
579                let body = axum::body::to_bytes(response.into_body(), usize::MAX)
580                    .await
581                    .unwrap();
582                assert!(body.len() < document.body.len());
583                assert_eq!(compression::decode(encoding, &body), document.body);
584            }
585            // 没有可用变体(identity、未启用压缩)时原文交给外层。
586            let identity = document.response(content_type, &accept("identity"), algorithms);
587            assert!(!identity.headers().contains_key(header::CONTENT_ENCODING));
588            assert_eq!(identity.headers()[header::VARY], "accept-encoding");
589            let off = document.response(content_type, &accept("br"), None);
590            assert!(!off.headers().contains_key(header::CONTENT_ENCODING));
591            assert!(!off.headers().contains_key(header::VARY));
592
593            let mut conditional = accept("br");
594            conditional.insert(header::IF_NONE_MATCH, document.etag.clone());
595            let response = document.response(content_type, &conditional, algorithms);
596            assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
597            assert!(!response.headers().contains_key(header::CONTENT_ENCODING));
598            assert_eq!(response.headers()[header::ETAG], document.etag);
599            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
600                .await
601                .unwrap();
602            assert!(body.is_empty());
603        }
604
605        // 内容不变则重建后的 ETag 不变;内容变了 ETag 跟着变。
606        let again = build_feed("https://example.com".into(), &[item.clone()], None).unwrap();
607        assert_eq!(again.rss.etag, feed.rss.etag);
608        assert_eq!(again.json.etag, feed.json.etag);
609        assert!(again.rss.encoded.is_empty());
610        item.title = "改了标题".into();
611        let changed = build_feed("https://example.com".into(), &[item], None).unwrap();
612        assert_ne!(changed.rss.etag, feed.rss.etag);
613        assert_ne!(changed.json.etag, feed.json.etag);
614    }
615
616    #[tokio::test]
617    #[serial]
618    async fn site_base_url_ignores_untrusted_host_when_no_settings() {
619        // 未配置固定 APP_BASE_URL 时,不能使用请求 Host 生成公开链接。
620        let mut headers = HeaderMap::new();
621        headers.insert(header::HOST, HeaderValue::from_static("attacker.example"));
622        let r = site_base_url(&headers).await;
623        assert_eq!(r, "http://localhost");
624    }
625
626    #[tokio::test]
627    #[serial]
628    async fn site_base_url_falls_back_to_localhost_when_nothing() {
629        // 既无配置也无 Host 头 → 兜底 localhost 并告警。
630        let r = site_base_url(&HeaderMap::new()).await;
631        assert_eq!(r, "http://localhost");
632    }
633}