//! The directory in a web browser (#215): anyone may browse it, no token, read only. The most //! subscribed and the categories, every feed A to Z, a search by name, and a page per feed with //! its address to subscribe to in any podcast app. Rendered here from the store, as plain HTML: //! no build step, and nothing the API does not already hold. //! //! A feed's description and items are the publisher's HTML, unsanitized in the store; they are //! shown as text, tags stripped, and an item's link only when it is http(s). use std::collections::BTreeMap; use std::fmt::Write; use axum::extract::{Path, Query, State}; use axum::http::{HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Response}; use axum::routing::get; use axum::Router; use crate::{Listing, Shared}; pub fn router() -> Router { Router::new() .route("/", get(home)) .route("/all", get(all)) .route("/search", get(search)) .route("/category/{name}", get(category)) .route("/feed/{id}", get(feed)) .route("/site.css", get(|| asset("text/css; charset=utf-8", include_bytes!("site.css")))) .route("/site.js", get(|| asset("text/javascript; charset=utf-8", include_bytes!("site.js")))) .route("/logo.svg", get(|| asset("image/svg+xml", include_bytes!("../../../web/logo.svg")))) .route("/logo-dark.svg", get(|| asset("image/svg+xml", include_bytes!("../../../web/logo-dark.svg")))) .route("/favicon.png", get(|| asset("image/png", include_bytes!("../../../web/favicon.png")))) } /// Its own script and stylesheet only; covers come from their publishers. const CSP: &str = "default-src 'none'; img-src https: http: data:; style-src 'self'; script-src 'self'; \ base-uri 'none'; form-action 'self'; frame-ancestors 'none'"; async fn asset(kind: &'static str, body: &'static [u8]) -> Response { ([(header::CONTENT_TYPE, kind), (header::CACHE_CONTROL, "public, max-age=86400")], body).into_response() } #[derive(serde::Deserialize, Default)] struct Filter { /// `podcasts` or `blogs`; anything else is both. kind: Option, sub: Option, q: Option, } impl Filter { fn keep(&self, l: &Listing) -> bool { match self.kind.as_deref() { Some("podcasts") => l.podcast, Some("blogs") => !l.podcast, _ => true, } } } async fn home(State(dir): State) -> Response { let all = match dir.store.list().await { Ok(a) => a, Err(e) => return failed(e), }; let mut top = all.clone(); top.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| name(a).to_lowercase().cmp(&name(b).to_lowercase()))); top.truncate(10); let mut cats: BTreeMap<&str, Vec<&Listing>> = BTreeMap::new(); for l in &all { cats.entry(l.category.as_deref().unwrap_or("Other")).or_default().push(l); } let mut b = String::new(); if all.is_empty() { b.push_str("

Nothing is listed yet. A feed appears once a server has shared it and the directory has read it.

"); } else { b.push_str("

Most subscribed

    "); for (i, l) in top.iter().enumerate() { let _ = write!(b, "
  1. {}{}{}{}
  2. ", esc(&l.id), i + 1, art(l, "art sm"), esc(name(l)), subscribers(l.subscribers)); } b.push_str("

Categories

"); for (c, ls) in &cats { let _ = write!(b, "{}{}", seg(c), esc(c), ls.len()); } let _ = write!(b, "

All {} feeds, A to Z

