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/<id>, 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 <noreply@anthropic.com>
This commit is contained in:
Ray Slakinski
2026-11-13 09:24:18 -05:00
parent 171c77475a
commit 679a674fc9
32 changed files with 1595 additions and 24 deletions

View File

@@ -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

View File

@@ -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 '<what changed>'`; 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

28
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

28
Dockerfile.play Normal file
View File

@@ -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"]

View File

@@ -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).

View File

@@ -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

View File

@@ -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;
}

View File

@@ -23,6 +23,7 @@ mod lists;
mod logbuf;
mod logging;
mod metrics;
mod play;
mod push;
mod retention;
mod scan;

121
crates/ipx/src/play.rs Normal file
View File

@@ -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/<id>`, 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<i64>) -> 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<i64>,
) -> Result<ipx_play::Shared, NoLink> {
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::<serde_json::Value>().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");
}
}

View File

@@ -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<i64>,
}
/// 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<String>,
why: Option<&'static str>,
}
async fn share_entry(
State(state): State<WebState>,
Path((feed_id, guid)): Path<(String, String)>,
user: crate::db::User,
Json(body): Json<ShareBody>,
) -> Result<Json<ShareLink>, 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<WebState>) -> Json<Settings> {
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<String>,
directory_share: Option<bool>,
/// Empty for none.
play_url: Option<String>,
/// Replaces the token; empty clears it.
play_token: Option<String>,
signals_on_server: Option<bool>,
/// 1 to 365.
signal_days: Option<u64>,
@@ -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;
}

18
crates/play/CHANGELOG.md Normal file
View File

@@ -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/<id>`: 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 <name>`: 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

39
crates/play/Cargo.toml Normal file
View File

@@ -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

115
crates/play/src/config.rs Normal file
View File

@@ -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<TokenCfg>,
}
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<Self> {
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<Self> {
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<Vec<Token>> {
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());
}
}

316
crates/play/src/lib.rs Normal file
View File

@@ -0,0 +1,316 @@
//! `ipx-play` (#447): the page a shared episode opens, at `play.ipxreader.app/e/<id>`, 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<String>,
pub feed_image: Option<String>,
pub title: Option<String>,
/// The feed's own HTML for it, sanitized here when the page is drawn.
pub description: Option<String>,
pub link: Option<String>,
/// Unix seconds.
pub published: Option<i64>,
/// Seconds.
pub duration: Option<i64>,
/// The publisher's file, which the page plays.
pub audio_url: String,
pub mime: Option<String>,
}
/// 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<config::Token>,
pub base: String,
}
type St = Arc<Play>;
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<St>, 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 <token>");
};
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<St>, Extension(who): Extension<Who>, Json(mut e): Json<Episode>) -> 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<String>,
}
async fn episode_page(State(st): State<St>, Path(id): Path<String>, Query(at): Query<At>) -> 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<St>) -> 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::<Who>().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 <One>".into()),
description: Some("<p>Notes</p><script>alert(1)</script>".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 &lt;One&gt;"), "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("<script>alert"), "the notes are sanitized");
assert!(!html.contains("javascript:"), "a link that is not a web address is dropped");
assert_eq!(get(&app, "/e/nothere").await.0, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn a_private_feed_is_refused() {
let app = app().await;
let mut e = episode();
e.feed_url = "https://www.patreon.com/rss/show?auth=secret".into();
assert_eq!(post(&app, Some(TOKEN), &e).await.0, StatusCode::UNPROCESSABLE_ENTITY);
let mut e = episode();
e.audio_url = "file:///etc/passwd".into();
assert_eq!(post(&app, Some(TOKEN), &e).await.0, StatusCode::UNPROCESSABLE_ENTITY);
}
}

117
crates/play/src/main.rs Normal file
View File

