1#![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
37const CHANNEL_TITLE: &str = "Yggdrasil";
39
40const CHANNEL_DESCRIPTION: &str = "极简、快速、现代。专注于文字本身的开源博客平台。";
42
43const FEED_LANGUAGE: &str = "zh-CN";
45
46const FEED_ITEM_LIMIT: i64 = 20;
48
49const RSS_CONTENT_TYPE: &str = "application/rss+xml; charset=utf-8";
51
52const JSON_CONTENT_TYPE: &str = "application/feed+json; charset=utf-8";
54
55const FEED_CACHE_CONTROL: &str = "public, max-age=600";
57
58async 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
75fn 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
129fn 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
167fn 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 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
198fn 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
252struct FeedDocument {
254 body: Bytes,
255 encoded: Vec<(Encoding, Bytes)>,
256 etag: HeaderValue,
257}
258
259impl FeedDocument {
260 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 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
331pub struct RenderedFeed {
333 base: String,
335 rss: FeedDocument,
336 json: FeedDocument,
337}
338
339fn 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
356static BUILD_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
358
359async 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 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 tracing::error!("feed 生成失败");
420 (StatusCode::INTERNAL_SERVER_ERROR, "feed unavailable").into_response()
421 }
422 }
423}
424
425pub async fn rss_feed(headers: HeaderMap) -> Response {
427 serve(headers, false).await
428}
429
430pub 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's <ok></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 & 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><p>hi</p></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>摘要 & 简介</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 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<T>() {}"#,
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<T>() {}"#,
530 "\n</code></pre>",
531 r#"<p><span>后文</span></p><pre>plain</pre>"#,
532 )
533 );
534 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 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 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 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 let r = site_base_url(&HeaderMap::new()).await;
631 assert_eq!(r, "http://localhost");
632 }
633}