Files
ipodderx-rs/src/config.rs
rays 06f182b555 Accounts and sign-in, and fix the read toggle
epAction's redraw closure called itself when handed a row, so Mark read
recursed until the stack blew; it now swaps that row in place. Opening an
item also marks it read, redrawn where it stands so nothing vanishes from
under the pointer on the Unread tab.

Step A of multi-user: users and sessions tables, Argon2id, a session
cookie, ipx user subcommands, and a trusted proxy header for Cloudflare
Zero Trust -- honoured only from a trusted_proxies address. The shared
token still works and is the admin. A new database starts with
admin/ipodderx.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 23:11:20 +00:00

498 lines
18 KiB
Rust

//! TOML configuration. Replaces iPXSettings.py and feeds.plist.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
pub struct Config {
#[serde(default)]
pub general: General,
#[serde(default)]
pub torrent: Torrent,
#[serde(default)]
pub web: Web,
/// Keyed by feed id: the TOML table name, which replaces the old genHash(feedURL).
#[serde(default)]
pub feeds: BTreeMap<String, Feed>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct General {
pub download_dir: PathBuf,
pub socket: PathBuf,
/// How often to re-check feeds: "every 30m", "every 4h", "90" (minutes), "1d".
/// A feed's own `schedule` overrides this.
pub schedule: String,
/// Superseded by `schedule`. Still read so existing configs keep working.
#[serde(skip_serializing_if = "Option::is_none")]
pub interval_mins: Option<u64>,
pub organize: Organize,
/// 0 = unlimited.
pub max_total_gb: f64,
/// 0 = keep forever.
pub max_age_days: u64,
/// How many new enclosures a single scan may take, when a feed does not say.
/// Unlimited by default was a trap: subscribing to an OPML of 80 feeds then pulled
/// every back-catalogue episode at once. 0 means unlimited, deliberately chosen.
pub max_new_per_check: usize,
/// Top-level media types worth downloading. Blog feeds put each article's header
/// image in an <enclosure>, so taking everything filled the disk with artwork and
/// counted it as episodes. Empty means take anything.
pub media_types: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Organize {
/// One folder per feed, named for the feed (or its `folder` override).
Feed,
/// One folder per day, MM-DD-YYYY, as iPXSettings.organizeDownloads == 1 did.
Date,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct Torrent {
pub enabled: bool,
/// Stop seeding at this ratio, or after seed_time_mins, whichever comes first.
pub seed_ratio: f64,
pub seed_time_mins: u64,
pub port_range: String,
/// Drop a torrent that has made no progress for this long.
pub stall_mins: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct Web {
pub enabled: bool,
/// Use 0.0.0.0 to reach it from the LAN. Anything but loopback needs the token.
pub bind: String,
/// Shared secret. Generated and written back on first run when left empty. It signs
/// in as the admin, which is what keeps the healthcheck and any scripts working.
pub token: String,
/// A header naming the signed-in user, set by whatever fronts this -- Cloudflare Zero
/// Trust sends `Cf-Access-Authenticated-User-Email`. Empty disables the whole path.
pub trusted_header: String,
/// Addresses allowed to assert that header. A header is only as trustworthy as the
/// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could
/// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon.
pub trusted_proxies: Vec<String>,
/// Create an account the first time the proxy vouches for a name it has not seen.
pub auto_create_users: bool,
/// Sign a session out after this long without a request.
pub session_days: i64,
}
impl Default for Web {
fn default() -> Self {
Self {
enabled: false,
bind: "127.0.0.1:8080".into(),
token: String::new(),
trusted_header: String::new(),
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
auto_create_users: true,
session_days: 30,
}
}
}
impl Web {
pub fn binds_publicly(&self) -> bool {
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Feed {
pub url: String,
/// Download folder name; defaults to the sanitized feed title.
#[serde(skip_serializing_if = "Option::is_none")]
pub folder: Option<String>,
/// Every whitespace-separated word of a keyword must appear in the
/// url/title/description/categories for an enclosure to be taken.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub keywords: Vec<String>,
#[serde(default)]
pub allow_explicit: bool,
#[serde(default = "yes")]
pub auto_download: bool,
/// Set on feeds that came from a subscribed OPML: the id of the OPML feed they
/// belong to. The OPML is re-read on every scan and this list kept in step.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
/// Overrides the global schedule for this feed. Same forms: "every 6h", "2d".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schedule: Option<String>,
/// Media types for this feed. None follows `[general]`; an empty list takes anything.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub media_types: Option<Vec<String>>,
/// Cap on new downloads per scan for this feed. None follows `[general]`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_new_per_check: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
/// Preferred over `password`: name of an env var holding the password.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password_env: Option<String>,
}
fn yes() -> bool {
true
}
impl Default for General {
fn default() -> Self {
Self {
download_dir: home().join("Podcasts"),
socket: default_socket(),
schedule: "every 60m".into(),
interval_mins: None,
organize: Organize::Feed,
max_total_gb: 0.0,
max_age_days: 0,
max_new_per_check: 3,
media_types: vec!["audio".into(), "video".into()],
}
}
}
impl Default for Torrent {
fn default() -> Self {
Self {
enabled: true,
seed_ratio: 1.0,
seed_time_mins: 60,
port_range: "6881-6889".into(),
stall_mins: 30,
}
}
}
impl General {
/// Minutes between checks. Falls back to the legacy `interval_mins`, then to an hour.
/// A malformed value warns rather than stopping the daemon.
pub fn interval(&self) -> u64 {
if let Some(n) = parse_interval(&self.schedule) {
return n;
}
if !self.schedule.trim().is_empty() {
tracing::warn!(schedule = %self.schedule, "unrecognised schedule; using the default");
}
self.interval_mins.filter(|n| *n > 0).unwrap_or(60)
}
}
/// Parses a check interval into minutes.
///
/// Accepts "every 30m", "30m", "4h", "1d", "2w", "every 4 hours", or a bare number of
/// minutes.
/// Returns None for anything it cannot read, or for zero.
pub fn parse_interval(s: &str) -> Option<u64> {
let s = s.trim().to_lowercase();
let s = s.strip_prefix("every").unwrap_or(&s).trim();
if s.is_empty() {
return None;
}
let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.is_empty() {
return None;
}
let n: u64 = digits.parse().ok()?;
let unit = s[digits.len()..].trim();
let mins = match unit {
"" | "m" | "min" | "mins" | "minute" | "minutes" => n,
"h" | "hr" | "hrs" | "hour" | "hours" => n.checked_mul(60)?,
"d" | "day" | "days" => n.checked_mul(1440)?,
"w" | "week" | "weeks" => n.checked_mul(10080)?,
_ => return None,
};
(mins > 0).then_some(mins)
}
impl Torrent {
/// Inclusive listen port range. Falls back to the BitTorrent default on garbage input.
pub fn ports(&self) -> (u16, u16) {
let parse = || -> Option<(u16, u16)> {
let (lo, hi) = self.port_range.split_once('-')?;
Some((lo.trim().parse().ok()?, hi.trim().parse().ok()?))
};
match parse() {
Some((lo, hi)) if lo <= hi => (lo, hi),
_ => (6881, 6889),
}
}
}
impl Feed {
/// Resolved password: `password_env` wins over a literal `password`.
pub fn password(&self) -> Option<String> {
if let Some(var) = &self.password_env {
return std::env::var(var).ok();
}
self.password.clone()
}
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
if !path.exists() {
// ponytail: a missing config is an empty one, so `list`/`add` work on a fresh install.
return Ok(Self::default());
}
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading {}", path.display()))?;
let mut cfg: Self =
toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
cfg.general.download_dir = expand_tilde(&cfg.general.download_dir);
Ok(cfg)
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)
.with_context(|| format!("creating {}", dir.display()))?;
}
let text = toml::to_string_pretty(self)?;
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
// Passwords may live in here.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
}
/// `$IPX_CONFIG`, else `$XDG_CONFIG_HOME/ipx/config.toml`.
pub fn config_path() -> PathBuf {
if let Ok(p) = std::env::var("IPX_CONFIG") {
return PathBuf::from(p);
}
dirs::config_dir()
.unwrap_or_else(|| home().join(".config"))
.join("ipx/config.toml")
}
/// `$IPX_DATA_DIR`, else `$XDG_DATA_HOME/ipx`.
pub fn data_dir() -> PathBuf {
if let Ok(p) = std::env::var("IPX_DATA_DIR") {
return PathBuf::from(p);
}
dirs::data_dir()
.unwrap_or_else(|| home().join(".local/share"))
.join("ipx")
}
fn default_socket() -> PathBuf {
match std::env::var_os("XDG_RUNTIME_DIR") {
Some(dir) => PathBuf::from(dir).join("ipx.sock"),
None => std::env::temp_dir().join("ipx.sock"),
}
}
/// Whether an enclosure's type is one we want.
///
/// An unknown type is allowed: the real type is only known after downloading, and
/// refusing everything untyped would drop feeds that simply omit the attribute.
pub fn wanted_media(mime: Option<&str>, wanted: &[String]) -> bool {
if wanted.is_empty() {
return true;
}
let Some(mime) = mime.map(str::trim).filter(|m| !m.is_empty()) else {
return true;
};
let top = mime.split('/').next().unwrap_or(mime).to_ascii_lowercase();
// A .torrent is a container for media, not media itself; judge it once unpacked.
if mime.to_ascii_lowercase().contains("torrent") {
return true;
}
wanted.iter().any(|w| w.trim().eq_ignore_ascii_case(&top) || w.trim().eq_ignore_ascii_case(mime))
}
/// Feed ids are the TOML table key, so they must be readable and punctuation-free.
pub fn slug(text: &str) -> String {
let mut out = String::new();
for c in text.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
} else if c.is_alphanumeric() {
out.push(c); // Keep non-ASCII letters; TOML bare keys are stricter, but we quote.
} else if !out.ends_with('-') {
out.push('-');
}
}
let out = out.trim_matches('-').to_owned();
let out: String = out.chars().take(40).collect();
let out = out.trim_matches('-').to_owned();
if out.is_empty() { "feed".into() } else { out }
}
/// `slug`, with a numeric suffix if that id is taken.
pub fn unique_slug(text: &str, taken: &BTreeMap<String, Feed>) -> String {
let base = slug(text);
if !taken.contains_key(&base) {
return base;
}
(2..).map(|n| format!("{base}-{n}")).find(|s| !taken.contains_key(s)).unwrap()
}
fn home() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))
}
fn expand_tilde(p: &Path) -> PathBuf {
match p.strip_prefix("~") {
Ok(rest) => home().join(rest),
Err(_) => p.to_path_buf(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_a_config_and_applies_defaults() {
let cfg: Config = toml::from_str(
r#"
[general]
download_dir = "/tmp/pods"
[feeds.example]
url = "https://example.com/feed.xml"
keywords = ["deep dive"]
"#,
)
.unwrap();
assert_eq!(cfg.general.download_dir, PathBuf::from("/tmp/pods"));
assert_eq!(cfg.general.interval(), 60);
assert_eq!(cfg.general.organize, Organize::Feed);
assert!(cfg.torrent.enabled);
let feed = &cfg.feeds["example"];
assert!(feed.auto_download, "auto_download defaults on");
assert!(!feed.allow_explicit);
assert_eq!(feed.max_new_per_check, None);
assert_eq!(feed.keywords, vec!["deep dive"]);
}
#[test]
fn intervals_parse_from_the_forms_people_actually_type() {
for (input, want) in [
("every 30m", 30), ("30m", 30), ("30", 30), ("every 30 minutes", 30),
("every 4h", 240), ("4h", 240), ("4 hours", 240), ("EVERY 4H", 240),
("1d", 1440), ("every 2 days", 2880), (" every 90m ", 90),
("1w", 10080), ("every 2 weeks", 20160), ("2 w", 20160),
] {
assert_eq!(parse_interval(input), Some(want), "{input:?}");
}
for bad in ["", " ", "every", "soon", "-5m", "0", "0h", "every 0 minutes", "5 fortnights"] {
assert_eq!(parse_interval(bad), None, "{bad:?} should not parse");
}
}
#[test]
fn interval_falls_back_through_legacy_then_default() {
let mut g = General::default();
assert_eq!(g.interval(), 60, "the default schedule");
g.schedule = "every 15m".into();
assert_eq!(g.interval(), 15);
// A config written before `schedule` existed still works.
g.schedule = String::new();
g.interval_mins = Some(45);
assert_eq!(g.interval(), 45);
// Garbage must not stop the daemon.
g.schedule = "whenever".into();
assert_eq!(g.interval(), 45);
g.interval_mins = None;
assert_eq!(g.interval(), 60);
}
#[test]
fn port_range_falls_back_when_malformed() {
let mut t = Torrent::default();
assert_eq!(t.ports(), (6881, 6889));
t.port_range = "51413-51420".into();
assert_eq!(t.ports(), (51413, 51420));
t.port_range = "nonsense".into();
assert_eq!(t.ports(), (6881, 6889));
t.port_range = "900-100".into();
assert_eq!(t.ports(), (6881, 6889), "reversed range is not a range");
}
#[test]
fn media_types_keep_article_artwork_out() {
let want = vec!["audio".to_string(), "video".to_string()];
assert!(wanted_media(Some("audio/mpeg"), &want));
assert!(wanted_media(Some("audio/mp4"), &want));
assert!(wanted_media(Some("video/quicktime"), &want));
assert!(!wanted_media(Some("image/jpeg"), &want), "a blog header image is not an episode");
assert!(!wanted_media(Some("text/html"), &want));
// A torrent is a container; what is inside is judged after unpacking.
assert!(wanted_media(Some("application/x-bittorrent"), &want));
// Unknown type: only discoverable by downloading, so do not refuse it outright.
assert!(wanted_media(None, &want));
assert!(wanted_media(Some(""), &want));
// An empty list means take anything, which is how it behaved before.
assert!(wanted_media(Some("image/jpeg"), &[]));
// A full type can be named exactly.
assert!(wanted_media(Some("image/jpeg"), &["image/jpeg".to_string()]));
}
#[test]
fn slugs_are_readable_and_unique() {
assert_eq!(slug("Accidental Tech Podcast"), "accidental-tech-podcast");
assert_eq!(slug(" The Daily!! "), "the-daily");
assert_eq!(slug("99% Invisible"), "99-invisible");
assert_eq!(slug("///"), "feed");
assert_eq!(slug("").len(), 4);
assert!(slug(&"x".repeat(100)).len() <= 40);
let mut taken = BTreeMap::new();
taken.insert("the-daily".to_string(), Feed {
url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
auto_download: true, max_new_per_check: None, username: None,
password: None, password_env: None,
});
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2");
}
#[test]
fn password_env_wins_over_literal() {
let mut f = Feed {
url: "https://x/y".into(),
folder: None,
group: None,
media_types: None,
schedule: None,
keywords: vec![],
allow_explicit: false,
auto_download: true,
max_new_per_check: None,
username: Some("ray".into()),
password: Some("literal".into()),
password_env: None,
};
assert_eq!(f.password().as_deref(), Some("literal"));
unsafe { std::env::set_var("IPX_TEST_PASS", "from-env") };
f.password_env = Some("IPX_TEST_PASS".into());
assert_eq!(f.password().as_deref(), Some("from-env"));
}
}