@@ -0,0 +1,117 @@
//! `ipx-play`: serve the shared-episode pages, make a token for an ipx server, or check that a
//! running one answers.
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use ipx_play::{Play, config, store::Store};
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::net::TcpListener;
use tokio::signal::unix::SignalKind;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::prelude::*;
// mimalloc comes with ipx-directory, whose library sets it as the allocator.
#[derive(Parser)]
#[command(version, about = "The pages ipx's shared episodes open")]
struct Cli {
/// The config file. Otherwise PLAY_CONFIG, or else /config/play.toml.
#[arg(long)]
config: Option<PathBuf>,
#[command(subcommand)]
cmd: Option<Cmd>,
}
#[derive(Subcommand)]
enum Cmd {
/// Serve the pages and the API (the default).
Serve,
/// Make a token for an ipx server, and print the lines that let it in.
Token {
/// Who it is for, as the log will name them.
name: String,
},
/// Check that ipx-play on this machine answers and can read its database.
Health,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let path = cli
.config
.or_else(|| std::env::var_os("PLAY_CONFIG").map(Into::into))
.unwrap_or_else(|| "/config/play.toml".into());
match cli.cmd.unwrap_or(Cmd::Serve) {
Cmd::Serve => serve(&path).await,
Cmd::Token { name } => {
let token = ipx_directory::config::new_token();
println!("A token for {name}, to enter in its Admin, Sharing; it is not kept anywhere:\n\n {token}\n");
println!("To let it in, add this to {} and restart ipx-play:\n", path.display());
println!("[[tokens]]\nname = {name:?}\ntoken_sha256 = {:?}", ipx_directory::config::hash(&token));
Ok(())
}
Cmd::Health => {
let cfg = config::Config::load(&path)?;
let port = cfg.bind.rsplit(':').next().unwrap_or("8102");
let resp = reqwest::Client::new()
.get(format!("http://127.0.0.1:{port}/healthz"))
.timeout(Duration::from_secs(4))
.send()
.await
.context("ipx-play does not answer")?;
anyhow::ensure!(resp.status().is_success(), "ipx-play answers {}", resp.status());
Ok(())
}
}
}
async fn serve(path: &Path) -> Result<()> {
// One JSON object a line for Loki when PLAY_LOG_FORMAT=json, as ipx and the directory do.
let json = std::env::var("PLAY_LOG_FORMAT").is_ok_and(|f| f.eq_ignore_ascii_case("json"));
let filter = || EnvFilter::try_from_env("PLAY_LOG").unwrap_or_else(|_| "ipx_play=info".into());
// Traces to Tempo when OTEL_EXPORTER_OTLP_ENDPOINT is set, joining ipx's.
let otel = ipx_directory::otel::provider("ipx-play")?;
let stderr: Box<dyn tracing_subscriber::Layer<tracing_subscriber::Registry> + Send + Sync> = if json {
ipx_directory::otel::json_layer(std::io::stderr, false).with_filter(filter()).boxed()
} else {
tracing_subscriber::fmt::layer().with_writer(std::io::stderr).with_filter(filter()).boxed()
};
tracing_subscriber::registry()
.with(stderr)
.with(otel.as_ref().map(|p| {
use opentelemetry::trace::TracerProvider;
tracing_opentelemetry::layer()
.with_tracer(p.tracer("ipx-play"))
.with_filter(EnvFilter::new("ipx_play=info"))
}))
.init();
let cfg = config::Config::load(path)?;
// ponytail: tokens are read at start; a new server's token takes a restart. Reload the file
// on a timer, as the directory does, if servers come and go often.
let tokens = cfg.tokens()?;
let url = std::env::var(&cfg.database_url_env)
.ok()
.filter(|u| !u.trim().is_empty())
.with_context(|| format!("{} is not set: ipx-play needs a database URL", cfg.database_url_env))?;
let store = Store::open(&url, true).await.context("opening the database")?;
let listener = TcpListener::bind(&cfg.bind).await.with_context(|| format!("listening on {}", cfg.bind))?;
tracing::info!(version = env!("CARGO_PKG_VERSION"), bind = %cfg.bind, tokens = tokens.len(), "ipx-play started");
let app = ipx_play::router(Play { store, tokens, base: cfg.base().to_owned() });
axum::serve(listener, app)
.with_graceful_shutdown(async {
let mut term = tokio::signal::unix::signal(SignalKind::terminate()).expect("SIGTERM handler");
tokio::select! {
_ = term.recv() => {}
_ = tokio::signal::ctrl_c() => {}
}
})
.await?;
// The batch exporter holds the last few seconds of spans; without this they are lost.
if let Some(p) = otel {
let _ = p.shutdown();
}
Ok(())
}

235
crates/play/src/page.rs Normal file
View File

