From 1c6f4eb3219cfaa2b57690c3c7646eb0f8d398a9 Mon Sep 17 00:00:00 2001 From: rays Date: Mon, 12 Oct 2026 17:16:41 +0000 Subject: [PATCH] A public, read-only web view of the directory (#215) The directory renders its own pages from the store: the most subscribed, categories, A to Z, search, and a page per feed with its address and a Copy button. No token, nothing that writes; /v1 keeps its token check. The publisher's HTML is shown as text, item links only when http(s), with a strict Content-Security-Policy; covers load from their publishers with no referrer. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 + Dockerfile.directory | 2 + crates/directory/CHANGELOG.md | 1 + crates/directory/Cargo.toml | 2 + crates/directory/src/lib.rs | 40 ++++ crates/directory/src/site.css | 67 +++++++ crates/directory/src/site.js | 10 + crates/directory/src/site.rs | 348 ++++++++++++++++++++++++++++++++++ docs/directory.md | 11 ++ 9 files changed, 483 insertions(+) create mode 100644 crates/directory/src/site.css create mode 100644 crates/directory/src/site.js create mode 100644 crates/directory/src/site.rs diff --git a/Cargo.lock b/Cargo.lock index 0846053..5849043 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1858,6 +1858,7 @@ dependencies = [ name = "ipx-directory" version = "0.1.0-dev" dependencies = [ + "ammonia", "anyhow", "axum", "clap", @@ -1865,6 +1866,7 @@ dependencies = [ "opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", + "percent-encoding", "reqwest", "sea-orm", "serde", diff --git a/Dockerfile.directory b/Dockerfile.directory index 68e91db..4c89e80 100644 --- a/Dockerfile.directory +++ b/Dockerfile.directory @@ -10,6 +10,8 @@ WORKDIR /src # the main Dockerfile if builds get slow enough to matter. COPY Cargo.toml Cargo.lock ./ COPY crates ./crates +# The logo and icon the public pages serve (#215), compiled into the binary. +COPY web/logo.svg web/logo-dark.svg web/favicon.png ./web/ RUN cargo build --release --locked -p ipx-directory FROM debian:trixie-slim diff --git a/crates/directory/CHANGELOG.md b/crates/directory/CHANGELOG.md index 939b6c7..6f3b295 100644 --- a/crates/directory/CHANGELOG.md +++ b/crates/directory/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Anyone can browse the directory in a web browser, read only: the most subscribed, categories, every feed A to Z, a search by name, and each feed's page with its latest items and its address to copy into any podcast app (#215). - `GET /v1/instances` lists the servers holding a snapshot, with their feeds and subscribers, for an admin token (#206). - `ipx-directory`, a directory of public podcasts that ipx servers share: each sends the public feeds its people subscribe to, and any server with a token browses them, each feed listed once it has been read. Tokens are in its config file, each read-only, read-write or admin, and revoked one at a time (#204, #205). - Traces: a span a request, joining the trace of the ipx server that called, and a trace a refresher round with a span a feed (#213). diff --git a/crates/directory/Cargo.toml b/crates/directory/Cargo.toml index 882ecc2..59699db 100644 --- a/crates/directory/Cargo.toml +++ b/crates/directory/Cargo.toml @@ -8,6 +8,7 @@ name = "ipx-directory" path = "src/main.rs" [dependencies] +ammonia.workspace = true anyhow.workspace = true axum.workspace = true clap.workspace = true @@ -15,6 +16,7 @@ ipx-feed.workspace = true opentelemetry.workspace = true opentelemetry-otlp.workspace = true opentelemetry_sdk.workspace = true +percent-encoding.workspace = true reqwest.workspace = true sea-orm.workspace = true serde.workspace = true diff --git a/crates/directory/src/lib.rs b/crates/directory/src/lib.rs index d20dc02..09822e3 100644 --- a/crates/directory/src/lib.rs +++ b/crates/directory/src/lib.rs @@ -9,6 +9,7 @@ pub mod config; pub mod otel; +mod site; pub mod store; use std::sync::{Arc, RwLock}; @@ -256,6 +257,8 @@ pub fn router(dir: Shared) -> Router { Router::new() .route("/healthz", get(healthz)) .merge(v1) + // Anyone may browse, no token, read only (#215). + .merge(site::router()) .layer(axum::middleware::from_fn(access_log)) .with_state(dir) } @@ -465,6 +468,43 @@ mod tests { (status, serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)) } + async fn get_page(app: &Router, path: &str) -> (u16, String, Option) { + let req = axum::http::Request::builder().uri(path).body(Body::empty()).unwrap(); + let resp = app.clone().oneshot(req).await.unwrap(); + let status = resp.status().as_u16(); + let csp = resp.headers().get("content-security-policy").map(|v| v.to_str().unwrap().to_owned()); + let bytes = axum::body::to_bytes(resp.into_body(), 1 << 22).await.unwrap(); + (status, String::from_utf8(bytes.to_vec()).unwrap(), csp) + } + + #[tokio::test] + async fn anyone_browses_the_pages_and_the_api_still_needs_a_token() { + let (app, dir) = app().await; + let snap = serde_json::json!([{ "url": "https://x.test/a.xml", "subscribers": 3 }]); + call(&app, "POST", "/v1/snapshot", Some("rw"), Some(snap)).await; + store::tests::fetch_all(&dir.store).await; + let id = dir.store.list().await.unwrap()[0].id.clone(); + + let (s, home, csp) = get_page(&app, "/").await; + assert_eq!(s, 200); + assert!(csp.unwrap().contains("script-src 'self'")); + assert!(home.contains("Most subscribed") && home.contains(&format!("/feed/{id}")), "{home}"); + assert!(get_page(&app, "/all").await.1.contains("3 people subscribe")); + let (s, page, _) = get_page(&app, &format!("/feed/{id}")).await; + assert_eq!(s, 200); + // Its address, to subscribe to anywhere. + assert!(page.contains("value=\"https://x.test/a.xml\""), "{page}"); + assert!(!page.contains("\ +
\ + \"\"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"); + } +} diff --git a/docs/directory.md b/docs/directory.md index 04429e2..5fb92a0 100644 --- a/docs/directory.md +++ b/docs/directory.md @@ -36,6 +36,17 @@ server's snapshot takes its feeds and counts out until it next shares; to keep a revoke its token. An admin's own subscriptions open as their feeds, not as Directory pages, so removing one of those is done by marking it private, or from another admin's account. +## In a web browser + +Anyone may browse it at its address, no token and no sign-in (#215): the most subscribed, the +categories, every feed A to Z, a search by name, and a page for each feed with its description, +its latest items and its address, with a Copy button, to subscribe to in any podcast app or feed +reader. The pages only read: nothing in them changes the directory. They are rendered by the +directory itself from what the API serves, with the publisher's HTML shown as text, an item's link +only when it is http(s), and a Content-Security-Policy that allows the site's own script and +stylesheet and the covers, which load from their publishers. `/v1` is the API and still needs a +token for everything. + ## Tokens Everything but `/healthz` needs a token, sent as `Authorization: Bearer `. Each one has a