Skip to main content

phemios_core/
podcast.rs

1//! Explicit public RSS/Atom retrieval, isolated from Aède sessions and source-library files.
2use std::net::IpAddr;
3use std::time::Duration;
4use ureq::http::Uri;
5use ureq::unversioned::resolver::{DefaultResolver, ResolvedSocketAddrs, Resolver};
6use ureq::unversioned::transport::{DefaultConnector, NextTimeout};
7
8fn public_ip(ip: IpAddr) -> bool {
9    match ip {
10        IpAddr::V4(ip) => {
11            let [a, b, c, d] = ip.octets();
12            !ip.is_private()
13                && !ip.is_loopback()
14                && !ip.is_link_local()
15                && !ip.is_documentation()
16                && !ip.is_broadcast()
17                && a != 0
18                && a < 224
19                && !(a == 100 && (64..=127).contains(&b))
20                && !(a == 198 && (18..=19).contains(&b))
21                // IANA protocol space has only the .9/.10 globally reachable exceptions.
22                && !(a == 192 && b == 0 && c == 0 && !matches!(d, 9 | 10))
23                && !(a == 192 && b == 88 && c == 99)
24        }
25        IpAddr::V6(ip) => {
26            if let Some(ip) = ip.to_ipv4_mapped() {
27                return public_ip(IpAddr::V4(ip));
28            }
29            let s = ip.segments();
30            (0x2000..=0x3fff).contains(&s[0])
31                && s[0] != 0x2002
32                && !(s[0] == 0x2001 && matches!(s[1], 0 | 2 | 0xdb8))
33        }
34    }
35}
36/// Only explicit public HTTP(S) feeds without URL credentials/fragments may enter the RSS client.
37pub fn feed_address(value: &str) -> Result<Uri, String> {
38    if value.len() > 2048 || value.chars().any(char::is_control) || value.contains('#') {
39        return Err("Adresse RSS invalide.".into());
40    }
41    let uri: Uri = value.trim().parse().map_err(|_| "Adresse RSS invalide.")?;
42    let authority = uri.authority().ok_or("Adresse RSS invalide.")?;
43    let host = uri
44        .host()
45        .ok_or("Adresse RSS invalide.")?
46        .trim_matches(['[', ']']);
47    if !matches!(uri.scheme_str(), Some("http" | "https"))
48        || authority.as_str().contains('@')
49        || !matches!(uri.port_u16(), None | Some(80 | 443))
50        || host.eq_ignore_ascii_case("localhost")
51        || host.ends_with(".local")
52        || host.parse::<IpAddr>().is_ok_and(|ip| !public_ip(ip))
53    {
54        return Err("Utilisez un flux RSS HTTP/HTTPS public sans identifiants.".into());
55    }
56    Ok(uri)
57}
58#[derive(Debug)]
59pub(crate) struct PublicResolver;
60impl Resolver for PublicResolver {
61    fn resolve(
62        &self,
63        uri: &Uri,
64        config: &ureq::config::Config,
65        timeout: NextTimeout,
66    ) -> Result<ResolvedSocketAddrs, ureq::Error> {
67        feed_address(&uri.to_string()).map_err(|_| ureq::Error::HostNotFound)?;
68        let addresses = DefaultResolver::default().resolve(uri, config, timeout)?;
69        if addresses.iter().any(|address| !public_ip(address.ip())) {
70            return Err(ureq::Error::HostNotFound);
71        }
72        Ok(addresses)
73    }
74}
75/// One bounded, public-only feed client; reading a subscription cache never invokes it.
76pub struct FeedClient {
77    agent: ureq::Agent,
78}
79impl Default for FeedClient {
80    fn default() -> Self {
81        let config = ureq::Agent::config_builder()
82            .timeout_global(Some(Duration::from_secs(15)))
83            .max_redirects(3)
84            .proxy(None)
85            .build();
86        Self {
87            agent: ureq::Agent::with_parts(config, DefaultConnector::default(), PublicResolver),
88        }
89    }
90}
91impl FeedClient {
92    /// Read at most eight MiB of UTF-8 XML after explicit subscribe/refresh, with bounded public redirects.
93    pub fn read(&self, url: &str) -> Result<String, String> {
94        let uri = feed_address(url)?;
95        let mut response = self
96            .agent
97            .get(uri.to_string())
98            .header(
99                "User-Agent",
100                concat!(
101                    "Phemios/",
102                    env!("CARGO_PKG_VERSION"),
103                    " (Podcast RSS client)"
104                ),
105            )
106            .header(
107                "Accept",
108                "application/rss+xml, application/atom+xml, application/xml, text/xml",
109            )
110            .call()
111            .map_err(|_| "Flux RSS indisponible ou adresse non publique.")?;
112        let bytes = response
113            .body_mut()
114            .with_config()
115            .limit(8 * 1024 * 1024)
116            .read_to_vec()
117            .map_err(|_| "Flux RSS trop grand ou interrompu.")?;
118        String::from_utf8(bytes).map_err(|_| "Le flux RSS doit utiliser UTF-8.".into())
119    }
120}
121#[cfg(test)]
122#[path = "podcast_tests.rs"]
123mod tests;