@@ -0,0 +1,235 @@
//! The episode page: what it is, a player that starts at the time shared, its notes, and how to
//! subscribe to the show in whatever app the reader has. No script: the start time is the media
//! fragment `#t=`, which every browser's player honours.
use crate::Share;
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use std::fmt::Write as _;
/// Nothing runs on the page, nothing loads but the art and the file, and no other site may frame
/// it. Images and audio from anywhere: they are the publisher's.
const CSP: &str = "default-src 'none'; img-src https: http: data:; media-src https: http:; style-src 'self'; \
base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
pub fn asset(content_type: &'static str, body: &'static [u8]) -> Response {
([(header::CONTENT_TYPE, content_type), (header::CACHE_CONTROL, "public, max-age=86400")], body).into_response()
}
fn esc(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;").replace('\'', "&#39;")
}
/// `?t=`: seconds (`754`), or as a clock reads (`12:34`, `1:02:03`). None for anything else.
#[must_use]
pub fn parse_time(t: &str) -> Option<u64> {
let parts: Vec<&str> = t.trim().split(':').collect();
if parts.is_empty()
|| parts.len() > 3
|| parts.iter().any(|p| p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()))
{
return None;
}
parts.iter().try_fold(0u64, |n, p| n.checked_mul(60)?.checked_add(p.parse().ok()?)).filter(|&s| s > 0)
}
/// `12:34`, or `1:02:03` past an hour, as the player shows it.
#[must_use]
pub fn clock(secs: u64) -> String {
let (h, m, s) = (secs / 3600, secs / 60 % 60, secs % 60);
if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") }
}
/// `1 hr 2 min`, `45 min`.
fn length(secs: i64) -> String {
let m = (secs + 30) / 60;
match (m / 60, m % 60) {
(0, m) => format!("{} min", m.max(1)),
(h, 0) => format!("{h} hr"),
(h, m) => format!("{h} hr {m} min"),
}
}
/// The notes as words, for a link preview: their tags gone, cut at a word near `max` characters.
fn plain(html: &str, max: usize) -> String {
// A space where each tag was, or two paragraphs read as one word ("Jerrytalk"). A preview
// can afford the odd space before a bold word.
let text = ammonia::Builder::empty().clean(&html.replace('<', " <")).to_string();
let text = text
.replace("&nbsp;", " ")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&amp;", "&");
let words = text.split_whitespace().collect::<Vec<_>>().join(" ");
if words.chars().count() <= max {
return words;
}
let cut: String = words.chars().take(max).collect();
format!("{}…", cut.rsplit_once(' ').map_or(cut.as_str(), |(a, _)| a))
}
/// Where a podcast app takes a feed to subscribe to, by the address schemes each one answers.
/// On a phone without the app the link does nothing, as Overcast's own page's do.
fn apps(feed: &str) -> Vec<(&'static str, String)> {
let bare = feed.split_once("://").map_or(feed, |(_, rest)| rest);
let enc = percent_encoding::utf8_percent_encode(feed, percent_encoding::NON_ALPHANUMERIC).to_string();
vec![
("Apple Podcasts", format!("podcast://{bare}")),
("Overcast", format!("overcast://x-callback-url/add?url={enc}")),
("Pocket Casts", format!("pktc://subscribe/{bare}")),
("Castro", format!("castros://subscribe/{bare}")),
]
}
fn html(status: StatusCode, body: String) -> Response {
let mut resp = (status, [(header::CONTENT_TYPE, "text/html; charset=utf-8")], body).into_response();
let h = resp.headers_mut();
h.insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_static(CSP));
h.insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=300"));
h.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
resp
}
fn frame(title: &str, head: &str, body: &str) -> String {
format!(
"<!doctype html><html lang=en><head><meta charset=utf-8>\
<meta name=viewport content=\"width=device-width, initial-scale=1\">\
<meta name=color-scheme content=\"light dark\"><title>{}</title>\
<link rel=icon href=/favicon.png><link rel=stylesheet href=/play.css>{head}</head>\
<body><main><a class=brand href=\"https://ipxreader.app\"><img src=/logo.svg alt=\"\" width=28 height=28>iPX</a>\
{body}<footer>Shared from <a href=\"https://ipxreader.app\">iPX</a>, a podcast and feed reader you run yourself.</footer>\
</main></body></html>",
esc(title)
)
}
pub fn not_found() -> Response {
html(
StatusCode::NOT_FOUND,
frame("Not here", "", "<h1>Not here</h1><p class=meta>That link is not one iPX made, or it was mistyped.</p>"),
)
}
pub fn failed() -> Response {
html(
StatusCode::SERVICE_UNAVAILABLE,
frame("Try again", "", "<h1>Try again</h1><p class=meta>The episode could not be read just now.</p>"),
)
}
pub fn episode(s: &Share, at: Option<u64>, base: &str) -> Response {
let e = &s.episode;
let show = e.feed_title.as_deref().map(str::trim).filter(|t| !t.is_empty()).unwrap_or("A podcast");
let title = e.title.as_deref().map(str::trim).filter(|t| !t.is_empty()).unwrap_or("Untitled episode");
let notes = e.description.as_deref().map(ammonia::clean).filter(|d| !d.trim().is_empty());
let summary = e.description.as_deref().map(|d| plain(d, 200)).filter(|d| !d.is_empty());
let url = format!("{base}/e/{}", s.id);
let mut head = String::new();
let _ = write!(
head,
"<meta property=\"og:type\" content=\"website\"><meta property=\"og:site_name\" content=\"iPX\">\
<meta property=\"og:title\" content=\"{}\"><meta property=\"og:url\" content=\"{}\">\
<meta property=\"og:audio\" content=\"{}\"><meta name=\"twitter:card\" content=\"summary\">",
esc(title),
esc(&url),
esc(&e.audio_url)
);
let desc = match &summary {
Some(sum) => format!("{show}: {sum}"),
None => show.to_owned(),
};
let _ = write!(
head,
"<meta property=\"og:description\" content=\"{0}\"><meta name=description content=\"{0}\">",
esc(&desc)
);
if let Some(img) = &e.feed_image {
let _ = write!(head, "<meta property=\"og:image\" content=\"{}\">", esc(img));
}
if let Some(m) = &e.mime {
let _ = write!(head, "<meta property=\"og:audio:type\" content=\"{}\">", esc(m));
}
let mut b = String::new();
b.push_str("<article class=ep><div class=head>");
match &e.feed_image {
Some(img) => {
let _ = write!(b, "<img class=art src=\"{}\" alt=\"\" width=160 height=160>", esc(img));
}
None => {
let initials: String = show.split_whitespace().filter_map(|w| w.chars().next()).take(2).collect();
let _ = write!(b, "<div class=\"art tile\" aria-hidden=true>{}</div>", esc(&initials.to_uppercase()));
}
}
let mut meta = Vec::new();
if let Some(p) = e.published.and_then(|p| chrono::DateTime::from_timestamp(p, 0)) {
meta.push(p.format("%B %-d, %Y").to_string());
}
if let Some(d) = e.duration.filter(|d| *d > 0) {
meta.push(length(d));
}
let _ = write!(
b,
"<div><p class=show>{}</p><h1>{}</h1><p class=meta>{}</p></div></div>",
esc(show),
esc(title),
esc(&meta.join(" · "))
);
// The file as the publisher serves it: a show that inserts ads by listener can move the time
// by the length of an ad, as Overcast's links do.
let src = match at {
Some(t) => format!("{}#t={t}", e.audio_url),
None => e.audio_url.clone(),
};
if let Some(t) = at {
let _ = write!(b, "<p class=at>Starts at {}</p>", clock(t));
}
let _ = write!(b, "<audio controls preload=metadata src=\"{}\"></audio>", esc(&src));
if let Some(link) = &e.link {
let _ = write!(
b,
"<p class=more><a href=\"{}\" rel=\"noopener nofollow\">The episode's own page</a></p>",
esc(link)
);
}
if let Some(n) = notes {
let _ = write!(b, "<section class=notes><h2>Notes</h2>{n}</section>");
}
let _ = write!(b, "<section class=sub><h2>Subscribe to {}</h2><p class=apps>", esc(show));
for (name, href) in apps(&e.feed_url) {
let _ = write!(b, "<a class=app href=\"{}\">{name}</a>", esc(&href));
}
let _ = write!(
b,
"</p><label for=feed>Or paste the feed's address into any podcast app</label>\
<input id=feed readonly value=\"{}\" spellcheck=false></section></article>",
esc(&e.feed_url)
);
html(StatusCode::OK, frame(&format!("{title} · {show}"), &head, &b))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_time_reads_as_seconds_or_a_clock() {
assert_eq!(parse_time("754"), Some(754));
assert_eq!(parse_time("12:34"), Some(754));
assert_eq!(parse_time("1:02:03"), Some(3723));
for bad in ["", "0", "-5", "1:2:3:4", "12:", "abc", "99999999999999999999999"] {
assert_eq!(parse_time(bad), None, "{bad:?}");
}
assert_eq!((clock(754), clock(3723)), ("12:34".to_owned(), "1:02:03".to_owned()));
assert_eq!((length(2700), length(3600), length(3720)), ("45 min".into(), "1 hr".into(), "1 hr 2 min".into()));
}
#[test]
fn a_preview_has_the_notes_as_words() {
assert_eq!(plain("<p>Tom &amp; Jerry</p><p>talk</p>", 200), "Tom & Jerry talk");
assert_eq!(plain("one two three four", 9), "one two…");
}
}

70
crates/play/src/play.css Normal file
View File

