diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 85edaff..654af1e 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,8 +1,8 @@ #!/bin/sh # Refuses a commit whose Rust is not as rustfmt writes it, or that clippy warns about. Clippy only # checks and never links, so it is light beside a test build (CLAUDE.md, Tests). It checks the -# crates the commit touches and those built on them, not the workspace: ipx uses ipx-directory, -# and both use ipx-feed. A commit to the page alone runs nothing here. +# crates the commit touches and those built on them, not the workspace: ipx uses ipx-play and +# ipx-directory, ipx-play uses ipx-directory, and all use ipx-feed. A commit to the page alone runs nothing here. staged=$(git diff --cached --name-only --diff-filter=ACMD) echo "$staged" | grep -q -e '\.rs$' -e 'Cargo\.toml$' -e '^Cargo\.lock$' || exit 0 if ! cargo fmt --all --check >/dev/null 2>&1; then @@ -10,11 +10,13 @@ if ! cargo fmt --all --check >/dev/null 2>&1; then exit 1 fi if echo "$staged" | grep -q -e '^Cargo\.toml$' -e '^Cargo\.lock$' -e '^crates/feed/'; then - pkgs="ipx-feed ipx-directory ipx ipx-tui" + pkgs="ipx-feed ipx-directory ipx-play ipx ipx-tui" elif echo "$staged" | grep -q '^crates/tui/'; then pkgs="ipx-tui" elif echo "$staged" | grep -q '^crates/directory/'; then - pkgs="ipx-directory ipx" + pkgs="ipx-directory ipx-play ipx" +elif echo "$staged" | grep -q '^crates/play/'; then + pkgs="ipx-play ipx" elif echo "$staged" | grep -q '^crates/ipx/'; then pkgs="ipx" else diff --git a/CLAUDE.md b/CLAUDE.md index ba8f0f3..4a86185 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,7 @@ service of the `content` project, beside audiobookshelf, immich and Plex (#208). | Old database | `/mnt/user/ipx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` | | Downloads | `/mnt/user/ipx/downloads` | `/downloads` | | Directory | `ipx-directory` in the same project, `192.168.1.130:8100`, public at `directory.ipxreader.app` through the tunnel with no Access in front: its pages are anyone's to browse, read only (#215), and its API (`/v1`) takes a token for everything. Until 2026-10-12 it was `ipx-dir.sdf1.net` (#214). Config `/mnt/fast/appdata/ipx-directory/directory.toml` (token hashes; Tower's own token in `tower.token` beside it, and entered in ipx's Admin, Directory, which keeps it in the database's `settings`, with the address ipx reaches it at: `http://ipx-directory:8100`, inside Docker, not the public address, which went out through Cloudflare and the tunnel, 170-340 ms a call where the directory answers in 16 ms), database `ipx_directory` on the same Postgres, URL in `directory.env` beside the compose file. See [docs/directory.md](docs/directory.md) | `/config/directory.toml` | +| Shared episodes | `ipx-play` in the same project, `192.168.1.130:8102`, public at `play.ipxreader.app` through the tunnel with no Access in front: the page a shared episode opens, anyone's to see (#447). `/v1` takes a token. Config `/mnt/fast/appdata/ipx-play/play.toml` (token hashes; Tower's token entered in ipx's Admin, Directory, Sharing episodes, with the address `http://ipx-play:8102`), database `ipx_play` on the same Postgres, URL in `play.env` beside the compose file. See [docs/play.md](docs/play.md) | `/config/play.toml` | | Home page | `ipxreader.app` (and `www`): a static page from `site/`, on the Cloudflare Pages project `ipxreader`, not on Tower. See below | | | Web UI | `ipx-web` (Caddy) in the same project, at `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel. It serves the page and passes `/api` and `/media` to ipx, whose own port is published nowhere; its address on `ipx_default` is pinned to `192.168.64.10` (#203) | ipx `0.0.0.0:8099`, Caddy `:8080` | | Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | Caddy passes `Cf-Access-*` only from `192.168.64.1`, the `ipx_default` gateway cloudflared arrives from (`TUNNEL_PEER`; the subnet is pinned in the compose file); ipx trusts them only from Caddy, `192.168.64.10`, and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) | @@ -73,6 +74,14 @@ docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx-directory docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx-directory ``` +ipx-play the same: + +```sh +sh scripts/build-image.sh play +docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx-play +docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx-play +``` + And the page, `ipx-web`, the same again; a change to `web/` alone needs only this one: ```sh @@ -240,16 +249,18 @@ session was stopped. So: run in the background beside another, and never two crates' builds at once. Do not tune `-j`. * Look at the load before and after each (`cat /proc/loadavg`). Above about 24, Tower's twelve threads twice over, wait for it to come down before starting another. -* Test only what the change is in. ipx uses ipx-directory, and both use ipx-feed, so: +* Test only what the change is in. ipx uses ipx-play and ipx-directory, ipx-play uses + ipx-directory, and all use ipx-feed, so: | changed | run | |---|---| | `web/` alone | the three page checks; `npx playwright test -g ''`; no cargo | | `crates/ipx/` | `cargo test -p ipx`; the browser tests too if the API changed | - | `crates/directory/` | `cargo test -p ipx-directory`, then `-p ipx` | - | `crates/feed/` | `cargo test -p ipx-feed`, then `-p ipx-directory`, then `-p ipx` | + | `crates/directory/` | `cargo test -p ipx-directory`, then `-p ipx-play`, then `-p ipx` | + | `crates/play/` | `cargo test -p ipx-play`, then `-p ipx` | + | `crates/feed/` | `cargo test -p ipx-feed`, then `-p ipx-directory`, `-p ipx-play`, then `-p ipx` | | `crates/tui/` | `cargo test -p ipx-tui`; it uses only the API, none of the other crates | - | `Cargo.toml`, `Cargo.lock` | all four, one at a time | + | `Cargo.toml`, `Cargo.lock` | all five, one at a time | The pre-commit hook's clippy follows the same table. The whole browser suite once, at the end of a change to the page or the API; never `cargo test` for the workspace in one command. @@ -411,7 +422,8 @@ Every change gets one line under `## [Unreleased]` in the changelog of the servi its [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated, Removed, Fixed or Security. One changelog a service: [crates/ipx/CHANGELOG.md](crates/ipx/CHANGELOG.md) (the daemon and its API; until 0.12.0 everything), [web/CHANGELOG.md](web/CHANGELOG.md) (the page -and ipx-web) [crates/directory/CHANGELOG.md](crates/directory/CHANGELOG.md) (ipx-directory) and +and ipx-web) [crates/directory/CHANGELOG.md](crates/directory/CHANGELOG.md) (ipx-directory), +[crates/play/CHANGELOG.md](crates/play/CHANGELOG.md) (ipx-play) and [ios/CHANGELOG.md](ios/CHANGELOG.md) (the iOS app). A change to more than one gets a line in each, each saying what that service does differently. The monitoring (the Grafana dashboard, the prod-check skill) and the repository's own tooling are not @@ -427,6 +439,7 @@ release: |---|---|---|---| | ipx | `crates/ipx/Cargo.toml` | `crates/ipx/CHANGELOG.md` | `vX.Y.Z`, as every release before the split | | ipx-directory | `crates/directory/Cargo.toml` | `crates/directory/CHANGELOG.md` | `directory-vX.Y.Z` | +| ipx-play | `crates/play/Cargo.toml` | `crates/play/CHANGELOG.md` | `play-vX.Y.Z` | | ipx-web, the page | `package.json` | `web/CHANGELOG.md` | `web-vX.Y.Z` | `crates/feed` is a library the two Rust services build in, not released (`0.0.0`); a change to it diff --git a/Cargo.lock b/Cargo.lock index 1bf37f1..c369726 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1983,6 +1983,7 @@ dependencies = [ "futures-util", "ipx-directory", "ipx-feed", + "ipx-play", "jsonwebtoken", "librqbit", "opentelemetry", @@ -2056,6 +2057,33 @@ dependencies = [ "url", ] +[[package]] +name = "ipx-play" +version = "0.1.0-dev" +dependencies = [ + "ammonia", + "anyhow", + "axum", + "chrono", + "clap", + "ipx-directory", + "ipx-feed", + "opentelemetry", + "percent-encoding", + "reqwest", + "sea-orm", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio", + "toml", + "tower", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", + "url", +] + [[package]] name = "ipx-tui" version = "0.1.0-dev" diff --git a/Cargo.toml b/Cargo.toml index 80fc8c5..ad96d24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/ipx", "crates/feed", "crates/directory", "crates/tui"] +members = ["crates/ipx", "crates/feed", "crates/directory", "crates/tui", "crates/play"] resolver = "3" # Each service has its own version, in its own Cargo.toml (ipx, ipx-directory) or package.json @@ -14,6 +14,7 @@ unsafe_code = "forbid" [workspace.dependencies] ipx-feed = { path = "crates/feed" } ipx-directory = { path = "crates/directory" } +ipx-play = { path = "crates/play" } ammonia = "4.1.4" anyhow = "1.0.104" argon2 = "0.6.0" diff --git a/Dockerfile.play b/Dockerfile.play new file mode 100644 index 0000000..42ac6c1 --- /dev/null +++ b/Dockerfile.play @@ -0,0 +1,28 @@ +# ipx-play (#447), the pages shared episodes open. Built from the repo's root: +# sh scripts/build-image.sh play +FROM rust:1-slim-trixie AS build +# SQLite is compiled from source for sqlx, which needs a C toolchain. +RUN apt-get update && apt-get install -y --no-install-recommends build-essential \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /src +# Every member of the workspace, since cargo reads them all to build one. +COPY Cargo.toml Cargo.lock ./ +COPY crates ./crates +# The logo and icon its pages serve, compiled into the binary. +COPY web/logo.svg web/favicon.png ./web/ +# Compiled crates kept in the builder between builds (#438), in a target of its own. +RUN --mount=type=cache,id=ipx-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \ + --mount=type=cache,id=ipx-play-target,target=/src/target,sharing=locked \ + touch crates/play/src/main.rs crates/play/src/lib.rs crates/directory/src/lib.rs crates/feed/src/lib.rs \ + && cargo build --release --locked -p ipx-play \ + && cp target/release/ipx-play /usr/local/bin/ipx-play + +FROM debian:trixie-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /usr/local/bin/ipx-play /usr/local/bin/ipx-play +ENV PLAY_CONFIG=/config/play.toml +# Unraid's nobody:users. It writes nothing to disk: the database is elsewhere. +USER 99:100 +EXPOSE 8102 +CMD ["ipx-play"] diff --git a/crates/ipx/CHANGELOG.md b/crates/ipx/CHANGELOG.md index f92c120..66c82b3 100644 --- a/crates/ipx/CHANGELOG.md +++ b/crates/ipx/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Sharing an episode gives a link to its page on ipx-play, which anyone can open and play, set up in Admin, Directory, Sharing episodes; private and paid feeds still share their own address (#447). - The iOS app's icon count stays right with the app closed: with `[push]` set to an APNs key, ipx sends each registered phone (`POST /api/push/devices`, removed with `DELETE`) its All Subscriptions unread count through Apple's push service, within a minute of a change (#433). The push also wakes the app for a moment, for its widget to show the same count (#428). - A place in an episode saved from DeadAir's shortened version names its file (`deadair` in `POST .../position`), so it is read in that version's clock even when ipx never handed out its playlist, as for a copy kept on the device or after a restart (#412). - `/api/art` also serves the pictures in the text of items iPX has sent, through its artwork cache, so the page can keep them to read offline (#389). diff --git a/crates/ipx/Cargo.toml b/crates/ipx/Cargo.toml index 259be04..d379196 100644 --- a/crates/ipx/Cargo.toml +++ b/crates/ipx/Cargo.toml @@ -16,6 +16,7 @@ workspace = true [dependencies] ipx-feed.workspace = true ipx-directory.workspace = true +ipx-play.workspace = true ammonia.workspace = true anyhow.workspace = true argon2.workspace = true diff --git a/crates/ipx/src/config.rs b/crates/ipx/src/config.rs index 470fe64..6222a04 100644 --- a/crates/ipx/src/config.rs +++ b/crates/ipx/src/config.rs @@ -54,6 +54,12 @@ pub struct General { pub directory_token: String, /// Send the directory the public feeds people here subscribe to, and how many of them do. pub directory_share: bool, + /// ipx-play, which keeps a shared episode and gives back a link to its page (#447), as + /// `https://play.ipxreader.app` or, on its own network, `http://ipx-play:8102`. Empty: Share + /// hands over the episode's own address. + pub play_url: String, + /// The token ipx-play gave this server. Never sent to the page. + pub play_token: String, /// Signals are judged on the server's TypeSafe key (`TYPESAFE_KEY`), for everyone, rather than /// on each person's own (#271). Without that key it changes nothing. pub signals_on_server: bool, @@ -248,6 +254,8 @@ impl Default for General { directory_url: String::new(), directory_token: String::new(), directory_share: false, + play_url: String::new(), + play_token: String::new(), signals_on_server: false, signal_days: 7, fetch_internal: vec![], @@ -388,6 +396,10 @@ pub struct Stored { #[serde(default)] pub directory_share: bool, #[serde(default)] + pub play_url: String, + #[serde(default)] + pub play_token: String, + #[serde(default)] pub signals_on_server: bool, #[serde(default = "signal_days")] pub signal_days: u64, @@ -402,7 +414,7 @@ fn art_cache_mb() -> u64 { } /// `[general]` keys that live in the database once it holds the configuration. -const STORED_KEYS: [&str; 11] = [ +const STORED_KEYS: [&str; 13] = [ "schedule", "max_total_gb", "max_age_days", @@ -412,6 +424,8 @@ const STORED_KEYS: [&str; 11] = [ "directory_url", "directory_token", "directory_share", + "play_url", + "play_token", "signals_on_server", "signal_days", ]; @@ -429,6 +443,8 @@ impl Stored { directory_url: g.directory_url.clone(), directory_token: g.directory_token.clone(), directory_share: g.directory_share, + play_url: g.play_url.clone(), + play_token: g.play_token.clone(), signals_on_server: g.signals_on_server, signal_days: g.signal_days, } @@ -445,6 +461,8 @@ impl Stored { g.directory_url = self.directory_url; g.directory_token = self.directory_token; g.directory_share = self.directory_share; + g.play_url = self.play_url; + g.play_token = self.play_token; g.signals_on_server = self.signals_on_server; g.signal_days = self.signal_days; } diff --git a/crates/ipx/src/lib.rs b/crates/ipx/src/lib.rs index cd94ff6..25f6345 100644 --- a/crates/ipx/src/lib.rs +++ b/crates/ipx/src/lib.rs @@ -23,6 +23,7 @@ mod lists; mod logbuf; mod logging; mod metrics; +mod play; mod push; mod retention; mod scan; diff --git a/crates/ipx/src/play.rs b/crates/ipx/src/play.rs new file mode 100644 index 0000000..b7f1279 --- /dev/null +++ b/crates/ipx/src/play.rs @@ -0,0 +1,121 @@ +//! Sharing an episode as a page (#447): ipx-play keeps the episode and hands back a short link, +//! `play.ipxreader.app/e/`, which anyone can open, as Overcast's links are. Only an episode of +//! a public feed goes: the same rule as what the directory is sent, so a private or paid feed's +//! file address, which carries someone's access, never leaves this server. + +use crate::ctx::Ctx; +use std::collections::HashMap; +use std::time::Duration; + +/// Why there is no link, which the page answers by sharing the episode's own address as it did +/// before. +#[derive(Debug)] +pub enum NoLink { + /// No ipx-play address or token in Admin, Sharing. + NotSetUp, + /// A private or paid feed, or one in a private OPML. + Private, + /// Nothing to play: a post, or a feed's picture. + NoFile, + NotFound, + /// ipx-play did not answer, or refused. + Failed(String), +} + +fn target(ctx: &Ctx) -> Option<(String, String)> { + let cfg = ctx.cfg(); + let url = cfg.general.play_url.trim().trim_end_matches('/'); + let token = cfg.general.play_token.trim(); + (!url.is_empty() && !token.is_empty()).then(|| (url.to_owned(), token.to_owned())) +} + +/// The file to share: the one asked for, or the first that is sound, since a page plays audio. +fn pick(encs: &[crate::db::EncRow], asked: Option) -> Option<&crate::db::EncRow> { + match asked { + Some(id) => encs.iter().find(|e| e.id == id), + None => encs.iter().find(|e| e.mime.as_deref().is_some_and(|m| m.starts_with("audio/"))), + } +} + +#[tracing::instrument(name = "share_episode", skip(ctx))] +pub async fn share( + ctx: &Ctx, + user_id: i64, + feed_id: &str, + guid: &str, + enc: Option, +) -> Result { + let (base, token) = target(ctx).ok_or(NoLink::NotSetUp)?; + let failed = |e: anyhow::Error| NoLink::Failed(format!("{e:#}")); + let row = ctx.db.entry(user_id, feed_id, guid).await.map_err(failed)?.ok_or(NoLink::NotFound)?; + // Public by the directory's own rule, so the two cannot disagree on what is private. + let catalogue = crate::catalogue::subscriptions(ctx).await.map_err(failed)?; + let sub = catalogue.iter().find(|s| s.id == feed_id).ok_or(NoLink::NotFound)?; + let public = crate::directory::snapshot_entries(&catalogue, &HashMap::new()); + if !public.iter().any(|p| p.url == sub.cfg.url) { + return Err(NoLink::Private); + } + let file = pick(&row.enclosures, enc).ok_or(NoLink::NoFile)?; + let feed = ctx.db.feed_summary(feed_id).await.map_err(failed)?; + let episode = ipx_play::Episode { + feed_url: sub.cfg.url.clone(), + guid: guid.to_owned(), + feed_title: feed.title, + feed_image: row.image.clone().or(feed.image), + title: row.title.clone(), + // As the feed sent it: ipx-play sanitizes it for its page. + description: row.description.clone(), + link: row.link.clone(), + published: row.published, + duration: row.duration, + audio_url: file.url.clone(), + mime: file.mime.clone(), + }; + let mut req = ctx + .local_client + .post(format!("{base}/v1/shares")) + .bearer_auth(token) + .timeout(Duration::from_secs(15)) + .json(&episode); + // Its spans join this request's trace. + if let Some(tp) = ipx_directory::otel::traceparent() { + req = req.header("traceparent", tp); + } + let resp = req.send().await.map_err(|e| NoLink::Failed(format!("reaching ipx-play: {e:#}")))?; + let status = resp.status(); + if !status.is_success() { + let msg = resp.json::().await.ok().and_then(|v| v["error"].as_str().map(str::to_owned)); + return Err(NoLink::Failed(format!("ipx-play answered {status}: {}", msg.unwrap_or_default()))); + } + resp.json().await.map_err(|e| NoLink::Failed(format!("ipx-play's answer did not read: {e:#}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn enc(id: i64, mime: Option<&str>) -> crate::db::EncRow { + crate::db::EncRow { + id, + deadair: None, + deadair_pending: false, + feed_id: "f".into(), + guid: "g".into(), + url: format!("https://x/{id}"), + mime: mime.map(Into::into), + length: None, + path: None, + state: "done".into(), + last_error: None, + } + } + + #[test] + fn the_file_shared_is_the_one_asked_for_or_the_first_sound() { + let encs = [enc(1, Some("image/jpeg")), enc(2, Some("video/mp4")), enc(3, Some("audio/mpeg"))]; + assert_eq!(pick(&encs, None).map(|e| e.id), Some(3)); + assert_eq!(pick(&encs, Some(2)).map(|e| e.id), Some(2)); + assert!(pick(&encs, Some(9)).is_none()); + assert!(pick(&encs[..2], None).is_none(), "a picture and a video are not a page's to play"); + } +} diff --git a/crates/ipx/src/web.rs b/crates/ipx/src/web.rs index 71739f0..d03c720 100644 --- a/crates/ipx/src/web.rs +++ b/crates/ipx/src/web.rs @@ -67,6 +67,7 @@ pub fn router(state: WebState) -> Router { .route("/api/entries/{feed_id}/{guid}", get(one_entry)) .route("/api/entries/{feed_id}/{guid}/flags", post(set_flags)) .route("/api/entries/{feed_id}/{guid}/position", post(set_position)) + .route("/api/entries/{feed_id}/{guid}/share", post(share_entry)) .route("/api/feeds/{id}/read-all", post(read_all)) .route("/api/read-all", post(read_all_mine)) .route("/api/feeds/{id}/download-latest", post(download_latest)) @@ -2077,6 +2078,43 @@ async fn one_entry( Ok(Json(row)) } +#[derive(Deserialize)] +struct ShareBody { + /// The file to share; the first sound file when left out. + enc: Option, +} + +/// A link to the episode's page on ipx-play (#447), or none, with why, when the page should +/// share the episode's own address as before: a private feed, a post, no ipx-play set up. +#[derive(Serialize)] +struct ShareLink { + url: Option, + why: Option<&'static str>, +} + +async fn share_entry( + State(state): State, + Path((feed_id, guid)): Path<(String, String)>, + user: crate::db::User, + Json(body): Json, +) -> Result, ApiError> { + subscribed(&state, &user, &feed_id).await?; + use crate::play::NoLink; + let why = match crate::play::share(&state.ctx, user.id, &feed_id, &guid, body.enc).await { + Ok(s) => return Ok(Json(ShareLink { url: Some(s.url), why: None })), + Err(NoLink::NotFound) => return Err(ApiError::not_found("no such entry")), + Err(NoLink::NotSetUp) => "not_set_up", + Err(NoLink::Private) => "private", + Err(NoLink::NoFile) => "no_file", + Err(NoLink::Failed(e)) => { + // The person still gets the episode's own address; this is for whoever runs the server. + tracing::warn!(error = %e, "sharing an episode through ipx-play failed"); + "failed" + } + }; + Ok(Json(ShareLink { url: None, why: Some(why) })) +} + /// An item's text as the page may show it: the feed's HTML cleaned, and its Markdown, where it /// sent one, made HTML and cleaned the same way (#285). fn clean_row(sanitizer: &mut ammonia::Builder<'static>, row: &mut crate::db::EntryRow) { @@ -3038,6 +3076,9 @@ struct Settings { /// Whether a token is set: the token itself never leaves the server. directory_token_set: bool, directory_share: bool, + play_url: String, + /// Whether a token is set, as for the directory's. + play_token_set: bool, signals_on_server: bool, signal_days: u64, /// Whether the server has a TypeSafe key (`TYPESAFE_KEY`) for `signals_on_server` to use. @@ -3058,6 +3099,8 @@ async fn get_settings(State(state): State) -> Json { directory_url: cfg.general.directory_url.clone(), directory_token_set: !cfg.general.directory_token.trim().is_empty(), directory_share: cfg.general.directory_share, + play_url: cfg.general.play_url.clone(), + play_token_set: !cfg.general.play_token.trim().is_empty(), signals_on_server: cfg.general.signals_on_server, signal_days: cfg.general.signal_days, typesafe_server_key: crate::signals::typesafe_key().is_some(), @@ -3077,6 +3120,10 @@ struct SettingsPatch { /// Replaces the token; empty clears it. directory_token: Option, directory_share: Option, + /// Empty for none. + play_url: Option, + /// Replaces the token; empty clears it. + play_token: Option, signals_on_server: Option, /// 1 to 365. signal_days: Option, @@ -3127,6 +3174,16 @@ async fn patch_settings( if let Some(v) = body.directory_token { cfg.general.directory_token = v.trim().to_owned(); } + if let Some(v) = body.play_url { + let v = v.trim().trim_end_matches('/').to_owned(); + if !v.is_empty() && !url::Url::parse(&v).is_ok_and(|u| matches!(u.scheme(), "http" | "https")) { + return Err(ApiError::bad_request(format!("{v:?} is not a web address; try https://play.ipxreader.app"))); + } + cfg.general.play_url = v; + } + if let Some(v) = body.play_token { + cfg.general.play_token = v.trim().to_owned(); + } if let Some(v) = body.directory_share { cfg.general.directory_share = v; } diff --git a/crates/play/CHANGELOG.md b/crates/play/CHANGELOG.md new file mode 100644 index 0000000..5f34df2 --- /dev/null +++ b/crates/play/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog: ipx-play + +The pages shared episodes open, at `play.ipxreader.app` (docs/play.md). ipx's changelog is +[crates/ipx/CHANGELOG.md](../ipx/CHANGELOG.md), the page's [web/CHANGELOG.md](../../web/CHANGELOG.md). + +Its version is in `crates/play/Cargo.toml`, and a release is tagged `play-vX.Y.Z`. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- A page for each shared episode, at `/e/`: its show, title, date, length and notes, a player that plays the publisher's own file from the time it was shared at (`?t=754` or `?t=12:34`), buttons to subscribe in Apple Podcasts, Overcast, Pocket Casts or Castro, the feed's address, and a preview where the link is pasted (#447). +- `POST /v1/shares`, for an ipx server with a token from `ipx-play token `: an episode in, its short link out, the same link however often and from whichever server it is shared. An address that looks like a private or paid feed's is refused (#447). + +[unreleased]: https://git.sdf1.net/rays/ipx/commits/main/crates/play diff --git a/crates/play/Cargo.toml b/crates/play/Cargo.toml new file mode 100644 index 0000000..96afe83 --- /dev/null +++ b/crates/play/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "ipx-play" +version = "0.1.0-dev" +edition.workspace = true +publish = false + +[[bin]] +name = "ipx-play" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +ammonia.workspace = true +anyhow.workspace = true +axum.workspace = true +chrono.workspace = true +clap.workspace = true +# Its tracing setup and token helpers, shared rather than copied: the same traceparent and JSON +# log lines as ipx and the directory. +ipx-directory.workspace = true +ipx-feed.workspace = true +opentelemetry.workspace = true +percent-encoding.workspace = true +reqwest.workspace = true +sea-orm.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tokio.workspace = true +toml.workspace = true +tracing.workspace = true +tracing-opentelemetry.workspace = true +tracing-subscriber.workspace = true +url.workspace = true + +[dev-dependencies] +tower.workspace = true diff --git a/crates/play/src/config.rs b/crates/play/src/config.rs new file mode 100644 index 0000000..989df7b --- /dev/null +++ b/crates/play/src/config.rs @@ -0,0 +1,115 @@ +//! `play.toml`: where ipx-play listens, its database, its public address, and the ipx servers +//! that may make a link. Tokens are changed only here, as the directory's are. + +use anyhow::{Context, Result, bail}; +use serde::Deserialize; +use std::collections::HashSet; +use std::path::Path; + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Config { + /// Where the API and the episode pages listen. + #[serde(default = "default_bind")] + pub bind: String, + /// The environment variable holding the database URL, so the file holds no secret. + #[serde(default = "default_database_url_env")] + pub database_url_env: String, + /// The address people open, which the links ipx is handed begin with. + #[serde(default = "default_public_url")] + pub public_url: String, + #[serde(default)] + pub tokens: Vec, +} + +fn default_bind() -> String { + "0.0.0.0:8102".into() +} + +fn default_database_url_env() -> String { + "PLAY_DATABASE_URL".into() +} + +fn default_public_url() -> String { + "https://play.ipxreader.app".into() +} + +/// An ipx server allowed to make links: its name, for the log and the shares it made, and only +/// its token's hash. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TokenCfg { + pub name: String, + /// What `ipx-play token new` prints. + pub token_sha256: String, +} + +/// A token as the server checks it. +#[derive(Debug, Clone)] +pub struct Token { + pub name: String, + pub sha256: String, +} + +impl Config { + pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; + Self::parse(&text).with_context(|| format!("in {}", path.display())) + } + + pub fn parse(text: &str) -> Result { + let cfg: Config = toml::from_str(text)?; + let u = url::Url::parse(&cfg.public_url).context("public_url is not an address")?; + if !matches!(u.scheme(), "http" | "https") { + bail!("public_url is not an http(s) address"); + } + Ok(cfg) + } + + /// Refuses an empty or repeated name, and a hash that is not one or is listed twice: which + /// server made a link would be ambiguous. + pub fn tokens(&self) -> Result> { + let (mut names, mut hashes) = (HashSet::new(), HashSet::new()); + self.tokens + .iter() + .map(|t| { + if t.name.trim().is_empty() || !names.insert(t.name.as_str()) { + bail!("token {:?}: each token needs a name of its own", t.name); + } + let sha256 = t.token_sha256.trim().to_ascii_lowercase(); + if sha256.len() != 64 || !sha256.bytes().all(|b| b.is_ascii_hexdigit()) { + bail!("token {:?}: token_sha256 is not a SHA-256 in hex", t.name); + } + if !hashes.insert(sha256.clone()) { + bail!("token {:?} is the same token as another", t.name); + } + Ok(Token { name: t.name.clone(), sha256 }) + }) + .collect() + } + + /// The public address without a trailing slash, for links. + #[must_use] + pub fn base(&self) -> &str { + self.public_url.trim_end_matches('/') + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_config_reads_with_its_defaults_and_refuses_ambiguous_tokens() { + let h = "a".repeat(64); + let cfg = Config::parse(&format!("[[tokens]]\nname = \"tower\"\ntoken_sha256 = \"{h}\"")).unwrap(); + assert_eq!((cfg.bind.as_str(), cfg.base()), ("0.0.0.0:8102", "https://play.ipxreader.app")); + assert_eq!(cfg.tokens().unwrap()[0].name, "tower"); + let two = format!( + "[[tokens]]\nname = \"a\"\ntoken_sha256 = \"{h}\"\n[[tokens]]\nname = \"b\"\ntoken_sha256 = \"{h}\"" + ); + assert!(Config::parse(&two).unwrap().tokens().is_err(), "one token under two names"); + assert!(Config::parse("[[tokens]]\nname = \"a\"\ntoken_sha256 = \"xyz\"").unwrap().tokens().is_err()); + assert!(Config::parse("public_url = \"ftp://x\"").is_err()); + } +} diff --git a/crates/play/src/lib.rs b/crates/play/src/lib.rs new file mode 100644 index 0000000..5735369 --- /dev/null +++ b/crates/play/src/lib.rs @@ -0,0 +1,316 @@ +//! `ipx-play` (#447): the page a shared episode opens, at `play.ipxreader.app/e/`, as +//! Overcast's links do. When someone shares an episode, their ipx server sends it here with its +//! token and gets back a short link; anyone with the link sees the episode, its show and its +//! notes, and plays it from the publisher's own file, from the time it was shared at if it was. +//! +//! Only public episodes come here: ipx sends none from a private or paid feed, and this refuses +//! an address that looks like one (`ipx_feed::url_looks_private`), which would hand out the +//! sharer's subscription. The files stay the publisher's; this keeps what an episode is and where +//! its file is, nothing more. + +pub mod config; +mod page; +pub mod store; + +use axum::extract::{Path, Query, Request, State}; +use axum::http::{StatusCode, header}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Extension, Json, Router}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use std::time::Instant; +use tracing::Instrument; + +/// An episode as ipx sends it to be shared. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Episode { + pub feed_url: String, + pub guid: String, + pub feed_title: Option, + pub feed_image: Option, + pub title: Option, + /// The feed's own HTML for it, sanitized here when the page is drawn. + pub description: Option, + pub link: Option, + /// Unix seconds. + pub published: Option, + /// Seconds. + pub duration: Option, + /// The publisher's file, which the page plays. + pub audio_url: String, + pub mime: Option, +} + +/// What ipx gets back: the link to hand on, to which it adds `?t=` for a time. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Shared { + pub id: String, + pub url: String, +} + +/// An episode kept, by its id. +#[derive(Debug, Clone)] +pub struct Share { + pub id: String, + pub episode: Episode, +} + +/// Longer than any real one, short enough that nobody keeps a book here. +const MAX_TEXT: usize = 100_000; +const MAX_SHORT: usize = 2_000; + +impl Episode { + /// What makes an episode unfit to keep, in words for whoever sent it; None when it is fine. + /// Over-long text is cut to fit rather than refused: a show's notes can be long. + fn check(&mut self) -> Option<&'static str> { + let web = |u: &str| url::Url::parse(u).is_ok_and(|u| matches!(u.scheme(), "http" | "https")); + if !web(&self.feed_url) || !web(&self.audio_url) { + return Some("the feed and its file need http(s) addresses"); + } + if ipx_feed::url_looks_private(&self.feed_url) || ipx_feed::url_looks_private(&self.audio_url) { + return Some("that looks like a private or paid feed, whose addresses carry someone's access"); + } + if self.guid.trim().is_empty() || self.guid.len() > MAX_SHORT { + return Some("an episode needs its guid"); + } + if self.feed_image.as_deref().is_some_and(|u| !web(u)) { + self.feed_image = None; + } + if self.link.as_deref().is_some_and(|u| !web(u)) { + self.link = None; + } + for (s, max) in + [(&mut self.title, MAX_SHORT), (&mut self.feed_title, MAX_SHORT), (&mut self.description, MAX_TEXT)] + { + if let Some(t) = s + && t.len() > max + { + let mut end = max; + while !t.is_char_boundary(end) { + end -= 1; + } + t.truncate(end); + } + } + None + } +} + +/// What every request shares. +pub struct Play { + pub store: store::Store, + pub tokens: Vec, + pub base: String, +} + +type St = Arc; + +pub fn router(play: Play) -> Router { + let st = Arc::new(play); + let api = Router::new() + .route("/v1/shares", post(share)) + .layer(axum::middleware::from_fn_with_state(st.clone(), authenticate)); + Router::new() + .route("/healthz", get(healthz)) + .route("/e/{id}", get(episode_page)) + .route("/play.css", get(|| async { page::asset("text/css; charset=utf-8", include_bytes!("play.css")) })) + .route("/logo.svg", get(|| async { page::asset("image/svg+xml", include_bytes!("../../../web/logo.svg")) })) + .route("/favicon.png", get(|| async { page::asset("image/png", include_bytes!("../../../web/favicon.png")) })) + .merge(api) + .fallback(|| async { page::not_found() }) + .layer(axum::middleware::from_fn(access_log)) + .with_state(st) +} + +/// Which server made the request, for the log and the share, set by `authenticate`. +#[derive(Clone)] +struct Who(String); + +async fn authenticate(State(st): State, mut req: Request, next: Next) -> Response { + let presented = req + .headers() + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(str::trim) + .filter(|t| !t.is_empty()); + let who = presented.and_then(|t| { + let h = ipx_directory::config::hash(t); + st.tokens.iter().find(|k| ipx_directory::config::same_secret(&k.sha256, &h)).map(|k| k.name.clone()) + }); + let Some(who) = who else { + return error(StatusCode::UNAUTHORIZED, "a valid token is needed: Authorization: Bearer "); + }; + req.extensions_mut().insert(Who(who.clone())); + let mut resp = next.run(req).await; + resp.extensions_mut().insert(Who(who)); + resp +} + +fn error(status: StatusCode, msg: &str) -> Response { + (status, Json(serde_json::json!({ "error": msg }))).into_response() +} + +#[tracing::instrument(name = "share", skip_all, fields(server = %who.0))] +async fn share(State(st): State, Extension(who): Extension, Json(mut e): Json) -> Response { + if let Some(why) = e.check() { + return error(StatusCode::UNPROCESSABLE_ENTITY, why); + } + match st.store.put(&who.0, &e).await { + Ok(id) => { + tracing::info!(server = %who.0, id = %id, "an episode was shared"); + Json(Shared { url: format!("{}/e/{id}", st.base), id }).into_response() + } + Err(err) => { + tracing::error!(error = %format!("{err:#}"), "keeping a share failed"); + error(StatusCode::INTERNAL_SERVER_ERROR, "it could not be kept; ipx-play's log says why") + } + } +} + +#[derive(Deserialize)] +struct At { + /// Where to start: seconds, or as written, `12:34` or `1:02:03`. + t: Option, +} + +async fn episode_page(State(st): State, Path(id): Path, Query(at): Query) -> Response { + match st.store.get(&id).instrument(tracing::info_span!("step", of = "get_share")).await { + Ok(Some(s)) => page::episode(&s, at.t.as_deref().and_then(page::parse_time), &st.base), + Ok(None) => page::not_found(), + Err(err) => { + tracing::error!(error = %format!("{err:#}"), "reading a share failed"); + page::failed() + } + } +} + +async fn healthz(State(st): State) -> Response { + match st.store.ping().await { + Ok(()) => (StatusCode::OK, "ok").into_response(), + Err(e) => (StatusCode::SERVICE_UNAVAILABLE, format!("database: {e:#}")).into_response(), + } +} + +/// A span a request, joined to the caller's trace when it sent a traceparent, as ipx does on its +/// calls here; and a line, with the server's name and never its token. +async fn access_log(req: Request, next: Next) -> Response { + let (method, path) = (req.method().clone(), req.uri().path().to_owned()); + let start = Instant::now(); + let span = if path == "/healthz" { + tracing::Span::none() + } else { + tracing::info_span!( + "http", + otel.name = %format!("{method} {}", if path.starts_with("/e/") { "/e/{id}" } else { path.as_str() }), + http.request.method = %method, + url.path = %path, + http.response.status_code = tracing::field::Empty, + ) + }; + if let Some(parent) = ipx_directory::otel::remote_parent(req.headers(), None) { + use tracing_opentelemetry::OpenTelemetrySpanExt; + let _ = span.set_parent(parent); + } + let resp = next.run(req).instrument(span.clone()).await; + let status = resp.status().as_u16(); + span.record("http.response.status_code", status); + if path != "/healthz" { + let _in = span.enter(); + let who = resp.extensions().get::().map_or("", |w| w.0.as_str()).to_owned(); + tracing::info!( + target: "ipx_play::http", + method = %method, path = %path, status, duration_ms = start.elapsed().as_millis() as u64, token = %who, + "{method} {path} -> {status}" + ); + } + resp +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use tower::ServiceExt; + + const TOKEN: &str = "ipxd_test"; + + async fn app() -> Router { + router(Play { + store: store::Store::scratch().await, + tokens: vec![config::Token { name: "tower".into(), sha256: ipx_directory::config::hash(TOKEN) }], + base: "https://play.test".into(), + }) + } + + fn episode() -> Episode { + Episode { + feed_url: "https://example.com/feed.xml".into(), + guid: "ep-1".into(), + feed_title: Some("A Show".into()), + feed_image: Some("https://example.com/art.jpg".into()), + title: Some("Episode ".into()), + description: Some("