", all.len()); } page("iPX Directory", None, &b) } async fn all(State(dir): State, Query(f): Query) -> Response { listed(&dir, "All feeds", "/all", &f, |_| true).await } async fn search(State(dir): State, Query(f): Query) -> Response { let q = f.q.clone().unwrap_or_default().trim().to_lowercase(); if q.is_empty() { return redirect("/all"); } let title = format!("Results for \u{201c}{}\u{201d}", f.q.as_deref().unwrap_or("").trim()); listed(&dir, &title, "/search", &f, move |l| name(l).to_lowercase().contains(&q)).await } async fn category(State(dir): State, Path(c): Path, Query(f): Query) -> Response { let here = format!("/category/{}", seg(&c)); let cat = c.clone(); listed(&dir, &c, &here, &f, move |l| l.category.as_deref().unwrap_or("Other") == cat).await } /// A page of tiles: those `pick` takes, of the kind and subcategory asked for, A to Z. async fn listed(dir: &Shared, title: &str, here: &str, f: &Filter, pick: impl Fn(&Listing) -> bool) -> Response { let mut ls: Vec = match dir.store.list().await { Ok(a) => a.into_iter().filter(|l| pick(l)).collect(), Err(e) => return failed(e), }; if ls.is_empty() && here.starts_with("/category/") { return not_found("That category has nothing in it."); } let subs: BTreeMap = ls.iter().filter_map(|l| l.subcategory.clone()).fold(BTreeMap::new(), |mut m, s| { *m.entry(s).or_default() += 1; m }); ls.retain(|l| f.keep(l) && f.sub.as_ref().is_none_or(|s| l.subcategory.as_ref() == Some(s))); ls.sort_by_key(|l| name(l).to_lowercase()); // The query a link keeps, with one of its keys changed. let link = |kind: Option<&str>, sub: Option<&str>| { let mut q = url::form_urlencoded::Serializer::new(String::new()); if let Some(v) = f.q.as_deref() { q.append_pair("q", v); } if let Some(v) = kind { q.append_pair("kind", v); } if let Some(v) = sub { q.append_pair("sub", v); } let q = q.finish(); if q.is_empty() { here.to_owned() } else { format!("{here}?{q}") } }; let mut b = String::new(); let _ = write!(b, "

Directory

{}

"); if !subs.is_empty() { b.push_str(""); } if ls.is_empty() { b.push_str("

Nothing here.

"); } else { let _ = write!(b, "

{}

", plural(ls.len(), "feed")); for l in &ls { let _ = write!(b, "{}{}{}", esc(&l.id), art(l, "art"), esc(name(l)), subscribers(l.subscribers)); } b.push_str("
"); } page(title, None, &b) } async fn feed(State(dir): State, Path(id): Path) -> Response { let p = match dir.store.feed(&id).await { Ok(Some(p)) => p, Ok(None) => return not_found("That feed is not in the directory."), Err(e) => return failed(e), }; let l = &p.listing; let mut b = String::new(); b.push_str("

Directory"); if let Some(c) = &l.category { let _ = write!(b, " {}", seg(c), esc(c)); } let _ = write!(b, "

{}

{}

{}{}

", art(l, "art lg"), esc(name(l)), subscribers(l.subscribers), l.subcategory.as_deref().map(|s| format!(" · {}", esc(s))).unwrap_or_default()); let _ = write!(b, "
\ Paste it into any podcast app or feed reader to subscribe.
", esc(&l.url), esc(&l.url)); if let Some(d) = p.description.as_deref().map(text).filter(|d| !d.is_empty() && d != name(l)) { let _ = write!(b, "

{}

", esc(&d)); } if let Some(why) = &p.failing { let _ = write!(b, "

{}

", esc(why)); } let _ = write!(b, "

{}

", if l.podcast { "Latest episodes" } else { "Latest posts" }); if p.items.is_empty() { b.push_str("

Nothing read from it yet. The directory checks each feed every six hours.

"); } for e in &p.items { let title = e.title.as_deref().map(text).filter(|t| !t.is_empty()).unwrap_or_else(|| "Untitled".into()); let head = match e.link.as_deref().filter(|u| u.starts_with("https://") || u.starts_with("http://")) { Some(u) => format!("{}", esc(u), esc(&title)), None => esc(&title), }; let blurb: String = e.description.as_deref().map(text).unwrap_or_default().chars().take(400).collect(); let when = [e.published.map(date), e.duration.filter(|d| *d > 0).map(clock)].into_iter().flatten().collect::>().join(" · "); let _ = write!(b, "
{head}{}{}
", if blurb.is_empty() { String::new() } else { format!("

{}