@@ -0,0 +1,70 @@
:root {
--bg: #f6f7f9;
--card: #ffffff;
--ink: #111827;
--muted: #5b6472;
--line: #e2e6ec;
--accent: #e8590c;
--tile: #dbe3ee;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0b1424;
--card: #111d31;
--ink: #e8edf4;
--muted: #9aa7b8;
--line: #22324a;
--accent: #ff7a2f;
--tile: #1d2c44;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
main { max-width: 680px; margin: 0 auto; padding: 20px 16px 48px; }
a { color: var(--accent); }
.brand {
display: inline-flex; align-items: center; gap: 8px;
color: var(--ink); font-weight: 700; text-decoration: none; margin-bottom: 20px;
}
.ep { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 20px; }
.head { display: flex; gap: 18px; align-items: center; }
.art { width: 120px; height: 120px; border-radius: 12px; object-fit: cover; flex: none; }
.tile {
display: grid; place-items: center; background: var(--tile);
font-size: 36px; font-weight: 700; color: var(--muted);
}
.show { margin: 0; color: var(--accent); font-weight: 600; font-size: 15px; }
h1 { margin: 2px 0 4px; font-size: 22px; line-height: 1.25; overflow-wrap: anywhere; }
h2 { font-size: 15px; margin: 0 0 8px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.meta { margin: 0; color: var(--muted); font-size: 14px; }
.at {
display: inline-block; margin: 18px 0 0; padding: 4px 10px; border-radius: 999px;
background: var(--tile); font-size: 14px; font-weight: 600;
}
audio { display: block; width: 100%; margin-top: 14px; }
.more { margin: 12px 0 0; font-size: 15px; }
.notes, .sub { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--line); }
.notes { overflow-wrap: anywhere; }
.notes img { max-width: 100%; height: auto; }
.apps { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 14px; }
.app {
padding: 8px 14px; border-radius: 999px; border: 1px solid var(--line);
color: var(--ink); text-decoration: none; font-size: 15px; font-weight: 600;
}
.app:hover { border-color: var(--accent); }
label { display: block; color: var(--muted); font-size: 14px; margin-bottom: 6px; }
input {
width: 100%; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--line);
background: var(--bg); color: var(--ink); font: 14px ui-monospace, Menlo, monospace;
}
footer { margin-top: 24px; color: var(--muted); font-size: 14px; text-align: center; }
@media (max-width: 480px) {
.head { flex-direction: column; align-items: flex-start; }
.art { width: 100%; height: auto; aspect-ratio: 1; max-width: 240px; }
}

200
crates/play/src/store.rs Normal file
View File

