feat: added RSS feed c498eeff
Steve · 2026-04-18 12:11 4 file(s) · +96 −0
apps/cellar/.env.example +3 −0
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
apps/cellar/src/server/handlers/public.rs +76 −0
106 106
    }
107 107
}
108 108
109 +
fn xml_escape(s: &str) -> String {
110 +
    s.replace('&', "&")
111 +
        .replace('<', "&lt;")
112 +
        .replace('>', "&gt;")
113 +
        .replace('"', "&quot;")
114 +
        .replace('\'', "&apos;")
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,
apps/cellar/src/server/mod.rs +16 −0
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
apps/cellar/templates/base.html +1 −0
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">