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
66fn site_base_url(headers: &HeaderMap) -> String {
71 if let Ok(base) = std::env::var("APP_BASE_URL") {
72 let base = base.trim();
73 if !base.is_empty() {
74 return base.trim_end_matches('/').to_string();
75 }
76 }
77 if let Some(host) = headers
78 .get(header::HOST)
79 .and_then(|h| h.to_str().ok())
80 .map(str::trim)
81 .filter(|h| !h.is_empty())
82 {
83 return format!("https://{}", host.trim_end_matches('/'));
84 }
85 tracing::warn!("feed: 未配置 APP_BASE_URL 且缺少 Host 头,回退 http://localhost");
86 "http://localhost".to_string()
87}
88
89fn render_rss(base: &str, now: DateTime<Utc>, items: &[FeedItem]) -> String {
91 let mut xml = String::with_capacity(4096 + items.len() * 512);
92 xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
93 xml.push_str("<rss version=\"2.0\"><channel>");
94 xml.push_str("<title>");
95 xml.push_str(CHANNEL_TITLE);
96 xml.push_str("</title><link>");
97 xml.push_str(base);
98 xml.push_str("</link><description>");
99 xml.push_str(CHANNEL_DESCRIPTION);
100 xml.push_str("</description><language>");
101 xml.push_str(FEED_LANGUAGE);
102 xml.push_str("</language><lastBuildDate>");
103 xml.push_str(&now.to_rfc2822());
104 xml.push_str("</lastBuildDate>");
105 for item in items {
106 xml.push_str("<item><title>");
107 xml.push_str(&escape_xml(&item.title));
108 xml.push_str("</title><link>");
109 xml.push_str(base);
110 xml.push_str("/post/");
111 xml.push_str(&item.slug);
112 xml.push_str("</link><guid isPermaLink=\"true\">");
113 xml.push_str(base);
114 xml.push_str("/post/");
115 xml.push_str(&item.slug);
116 xml.push_str("</guid><pubDate>");
117 xml.push_str(&item.published_at.to_rfc2822());
118 xml.push_str("</pubDate>");
119 for tag in &item.tags {
120 xml.push_str("<category>");
121 xml.push_str(&escape_xml(tag));
122 xml.push_str("</category>");
123 }
124 if let Some(html) = &item.content_html {
125 xml.push_str("<description>");
126 xml.push_str(&escape_xml(html));
127 xml.push_str("</description>");
128 } else if let Some(summary) = &item.summary {
129 xml.push_str("<description>");
130 xml.push_str(&escape_xml(summary));
131 xml.push_str("</description>");
132 }
133 xml.push_str("</item>");
134 }
135 xml.push_str("</channel></rss>");
136 xml
137}
138
139fn render_json(base: &str, items: &[FeedItem]) -> Result<String, serde_json::Error> {
141 let feed_items: Vec<Value> = items
142 .iter()
143 .map(|item| {
144 let url = format!("{base}/post/{}", item.slug);
145 let mut m = Map::new();
146 m.insert("id".to_string(), json!(url.clone()));
147 m.insert("url".to_string(), json!(url));
148 m.insert("title".to_string(), json!(item.title));
149 if let Some(html) = &item.content_html {
150 m.insert("content_html".to_string(), json!(html));
151 }
152 if let Some(summary) = &item.summary {
153 m.insert("summary".to_string(), json!(summary));
154 }
155 m.insert(
156 "date_published".to_string(),
157 json!(item.published_at.to_rfc3339()),
158 );
159 m.insert(
160 "date_modified".to_string(),
161 json!(item.updated_at.to_rfc3339()),
162 );
163 m.insert("tags".to_string(), json!(item.tags));
164 Value::Object(m)
165 })
166 .collect();
167 serde_json::to_string(&json!({
168 "version": "https://jsonfeed.org/version/1.1",
169 "title": CHANNEL_TITLE,
170 "home_page_url": base,
171 "feed_url": format!("{base}/feed.json"),
172 "language": FEED_LANGUAGE,
173 "items": feed_items,
174 }))
175}
176
177fn row_to_feed_item(row: &tokio_postgres::Row) -> FeedItem {
182 let updated_at: DateTime<Utc> = row.get("updated_at");
183 let mut tags: Vec<String> = row.try_get::<_, Vec<String>>("tags").unwrap_or_default();
184 tags.retain(|t| !t.is_empty());
185 FeedItem {
186 title: row.get("title"),
187 slug: row.get("slug"),
188 summary: row.get("summary"),
189 content_html: row.get("content_html"),
190 published_at: row
191 .get::<_, Option<DateTime<Utc>>>("published_at")
192 .unwrap_or(updated_at),
193 updated_at,
194 tags,
195 }
196}
197
198async fn load_feed_items() -> Result<Vec<FeedItem>, AppError> {
200 if let Some(items) = crate::cache::get_feed().await {
201 return Ok(items);
202 }
203 let client = get_conn().await.map_err(AppError::db_conn)?;
204 let rows = client
205 .query(
206 "SELECT p.title, p.slug, p.summary, p.content_html, p.published_at, p.updated_at,
207 COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') AS tags
208 FROM posts p
209 LEFT JOIN post_tags pt ON p.id = pt.post_id
210 LEFT JOIN tags t ON pt.tag_id = t.id
211 WHERE p.status = 'published' AND p.deleted_at IS NULL
212 GROUP BY p.id
213 ORDER BY p.published_at DESC
214 LIMIT $1",
215 &[&FEED_ITEM_LIMIT],
216 )
217 .await
218 .map_err(AppError::query)?;
219 let items: Vec<FeedItem> = rows.iter().map(row_to_feed_item).collect();
220 crate::cache::set_feed(items.clone()).await;
221 Ok(items)
222}
223
224pub async fn rss_feed(headers: HeaderMap) -> Response {
226 match load_feed_items().await {
227 Ok(items) => {
228 let base = site_base_url(&headers);
229 let body = render_rss(&base, Utc::now(), &items);
230 (
231 [
232 (
233 header::CONTENT_TYPE,
234 HeaderValue::from_static(RSS_CONTENT_TYPE),
235 ),
236 (
237 header::CACHE_CONTROL,
238 HeaderValue::from_static(FEED_CACHE_CONTROL),
239 ),
240 ],
241 body,
242 )
243 .into_response()
244 }
245 Err(_) => {
246 tracing::error!("feed 生成失败");
248 (StatusCode::INTERNAL_SERVER_ERROR, "feed unavailable").into_response()
249 }
250 }
251}
252
253pub async fn json_feed(headers: HeaderMap) -> Response {
255 match load_feed_items().await {
256 Ok(items) => {
257 let base = site_base_url(&headers);
258 match render_json(&base, &items) {
259 Ok(body) => (
260 [
261 (
262 header::CONTENT_TYPE,
263 HeaderValue::from_static(JSON_CONTENT_TYPE),
264 ),
265 (
266 header::CACHE_CONTROL,
267 HeaderValue::from_static(FEED_CACHE_CONTROL),
268 ),
269 ],
270 body,
271 )
272 .into_response(),
273 Err(e) => {
274 tracing::error!("feed JSON 序列化失败: {e}");
276 (StatusCode::INTERNAL_SERVER_ERROR, "feed unavailable").into_response()
277 }
278 }
279 }
280 Err(_) => {
281 tracing::error!("feed 生成失败");
283 (StatusCode::INTERNAL_SERVER_ERROR, "feed unavailable").into_response()
284 }
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use serial_test::serial;
292
293 fn fixed_now() -> DateTime<Utc> {
294 DateTime::parse_from_rfc3339("2026-01-02T03:04:05Z")
295 .unwrap()
296 .with_timezone(&Utc)
297 }
298
299 fn fixture_item() -> FeedItem {
300 let ts = fixed_now();
301 FeedItem {
302 title: "A & B".to_string(),
303 slug: "my-post".to_string(),
304 summary: None,
305 content_html: Some("<p>hi</p>".to_string()),
306 published_at: ts,
307 updated_at: ts,
308 tags: vec!["Rust".to_string()],
309 }
310 }
311
312 #[test]
313 fn escape_xml_escapes_all_special_chars() {
314 assert_eq!(escape_xml("&<>\"'"), "&<>"'");
315 }
316
317 #[test]
318 fn escape_xml_plain_text_unchanged() {
319 assert_eq!(escape_xml("plain text 123"), "plain text 123");
320 }
321
322 #[test]
323 fn render_rss_contains_escaped_fields() {
324 let xml = render_rss("https://example.com", fixed_now(), &[fixture_item()]);
325 assert!(
326 xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rss version=\"2.0\">")
327 );
328 assert!(xml.ends_with("</channel></rss>"));
329 assert!(xml.contains("<title>A & B</title>"));
330 assert!(xml.contains("<link>https://example.com</link>"));
331 assert!(xml.contains("<guid isPermaLink=\"true\">https://example.com/post/my-post</guid>"));
332 assert!(xml.contains("<category>Rust</category>"));
333 assert!(xml.contains("<description><p>hi</p></description>"));
334 assert!(xml.contains("<lastBuildDate>Fri, 2 Jan 2026 03:04:05 +0000</lastBuildDate>"));
335 }
336
337 #[test]
338 fn render_rss_falls_back_to_summary_and_omits_item_description() {
339 let mut item = fixture_item();
340 item.content_html = None;
341 item.summary = Some("摘要 & 简介".to_string());
342 let xml = render_rss("https://example.com", fixed_now(), &[item]);
343 assert!(xml.contains("<description>摘要 & 简介</description>"));
344
345 let mut item2 = fixture_item();
346 item2.content_html = None;
347 item2.summary = None;
348 let xml2 = render_rss("https://example.com", fixed_now(), &[item2]);
349 assert_eq!(xml2.matches("<description>").count(), 1);
351 }
352
353 #[test]
354 fn render_json_roundtrips() {
355 let out = render_json("https://example.com", &[fixture_item()]).unwrap();
356 let v: Value = serde_json::from_str(&out).unwrap();
357 assert_eq!(v["version"], "https://jsonfeed.org/version/1.1");
358 assert_eq!(v["title"], "Yggdrasil");
359 assert_eq!(v["home_page_url"], "https://example.com");
360 assert_eq!(v["feed_url"], "https://example.com/feed.json");
361 assert_eq!(v["language"], "zh-CN");
362 assert_eq!(v["items"][0]["id"], "https://example.com/post/my-post");
363 assert_eq!(v["items"][0]["url"], "https://example.com/post/my-post");
364 assert_eq!(v["items"][0]["title"], "A & B");
365 assert_eq!(v["items"][0]["content_html"], "<p>hi</p>");
366 assert_eq!(v["items"][0]["tags"][0], "Rust");
367 assert_eq!(v["items"][0]["date_published"], "2026-01-02T03:04:05+00:00");
368 assert!(v["items"][0].get("summary").is_none());
369 }
370
371 fn with_env_removed<R>(f: impl FnOnce() -> R) -> R {
373 let saved = std::env::var("APP_BASE_URL").ok();
374 std::env::remove_var("APP_BASE_URL");
375 let r = f();
376 match saved {
377 Some(v) => std::env::set_var("APP_BASE_URL", v),
378 None => std::env::remove_var("APP_BASE_URL"),
379 }
380 r
381 }
382
383 #[test]
384 #[serial]
385 fn site_base_url_prefers_env() {
386 let saved = std::env::var("APP_BASE_URL").ok();
387 std::env::set_var("APP_BASE_URL", "https://blog.example.com/");
388 let r = site_base_url(&HeaderMap::new());
389 match saved {
390 Some(v) => std::env::set_var("APP_BASE_URL", v),
391 None => std::env::remove_var("APP_BASE_URL"),
392 }
393 assert_eq!(r, "https://blog.example.com");
394 }
395
396 #[test]
397 #[serial]
398 fn site_base_url_falls_back_to_host() {
399 let mut headers = HeaderMap::new();
400 headers.insert(header::HOST, HeaderValue::from_static("blog.example.com"));
401 assert_eq!(
402 with_env_removed(|| site_base_url(&headers)),
403 "https://blog.example.com"
404 );
405 }
406}