@@ -0,0 +1,200 @@
//! The episodes people have shared. SQL written by hand, as the directory's is, so it runs on
//! SQLite (tests) and Postgres (production) both.
use crate::{Episode, Share};
use anyhow::Result;
use sea_orm::{ConnectOptions, ConnectionTrait, Database, DatabaseConnection, QueryResult, Statement, Value};
use std::time::SystemTime;
const SCHEMA: &[&str] = &["CREATE TABLE IF NOT EXISTS shares (
id TEXT PRIMARY KEY,
feed_url TEXT NOT NULL,
guid TEXT NOT NULL,
feed_title TEXT,
feed_image TEXT,
title TEXT,
description TEXT,
link TEXT,
published BIGINT,
duration BIGINT,
audio_url TEXT NOT NULL,
mime TEXT,
instance TEXT NOT NULL,
created BIGINT NOT NULL,
updated BIGINT NOT NULL
)"];
pub struct Store {
db: DatabaseConnection,
}
#[must_use]
pub fn now() -> i64 {
SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)
}
/// An episode's id: ten letters and digits from the SHA-256 of its feed's address (written as
/// the directory writes it) and its guid, so sharing it again, from any server, gives the same
/// link, and nothing needs looking up to make one. 50 bits: a collision is not expected before
/// some billions of episodes.
#[must_use]
pub fn share_id(feed_url: &str, guid: &str) -> String {
use sha2::Digest;
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567";
let feed = ipx_directory::store::normalise(feed_url).unwrap_or_else(|| feed_url.trim().to_owned());
let digest = sha2::Sha256::digest(format!("{feed}\n{guid}").as_bytes());
let bits = digest[..8].iter().fold(0u64, |n, b| (n << 8) | u64::from(*b));
(0..10).map(|i| ALPHABET[((bits >> (59 - 5 * i)) & 31) as usize] as char).collect()
}
impl Store {
pub async fn open(url: &str, keep_open: bool) -> Result<Self> {
let mut url = url.to_owned();
if url.starts_with("postgres") && !url.contains("options=") {
// A notice for every CREATE ... IF NOT EXISTS on something there, otherwise.
let sep = if url.contains('?') { '&' } else { '?' };
url = format!("{url}{sep}options=-c%20client_min_messages%3Dwarning");
}
let mut opts = ConnectOptions::new(url.clone());
opts.sqlx_logging(false);
// An in-memory SQLite database is one per connection.
if url.contains(":memory:") {
opts.max_connections(1);
}
// Kept open: a Postgres connection takes 80-330 ms to open here (#241).
if keep_open && url.starts_with("postgres") {
opts.max_connections(4).min_connections(2);
}
let db = Database::connect(opts).await?;
for sql in SCHEMA {
db.execute_unprepared(sql).await?;
}
Ok(Self { db })
}
#[cfg(test)]
pub async fn scratch() -> Self {
Self::open("sqlite::memory:", false).await.unwrap()
}
fn stmt(&self, sql: &str, values: Vec<Value>) -> Statement {
Statement::from_sql_and_values(self.db.get_database_backend(), sql, values)
}
async fn rows(&self, sql: &str, values: Vec<Value>) -> Result<Vec<QueryResult>> {
Ok(self.db.query_all_raw(self.stmt(sql, values)).await?)
}
pub async fn ping(&self) -> Result<()> {
self.rows("SELECT 1", vec![]).await?;
Ok(())
}
/// Keeps an episode, or what it says now if it was shared before, and gives its id. The
/// server that first shared it stays its sharer.
pub async fn put(&self, instance: &str, e: &Episode) -> Result<String> {
let id = share_id(&e.feed_url, &e.guid);
let t = now();
self.db
.execute_raw(self.stmt(
"INSERT INTO shares (id, feed_url, guid, feed_title, feed_image, title, description, link,
published, duration, audio_url, mime, instance, created, updated)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $14)
ON CONFLICT (id) DO UPDATE SET feed_title = excluded.feed_title, feed_image = excluded.feed_image,
title = excluded.title, description = excluded.description, link = excluded.link,
published = excluded.published, duration = excluded.duration, audio_url = excluded.audio_url,
mime = excluded.mime, updated = excluded.updated",
vec![
id.clone().into(),
e.feed_url.clone().into(),
e.guid.clone().into(),
e.feed_title.clone().into(),
e.feed_image.clone().into(),
e.title.clone().into(),
e.description.clone().into(),
e.link.clone().into(),
e.published.into(),
e.duration.into(),
e.audio_url.clone().into(),
e.mime.clone().into(),
instance.into(),
t.into(),
],
))
.await?;
Ok(id)
}
pub async fn get(&self, id: &str) -> Result<Option<Share>> {
let Some(r) = self
.rows(
"SELECT feed_url, guid, feed_title, feed_image, title, description, link, published, duration,
audio_url, mime FROM shares WHERE id = $1",
vec![id.into()],
)
.await?
.into_iter()
.next()
else {
return Ok(None);
};
Ok(Some(Share {
id: id.to_owned(),
episode: Episode {
feed_url: r.try_get("", "feed_url")?,
guid: r.try_get("", "guid")?,
feed_title: r.try_get("", "feed_title")?,
feed_image: r.try_get("", "feed_image")?,
title: r.try_get("", "title")?,
description: r.try_get("", "description")?,
link: r.try_get("", "link")?,
published: r.try_get("", "published")?,
duration: r.try_get("", "duration")?,
audio_url: r.try_get("", "audio_url")?,
mime: r.try_get("", "mime")?,
},
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
pub(crate) 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 One".into()),
description: Some("<p>Notes</p>".into()),
link: None,
published: Some(1_800_000_000),
duration: Some(3600),
audio_url: "https://example.com/ep1.mp3".into(),
mime: Some("audio/mpeg".into()),
}
}
#[test]
fn an_episode_has_one_short_id_whoever_shares_it() {
let a = share_id("https://example.com/feed.xml", "ep-1");
assert_eq!(a.len(), 10);
assert!(a.bytes().all(|b| b.is_ascii_lowercase() || (b'2'..=b'7').contains(&b)));
assert_eq!(a, share_id("HTTPS://Example.com/feed.xml", "ep-1"), "the address as every server writes it");
assert_ne!(a, share_id("https://example.com/feed.xml", "ep-2"));
}
#[tokio::test]
async fn sharing_again_keeps_the_link_and_takes_what_the_episode_says_now() {
let s = Store::scratch().await;
let mut e = episode();
let id = s.put("tower", &e).await.unwrap();
e.title = Some("Episode One, fixed".into());
assert_eq!(s.put("elsewhere", &e).await.unwrap(), id);
let got = s.get(&id).await.unwrap().unwrap();
assert_eq!(got.episode, e);
assert!(s.get("nothere").await.unwrap().is_none());
}
}

View File

@@ -187,6 +187,11 @@ and are the same for everyone. See [users.md](users.md).
`username` and its password go to the feed's own host alone: not to episodes on another host, nor to
the feeds an OPML lists on other hosts, which would hand the password to whoever runs them.
**Shared episodes** go to ipx-play ([play.md](play.md)), set up in Admin, Directory, Sharing
episodes: its address and this server's token, kept in the database (`play_url`, `play_token`).
Only episodes of feeds the directory would be sent; without it, Share hands over an episode's own
address.
**The Directory** is the directory service's ([directory.md](directory.md)), which an admin sets
up in Admin, Directory: its address, this server's token and whether to share. They are server
settings, kept in the database (`directory_url`, `directory_token`, `directory_share`), not here.

78
docs/play.md Normal file
View File

@@ -0,0 +1,78 @@
# ipx-play: shared episodes (#447)
When someone shares an episode in iPX, their server sends it to ipx-play, which keeps it and
answers with a short link, `https://play.ipxreader.app/e/<id>`. Anyone can open that link: it
shows the episode, plays it from the publisher's own file (from the time it was shared at, with
`?t=`), and offers to subscribe in the reader's own podcast app. Overcast's links are the model.
Only episodes of public feeds are sent, by the same rule that decides what the directory is sent:
nothing from a feed marked private, one with a login, one whose address carries a key, or one in
a private OPML. ipx-play refuses an address that looks like one too. Everything else is shared by
its own address, as before. ipx-play keeps what an episode is and where its file is; it serves no
file itself.
## Running it
A container of its own, `ipx-play`, in the Arcane project `ipx`, from its own image:
```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
```
The compose service, beside `ipx-directory`:
```yaml
ipx-play:
image: 192.168.1.130:5000/ipx-play:latest
container_name: ipx-play
restart: unless-stopped
env_file: play.env # PLAY_DATABASE_URL=postgres://...
environment:
PLAY_LOG_FORMAT: json
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4318
volumes:
- /mnt/fast/appdata/ipx-play:/config
ports:
- "8102:8102"
```
Its config, `/mnt/fast/appdata/ipx-play/play.toml`:
```toml
bind = "0.0.0.0:8102"
public_url = "https://play.ipxreader.app"
# One for each ipx server allowed to make links; `ipx-play token <name>` prints the lines.
[[tokens]]
name = "tower"
token_sha256 = "..."
```
Tokens are read at start: after adding one, restart the container.
Its database is Postgres, `ipx_play` on the `databases` project's server, its URL in `play.env`
beside the compose file (mode 600), as the directory's is. It makes its one table, `shares`, on
start.
The tunnel publishes `play.ipxreader.app` to `http://ipx-play:8102` with no Access in front: the
pages are anyone's. `/v1` takes a token for everything.
## Setting up ipx
In Admin, Directory, Sharing episodes: the address ipx reaches it at, `http://ipx-play:8102`
inside Docker (the public address would go out through Cloudflare and back), and the token
`ipx-play token tower` printed. Without them Share hands over the episode's own address.
## The page
`/e/<id>` shows the show's art, its name, the episode's title, date and length, a player starting
at `?t=` (seconds, or `12:34`), the episode's own page if it has one, its notes (sanitized by
ammonia), and buttons that open Apple Podcasts, Overcast, Pocket Casts and Castro at the feed, with
the feed's address to paste into anything else. Open Graph tags give it a preview in Messages,
Slack and the like. It runs no script: the start time is the media fragment `#t=` on the file. Its
Content-Security-Policy lets it load only its stylesheet, the art and the file.
A show that inserts its ads per listener can move the time by an ad's length, as Overcast's links
can: the file is the publisher's, fetched fresh by each listener.

