1#![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
26const CHANNEL_TITLE: &str = "Yggdrasil";
28
29const CHANNEL_DESCRIPTION: &str = "极简、快速、现代。专注于文字本身的开源博客平台。";
31
32const FEED_LANGUAGE: &str = "zh-CN";
34
35const FEED_ITEM_LIMIT: i64 = 20;
37
38const RSS_CONTENT_TYPE: &str = "application/rss+xml; charset=utf-8";
40
41const JSON_CONTENT_TYPE: &str = "application/feed+json; charset=utf-8";
43
44const FEED_CACHE_CONTROL: &str = "public, max-age=600";
46
47fn 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("&"),
56 '<' => out.push_str("<"),
57 '>' => out.push_str(">"),
58 '"' => out.push_str("""),
59 '\'' => out.push_str("'"),
60 _ => out.push(c),
61 }
62 }
63 out
64}
65
66async 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
83fn 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
133fn 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
171fn 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
192async 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
218pub 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 tracing::error!("feed 生成失败");
242 (StatusCode::INTERNAL_SERVER_ERROR, "feed unavailable").into_response()
243 }
244 }
245}
246
247pub 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 tracing::error!("feed JSON 序列化失败: {e}");
270 (StatusCode::INTERNAL_SERVER_ERROR, "feed unavailable").into_response()
271 }
272 }
273 }
274 Err(_) => {
275 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("&<>\"'"), "&<>"'");
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 & 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><p>hi</p></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>摘要 & 简介</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 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 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 let r = site_base_url(&HeaderMap::new()).await;
380 assert_eq!(r, "http://localhost");
381 }
382}