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 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
91fn 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
141fn 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
179fn 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
200async 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
226pub 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 tracing::error!("feed 生成失败");
250 (StatusCode::INTERNAL_SERVER_ERROR, "feed unavailable").into_response()
251 }
252 }
253}
254
255pub 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 tracing::error!("feed JSON 序列化失败: {e}");
278 (StatusCode::INTERNAL_SERVER_ERROR, "feed unavailable").into_response()
279 }
280 }
281 }
282 Err(_) => {
283 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("&<>\"'"), "&<>"'");
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 & 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><p>hi</p></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>摘要 & 简介</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 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 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 let r = site_base_url(&HeaderMap::new()).await;
389 assert_eq!(r, "http://localhost");
390 }
391}