View File

@@ -48,6 +48,16 @@ export default defineConfig({
env: { DIRECTORY_DATABASE_URL: `sqlite://${setup.root}/directory.db?mode=rwc`, ...PROFILE },
...STOP,
},
{
// ipx-play (#447), where a shared episode's link goes, on SQLite in the scratch directory.
command: `${setup.API_COVERAGE ? '' : 'cargo build -q -p ipx-play && '}exec ${setup.PLAY_BIN} --config ${setup.root}/play.toml`,
url: `${setup.PLAY}/healthz`,
reuseExistingServer: false,
timeout: 180_000,
stdout: 'ignore',
env: { PLAY_DATABASE_URL: `sqlite://${setup.root}/play.db?mode=rwc`, ...PROFILE },
...STOP,
},
{
// The frontend as production serves it (#203): built from web/, served by Caddy on the
// suite's port, the API passed to the daemon behind it.

View File

@@ -1,7 +1,7 @@
#!/bin/sh
# Build and push one service's image, inside limits Tower can afford.
#
# sh scripts/build-image.sh ipx|directory|web|coverage
# sh scripts/build-image.sh ipx|directory|play|web|coverage
#
# On 2026-11-04 a plain `docker buildx build` of ipx (its dependencies' cache layer invalidated
# by a Cargo.toml change) rebuilt everything in release on all twelve threads with 5 GB free and
@@ -19,10 +19,11 @@ BUILDER=ipx-capped
case "${1:-}" in
ipx) FILE=Dockerfile; IMAGE=ipx ;;
directory) FILE=Dockerfile.directory; IMAGE=ipx-directory ;;
play) FILE=Dockerfile.play; IMAGE=ipx-play ;;
web) FILE=Dockerfile.web; IMAGE=ipx-web ;;
# Not a service: what scripts/coverage.sh runs the Rust tests in (#390).
coverage) FILE=Dockerfile.coverage; IMAGE=ipx-coverage ;;
*) echo "usage: $0 ipx|directory|web|coverage" >&2; exit 2 ;;
*) echo "usage: $0 ipx|directory|play|web|coverage" >&2; exit 2 ;;
esac
cd "$(dirname "$0")/.."

View File

@@ -1,10 +1,10 @@
#!/bin/sh
# Which lines of the Rust the tests reach (#390, #394), inside the limits builds are held to.
#
# sh scripts/coverage.sh the cargo tests, all three crates, then their report
# sh scripts/coverage.sh the cargo tests, all four crates, then their report
# sh scripts/coverage.sh ipx-feed one crate's cargo tests, then the report of what has run
# sh scripts/coverage.sh api the browser suite against an instrumented ipx and
# ipx-directory: what the API and the daemon run (and the
# sh scripts/coverage.sh api the browser suite against an instrumented ipx, ipx-directory
# and ipx-play: what the API and the daemon run (and the
# page's coverage, which the same run measures)
#
# A coverage build instruments every crate, dependencies included, in a target of its own: a whole
@@ -96,7 +96,7 @@ if [ "${1:-}" = api ]; then
# Built as cargo-llvm-cov builds for a test of its own, instrumented, with no tests: the
# binaries the browser suite starts. Profiles from an earlier run are not this one's.
run cargo llvm-cov clean --workspace
for p in ipx-directory ipx; do
for p in ipx-directory ipx-play ipx; do
room
echo "== build $p"
run sh -c "eval \"\$(cargo llvm-cov show-env --export-prefix)\" && cargo build --locked -p $p"
@@ -116,7 +116,7 @@ if [ "${1:-}" = api ]; then
exit "$suite"
fi
crates=${1:-ipx-feed ipx-directory ipx}
crates=${1:-ipx-feed ipx-directory ipx-play ipx}
# A whole run starts from nothing; one crate adds to what has run.
[ $# -eq 0 ] && run cargo llvm-cov clean --workspace
for c in $crates; do

View File

@@ -1,7 +1,7 @@
#!/bin/sh
# Publish a Gitea release for one service's tag, with a tarball taken from the image that was
# deployed, so what is attached is what runs on Tower. Run after the release commit is tagged,
# pushed and its image built and pushed: sh scripts/release.sh ipx|directory|web
# pushed and its image built and pushed: sh scripts/release.sh ipx|directory|play|web
set -eu
cd "$(dirname "$0")/.."
@@ -10,9 +10,11 @@ ipx) name=ipx; ver=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/
tag=v$ver; log=crates/ipx/CHANGELOG.md; from=/usr/local/bin/ipxd ;;
directory) name=ipx-directory; ver=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/directory/Cargo.toml | head -1)
tag=directory-v$ver; log=crates/directory/CHANGELOG.md; from=/usr/local/bin/ipx-directory ;;
play) name=ipx-play; ver=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/play/Cargo.toml | head -1)
tag=play-v$ver; log=crates/play/CHANGELOG.md; from=/usr/local/bin/ipx-play ;;
web) name=ipx-web; ver=$(node -p 'require("./package.json").version')
tag=web-v$ver; log=web/CHANGELOG.md; from=/srv ;;
*) echo "usage: $0 ipx|directory|web" >&2; exit 2 ;;
*) echo "usage: $0 ipx|directory|play|web" >&2; exit 2 ;;
esac
case "$ver" in *-dev) echo "$name is at $ver: release it first" >&2; exit 1 ;; esac

