feat: add basic rss feat support

This commit is contained in:
Jet 2026-03-23 13:55:52 -07:00
parent 183b2c2c88
commit 452b8b49c3
No known key found for this signature in database
13 changed files with 232 additions and 166 deletions

View file

@ -3,6 +3,9 @@ name = "noisebell-cache"
version = "0.1.0"
edition = "2021"
[lints]
workspace = true
[dependencies]
anyhow = "1.0"
axum = "0.8"

View file

@ -22,6 +22,7 @@ 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,
@ -30,10 +31,7 @@ pub struct AppState {
}
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
}
fn format_full_timestamp(ts: u64) -> String {
@ -42,13 +40,92 @@ 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"),
];
let units = [(86_400, "day"), (3_600, "hour"), (60, "minute"), (1, "second")];
let mut remaining = seconds;
let mut parts = Vec::new();
@ -75,7 +152,12 @@ fn format_duration(seconds: u64) -> String {
}
}
fn status_summary(status: DoorStatus, since: Option<u64>, last_checked: Option<u64>, now: u64) -> String {
fn status_summary(
status: DoorStatus,
since: Option<u64>,
last_checked: Option<u64>,
now: u64,
) -> String {
let since_text = since
.map(|ts| {
format!(
@ -111,16 +193,10 @@ pub async fn post_webhook(
// Simple rate limiting: reset tokens every window, reject if exhausted.
let now = unix_now();
let last = state
.webhook_last_request
.load(std::sync::atomic::Ordering::Relaxed);
let last = state.webhook_last_request.load(std::sync::atomic::Ordering::Relaxed);
if now.saturating_sub(last) >= WEBHOOK_RATE_WINDOW_SECS {
state
.webhook_tokens
.store(WEBHOOK_RATE_LIMIT, std::sync::atomic::Ordering::Relaxed);
state
.webhook_last_request
.store(now, std::sync::atomic::Ordering::Relaxed);
state.webhook_tokens.store(WEBHOOK_RATE_LIMIT, std::sync::atomic::Ordering::Relaxed);
state.webhook_last_request.store(now, std::sync::atomic::Ordering::Relaxed);
}
let remaining = state.webhook_tokens.fetch_update(
std::sync::atomic::Ordering::Relaxed,
@ -153,10 +229,7 @@ pub async fn post_webhook(
webhook::forward(
&state.client,
&state.webhooks,
&WebhookPayload {
status,
timestamp: body.timestamp,
},
&WebhookPayload { status, timestamp: body.timestamp },
state.retry_attempts,
state.retry_base_delay_secs,
)
@ -200,41 +273,63 @@ pub async fn get_status(
StatusCode::INTERNAL_SERVER_ERROR
})?;
status.human_readable = status_summary(status.status, status.since, status.last_checked, unix_now());
status.human_readable =
status_summary(status.status, status.since, status.last_checked, unix_now());
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
}
pub async fn get_image_open() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "image/png"),
(header::CACHE_CONTROL, "public, max-age=86400"),
],
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=86400")],
OPEN_PNG,
)
}
pub async fn get_image_closed() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "image/png"),
(header::CACHE_CONTROL, "public, max-age=86400"),
],
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=86400")],
CLOSED_PNG,
)
}
pub async fn get_image_offline() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "image/png"),
(header::CACHE_CONTROL, "public, max-age=86400"),
],
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=86400")],
OFFLINE_PNG,
)
}
@ -260,13 +355,7 @@ pub async fn get_image(State(state): State<Arc<AppState>>) -> Response {
DoorStatus::Closed => CLOSED_PNG,
DoorStatus::Offline => OFFLINE_PNG,
};
(
[
(header::CONTENT_TYPE, "image/png"),
(header::CACHE_CONTROL, "public, max-age=5"),
],
image,
)
([(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=5")], image)
.into_response()
}
@ -290,4 +379,25 @@ 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

@ -1,6 +1,3 @@
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use noisebell_common::{CacheStatusResponse, DoorStatus};
use rusqlite::{Connection, OptionalExtension};
@ -63,9 +60,7 @@ struct CurrentStateRow {
}
fn parse_status(status: &str, location: &str) -> Result<DoorStatus> {
status
.parse()
.with_context(|| format!("invalid door status {status:?} in {location}"))
status.parse().with_context(|| format!("invalid door status {status:?} in {location}"))
}
pub fn init(path: &str) -> Result<Connection> {
@ -103,18 +98,12 @@ fn current_state_has_column(conn: &Connection, column: &str) -> Result<bool> {
fn migrate_current_state(conn: &Connection) -> Result<()> {
if !current_state_has_column(conn, "last_checked")? {
conn.execute(
"ALTER TABLE current_state ADD COLUMN last_checked INTEGER",
[],
)
.context("failed to add current_state.last_checked")?;
conn.execute("ALTER TABLE current_state ADD COLUMN last_checked INTEGER", [])
.context("failed to add current_state.last_checked")?;
}
conn.execute(
"UPDATE current_state SET status = 'offline' WHERE status IS NULL",
[],
)
.context("failed to backfill NULL current_state.status")?;
conn.execute("UPDATE current_state SET status = 'offline' WHERE status IS NULL", [])
.context("failed to backfill NULL current_state.status")?;
validate_status_values(conn)?;
Ok(())
@ -168,14 +157,12 @@ fn current_state_row(conn: &Connection) -> Result<CurrentStateRow> {
let status = parse_status(&status_str, "current_state.status")?;
let state = match (status, since) {
(DoorStatus::Open, Some(since)) => CachedState::Live {
status: LiveDoorStatus::Open,
since,
},
(DoorStatus::Closed, Some(since)) => CachedState::Live {
status: LiveDoorStatus::Closed,
since,
},
(DoorStatus::Open, Some(since)) => {
CachedState::Live { status: LiveDoorStatus::Open, since }
}
(DoorStatus::Closed, Some(since)) => {
CachedState::Live { status: LiveDoorStatus::Closed, since }
}
(DoorStatus::Offline, Some(since)) => CachedState::Offline { since },
(DoorStatus::Offline, None) => CachedState::Unknown,
(DoorStatus::Open | DoorStatus::Closed, None) => {
@ -183,11 +170,7 @@ fn current_state_row(conn: &Connection) -> Result<CurrentStateRow> {
}
};
Ok(CurrentStateRow {
state,
last_seen,
last_checked,
})
Ok(CurrentStateRow { state, last_seen, last_checked })
}
pub fn get_status(conn: &Connection) -> Result<CacheStatusResponse> {
@ -228,10 +211,11 @@ pub fn apply_state(
CachedState::Offline { since } if timestamp < since => ApplyStateOutcome::Stale,
CachedState::Offline { .. } => ApplyStateOutcome::Applied,
CachedState::Live { status: _, since } if timestamp < since => ApplyStateOutcome::Stale,
CachedState::Live {
status: current_status,
since,
} if timestamp == since && live_status == current_status => ApplyStateOutcome::Duplicate,
CachedState::Live { status: current_status, since }
if timestamp == since && live_status == current_status =>
{
ApplyStateOutcome::Duplicate
}
CachedState::Live { .. } => ApplyStateOutcome::Applied,
};
@ -244,10 +228,7 @@ pub fn apply_state(
}
pub fn update_last_seen(conn: &Connection, now: u64) -> Result<()> {
conn.execute(
"UPDATE current_state SET last_seen = ?1 WHERE id = 1",
rusqlite::params![now],
)?;
conn.execute("UPDATE current_state SET last_seen = ?1 WHERE id = 1", rusqlite::params![now])?;
Ok(())
}
@ -275,16 +256,15 @@ pub fn get_current_status(conn: &Connection) -> Result<DoorStatus> {
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
fn test_db() -> Connection {
init(":memory:").expect("failed to init test db")
}
fn temp_db_path(label: &str) -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
std::env::temp_dir().join(format!("noisebell-{label}-{nanos}.sqlite"))
}
@ -464,11 +444,7 @@ mod tests {
create_legacy_db(&path);
let conn = Connection::open(&path).unwrap();
conn.execute(
"UPDATE current_state SET status = 'mystery' WHERE id = 1",
[],
)
.unwrap();
conn.execute("UPDATE current_state SET status = 'mystery' WHERE id = 1", []).unwrap();
drop(conn);
let err = init(path.to_str().unwrap()).unwrap_err().to_string();

View file

@ -39,6 +39,11 @@ 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());
@ -116,6 +121,7 @@ async fn main() -> Result<()> {
db,
client,
inbound_api_key,
public_base_url,
webhooks,
retry_attempts,
retry_base_delay_secs,
@ -127,6 +133,7 @@ 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))

View file

@ -21,10 +21,7 @@ pub struct PollerConfig {
}
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
}
pub fn spawn_status_poller(
@ -168,10 +165,7 @@ pub fn spawn_status_poller(
webhook::forward(
&client,
&config.webhooks,
&WebhookPayload {
status: DoorStatus::Offline,
timestamp: now,
},
&WebhookPayload { status: DoorStatus::Offline, timestamp: now },
config.retry_attempts,
config.retry_base_delay_secs,
)