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:
@@ -1,8 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Refuses a commit whose Rust is not as rustfmt writes it, or that clippy warns about. Clippy only
|
# 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
|
# 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,
|
# crates the commit touches and those built on them, not the workspace: ipx uses ipx-play and
|
||||||
# and both use ipx-feed. A commit to the page alone runs nothing here.
|
# 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)
|
staged=$(git diff --cached --name-only --diff-filter=ACMD)
|
||||||
echo "$staged" | grep -q -e '\.rs$' -e 'Cargo\.toml$' -e '^Cargo\.lock$' || exit 0
|
echo "$staged" | grep -q -e '\.rs$' -e 'Cargo\.toml$' -e '^Cargo\.lock$' || exit 0
|
||||||
if ! cargo fmt --all --check >/dev/null 2>&1; then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
if echo "$staged" | grep -q -e '^Cargo\.toml$' -e '^Cargo\.lock$' -e '^crates/feed/'; then
|
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
|
elif echo "$staged" | grep -q '^crates/tui/'; then
|
||||||
pkgs="ipx-tui"
|
pkgs="ipx-tui"
|
||||||
elif echo "$staged" | grep -q '^crates/directory/'; then
|
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
|
elif echo "$staged" | grep -q '^crates/ipx/'; then
|
||||||
pkgs="ipx"
|
pkgs="ipx"
|
||||||
else
|
else
|
||||||
|
|||||||
23
CLAUDE.md
23
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` |
|
| 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` |
|
| 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` |
|
| 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 | |
|
| 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` |
|
| 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`) |
|
| 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
|
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:
|
And the page, `ipx-web`, the same again; a change to `web/` alone needs only this one:
|
||||||
|
|
||||||
```sh
|
```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`.
|
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
|
* 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.
|
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 |
|
| changed | run |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `web/` alone | the three page checks; `npx playwright test -g '<what changed>'`; no cargo |
|
| `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/ipx/` | `cargo test -p ipx`; the browser tests too if the API changed |
|
||||||
| `crates/directory/` | `cargo test -p ipx-directory`, then `-p ipx` |
|
| `crates/directory/` | `cargo test -p ipx-directory`, then `-p ipx-play`, then `-p ipx` |
|
||||||
| `crates/feed/` | `cargo test -p ipx-feed`, then `-p ipx-directory`, 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 |
|
| `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
|
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.
|
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,
|
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)
|
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
|
(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
|
[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
|
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
|
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 | `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-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` |
|
| 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
|
`crates/feed` is a library the two Rust services build in, not released (`0.0.0`); a change to it
|
||||||
|
|||||||
28
Cargo.lock
generated
28
Cargo.lock
generated
@@ -1983,6 +1983,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"ipx-directory",
|
"ipx-directory",
|
||||||
"ipx-feed",
|
"ipx-feed",
|
||||||
|
"ipx-play",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"librqbit",
|
"librqbit",
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
@@ -2056,6 +2057,33 @@ dependencies = [
|
|||||||
"url",
|
"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]]
|
[[package]]
|
||||||
name = "ipx-tui"
|
name = "ipx-tui"
|
||||||
version = "0.1.0-dev"
|
version = "0.1.0-dev"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["crates/ipx", "crates/feed", "crates/directory", "crates/tui"]
|
members = ["crates/ipx", "crates/feed", "crates/directory", "crates/tui", "crates/play"]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
# Each service has its own version, in its own Cargo.toml (ipx, ipx-directory) or package.json
|
# 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]
|
[workspace.dependencies]
|
||||||
ipx-feed = { path = "crates/feed" }
|
ipx-feed = { path = "crates/feed" }
|
||||||
ipx-directory = { path = "crates/directory" }
|
ipx-directory = { path = "crates/directory" }
|
||||||
|
ipx-play = { path = "crates/play" }
|
||||||
ammonia = "4.1.4"
|
ammonia = "4.1.4"
|
||||||
anyhow = "1.0.104"
|
anyhow = "1.0.104"
|
||||||
argon2 = "0.6.0"
|
argon2 = "0.6.0"
|
||||||
|
|||||||
28
Dockerfile.play
Normal file
28
Dockerfile.play
Normal 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"]
|
||||||
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### 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).
|
- 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).
|
- 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).
|
- `/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).
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
ipx-feed.workspace = true
|
ipx-feed.workspace = true
|
||||||
ipx-directory.workspace = true
|
ipx-directory.workspace = true
|
||||||
|
ipx-play.workspace = true
|
||||||
ammonia.workspace = true
|
ammonia.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
argon2.workspace = true
|
argon2.workspace = true
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ pub struct General {
|
|||||||
pub directory_token: String,
|
pub directory_token: String,
|
||||||
/// Send the directory the public feeds people here subscribe to, and how many of them do.
|
/// Send the directory the public feeds people here subscribe to, and how many of them do.
|
||||||
pub directory_share: bool,
|
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
|
/// 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.
|
/// on each person's own (#271). Without that key it changes nothing.
|
||||||
pub signals_on_server: bool,
|
pub signals_on_server: bool,
|
||||||
@@ -248,6 +254,8 @@ impl Default for General {
|
|||||||
directory_url: String::new(),
|
directory_url: String::new(),
|
||||||
directory_token: String::new(),
|
directory_token: String::new(),
|
||||||
directory_share: false,
|
directory_share: false,
|
||||||
|
play_url: String::new(),
|
||||||
|
play_token: String::new(),
|
||||||
signals_on_server: false,
|
signals_on_server: false,
|
||||||
signal_days: 7,
|
signal_days: 7,
|
||||||
fetch_internal: vec![],
|
fetch_internal: vec![],
|
||||||
@@ -388,6 +396,10 @@ pub struct Stored {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub directory_share: bool,
|
pub directory_share: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub play_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub play_token: String,
|
||||||
|
#[serde(default)]
|
||||||
pub signals_on_server: bool,
|
pub signals_on_server: bool,
|
||||||
#[serde(default = "signal_days")]
|
#[serde(default = "signal_days")]
|
||||||
pub signal_days: u64,
|
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.
|
/// `[general]` keys that live in the database once it holds the configuration.
|
||||||
const STORED_KEYS: [&str; 11] = [
|
const STORED_KEYS: [&str; 13] = [
|
||||||
"schedule",
|
"schedule",
|
||||||
"max_total_gb",
|
"max_total_gb",
|
||||||
"max_age_days",
|
"max_age_days",
|
||||||
@@ -412,6 +424,8 @@ const STORED_KEYS: [&str; 11] = [
|
|||||||
"directory_url",
|
"directory_url",
|
||||||
"directory_token",
|
"directory_token",
|
||||||
"directory_share",
|
"directory_share",
|
||||||
|
"play_url",
|
||||||
|
"play_token",
|
||||||
"signals_on_server",
|
"signals_on_server",
|
||||||
"signal_days",
|
"signal_days",
|
||||||
];
|
];
|
||||||
@@ -429,6 +443,8 @@ impl Stored {
|
|||||||
directory_url: g.directory_url.clone(),
|
directory_url: g.directory_url.clone(),
|
||||||
directory_token: g.directory_token.clone(),
|
directory_token: g.directory_token.clone(),
|
||||||
directory_share: g.directory_share,
|
directory_share: g.directory_share,
|
||||||
|
play_url: g.play_url.clone(),
|
||||||
|
play_token: g.play_token.clone(),
|
||||||
signals_on_server: g.signals_on_server,
|
signals_on_server: g.signals_on_server,
|
||||||
signal_days: g.signal_days,
|
signal_days: g.signal_days,
|
||||||
}
|
}
|
||||||
@@ -445,6 +461,8 @@ impl Stored {
|
|||||||
g.directory_url = self.directory_url;
|
g.directory_url = self.directory_url;
|
||||||
g.directory_token = self.directory_token;
|
g.directory_token = self.directory_token;
|
||||||
g.directory_share = self.directory_share;
|
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.signals_on_server = self.signals_on_server;
|
||||||
g.signal_days = self.signal_days;
|
g.signal_days = self.signal_days;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ mod lists;
|
|||||||
mod logbuf;
|
mod logbuf;
|
||||||
mod logging;
|
mod logging;
|
||||||
mod metrics;
|
mod metrics;
|
||||||
|
mod play;
|
||||||
mod push;
|
mod push;
|
||||||
mod retention;
|
mod retention;
|
||||||
mod scan;
|
mod scan;
|
||||||
|
|||||||
121
crates/ipx/src/play.rs
Normal file
121
crates/ipx/src/play.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}", get(one_entry))
|
||||||
.route("/api/entries/{feed_id}/{guid}/flags", post(set_flags))
|
.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}/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/feeds/{id}/read-all", post(read_all))
|
||||||
.route("/api/read-all", post(read_all_mine))
|
.route("/api/read-all", post(read_all_mine))
|
||||||
.route("/api/feeds/{id}/download-latest", post(download_latest))
|
.route("/api/feeds/{id}/download-latest", post(download_latest))
|
||||||
@@ -2077,6 +2078,43 @@ async fn one_entry(
|
|||||||
Ok(Json(row))
|
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
|
/// 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).
|
/// sent one, made HTML and cleaned the same way (#285).
|
||||||
fn clean_row(sanitizer: &mut ammonia::Builder<'static>, row: &mut crate::db::EntryRow) {
|
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.
|
/// Whether a token is set: the token itself never leaves the server.
|
||||||
directory_token_set: bool,
|
directory_token_set: bool,
|
||||||
directory_share: 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,
|
signals_on_server: bool,
|
||||||
signal_days: u64,
|
signal_days: u64,
|
||||||
/// Whether the server has a TypeSafe key (`TYPESAFE_KEY`) for `signals_on_server` to use.
|
/// 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_url: cfg.general.directory_url.clone(),
|
||||||
directory_token_set: !cfg.general.directory_token.trim().is_empty(),
|
directory_token_set: !cfg.general.directory_token.trim().is_empty(),
|
||||||
directory_share: cfg.general.directory_share,
|
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,
|
signals_on_server: cfg.general.signals_on_server,
|
||||||
signal_days: cfg.general.signal_days,
|
signal_days: cfg.general.signal_days,
|
||||||
typesafe_server_key: crate::signals::typesafe_key().is_some(),
|
typesafe_server_key: crate::signals::typesafe_key().is_some(),
|
||||||
@@ -3077,6 +3120,10 @@ struct SettingsPatch {
|
|||||||
/// Replaces the token; empty clears it.
|
/// Replaces the token; empty clears it.
|
||||||
directory_token: Option<String>,
|
directory_token: Option<String>,
|
||||||
directory_share: Option<bool>,
|
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>,
|
signals_on_server: Option<bool>,
|
||||||
/// 1 to 365.
|
/// 1 to 365.
|
||||||
signal_days: Option<u64>,
|
signal_days: Option<u64>,
|
||||||
@@ -3127,6 +3174,16 @@ async fn patch_settings(
|
|||||||
if let Some(v) = body.directory_token {
|
if let Some(v) = body.directory_token {
|
||||||
cfg.general.directory_token = v.trim().to_owned();
|
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 {
|
if let Some(v) = body.directory_share {
|
||||||
cfg.general.directory_share = v;
|
cfg.general.directory_share = v;
|
||||||
}
|
}
|
||||||
|
|||||||
18
crates/play/CHANGELOG.md
Normal file
18
crates/play/CHANGELOG.md
Normal 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
39
crates/play/Cargo.toml
Normal 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
115
crates/play/src/config.rs
Normal 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
316
crates/play/src/lib.rs
Normal 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 <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("<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
117
crates/play/src/main.rs
Normal 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
235
crates/play/src/page.rs
Normal 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('&', "&").replace('<', "<").replace('>', ">").replace('"', """).replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `?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(" ", " ")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("&", "&");
|
||||||
|
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 & 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
70
crates/play/src/play.css
Normal 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
200
crates/play/src/store.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
`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.
|
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
|
**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
|
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.
|
settings, kept in the database (`directory_url`, `directory_token`, `directory_share`), not here.
|
||||||
|
|||||||
78
docs/play.md
Normal file
78
docs/play.md
Normal 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.
|
||||||
@@ -48,6 +48,16 @@ export default defineConfig({
|
|||||||
env: { DIRECTORY_DATABASE_URL: `sqlite://${setup.root}/directory.db?mode=rwc`, ...PROFILE },
|
env: { DIRECTORY_DATABASE_URL: `sqlite://${setup.root}/directory.db?mode=rwc`, ...PROFILE },
|
||||||
...STOP,
|
...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
|
// 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.
|
// suite's port, the API passed to the daemon behind it.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Build and push one service's image, inside limits Tower can afford.
|
# 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
|
# 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
|
# 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
|
case "${1:-}" in
|
||||||
ipx) FILE=Dockerfile; IMAGE=ipx ;;
|
ipx) FILE=Dockerfile; IMAGE=ipx ;;
|
||||||
directory) FILE=Dockerfile.directory; IMAGE=ipx-directory ;;
|
directory) FILE=Dockerfile.directory; IMAGE=ipx-directory ;;
|
||||||
|
play) FILE=Dockerfile.play; IMAGE=ipx-play ;;
|
||||||
web) FILE=Dockerfile.web; IMAGE=ipx-web ;;
|
web) FILE=Dockerfile.web; IMAGE=ipx-web ;;
|
||||||
# Not a service: what scripts/coverage.sh runs the Rust tests in (#390).
|
# Not a service: what scripts/coverage.sh runs the Rust tests in (#390).
|
||||||
coverage) FILE=Dockerfile.coverage; IMAGE=ipx-coverage ;;
|
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
|
esac
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Which lines of the Rust the tests reach (#390, #394), inside the limits builds are held to.
|
# 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 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
|
# sh scripts/coverage.sh api the browser suite against an instrumented ipx, ipx-directory
|
||||||
# ipx-directory: what the API and the daemon run (and the
|
# and ipx-play: what the API and the daemon run (and the
|
||||||
# page's coverage, which the same run measures)
|
# page's coverage, which the same run measures)
|
||||||
#
|
#
|
||||||
# A coverage build instruments every crate, dependencies included, in a target of its own: a whole
|
# 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
|
# 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.
|
# binaries the browser suite starts. Profiles from an earlier run are not this one's.
|
||||||
run cargo llvm-cov clean --workspace
|
run cargo llvm-cov clean --workspace
|
||||||
for p in ipx-directory ipx; do
|
for p in ipx-directory ipx-play ipx; do
|
||||||
room
|
room
|
||||||
echo "== build $p"
|
echo "== build $p"
|
||||||
run sh -c "eval \"\$(cargo llvm-cov show-env --export-prefix)\" && cargo build --locked -p $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"
|
exit "$suite"
|
||||||
fi
|
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.
|
# A whole run starts from nothing; one crate adds to what has run.
|
||||||
[ $# -eq 0 ] && run cargo llvm-cov clean --workspace
|
[ $# -eq 0 ] && run cargo llvm-cov clean --workspace
|
||||||
for c in $crates; do
|
for c in $crates; do
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Publish a Gitea release for one service's tag, with a tarball taken from the image that was
|
# 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,
|
# 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
|
set -eu
|
||||||
cd "$(dirname "$0")/.."
|
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 ;;
|
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)
|
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 ;;
|
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')
|
web) name=ipx-web; ver=$(node -p 'require("./package.json").version')
|
||||||
tag=web-v$ver; log=web/CHANGELOG.md; from=/srv ;;
|
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
|
esac
|
||||||
|
|
||||||
case "$ver" in *-dev) echo "$name is at $ver: release it first" >&2; exit 1 ;; esac
|
case "$ver" in *-dev) echo "$name is at $ver: release it first" >&2; exit 1 ;; esac
|
||||||
|
|||||||
@@ -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');
|
assert.equal(run('mediaSrc({id: 3, deadair: null})'), '/media/3?offline');
|
||||||
run('kept.clear()');
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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: [] }) }));
|
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.evaluate(() => { window.shared = []; navigator.share = async (d?: ShareData) => { window.shared.push(d!); }; });
|
||||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
await page.locator('#content .acts [data-a="share"]').click();
|
await page.locator('#content .acts [data-a="share"]').click();
|
||||||
await page.locator('.ep', { hasText: 'Second Episode' }).click();
|
await page.locator('.ep', { hasText: 'Second Episode' }).click();
|
||||||
await page.locator('.encbox [data-a="share"]').first().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([
|
await expect.poll(() => page.evaluate(() => window.shared)).toEqual([
|
||||||
{ title: 'Test Show', url: expect.stringContaining('/show.xml') },
|
{ 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 }) => {
|
test('a refresh button turns from the press to the end of the scan it asked for', async ({ page }) => {
|
||||||
|
|||||||
@@ -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.
|
// 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 = 'http://127.0.0.1:8795';
|
||||||
const DIRECTORY_TOKEN = 'ipxd_testdirectorytoken';
|
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
|
// 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
|
// 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_url = "${DIRECTORY}"
|
||||||
directory_token = "${DIRECTORY_TOKEN}"
|
directory_token = "${DIRECTORY_TOKEN}"
|
||||||
directory_share = true
|
directory_share = true
|
||||||
|
play_url = "${PLAY}"
|
||||||
|
play_token = "${PLAY_TOKEN}"
|
||||||
|
|
||||||
[torrent]
|
[torrent]
|
||||||
enabled = false
|
enabled = false
|
||||||
@@ -85,6 +90,14 @@ fetch_internal = ["127.0.0.1"]
|
|||||||
name = "test"
|
name = "test"
|
||||||
access = "rwa"
|
access = "rwa"
|
||||||
token_sha256 = "${crypto.createHash('sha256').update(DIRECTORY_TOKEN).digest('hex')}"
|
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 API_COVERAGE = process.env.IPX_API_COVERAGE ?? '';
|
||||||
const IPX_BIN = API_COVERAGE ? path.join(API_COVERAGE, 'debug', 'ipxd') : './target/debug/ipxd';
|
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 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 };
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### 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, 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 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).
|
- 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).
|
||||||
|
|||||||
@@ -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}
|
.inline input[type=number]{flex:none;width:90px}
|
||||||
.check input{width:16px;height:16px;accent-color:var(--accent)}
|
.check input{width:16px;height:16px;accent-color:var(--accent)}
|
||||||
.cardacts{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
|
.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 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
|
/* A frame of its own: the body scrolls and Save stays at the foot, where on a phone it had been
|
||||||
|
|||||||
@@ -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
|
<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>
|
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>`)}
|
<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 <name></code>.</p></div>
|
||||||
|
<div class="cardacts"><button class="btn primary" id="psave">${ICON.check} Save</button></div>`)}
|
||||||
<div id="dadmin"></div>`;
|
<div id="dadmin"></div>`;
|
||||||
const save = async (patch: Record<string, unknown>, said: string) => {
|
const save = async (patch: Record<string, unknown>, said: string) => {
|
||||||
try{
|
try{
|
||||||
@@ -123,6 +134,13 @@ async function drawDirectorySettings(){
|
|||||||
};
|
};
|
||||||
const clear = $<HTMLButtonElement>('#dclear');
|
const clear = $<HTMLButtonElement>('#dclear');
|
||||||
if(clear) clear.onclick = () => save({directory_token: ''}, 'Token cleared');
|
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();
|
drawDirectoryStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -518,6 +518,44 @@ function encBox(x: Enclosure){
|
|||||||
</div>`;
|
</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){
|
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.
|
// 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); };
|
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{
|
try{
|
||||||
if(a==='play'){ haptic('tap'); play(e, encId!=null ? enc : undefined); }
|
if(a==='play'){ haptic('tap'); play(e, encId!=null ? enc : undefined); }
|
||||||
if(a==='offline' && enc) await toggleKept(enc, entryName(e).text);
|
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==='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==='read'){ await setRead(e,!e.read); haptic(e.read?'done':'tap'); redraw(); }
|
||||||
if(a==='get'){
|
if(a==='get'){
|
||||||
|
|||||||
Reference in New Issue
Block a user