diff --git a/Cargo.lock b/Cargo.lock index c03f31a..b81fe4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -906,6 +906,21 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "noisebell-rss" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "noisebell-common", + "reqwest", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" diff --git a/Cargo.toml b/Cargo.toml index 207e891..b8e0be0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "pi/pi-service", "remote/noisebell-common", "remote/cache-service", + "remote/rss-service", "remote/discord-bot", ] resolver = "2" diff --git a/flake.nix b/flake.nix index 3484335..7d41d68 100644 --- a/flake.nix +++ b/flake.nix @@ -66,6 +66,7 @@ ); noisebell-cache = buildRemoteMember "noisebell-cache"; + noisebell-rss = buildRemoteMember "noisebell-rss"; noisebell-discord = buildRemoteMember "noisebell-discord"; crossPkgs = import nixpkgs { @@ -346,6 +347,7 @@ packages.${system} = { inherit noisebell-cache + noisebell-rss noisebell-discord flash-pi-sd pi-serial @@ -361,10 +363,12 @@ nixosModules = { cache = import ./remote/cache-service/module.nix noisebell-cache; + rss = import ./remote/rss-service/module.nix noisebell-rss; discord = import ./remote/discord-bot/module.nix noisebell-discord; default = { imports = [ (import ./remote/cache-service/module.nix noisebell-cache) + (import ./remote/rss-service/module.nix noisebell-rss) (import ./remote/discord-bot/module.nix noisebell-discord) (import ./remote/hosted-module.nix { inherit self agenix; diff --git a/remote/README.md b/remote/README.md index f1171e7..20782d0 100644 --- a/remote/README.md +++ b/remote/README.md @@ -5,6 +5,7 @@ Cargo workspace with the server-side pieces of Noisebell. Runs on any Linux box. | Service | Port | What it does | |---------|------|--------------| | [`cache-service/`](cache-service/) | 3000 | Polls the Pi, stores the latest state in SQLite, fans out webhooks | +| [`rss-service/`](rss-service/) | 3002 | Fetches current status from cache and serves RSS/Atom feeds | | [`discord-bot/`](discord-bot/) | 3001 | Posts door status to a Discord channel | | [`noisebell-common/`](noisebell-common/) | — | Shared types and helpers | @@ -20,6 +21,7 @@ Or with Nix: ```sh nix build .#noisebell-cache +nix build .#noisebell-rss nix build .#noisebell-discord ``` @@ -42,6 +44,10 @@ The flake exports a NixOS module for the hosted remote machine. It imports `agen domain = "cache.noisebell.example.com"; piAddress = "http://noisebell-pi:80"; }; + services.noisebell-rss = { + enable = true; + domain = "rss.noisebell.example.com"; + }; services.noisebell-discord = { enable = true; domain = "discord.noisebell.example.com"; diff --git a/remote/cache-service/src/api.rs b/remote/cache-service/src/api.rs index 2ee7d34..71b65e9 100644 --- a/remote/cache-service/src/api.rs +++ b/remote/cache-service/src/api.rs @@ -22,7 +22,6 @@ pub struct AppState { pub db: Arc>, pub client: reqwest::Client, pub inbound_api_key: String, - pub public_base_url: Option, pub webhooks: Vec, 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| 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 { - 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!( - "\n", - "\n", - " \n", - " Noisebell status\n", - " {channel_link}\n", - " Current noisebell state as a single rolling RSS item.\n", - " {pub_date}\n", - " 5\n", - " \n", - " {item_title}\n", - " {item_link}\n", - " {item_guid}\n", - " {pub_date}\n", - " {item_description}\n", - " \n", - " \n", - "\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>, - headers: HeaderMap, -) -> Result { - 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("Noisebell is closed")); - assert!(feed - .contains("urn:noisebell:status:closed:1700000000")); - assert!(feed.contains("https://noisebell.example.com/status")); - assert_eq!(feed.matches("").count(), 1); - } } diff --git a/remote/cache-service/src/main.rs b/remote/cache-service/src/main.rs index ef65872..dae7927 100644 --- a/remote/cache-service/src/main.rs +++ b/remote/cache-service/src/main.rs @@ -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)) diff --git a/remote/hosted-module.nix b/remote/hosted-module.nix index 04ca977..e3169fa 100644 --- a/remote/hosted-module.nix +++ b/remote/hosted-module.nix @@ -3,6 +3,7 @@ let cfgCache = config.services.noisebell-cache; + cfgRss = config.services.noisebell-rss; cfgDiscord = config.services.noisebell-discord; in { @@ -11,6 +12,7 @@ in users.groups.noisebell = { }; users.users.noisebell-cache.extraGroups = lib.mkIf cfgCache.enable [ "noisebell" ]; + users.users.noisebell-rss.extraGroups = lib.mkIf cfgRss.enable [ "noisebell" ]; users.users.noisebell-discord.extraGroups = lib.mkIf cfgDiscord.enable [ "noisebell" ]; age.secrets.noisebell-pi-to-cache-key = { @@ -48,6 +50,12 @@ in ); }; + services.noisebell-rss = lib.mkIf cfgRss.enable ( + lib.optionalAttrs cfgCache.enable { + cacheUrl = lib.mkDefault "http://127.0.0.1:${toString cfgCache.port}"; + } + ); + services.noisebell-discord = lib.mkIf cfgDiscord.enable ( { discordTokenFile = lib.mkDefault config.age.secrets.noisebell-discord-token.path; diff --git a/remote/rss-service/Cargo.toml b/remote/rss-service/Cargo.toml new file mode 100644 index 0000000..dd56d5e --- /dev/null +++ b/remote/rss-service/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "noisebell-rss" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0" +axum = "0.8" +chrono = "0.4" +noisebell-common = { path = "../noisebell-common" } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal", "time"] } +tower-http = { version = "0.6", features = ["trace"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/remote/rss-service/README.md b/remote/rss-service/README.md new file mode 100644 index 0000000..73ce424 --- /dev/null +++ b/remote/rss-service/README.md @@ -0,0 +1,74 @@ +# RSS Service + +Serves public feed endpoints for Noisebell. + +This service does not store history. On each request it fetches the current state from `cache-service` and renders that one current state as RSS or Atom. + +## States + +- `open`: Noisebridge is open right now. +- `closed`: Noisebridge is closed right now. +- `offline`: the cache cannot currently confirm the Pi state. + +## Feed behavior + +- Feeds are current-state only, not historical archives. +- Each feed emits either one item or zero items. +- The item `guid` changes when the current status event changes, so feed readers can treat it as new. +- Filtered feeds omit the item when the current state does not match that feed. + +## Endpoints + +| Format | Purpose | Path | +|--------|---------|------| +| JSON | Current status passthrough | `/status` | +| RSS | All states | `/all/rss.xml` | +| Atom | All states | `/all/atom.xml` | +| RSS | Open + closed only | `/door/rss.xml` | +| Atom | Open + closed only | `/door/atom.xml` | +| RSS | Open only | `/open/rss.xml` | +| Atom | Open only | `/open/atom.xml` | +| HTTP | Health check | `/health` | +| HTTP | Redirect to docs | `/` | + +Current aliases: + +- `/rss.xml` currently serves the same feed as `/all/rss.xml`. +- `/atom.xml` currently serves the same feed as `/all/atom.xml`. +- `/` redirects to the repo README for this module. + +## Feed text + +Items use custom titles: + +- `Noisebridge opened` +- `Noisebridge closed` +- `Noisebridge sensor went offline` + +Descriptions include: + +- the current state +- when that state began, if known +- when the cache last confirmed the state + +## Caching and polling hints + +- RSS includes `1`, which suggests a 1-minute polling interval. +- HTTP responses include `Cache-Control: public, max-age=60`. +- HTTP responses also include `ETag` and `Last-Modified` so clients can revalidate cheaply. + +## Atom vs RSS + +RSS and Atom are both XML feed formats. + +- RSS is older and very widely supported. +- Atom is newer and has a more regular structure. +- For this service they contain the same information in two different standard formats. + +## Configuration + +| Variable | Default | Description | +|----------|---------|-------------| +| `NOISEBELL_RSS_CACHE_URL` | required | Base URL of `cache-service` | +| `NOISEBELL_RSS_PORT` | `3002` | Listen port | +| `NOISEBELL_RSS_HTTP_TIMEOUT_SECS` | `10` | Timeout when requesting cache status | diff --git a/remote/rss-service/module.nix b/remote/rss-service/module.nix new file mode 100644 index 0000000..f7c0892 --- /dev/null +++ b/remote/rss-service/module.nix @@ -0,0 +1,75 @@ +pkg: +{ config, lib, ... }: + +let + cfg = config.services.noisebell-rss; + bin = "${pkg}/bin/noisebell-rss"; +in +{ + options.services.noisebell-rss = { + enable = lib.mkEnableOption "noisebell RSS service"; + + domain = lib.mkOption { + type = lib.types.str; + description = "Domain for the Caddy virtual host."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 3002; + }; + + cacheUrl = lib.mkOption { + type = lib.types.str; + description = "URL of the cache service (e.g. http://localhost:3000)."; + }; + + httpTimeoutSecs = lib.mkOption { + type = lib.types.ints.positive; + default = 10; + }; + }; + + config = lib.mkIf cfg.enable { + users.users.noisebell-rss = { + isSystemUser = true; + group = "noisebell-rss"; + }; + users.groups.noisebell-rss = { }; + + services.caddy.virtualHosts.${cfg.domain}.extraConfig = '' + reverse_proxy localhost:${toString cfg.port} + ''; + + systemd.services.noisebell-rss = { + description = "Noisebell RSS service"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + environment = { + NOISEBELL_RSS_PORT = toString cfg.port; + NOISEBELL_RSS_CACHE_URL = cfg.cacheUrl; + NOISEBELL_RSS_HTTP_TIMEOUT_SECS = toString cfg.httpTimeoutSecs; + RUST_LOG = "info"; + }; + script = '' + exec ${bin} + ''; + serviceConfig = { + Type = "simple"; + Restart = "on-failure"; + RestartSec = 5; + User = "noisebell-rss"; + Group = "noisebell-rss"; + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictSUIDSGID = true; + }; + }; + }; +} diff --git a/remote/rss-service/src/main.rs b/remote/rss-service/src/main.rs new file mode 100644 index 0000000..0c77ab0 --- /dev/null +++ b/remote/rss-service/src/main.rs @@ -0,0 +1,677 @@ +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result}; +use axum::extract::State; +use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::routing::get; +use axum::{Json, Router}; +use chrono::{DateTime, Utc}; +use noisebell_common::{CacheStatusResponse, DoorStatus}; +use tower_http::trace::TraceLayer; +use tracing::{error, info, Level}; + +const FEED_TTL_MINUTES: u32 = 1; +const README_URL: &str = + "https://git.extremist.software/jet/noisebell/src/branch/main/remote/rss-service/README.md"; + +struct AppState { + cache_url: String, + client: reqwest::Client, +} + +#[derive(Clone, Copy)] +enum FeedFormat { + Rss, + Atom, +} + +#[derive(Clone, Copy)] +enum FeedKind { + All, + Door, + Open, +} + +#[derive(Clone, Copy)] +struct FeedSpec { + kind: FeedKind, + format: FeedFormat, + path: &'static str, +} + +struct FeedDocument { + body: String, + etag: String, + last_modified: String, + content_type: &'static str, +} + +fn unix_now() -> u64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() +} + +fn format_full_timestamp(ts: u64) -> String { + DateTime::from_timestamp(ts as i64, 0) + .map(|dt: DateTime| dt.format("%A, %B %-d, %Y at %-I:%M:%S %p UTC").to_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| dt.to_rfc2822()) + .unwrap_or_else(|| "Thu, 01 Jan 1970 00:00:00 +0000".to_string()) +} + +fn format_rfc3339_timestamp(ts: u64) -> String { + DateTime::from_timestamp(ts as i64, 0) + .map(|dt: DateTime| dt.to_rfc3339()) + .unwrap_or_else(|| "1970-01-01T00:00:00+00:00".to_string()) +} + +fn format_duration(seconds: u64) -> String { + let units = [(86_400, "day"), (3_600, "hour"), (60, "minute"), (1, "second")]; + + let mut remaining = seconds; + let mut parts = Vec::new(); + + for (unit_seconds, name) in units { + if remaining >= unit_seconds { + let count = remaining / unit_seconds; + remaining %= unit_seconds; + let suffix = if count == 1 { "" } else { "s" }; + parts.push(format!("{count} {name}{suffix}")); + } + + if parts.len() == 2 { + break; + } + } + + if parts.is_empty() { + "0 seconds".to_string() + } else if parts.len() == 1 { + parts.remove(0) + } else { + format!("{} and {}", parts[0], parts[1]) + } +} + +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 { + 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(headers: &HeaderMap) -> String { + let host = header_value(headers, "x-forwarded-host") + .or_else(|| header_value(headers, "host")) + .unwrap_or_else(|| "localhost:3002".to_string()); + let scheme = header_value(headers, "x-forwarded-proto").unwrap_or_else(|| "http".to_string()); + format!("{scheme}://{host}") +} + +fn item_timestamp(status: &CacheStatusResponse) -> u64 { + status.since.or(status.last_checked).unwrap_or(0) +} + +fn item_guid(status: &CacheStatusResponse) -> String { + let ts = item_timestamp(status); + format!("urn:noisebell:status:{}:{ts}", status.status.as_str()) +} + +fn feed_kind_slug(kind: FeedKind) -> &'static str { + match kind { + FeedKind::All => "all", + FeedKind::Door => "door", + FeedKind::Open => "open", + } +} + +fn feed_title(kind: FeedKind) -> &'static str { + match kind { + FeedKind::All => "Noisebell all status feed", + FeedKind::Door => "Noisebell door state feed", + FeedKind::Open => "Noisebell open-only feed", + } +} + +fn feed_description(kind: FeedKind) -> &'static str { + match kind { + FeedKind::All => "Current Noisebell state including open, closed, and offline.", + FeedKind::Door => "Current Noisebell door state, limited to open and closed.", + FeedKind::Open => "Current Noisebell state when the space is open.", + } +} + +fn feed_self_path(spec: FeedSpec) -> &'static str { + spec.path +} + +fn feed_alt_path(kind: FeedKind, format: FeedFormat) -> &'static str { + match (kind, format) { + (FeedKind::All, FeedFormat::Rss) => "/all/rss.xml", + (FeedKind::All, FeedFormat::Atom) => "/all/atom.xml", + (FeedKind::Door, FeedFormat::Rss) => "/door/", + (FeedKind::Door, FeedFormat::Atom) => "/door/atom.xml", + (FeedKind::Open, FeedFormat::Rss) => "/open/", + (FeedKind::Open, FeedFormat::Atom) => "/open/atom.xml", + } +} + +fn matches_feed(kind: FeedKind, status: DoorStatus) -> bool { + match kind { + FeedKind::All => true, + FeedKind::Door => matches!(status, DoorStatus::Open | DoorStatus::Closed), + FeedKind::Open => status == DoorStatus::Open, + } +} + +fn custom_item_title(status: DoorStatus) -> &'static str { + match status { + DoorStatus::Open => "Noisebridge opened", + DoorStatus::Closed => "Noisebridge closed", + DoorStatus::Offline => "Noisebridge sensor went offline", + } +} + +fn richer_item_text(status: &CacheStatusResponse, now: u64) -> String { + let since_sentence = status + .since + .map(|ts| { + format!( + "The current state became {} at {}, about {} ago.", + status.status, + format_full_timestamp(ts), + format_duration(now.saturating_sub(ts)), + ) + }) + .unwrap_or_else(|| { + format!("The current state is {}, but the start time is unknown.", status.status) + }); + + let checked_sentence = status + .last_checked + .map(|ts| { + format!( + "The cache last confirmed this at {}, about {} ago.", + format_full_timestamp(ts), + format_duration(now.saturating_sub(ts)), + ) + }) + .unwrap_or_else(|| "The cache has no recorded last-checked time yet.".to_string()); + + let lead = match status.status { + DoorStatus::Open => "Noisebridge is open right now.", + DoorStatus::Closed => "Noisebridge is closed right now.", + DoorStatus::Offline => "Noisebridge is currently offline right now.", + }; + + format!("{lead} {since_sentence} {checked_sentence}") +} + +fn effective_human_readable(status: &CacheStatusResponse, now: u64) -> String { + if status.human_readable.is_empty() { + richer_item_text(status, now) + } else { + status.human_readable.clone() + } +} + +fn build_rss_item(base_url: &str, status: &CacheStatusResponse, now: u64) -> String { + let ts = item_timestamp(status); + let pub_date = format_rfc2822_timestamp(ts); + let link = format!("{base_url}/status"); + let guid = item_guid(status); + let title = custom_item_title(status.status); + let description = richer_item_text(status, now); + + format!( + concat!( + " \n", + " {title}\n", + " {link}\n", + " {guid}\n", + " {pub_date}\n", + " {description}\n", + " \n" + ), + title = xml_escape(title), + link = xml_escape(&link), + guid = xml_escape(&guid), + pub_date = xml_escape(&pub_date), + description = xml_escape(&description), + ) +} + +fn build_rss_feed( + base_url: &str, + spec: FeedSpec, + status: &CacheStatusResponse, + now: u64, +) -> FeedDocument { + let ts = item_timestamp(status); + let pub_date = format_rfc2822_timestamp(ts); + let self_url = format!("{base_url}{}", feed_self_path(spec)); + let atom_alt_url = format!("{base_url}{}", feed_alt_path(spec.kind, FeedFormat::Atom)); + let item = if matches_feed(spec.kind, status.status) { + build_rss_item(base_url, status, now) + } else { + String::new() + }; + let etag = format!("\"rss:{}:{}:{}\"", feed_kind_slug(spec.kind), status.status.as_str(), ts); + let body = format!( + concat!( + "\n", + "\n", + " \n", + " {title}\n", + " {self_url}\n", + " {description}\n", + " {pub_date}\n", + " {ttl}\n", + " \n", + " \n", + "{item}", + " \n", + "\n" + ), + title = xml_escape(feed_title(spec.kind)), + self_url = xml_escape(&self_url), + description = xml_escape(feed_description(spec.kind)), + pub_date = xml_escape(&pub_date), + ttl = FEED_TTL_MINUTES, + self_url_attr = xml_escape(&self_url), + atom_url_attr = xml_escape(&atom_alt_url), + item = item, + ); + + FeedDocument { + body, + etag, + last_modified: pub_date, + content_type: "application/rss+xml; charset=utf-8", + } +} + +fn build_atom_entry(base_url: &str, status: &CacheStatusResponse, now: u64) -> String { + let ts = item_timestamp(status); + let updated = format_rfc3339_timestamp(ts); + let link = format!("{base_url}/status"); + let guid = item_guid(status); + let title = custom_item_title(status.status); + let description = richer_item_text(status, now); + + format!( + concat!( + " \n", + " {title}\n", + " {guid}\n", + " {updated}\n", + " \n", + " {description}\n", + " \n" + ), + title = xml_escape(title), + guid = xml_escape(&guid), + updated = xml_escape(&updated), + link_attr = xml_escape(&link), + description = xml_escape(&description), + ) +} + +fn build_atom_feed( + base_url: &str, + spec: FeedSpec, + status: &CacheStatusResponse, + now: u64, +) -> FeedDocument { + let ts = item_timestamp(status); + let updated = format_rfc3339_timestamp(ts); + let self_url = format!("{base_url}{}", feed_self_path(spec)); + let rss_alt_url = format!("{base_url}{}", feed_alt_path(spec.kind, FeedFormat::Rss)); + let id = format!("urn:noisebell:feed:{}", feed_kind_slug(spec.kind)); + let entry = if matches_feed(spec.kind, status.status) { + build_atom_entry(base_url, status, now) + } else { + String::new() + }; + let etag = format!("\"atom:{}:{}:{}\"", feed_kind_slug(spec.kind), status.status.as_str(), ts); + let body = format!( + concat!( + "\n", + "\n", + " {title}\n", + " {id}\n", + " {updated}\n", + " \n", + " \n", + " {description}\n", + "{entry}", + "\n" + ), + title = xml_escape(feed_title(spec.kind)), + id = xml_escape(&id), + updated = xml_escape(&updated), + self_url_attr = xml_escape(&self_url), + rss_url_attr = xml_escape(&rss_alt_url), + description = xml_escape(feed_description(spec.kind)), + entry = entry, + ); + + FeedDocument { + body, + etag, + last_modified: format_rfc2822_timestamp(ts), + content_type: "application/atom+xml; charset=utf-8", + } +} + +fn build_feed_document( + base_url: &str, + spec: FeedSpec, + status: &CacheStatusResponse, + now: u64, +) -> FeedDocument { + match spec.format { + FeedFormat::Rss => build_rss_feed(base_url, spec, status, now), + FeedFormat::Atom => build_atom_feed(base_url, spec, status, now), + } +} + +fn header_matches(headers: &HeaderMap, name: &'static str, expected: &str) -> bool { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .map(|value| value == expected) + .unwrap_or(false) +} + +fn response_with_cache(headers: &HeaderMap, document: FeedDocument) -> Response { + if header_matches(headers, "if-none-match", &document.etag) { + return ( + StatusCode::NOT_MODIFIED, + [ + (header::ETAG, document.etag), + (header::LAST_MODIFIED, document.last_modified), + (header::CACHE_CONTROL, "public, max-age=60".to_string()), + ], + ) + .into_response(); + } + + let mut response = document.body.into_response(); + response + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static(document.content_type)); + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=60")); + response + .headers_mut() + .insert(header::ETAG, HeaderValue::from_str(&document.etag).expect("etag is always valid")); + response.headers_mut().insert( + header::LAST_MODIFIED, + HeaderValue::from_str(&document.last_modified).expect("last-modified is always valid"), + ); + response +} + +async fn fetch_status(state: &AppState) -> Result { + let url = format!("{}/status", state.cache_url); + let response = state.client.get(&url).send().await.map_err(|e| { + error!(error = %e, %url, "failed to reach cache service"); + StatusCode::BAD_GATEWAY + })?; + + if !response.status().is_success() { + error!(status = %response.status(), %url, "cache service returned non-success status"); + return Err(StatusCode::BAD_GATEWAY); + } + + response.json::().await.map_err(|e| { + error!(error = %e, %url, "failed to parse cache status response"); + StatusCode::BAD_GATEWAY + }) +} + +async fn health() -> StatusCode { + StatusCode::OK +} + +async fn root_redirect() -> Redirect { + Redirect::temporary(README_URL) +} + +async fn get_status( + State(state): State>, +) -> Result, StatusCode> { + let mut status = fetch_status(&state).await?; + status.human_readable = effective_human_readable(&status, unix_now()); + Ok(Json(status)) +} + +async fn serve_feed( + State(state): State>, + headers: HeaderMap, + spec: FeedSpec, +) -> Result { + let mut status = fetch_status(&state).await?; + let now = unix_now(); + status.human_readable = effective_human_readable(&status, now); + let base_url = public_base_url(&headers); + let document = build_feed_document(&base_url, spec, &status, now); + Ok(response_with_cache(&headers, document)) +} + +async fn get_all_rss( + state: State>, + headers: HeaderMap, +) -> Result { + serve_feed(state, headers, FeedSpec { kind: FeedKind::All, format: FeedFormat::Rss, path: "/" }) + .await +} + +async fn get_all_atom( + state: State>, + headers: HeaderMap, +) -> Result { + serve_feed( + state, + headers, + FeedSpec { kind: FeedKind::All, format: FeedFormat::Atom, path: "/atom.xml" }, + ) + .await +} + +async fn get_door_rss( + state: State>, + headers: HeaderMap, +) -> Result { + serve_feed( + state, + headers, + FeedSpec { kind: FeedKind::Door, format: FeedFormat::Rss, path: "/door/" }, + ) + .await +} + +async fn get_door_atom( + state: State>, + headers: HeaderMap, +) -> Result { + serve_feed( + state, + headers, + FeedSpec { kind: FeedKind::Door, format: FeedFormat::Atom, path: "/door/atom.xml" }, + ) + .await +} + +async fn get_open_rss( + state: State>, + headers: HeaderMap, +) -> Result { + serve_feed( + state, + headers, + FeedSpec { kind: FeedKind::Open, format: FeedFormat::Rss, path: "/open/" }, + ) + .await +} + +async fn get_open_atom( + state: State>, + headers: HeaderMap, +) -> Result { + serve_feed( + state, + headers, + FeedSpec { kind: FeedKind::Open, format: FeedFormat::Atom, path: "/open/atom.xml" }, + ) + .await +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let port: u16 = std::env::var("NOISEBELL_RSS_PORT") + .unwrap_or_else(|_| "3002".into()) + .parse() + .context("NOISEBELL_RSS_PORT must be a valid u16")?; + + let cache_url = std::env::var("NOISEBELL_RSS_CACHE_URL") + .context("NOISEBELL_RSS_CACHE_URL is required")? + .trim_end_matches('/') + .to_string(); + + let http_timeout_secs: u64 = std::env::var("NOISEBELL_RSS_HTTP_TIMEOUT_SECS") + .unwrap_or_else(|_| "10".into()) + .parse() + .context("NOISEBELL_RSS_HTTP_TIMEOUT_SECS must be a valid u64")?; + + info!(port, %cache_url, ttl_minutes = FEED_TTL_MINUTES, "starting noisebell-rss"); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(http_timeout_secs)) + .build() + .context("failed to build HTTP client")?; + + let app_state = Arc::new(AppState { cache_url, client }); + + let app = Router::new() + .route("/health", get(health)) + .route("/status", get(get_status)) + .route("/", get(root_redirect)) + .route("/rss.xml", get(get_all_rss)) + .route("/atom.xml", get(get_all_atom)) + .route("/all/rss.xml", get(get_all_rss)) + .route("/all/atom.xml", get(get_all_atom)) + .route("/door/", get(get_door_rss)) + .route("/door/rss.xml", get(get_door_rss)) + .route("/door/atom.xml", get(get_door_atom)) + .route("/open/", get(get_open_rss)) + .route("/open/rss.xml", get(get_open_rss)) + .route("/open/atom.xml", get(get_open_atom)) + .layer( + TraceLayer::new_for_http() + .make_span_with(tower_http::trace::DefaultMakeSpan::new().level(Level::INFO)) + .on_response(tower_http::trace::DefaultOnResponse::new().level(Level::INFO)), + ) + .with_state(app_state); + + let listener = tokio::net::TcpListener::bind(("0.0.0.0", port)) + .await + .context(format!("failed to bind to 0.0.0.0:{port}"))?; + + info!(port, "listening"); + + let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .context("failed to register SIGTERM handler")?; + axum::serve(listener, app) + .with_graceful_shutdown(async move { + sigterm.recv().await; + }) + .await + .context("server error")?; + + info!("shutdown complete"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_status(status: DoorStatus) -> CacheStatusResponse { + CacheStatusResponse { + status, + since: Some(1_700_000_000), + last_checked: Some(1_700_000_120), + human_readable: String::new(), + } + } + + #[test] + fn open_feed_omits_closed_item() { + let feed = build_rss_feed( + "https://rss.example.com", + FeedSpec { kind: FeedKind::Open, format: FeedFormat::Rss, path: "/open/" }, + &sample_status(DoorStatus::Closed), + 1_700_000_200, + ); + + assert_eq!(feed.body.matches("").count(), 0); + } + + #[test] + fn all_rss_feed_contains_custom_title() { + let feed = build_rss_feed( + "https://rss.example.com", + FeedSpec { kind: FeedKind::All, format: FeedFormat::Rss, path: "/" }, + &sample_status(DoorStatus::Open), + 1_700_000_200, + ); + + assert!(feed.body.contains("Noisebridge opened")); + assert!(feed.body.contains("1")); + assert!(feed.body.contains("/atom.xml")); + } + + #[test] + fn atom_feed_contains_entry() { + let feed = build_atom_feed( + "https://rss.example.com", + FeedSpec { kind: FeedKind::Door, format: FeedFormat::Atom, path: "/door/atom.xml" }, + &sample_status(DoorStatus::Closed), + 1_700_000_200, + ); + + assert!(feed.body.contains("")); + assert!(feed.body.contains("")); + assert!(feed.body.contains("Noisebridge closed")); + } +}