feat: move rss to it's own module and add atom support

This commit is contained in:
Jet 2026-03-23 15:43:02 -07:00
parent 452b8b49c3
commit 3991d25293
No known key found for this signature in database
11 changed files with 875 additions and 143 deletions

View file

@ -22,7 +22,6 @@ pub struct AppState {
pub db: Arc<Mutex<rusqlite::Connection>>,
pub client: reqwest::Client,
pub inbound_api_key: String,
pub public_base_url: Option<String>,
pub webhooks: Vec<WebhookTarget>,
pub retry_attempts: u32,
pub retry_base_delay_secs: u64,
@ -40,90 +39,6 @@ fn format_full_timestamp(ts: u64) -> String {
.unwrap_or_else(|| format!("unix timestamp {ts}"))
}
fn format_rfc2822_timestamp(ts: u64) -> String {
DateTime::from_timestamp(ts as i64, 0)
.map(|dt: DateTime<Utc>| dt.to_rfc2822())
.unwrap_or_else(|| "Thu, 01 Jan 1970 00:00:00 +0000".to_string())
}
fn xml_escape(text: &str) -> String {
let mut escaped = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => escaped.push_str("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&apos;"),
_ => escaped.push(ch),
}
}
escaped
}
fn header_value(headers: &HeaderMap, name: &'static str) -> Option<String> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn public_base_url(state: &AppState, headers: &HeaderMap) -> String {
if let Some(url) = &state.public_base_url {
return url.clone();
}
let host = header_value(headers, "x-forwarded-host")
.or_else(|| header_value(headers, "host"))
.unwrap_or_else(|| "localhost:3000".to_string());
let scheme = header_value(headers, "x-forwarded-proto").unwrap_or_else(|| "http".to_string());
format!("{scheme}://{host}")
}
fn build_rss_feed(base_url: &str, status: &CacheStatusResponse) -> String {
let item_timestamp = status.since.or(status.last_checked).unwrap_or(0);
let pub_date = format_rfc2822_timestamp(item_timestamp);
let feed_url = format!("{base_url}/rss.xml");
let status_url = format!("{base_url}/status");
let guid = format!("urn:noisebell:status:{}:{item_timestamp}", status.status.as_str());
let title = format!("Noisebell is {}", status.status);
let description = if status.human_readable.is_empty() {
format!("Current status: {}.", status.status)
} else {
status.human_readable.clone()
};
format!(
concat!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
"<rss version=\"2.0\">\n",
" <channel>\n",
" <title>Noisebell status</title>\n",
" <link>{channel_link}</link>\n",
" <description>Current noisebell state as a single rolling RSS item.</description>\n",
" <lastBuildDate>{pub_date}</lastBuildDate>\n",
" <ttl>5</ttl>\n",
" <item>\n",
" <title>{item_title}</title>\n",
" <link>{item_link}</link>\n",
" <guid isPermaLink=\"false\">{item_guid}</guid>\n",
" <pubDate>{pub_date}</pubDate>\n",
" <description>{item_description}</description>\n",
" </item>\n",
" </channel>\n",
"</rss>\n"
),
channel_link = xml_escape(&feed_url),
pub_date = xml_escape(&pub_date),
item_title = xml_escape(&title),
item_link = xml_escape(&status_url),
item_guid = xml_escape(&guid),
item_description = xml_escape(&description),
)
}
fn format_duration(seconds: u64) -> String {
let units = [(86_400, "day"), (3_600, "hour"), (60, "minute"), (1, "second")];
@ -279,36 +194,6 @@ pub async fn get_status(
Ok(Json(status))
}
pub async fn get_rss(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, StatusCode> {
let db = state.db.clone();
let mut status = tokio::task::spawn_blocking(move || {
let conn = db.blocking_lock();
db::get_status(&conn)
})
.await
.expect("db task panicked")
.map_err(|e| {
error!(error = %e, "failed to get status for rss");
StatusCode::INTERNAL_SERVER_ERROR
})?;
status.human_readable =
status_summary(status.status, status.since, status.last_checked, unix_now());
let base_url = public_base_url(&state, &headers);
let feed = build_rss_feed(&base_url, &status);
Ok((
[
(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8"),
(header::CACHE_CONTROL, "public, max-age=60"),
],
feed,
))
}
pub async fn health() -> StatusCode {
StatusCode::OK
}
@ -379,25 +264,4 @@ mod tests {
assert!(summary.contains("Last checked"));
assert!(summary.contains("55 seconds ago"));
}
#[test]
fn rss_feed_uses_single_current_item() {
let feed = build_rss_feed(
"https://noisebell.example.com",
&CacheStatusResponse {
status: DoorStatus::Closed,
since: Some(1_700_000_000),
last_checked: Some(1_700_000_120),
human_readable:
"We've been closed since Tuesday, November 14, 2023 at 10:13:20 PM UTC."
.to_string(),
},
);
assert!(feed.contains("<title>Noisebell is closed</title>"));
assert!(feed
.contains("<guid isPermaLink=\"false\">urn:noisebell:status:closed:1700000000</guid>"));
assert!(feed.contains("<link>https://noisebell.example.com/status</link>"));
assert_eq!(feed.matches("<item>").count(), 1);
}
}

View file

@ -39,11 +39,6 @@ async fn main() -> Result<()> {
let inbound_api_key = std::env::var("NOISEBELL_CACHE_INBOUND_API_KEY")
.context("NOISEBELL_CACHE_INBOUND_API_KEY is required")?;
let public_base_url = std::env::var("NOISEBELL_CACHE_PUBLIC_BASE_URL")
.ok()
.map(|url| url.trim_end_matches('/').to_string())
.filter(|url| !url.is_empty());
let data_dir = std::env::var("NOISEBELL_CACHE_DATA_DIR")
.unwrap_or_else(|_| "/var/lib/noisebell-cache".into());
@ -121,7 +116,6 @@ async fn main() -> Result<()> {
db,
client,
inbound_api_key,
public_base_url,
webhooks,
retry_attempts,
retry_base_delay_secs,
@ -133,7 +127,6 @@ async fn main() -> Result<()> {
.route("/health", get(api::health))
.route("/webhook", post(api::post_webhook))
.route("/status", get(api::get_status))
.route("/rss.xml", get(api::get_rss))
.route("/image", get(api::get_image))
.route("/image/open.png", get(api::get_image_open))
.route("/image/closed.png", get(api::get_image_closed))