View File

@@ -325,3 +325,9 @@ test('an episode kept on the device is the copy it plays, and plays from there (
assert.equal(run('mediaSrc({id: 3, deadair: null})'), '/media/3?offline');
run('kept.clear()');
});
test('a shared link starts where you are, from five seconds in (#447)', () => {
const url = 'https://play.ipxreader.app/e/abcdefghij';
assert.equal(run(`shareAt(${JSON.stringify(url)}, 754.6)`), `${url}?t=754`);
assert.equal(run(`shareAt(${JSON.stringify(url)}, 3)`), url);
});

View File

@@ -2233,17 +2233,31 @@ test('words in Settings hide the items that mention them', async ({ page }) => {
await page.evaluate(() => api('/api/me', { method: 'PATCH', body: JSON.stringify({ blocked: [] }) }));
});
test('Share hands the feed, the item and the file to the share sheet', async ({ page }) => {
test('Share hands the feed, and an episode as a link to its page on ipx-play (#447)', async ({ page }) => {
await page.evaluate(() => { window.shared = []; navigator.share = async (d?: ShareData) => { window.shared.push(d!); }; });
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'All' }).first().click();
await page.locator('#content .acts [data-a="share"]').click();
await page.locator('.ep', { hasText: 'Second Episode' }).click();
await page.locator('.encbox [data-a="share"]').first().click();
// A menu, whose press opens the share sheet: the request for the link used up the first.
await page.locator('#shlink').click();
await expect.poll(() => page.evaluate(() => window.shared)).toEqual([
{ title: 'Test Show', url: expect.stringContaining('/show.xml') },
{ title: 'Second Episode', url: expect.stringContaining('/ep1.mp3?2') },
{ title: 'Second Episode', url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:8798\/e\/[a-z2-7]{10}$/) },
]);
// Anyone can open it: the episode, its show, and its file to play.
const url = await page.evaluate(() => window.shared[1].url!);
const html = await (await page.request.get(url)).text();
expect(html).toContain('Second Episode');
expect(html).toContain('Test Show');
expect(html).toContain('/ep1.mp3');
expect(await (await page.request.get(`${url}?t=90`)).text()).toContain('Starts at 1:30');
// A paid feed's episode never goes there: the page shares its own address instead.
const paid = await page.evaluate(() => api('/api/entries/paid-show/paid-1/share', { method: 'POST', body: '{}' }));
expect(paid).toEqual({ url: null, why: 'private' });
});
test('a refresh button turns from the press to the end of the scan it asked for', async ({ page }) => {

View File

@@ -10,6 +10,9 @@ const TOKEN = 'testtokentesttokentesttoken12345'; // fixed, so tests need not
// The suite's own directory service (#205), and this daemon's token for it, which may do anything.
const DIRECTORY = 'http://127.0.0.1:8795';
const DIRECTORY_TOKEN = 'ipxd_testdirectorytoken';
// The suite's own ipx-play (#447), which shared episodes' links go to.
const PLAY = 'http://127.0.0.1:8798';
const PLAY_TOKEN = 'ipxd_testplaytoken';
// Called from playwright.config.ts at load time, NOT as globalSetup: Playwright starts
// webServer *before* globalSetup, so a config written there does not exist yet when the
@@ -38,6 +41,8 @@ fetch_internal = ["127.0.0.1"]
directory_url = "${DIRECTORY}"
directory_token = "${DIRECTORY_TOKEN}"
directory_share = true
play_url = "${PLAY}"
play_token = "${PLAY_TOKEN}"
[torrent]
enabled = false
@@ -85,6 +90,14 @@ fetch_internal = ["127.0.0.1"]
name = "test"
access = "rwa"
token_sha256 = "${crypto.createHash('sha256').update(DIRECTORY_TOKEN).digest('hex')}"
`);
fs.writeFileSync(path.join(root, 'play.toml'), `
bind = "127.0.0.1:8798"
public_url = "${PLAY}"
[[tokens]]
name = "test"
token_sha256 = "${crypto.createHash('sha256').update(PLAY_TOKEN).digest('hex')}"
`);
}
@@ -94,5 +107,6 @@ token_sha256 = "${crypto.createHash('sha256').update(DIRECTORY_TOKEN).digest('he
const API_COVERAGE = process.env.IPX_API_COVERAGE ?? '';
const IPX_BIN = API_COVERAGE ? path.join(API_COVERAGE, 'debug', 'ipxd') : './target/debug/ipxd';
const DIRECTORY_BIN = API_COVERAGE ? path.join(API_COVERAGE, 'debug', 'ipx-directory') : './target/debug/ipx-directory';
const PLAY_BIN = API_COVERAGE ? path.join(API_COVERAGE, 'debug', 'ipx-play') : './target/debug/ipx-play';
export { prepare, root, TOKEN, DIRECTORY, DIRECTORY_TOKEN, API_COVERAGE, IPX_BIN, DIRECTORY_BIN };
export { prepare, root, TOKEN, DIRECTORY, DIRECTORY_TOKEN, PLAY, API_COVERAGE, IPX_BIN, DIRECTORY_BIN, PLAY_BIN };

View File

@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Share on an episode offers Share link, Share from where you are, and Copy link, with a link to the episode's page on ipx-play that anyone can play; an admin sets ipx-play up in Admin, Directory (#447).
- In the iOS app, Settings has a This device section for the app's server and player token, with a button that makes a token and hands it to the app (#445).
- In the iOS app, the page registers the phone with ipx for its icon's count, and signing out removes it (#433).
- In the iOS app, the player bar shows what CarPlay plays, so it can be paused on the phone after leaving the car, including when the app was opened by the car (#420, #442).

View File

@@ -1182,6 +1182,9 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
.inline input[type=number]{flex:none;width:90px}
.check input{width:16px;height:16px;accent-color:var(--accent)}
.cardacts{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
/* Share an episode (#447): one wide button a choice, easy to hit on a phone. */
.sharemenu{display:flex;flex-direction:column;gap:8px;margin:14px 0}
.sharemenu .btn{justify-content:center}
/* ---------- a feed's settings (#167) ---------- */
/* A frame of its own: the body scrolls and Save stays at the foot, where on a phone it had been

View File

@@ -107,6 +107,17 @@ async function drawDirectorySettings(){
<p class="cap">Every half hour, the feeds people here subscribe to and how many do. Never a
private feed: one marked private, one with a login, or one whose address carries a key.</p>
<div class="cardacts"><button class="btn primary" id="dsave">${ICON.check} Save</button></div>`)}
${asect('Sharing episodes', `<p class="cap">Share gives a link to the episode's own page on
ipx-play, which anyone can open and play, as Overcast's links do. Only episodes of public
feeds; without it, Share hands over the episode's own address.</p>
<div class="ffield"><label class="flabel" for="purl">ipx-play address</label>
<input type="text" inputmode="url" id="purl" value="${esc(g.play_url)}" placeholder="https://play.ipxreader.app" spellcheck="false"></div>
<div class="ffield"><label class="flabel" for="ptoken">Access token</label>
<div class="inline"><input type="password" id="ptoken" autocomplete="off" spellcheck="false"
placeholder="${g.play_token_set ? 'Set. Paste a new one to replace it' : 'Paste the token ipx-play gave this server'}">
${g.play_token_set ? `<button type="button" class="btn" id="pclear">Clear</button>` : ''}</div>
<p class="cap">Whoever runs ipx-play makes one with <code>ipx-play token &lt;name&gt;</code>.</p></div>
<div class="cardacts"><button class="btn primary" id="psave">${ICON.check} Save</button></div>`)}
<div id="dadmin"></div>`;
const save = async (patch: Record<string, unknown>, said: string) => {
try{
@@ -123,6 +134,13 @@ async function drawDirectorySettings(){
};
const clear = $<HTMLButtonElement>('#dclear');
if(clear) clear.onclick = () => save({directory_token: ''}, 'Token cleared');
$<HTMLButtonElement>('#psave').onclick = () => {
const patch: Record<string, unknown> = {play_url: $<HTMLInputElement>('#purl').value.trim()};
if($<HTMLInputElement>('#ptoken').value.trim()) patch.play_token = $<HTMLInputElement>('#ptoken').value.trim();
save(patch, 'Sharing settings saved');
};
const pclear = $<HTMLButtonElement>('#pclear');
if(pclear) pclear.onclick = () => save({play_token: ''}, 'Token cleared');
drawDirectoryStatus();
}

View File

@@ -518,6 +518,44 @@ function encBox(x: Enclosure){
</div>`;
}
/// A shared link that starts at a time (#447): ipx-play's page reads `?t=` in seconds. Under five
/// seconds in is the start.
const shareAt=(url: string, secs: number)=>secs>=5 ? `${url}?t=${Math.floor(secs)}` : url;
/// An episode shared as Overcast shares one (#447): a link to its page on ipx-play, where anyone
/// can play it, from where you are in it if you like. A private feed, a post, or a server with no
/// ipx-play shares the item's own address, as before.
async function shareEpisode(e: Entry, encId: number|null, btn?: HTMLElement){
const name=entryName(e).text;
const own=encId!=null ? e.enclosures.find(x=>x.id===encId)?.url : e.link;
let link: {url: string|null, why: string|null}={url:null, why:null};
try{
link=await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/share`,
{method:'POST', body:JSON.stringify(encId!=null ? {enc:encId} : {})});
}catch{}
if(!link.url){
if(own) await share(name, own, btn); else toast('There is nothing to share for this item.', true);
return;
}
const url=link.url;
// Where you are: the player's clock when it has this one, else where you left off.
const at=player.guid===e.guid && player.feed===e.feed_id ? audio.currentTime : e.position;
// A menu rather than the share sheet at once: the sheet needs a press of its own, and the
// request above has used up the one that opened this.
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
<h3>Share</h3><p class="hint">${esc(name)}</p>
<div class="sharemenu">
<button class="btn primary" id="shlink">${ICON.share} Share link</button>
${at>=5?`<button class="btn" id="shat">${ICON.share} Share from ${clock(at)}</button>`:''}
<button class="btn" id="shcopy">${ICON.copy} Copy link</button>
</div>
<p class="hint">Anyone with the link can play it, and subscribe in their own podcast app.</p>`);
$<HTMLButtonElement>('#shlink').onclick=()=>{ closeModal(); share(name, url); };
const timed=$<HTMLButtonElement>('#shat');
if(timed) timed.onclick=()=>{ closeModal(); share(name, shareAt(url, at)); };
$<HTMLButtonElement>('#shcopy').onclick=async()=>{ await copyText(url, $<HTMLButtonElement>('#shcopy')); toast('Link copied'); closeModal(); };
}
async function epAction(a: string, e: Entry, el: HTMLElement|null, encId?: number){
// Swap the row in place and refresh the text below when it is the one being read.
const redraw=()=>{ swapRow(e); if(!el || S.sel===entryKey(e)) showDetail(e); };
@@ -526,7 +564,7 @@ async function epAction(a: string, e: Entry, el: HTMLElement|null, encId?: numbe
try{
if(a==='play'){ haptic('tap'); play(e, encId!=null ? enc : undefined); }
if(a==='offline' && enc) await toggleKept(enc, entryName(e).text);
if(a==='share') await share(entryName(e).text, encId!=null ? enc.url : e.link!, el??undefined);
if(a==='share') await shareEpisode(e, encId??null, el??undefined);
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); haptic('tap'); redraw(); }
if(a==='read'){ await setRead(e,!e.read); haptic(e.read?'done':'tap'); redraw(); }
if(a==='get'){