From 679a674fc939923b2f361710c3db7bce2490def1 Mon Sep 17 00:00:00 2001 From: Ray Slakinski <19767+rays@users.noreply.github.com> Date: Fri, 13 Nov 2026 09:24:18 -0500 Subject: [PATCH] Share an episode as a page, on ipx-play (#447) Share handed over the raw file address or the item's link: no preview, no time, nothing a person without iPX could do much with. Now, as Overcast does, an episode shares as a short link to its own page, play.ipxreader.app/e/, which anyone can open: the show's art and name, the episode's title, date, length and notes, a player on the publisher's own file starting at ?t= if a time was shared, buttons to subscribe in Apple Podcasts, Overcast, Pocket Casts or Castro, and Open Graph tags for a preview where the link is pasted. ipx-play is a service of its own (crates/play), built like the directory: ipx sends it the episode with its token (POST /v1/shares) and gets the link back. The id comes from the feed's address and the guid, so the same episode is one link whoever shares it. Only episodes of feeds the directory would be sent go there, by the directory's own rule (snapshot_entries), so the two cannot disagree on what is private; ipx-play also refuses an address that looks private. Anything else, and any server without ipx-play set up, shares as before. In the page, Share on an episode opens a menu once the link is back: Share link, Share from 12:34 (the player's clock, or where you left off), Copy link. A menu because the share sheet needs a press of its own, and the request for the link used up the one that opened it. The page runs no script: the start time is the media fragment #t=, and its CSP loads nothing but its stylesheet, the art and the file. Admin, Directory, Sharing episodes sets the address and token. The browser suite starts its own ipx-play; it was not run here (no Caddy on this Mac). Co-Authored-By: Claude Opus 5.5 --- .githooks/pre-commit | 10 +- CLAUDE.md | 23 ++- Cargo.lock | 28 ++++ Cargo.toml | 3 +- Dockerfile.play | 28 ++++ crates/ipx/CHANGELOG.md | 1 + crates/ipx/Cargo.toml | 1 + crates/ipx/src/config.rs | 20 ++- crates/ipx/src/lib.rs | 1 + crates/ipx/src/play.rs | 121 +++++++++++++++ crates/ipx/src/web.rs | 57 +++++++ crates/play/CHANGELOG.md | 18 +++ crates/play/Cargo.toml | 39 +++++ crates/play/src/config.rs | 115 ++++++++++++++ crates/play/src/lib.rs | 316 ++++++++++++++++++++++++++++++++++++++ crates/play/src/main.rs | 117 ++++++++++++++ crates/play/src/page.rs | 235 ++++++++++++++++++++++++++++ crates/play/src/play.css | 70 +++++++++ crates/play/src/store.rs | 200 ++++++++++++++++++++++++ docs/configuration.md | 5 + docs/play.md | 78 ++++++++++ playwright.config.ts | 10 ++ scripts/build-image.sh | 5 +- scripts/coverage.sh | 10 +- scripts/release.sh | 6 +- tests/page-unit.ts | 6 + tests/ui/app.spec.ts | 18 ++- tests/ui/global-setup.ts | 16 +- web/CHANGELOG.md | 1 + web/app.css | 3 + web/src/admin.ts | 18 +++ web/src/items.ts | 40 ++++- 32 files changed, 1595 insertions(+), 24 deletions(-) create mode 100644 Dockerfile.play create mode 100644 crates/ipx/src/play.rs create mode 100644 crates/play/CHANGELOG.md create mode 100644 crates/play/Cargo.toml create mode 100644 crates/play/src/config.rs create mode 100644 crates/play/src/lib.rs create mode 100644 crates/play/src/main.rs create mode 100644 crates/play/src/page.rs create mode 100644 crates/play/src/play.css create mode 100644 crates/play/src/store.rs create mode 100644 docs/play.md 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("