Merge pull request #31 from stevedylandev/feat/cellar-add-rss
17dadbdd
feat: added RSS feed
6 file(s) · +98 −2
feat: added RSS feed
| 687 | 687 | ||
| 688 | 688 | [[package]] |
|
| 689 | 689 | name = "cellar" |
|
| 690 | - | version = "0.1.4" |
|
| 690 | + | version = "0.2.0" |
|
| 691 | 691 | dependencies = [ |
|
| 692 | 692 | "andromeda-auth", |
|
| 693 | 693 | "andromeda-db", |
| 4 | 4 | COOKIE_SECURE=false |
|
| 5 | 5 | HOST=127.0.0.1 |
|
| 6 | 6 | PORT=3000 |
|
| 7 | + | SITE_URL=http://localhost:3000 |
|
| 8 | + | SITE_TITLE=Cellar |
|
| 9 | + | SITE_DESCRIPTION=Personal wine tasting log |
| 1 | 1 | [package] |
|
| 2 | 2 | name = "cellar" |
|
| 3 | - | version = "0.1.4" |
|
| 3 | + | version = "0.2.0" |
|
| 4 | 4 | edition = "2024" |
|
| 5 | 5 | description = "Personal wine tasting log" |
|
| 6 | 6 | license = "MIT" |
| 106 | 106 | } |
|
| 107 | 107 | } |
|
| 108 | 108 | ||
| 109 | + | fn xml_escape(s: &str) -> String { |
|
| 110 | + | s.replace('&', "&") |
|
| 111 | + | .replace('<', "<") |
|
| 112 | + | .replace('>', ">") |
|
| 113 | + | .replace('"', """) |
|
| 114 | + | .replace('\'', "'") |
|
| 115 | + | } |
|
| 116 | + | ||
| 117 | + | pub async fn rss_feed(State(state): State<Arc<AppState>>) -> Response { |
|
| 118 | + | let site_url = &state.site_url; |
|
| 119 | + | ||
| 120 | + | let wines = match db::get_cellar_wines(&state.db) { |
|
| 121 | + | Ok(wines) => wines, |
|
| 122 | + | Err(e) => { |
|
| 123 | + | tracing::error!("Failed to get wines for RSS: {}", e); |
|
| 124 | + | return (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response(); |
|
| 125 | + | } |
|
| 126 | + | }; |
|
| 127 | + | ||
| 128 | + | let mut items = String::new(); |
|
| 129 | + | for wine in &wines { |
|
| 130 | + | let link = format!("{}/wines/{}", site_url, xml_escape(&wine.short_id)); |
|
| 131 | + | let title = xml_escape(&wine.name); |
|
| 132 | + | let mut desc_parts: Vec<String> = Vec::new(); |
|
| 133 | + | if !wine.origin.is_empty() { |
|
| 134 | + | desc_parts.push(format!("Origin: {}", wine.origin)); |
|
| 135 | + | } |
|
| 136 | + | if !wine.grape.is_empty() { |
|
| 137 | + | desc_parts.push(format!("Grape: {}", wine.grape)); |
|
| 138 | + | } |
|
| 139 | + | if !wine.notes.is_empty() { |
|
| 140 | + | desc_parts.push(wine.notes.clone()); |
|
| 141 | + | } |
|
| 142 | + | let description = xml_escape(&desc_parts.join(" — ")); |
|
| 143 | + | let pub_date = &wine.created_at; |
|
| 144 | + | let guid = format!("{}/wines/{}", site_url, xml_escape(&wine.short_id)); |
|
| 145 | + | ||
| 146 | + | items.push_str(&format!( |
|
| 147 | + | " <item>\n <title>{title}</title>\n <link>{link}</link>\n <guid>{guid}</guid>\n <description>{description}</description>\n <pubDate>{pub_date}</pubDate>\n </item>\n" |
|
| 148 | + | )); |
|
| 149 | + | } |
|
| 150 | + | ||
| 151 | + | let last_build = wines |
|
| 152 | + | .first() |
|
| 153 | + | .map(|w| w.created_at.as_str()) |
|
| 154 | + | .unwrap_or(""); |
|
| 155 | + | ||
| 156 | + | let xml = format!( |
|
| 157 | + | r#"<?xml version="1.0" encoding="UTF-8"?> |
|
| 158 | + | <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> |
|
| 159 | + | <channel> |
|
| 160 | + | <title>{title}</title> |
|
| 161 | + | <link>{site_url}</link> |
|
| 162 | + | <description>{desc}</description> |
|
| 163 | + | <lastBuildDate>{last_build}</lastBuildDate> |
|
| 164 | + | <atom:link href="{site_url}/feed.xml" rel="self" type="application/rss+xml"/> |
|
| 165 | + | {items} </channel> |
|
| 166 | + | </rss>"#, |
|
| 167 | + | title = xml_escape(&state.site_title), |
|
| 168 | + | desc = xml_escape(&state.site_description), |
|
| 169 | + | site_url = site_url, |
|
| 170 | + | last_build = last_build, |
|
| 171 | + | items = items, |
|
| 172 | + | ); |
|
| 173 | + | ||
| 174 | + | ( |
|
| 175 | + | StatusCode::OK, |
|
| 176 | + | [( |
|
| 177 | + | axum::http::header::CONTENT_TYPE, |
|
| 178 | + | HeaderValue::from_static("application/rss+xml; charset=utf-8"), |
|
| 179 | + | )], |
|
| 180 | + | xml, |
|
| 181 | + | ) |
|
| 182 | + | .into_response() |
|
| 183 | + | } |
|
| 184 | + | ||
| 109 | 185 | pub async fn get_wishlist( |
|
| 110 | 186 | State(state): State<Arc<AppState>>, |
|
| 111 | 187 | headers: axum::http::HeaderMap, |
| 18 | 18 | pub app_password: String, |
|
| 19 | 19 | pub cookie_secure: bool, |
|
| 20 | 20 | pub anthropic_api_key: Option<String>, |
|
| 21 | + | pub site_url: String, |
|
| 22 | + | pub site_title: String, |
|
| 23 | + | pub site_description: String, |
|
| 21 | 24 | } |
|
| 22 | 25 | ||
| 23 | 26 | #[derive(Embed)] |
|
| 512 | 515 | ||
| 513 | 516 | let anthropic_api_key = std::env::var("ANTHROPIC_API_KEY").ok().filter(|k| !k.is_empty()); |
|
| 514 | 517 | ||
| 518 | + | let site_url = std::env::var("SITE_URL") |
|
| 519 | + | .unwrap_or_else(|_| "http://localhost:3000".to_string()) |
|
| 520 | + | .trim_end_matches('/') |
|
| 521 | + | .to_string(); |
|
| 522 | + | ||
| 523 | + | let site_title = std::env::var("SITE_TITLE").unwrap_or_else(|_| "Cellar".to_string()); |
|
| 524 | + | let site_description = std::env::var("SITE_DESCRIPTION") |
|
| 525 | + | .unwrap_or_else(|_| "Personal wine tasting log".to_string()); |
|
| 526 | + | ||
| 515 | 527 | let state = Arc::new(AppState { |
|
| 516 | 528 | db, |
|
| 517 | 529 | app_password, |
|
| 518 | 530 | cookie_secure, |
|
| 519 | 531 | anthropic_api_key, |
|
| 532 | + | site_url, |
|
| 533 | + | site_title, |
|
| 534 | + | site_description, |
|
| 520 | 535 | }); |
|
| 521 | 536 | ||
| 522 | 537 | let app = Router::new() |
|
| 523 | 538 | // Public routes |
|
| 524 | 539 | .route("/", get(public::get_index)) |
|
| 540 | + | .route("/feed.xml", get(public::rss_feed)) |
|
| 525 | 541 | .route("/wines/{short_id}", get(public::get_wine_detail)) |
|
| 526 | 542 | .route("/wines/{short_id}/image", get(public::get_wine_image)) |
|
| 527 | 543 | // Admin auth routes |
|
| 14 | 14 | <meta property="og:type" content="website"> |
|
| 15 | 15 | <meta name="theme-color" content="#121113" /> |
|
| 16 | 16 | <link rel="stylesheet" href="/static/styles.css"> |
|
| 17 | + | <link rel="alternate" type="application/rss+xml" title="Cellar RSS" href="/feed.xml"> |
|
| 17 | 18 | </head> |
|
| 18 | 19 | <body> |
|
| 19 | 20 | <header class="header"> |