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:
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user