", esc(&blurb)) }, if when.is_empty() { String::new() } else { format!("{when}") }); } page(name(l), Some(&text(p.description.as_deref().unwrap_or(""))), &b) } fn page(title: &str, description: Option<&str>, body: &str) -> Response { let full = if title == "iPX Directory" { title.to_owned() } else { format!("{title} | iPX Directory") }; let desc = description.filter(|d| !d.is_empty()).map(|d| d.chars().take(200).collect::()) .unwrap_or_else(|| "Public podcasts and blogs that iPX servers share.".into()); let html = format!( "\ {}\ \
\ \"\"iPX Directory\
\
{body}
Public podcasts and blogs that iPX servers share, and how many people on them subscribe. \ Read only: to change what is here, an iPX server shares its public feeds.
", esc(&full), esc(&desc), ); let mut r = html.into_response(); let h = r.headers_mut(); h.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8")); h.insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=300")); h.insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_static(CSP)); h.insert("x-content-type-options", HeaderValue::from_static("nosniff")); h.insert("referrer-policy", HeaderValue::from_static("no-referrer")); r } fn not_found(why: &str) -> Response { let mut r = page("Not found", None, &format!("

Directory

Not found

{}

", esc(why))); *r.status_mut() = StatusCode::NOT_FOUND; r } fn failed(e: anyhow::Error) -> Response { tracing::error!(error = %format!("{e:#}"), "a directory page failed"); let mut r = page("Unavailable", None, "

Unavailable

The directory could not read its list just now. Try again in a minute.

"); *r.status_mut() = StatusCode::SERVICE_UNAVAILABLE; r } fn redirect(to: &'static str) -> Response { (StatusCode::SEE_OTHER, [(header::LOCATION, to)]).into_response() } /// A cover from its publisher, over the feed's initials, which show where it has none or it fails. fn art(l: &Listing, class: &str) -> String { // The first letter of each of the first two words that have one: `` is A, not nothing. let initials: String = name(l).split_whitespace().filter_map(|w| w.chars().find(|c| c.is_alphanumeric())).take(2).collect(); let img = l.image.as_deref().filter(|u| u.starts_with("https://") || u.starts_with("http://")) .map(|u| format!("\"\"", esc(u))) .unwrap_or_default(); format!("", esc(&initials.to_uppercase())) } fn name(l: &Listing) -> &str { l.title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(&l.id) } fn subscribers(n: i64) -> String { match n { 0 => "Listed, no subscribers yet".into(), 1 => "1 person subscribes".into(), n => format!("{n} people subscribe"), } } fn plural(n: usize, word: &str) -> String { format!("{n} {word}{}", if n == 1 { "" } else { "s" }) } /// `12 Oct 2026`, from a Unix time, with no date library: the civil-from-days algorithm. fn date(t: i64) -> String { let z = t.div_euclid(86_400) + 719_468; let era = z.div_euclid(146_097); let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = yoe + era * 400 + i64::from(m <= 2); const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; format!("{d} {} {y}", MONTHS[(m - 1) as usize]) } fn clock(secs: i64) -> String { let (h, m) = (secs / 3600, secs % 3600 / 60); if h > 0 { format!("{h} h {m} min") } else { format!("{} min", m.max(1)) } } /// The publisher's HTML as plain text: tags gone, entities read, whitespace collapsed. Escaped /// again where it is shown. fn text(html: &str) -> String { // A space where each tag was, or two paragraphs run together: "drives.For over". let stripped = ammonia::Builder::empty().clean(&html.replace('<', " <")).to_string(); let decoded = stripped.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace(" ", " ").replace("&", "&"); decoded.split_whitespace().collect::>().join(" ") } fn esc(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), c => out.push(c), } } out } /// A category as a path segment. fn seg(s: &str) -> String { percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string() } #[cfg(test)] mod tests { use super::*; #[test] fn the_publishers_html_becomes_text_and_is_escaped_where_shown() { assert_eq!(text("

Tom & Jerry live

"), "Tom & Jerry live"); assert_eq!(text("

One.

Two.

"), "One. Two."); assert_eq!(esc(&text("a <b> c")), "a <b> c"); assert_eq!(esc("\">"), ""><img onerror=x>"); } #[test] fn dates_and_lengths_read_as_people_write_them() { assert_eq!(date(0), "1 Jan 1970"); assert_eq!(date(1_791_820_800), "12 Oct 2026"); assert_eq!(date(951_782_400), "29 Feb 2000"); assert_eq!(clock(59), "1 min"); assert_eq!(clock(3_725), "1 h 2 min"); } }