//! The private podcast feed. //! //! A podcast app is the right client for this: it downloads overnight, plays //! offline, remembers position, and has lock-screen controls — none of which a //! file in a folder gives you at the gym. //! //! Auth is a token in the query string, not a bearer header, because no podcast //! app lets you set headers. That is a real trade: the token is in the URL and //! therefore in the app's database and any proxy log it passes. It is scoped to //! reading this feed and nothing else, and can be rotated by reissuing it. use axum::extract::{Path, Query, State}; use axum::http::{header, StatusCode}; use axum::response::{IntoResponse, Response}; use serde::Deserialize; use sqlx::Row; use crate::{ApiError, AppState}; #[derive(Deserialize)] pub struct FeedAuth { pub token: String, } /// The audio endpoint accepts a token either way — see `episode_audio`. #[derive(Deserialize)] pub struct OptionalAuth { #[serde(default)] pub token: Option, } /// Resolve a feed token to the workspace it may read. /// /// Reuses the normal API token table, so revoking a token revokes the feed with /// it — a second secret store for podcasts would be one more thing to forget to /// rotate. async fn workspace_for(state: &AppState, token: &str) -> Result { let user = state .auth .authenticate(token) .await .map_err(|_| ApiError::Unauthorized)?; Ok(user.workspace_id.as_uuid()) } fn xml_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } fn rfc2822(ts: time::OffsetDateTime) -> String { // Podcast clients are strict about pubDate. `time`'s RFC2822 is exactly it. ts.format(&time::format_description::well_known::Rfc2822) .unwrap_or_else(|_| "Thu, 01 Jan 1970 00:00:00 +0000".into()) } /// `GET /api/podcast/feed.xml?token=…` pub async fn feed( State(state): State, Query(auth): Query, ) -> Result { let workspace_id = workspace_for(&state, &auth.token).await?; let rows = sqlx::query( "SELECT id, episode_date, title, bytes, duration_secs, created_at FROM podcast_episodes WHERE workspace_id = $1 -- Skip markers for missions whose script was reaped before the -- render sweep reached them: a zero-byte enclosure makes a podcast -- app show a broken episode rather than simply not showing one. AND bytes > 0 ORDER BY created_at DESC LIMIT 100", ) .bind(workspace_id) .fetch_all(&state.pool) .await?; let base = std::env::var("CLAWMATES_PUBLIC_URL") .unwrap_or_else(|_| "http://localhost:8080".to_string()); let base = base.trim_end_matches('/'); let mut items = String::new(); for r in &rows { let id: uuid::Uuid = r.get("id"); let title: String = r.get("title"); let date: String = r.get("episode_date"); let bytes: i64 = r.get("bytes"); let secs: i32 = r.get("duration_secs"); let created: time::OffsetDateTime = r.get("created_at"); // The token rides on the enclosure too: the app fetches the audio in a // separate request that carries none of the feed's context. let url = format!("{base}/api/podcast/episodes/{id}.mp3?token={}", auth.token); items.push_str(&format!( r#" {t} Research digest for {d} {p} {id} {secs} "#, t = xml_escape(&title), d = xml_escape(&date), p = rfc2822(created), u = xml_escape(&url), len = bytes, )); } let xml = format!( r#" ClawMates Research {base} Papers read against your projects, every morning. en-us false {items} "# ); Ok(( StatusCode::OK, [(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")], xml, ) .into_response()) } /// `GET /api/podcast/episodes/{id}.mp3?token=…` pub async fn episode_audio( State(state): State, Path(file): Path, Query(auth): Query, headers: axum::http::HeaderMap, ) -> Result { // A podcast app fetches this with the token in the URL, because it cannot // set headers. The browser plays it through the same-origin proxy, which // supplies a bearer and no query token. Both are the same session; refusing // either would break one of the two ways this is listened to. let token = auth .token .or_else(|| { headers .get(axum::http::header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) .map(str::to_string) }) .ok_or(ApiError::Unauthorized)?; let workspace_id = workspace_for(&state, &token).await?; let id = file .strip_suffix(".mp3") .and_then(|s| uuid::Uuid::parse_str(s).ok()) .ok_or(ApiError::NotFound)?; let row = sqlx::query( "SELECT blob_key, bytes FROM podcast_episodes WHERE id = $1 AND workspace_id = $2", ) .bind(id) .bind(workspace_id) .fetch_optional(&state.pool) .await? .ok_or(ApiError::NotFound)?; let key: String = row.get("blob_key"); let blobs = state.blobs.clone().ok_or(ApiError::Internal)?; let bytes = blobs.get(&key).await.map_err(|e| { eprintln!("podcast: reading {key}: {e}"); ApiError::Internal })?; Ok(( StatusCode::OK, [ (header::CONTENT_TYPE, "audio/mpeg".to_string()), (header::CONTENT_LENGTH, bytes.len().to_string()), // Podcast apps re-fetch on every refresh otherwise. (header::CACHE_CONTROL, "private, max-age=86400".to_string()), ], bytes, ) .into_response()) } /// `GET /api/podcast/subscription` — the URL to paste into a podcast app. /// /// Minted here rather than in the browser because the session lives in an /// httpOnly cookie that JavaScript cannot read, and the same-origin proxy that /// normally supplies the bearer is not available to a podcast app on a phone. /// So the caller's own token is echoed back inside a URL that points DIRECTLY /// at this backend. pub async fn subscription( State(state): State, headers: axum::http::HeaderMap, crate::extract::Authed(_user): crate::extract::Authed, ) -> Result, ApiError> { let token = headers .get(axum::http::header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) .ok_or(ApiError::Unauthorized)?; let base = std::env::var("CLAWMATES_PUBLIC_URL") .unwrap_or_else(|_| "http://localhost:8080".to_string()); let base = base.trim_end_matches('/'); let _ = &state; Ok(axum::Json(serde_json::json!({ "feedUrl": format!("{base}/api/podcast/feed.xml?token={token}"), // The panel warns when this is still localhost: a phone cannot reach it, // and a feed that only works on the machine that made it is a feed that // silently never syncs. "reachable": !base.contains("localhost") && !base.contains("127.0.0.1"), }))) } /// `GET /api/podcast/episodes` — the list behind the UI panel. /// /// Normal bearer auth, unlike the feed: this is the app talking to its own API, /// where a header is available and a token in a URL would be needless exposure. pub async fn list_episodes( State(state): State, crate::extract::Authed(user): crate::extract::Authed, ) -> Result, ApiError> { let rows = sqlx::query( "SELECT e.id, e.episode_date, e.title, e.bytes, e.duration_secs, e.rendered_by, e.created_at, e.mission_id, m.title AS mission_title FROM podcast_episodes e -- LEFT: an episode outlives its mission (migration 0080). An inner -- join would hide exactly the back-catalogue that change protects. LEFT JOIN missions m ON m.id = e.mission_id WHERE e.workspace_id = $1 AND e.bytes > 0 ORDER BY e.created_at DESC LIMIT 50", ) .bind(user.workspace_id.as_uuid()) .fetch_all(&state.pool) .await?; let episodes: Vec = rows .iter() .map(|r| { let secs: i32 = r.get("duration_secs"); let created: time::OffsetDateTime = r.get("created_at"); serde_json::json!({ "id": r.get::("id"), "missionId": r.get::, _>("mission_id"), "missionTitle": r .get::, _>("mission_title") .unwrap_or_else(|| "(mission deleted)".to_string()), "title": r.get::("title"), "date": r.get::("episode_date"), "bytes": r.get::("bytes"), "durationSecs": secs, "renderedBy": r.get::("rendered_by"), "createdAt": created.unix_timestamp(), }) }) .collect(); // How many missions produced no audio, so the panel can say so rather than // leaving a silent gap the operator has to notice for themselves. let unrenderable: i64 = sqlx::query_scalar( "SELECT count(*) FROM podcast_episodes WHERE workspace_id = $1 AND bytes = 0", ) .bind(user.workspace_id.as_uuid()) .fetch_one(&state.pool) .await .unwrap_or(0); Ok(axum::Json(serde_json::json!({ "episodes": episodes, "unrenderable": unrenderable, }))) } #[cfg(test)] mod tests { use super::*; /// A title with an ampersand must not produce invalid XML — a single bad /// character makes a podcast app reject the WHOLE feed, not one episode. #[test] fn titles_are_xml_escaped() { let out = xml_escape(r#"BM25 & "hybrid""#); assert_eq!(out, "BM25 & <dense> "hybrid""); assert!(!out.contains(" & "), "raw ampersand breaks the feed"); } #[test] fn pubdate_is_rfc2822() { let t = time::OffsetDateTime::from_unix_timestamp(1_755_000_000).unwrap(); let s = rfc2822(t); // "Mon, 12 Aug 2025 ..." — clients parse this strictly. assert!(s.contains(", "), "{s}"); assert!(s.ends_with("+0000"), "{s}"); } }