Notes

".into()), + link: Some("javascript:alert(1)".into()), + published: Some(1_800_000_000), + duration: Some(3600), + audio_url: "https://example.com/ep1.mp3".into(), + mime: Some("audio/mpeg".into()), + } + } + + async fn post(app: &Router, token: Option<&str>, e: &Episode) -> (StatusCode, serde_json::Value) { + let mut req = axum::http::Request::post("/v1/shares").header("content-type", "application/json"); + if let Some(t) = token { + req = req.header("authorization", format!("Bearer {t}")); + } + let resp = app.clone().oneshot(req.body(Body::from(serde_json::to_vec(e).unwrap())).unwrap()).await.unwrap(); + let status = resp.status(); + let body = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap(); + (status, serde_json::from_slice(&body).unwrap_or_default()) + } + + async fn get(app: &Router, path: &str) -> (StatusCode, String) { + let resp = app.clone().oneshot(axum::http::Request::get(path).body(Body::empty()).unwrap()).await.unwrap(); + let status = resp.status(); + let body = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap(); + (status, String::from_utf8_lossy(&body).into_owned()) + } + + #[tokio::test] + async fn a_server_with_a_token_makes_a_link_and_anyone_opens_it() { + let app = app().await; + assert_eq!(post(&app, None, &episode()).await.0, StatusCode::UNAUTHORIZED); + assert_eq!(post(&app, Some("wrong"), &episode()).await.0, StatusCode::UNAUTHORIZED); + + let (status, body) = post(&app, Some(TOKEN), &episode()).await; + assert_eq!(status, StatusCode::OK); + let url = body["url"].as_str().unwrap(); + assert!(url.starts_with("https://play.test/e/"), "{url}"); + + let path = url.trim_start_matches("https://play.test"); + let (status, html) = get(&app, &format!("{path}?t=12:34")).await; + assert_eq!(status, StatusCode::OK); + assert!(html.contains("Episode <One>"), "the title, escaped"); + assert!(html.contains("ep1.mp3#t=754"), "the player starts at the time shared"); + assert!(html.contains("Starts at 12:34")); + assert!(html.contains(r#"property="og:title""#), "a link preview"); + assert!(!html.contains("