feat: move rss to it's own module and add atom support
This commit is contained in:
parent
452b8b49c3
commit
3991d25293
11 changed files with 875 additions and 143 deletions
|
|
@ -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("&"),
|
||||
'<' => escaped.push_str("<"),
|
||||
'>' => escaped.push_str(">"),
|
||||
'"' => escaped.push_str("""),
|
||||
'\'' => escaped.push_str("'"),
|
||||
_ => 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue