Skip to main content

phemios_core/
network.rs

1//! Bounded HTTP client for one explicit Aède authority; sessions stay in memory.
2use serde_json::{Value, json};
3use std::sync::{Arc, Mutex};
4use std::time::{Duration, Instant};
5#[path = "network_errors.rs"]
6mod errors;
7#[path = "network_parental.rs"]
8mod parental;
9#[path = "network_request.rs"]
10mod request;
11#[path = "network_snapshot.rs"]
12mod snapshot;
13#[path = "network_tls.rs"]
14mod tls;
15
16const JSON_LIMIT: u64 = 16 * 1024 * 1024;
17/// Trusted native session; never serialize or log this value.
18#[derive(Clone)]
19pub struct Client {
20    agent: ureq::Agent,
21    server: String,
22    token: String,
23    account: Value,
24    artwork_parental: Arc<Mutex<Option<(Instant, Value)>>>,
25    playback_tls: Option<Arc<rustls::ClientConfig>>,
26}
27/// Reject insecure remote login, URL credentials, redirects and embedded queries.
28pub fn server_address(value: &str) -> Result<String, String> {
29    let value = value.trim().trim_end_matches('/');
30    let uri: value_uri::Uri = value.parse().map_err(|_| "Adresse de serveur invalide.")?;
31    let authority = uri.authority().ok_or("Adresse de serveur invalide.")?;
32    let host = uri.host().ok_or("Adresse de serveur invalide.")?;
33    let scheme = uri.scheme_str().ok_or("Adresse de serveur invalide.")?;
34    if authority.as_str().contains('@')
35        || uri.query().is_some()
36        || uri.path() != "/"
37        || value.contains('#')
38        || !matches!(scheme, "http" | "https")
39    {
40        return Err(
41            "Utilisez une adresse HTTP/HTTPS sans identifiants, chemin ni paramètres.".into(),
42        );
43    }
44    let loopback = host == "localhost"
45        || host == "[::1]"
46        || host
47            .parse::<std::net::IpAddr>()
48            .is_ok_and(|address| address.is_loopback());
49    if scheme == "http" && !loopback {
50        return Err("Un serveur distant nécessite HTTPS.".into());
51    }
52    Ok(value.into())
53}
54// Reuse the HTTP parser shipped by ureq rather than a second URL parser.
55mod value_uri {
56    pub use ureq::http::Uri;
57}
58fn agent() -> ureq::Agent {
59    ureq::Agent::config_builder()
60        .timeout_global(Some(Duration::from_secs(30)))
61        .max_redirects(0)
62        .http_status_as_error(false)
63        .build()
64        .into()
65}
66fn failure(status: u16) -> String {
67    match status {
68        401 => "Session absente ou expirée. Reconnectez-vous.",
69        403 => "Votre compte ne permet pas cette opération.",
70        404 => "Service ou ressource indisponible sur ce serveur.",
71        409 => "Les données ont changé ou sont occupées. Réessayez.",
72        429 | 503 => "Aède est occupé. Réessayez dans quelques instants.",
73        _ => "La requête Aède a échoué.",
74    }
75    .into()
76}
77fn bytes(mut response: ureq::http::Response<ureq::Body>, limit: u64) -> Result<Vec<u8>, String> {
78    let status = response.status().as_u16();
79    if !(200..300).contains(&status) {
80        return Err(failure(status));
81    }
82    response
83        .body_mut()
84        .with_config()
85        .limit(limit)
86        .read_to_vec()
87        .map_err(|_| "Réponse Aède trop grande ou interrompue.".into())
88}
89fn decode(data: &[u8]) -> Result<Value, String> {
90    if data.is_empty() {
91        return Ok(Value::Null);
92    }
93    serde_json::from_slice(data).map_err(|_| "Réponse JSON Aède invalide.".into())
94}
95impl Client {
96    pub(crate) fn playback_credentials(&self) -> (&str, &str) {
97        (&self.server, &self.token)
98    }
99    /// Authenticate only against the selected authority. Passwords are not retained.
100    pub fn login(server: &str, username: &str, password: &str) -> Result<Self, String> {
101        Self::authenticate(server, username, password, agent(), None)
102    }
103    fn authenticate(
104        server: &str,
105        username: &str,
106        password: &str,
107        agent: ureq::Agent,
108        playback_tls: Option<Arc<rustls::ClientConfig>>,
109    ) -> Result<Self, String> {
110        let server = server_address(server)?;
111        if username.trim().is_empty()
112            || username.len() > 128
113            || password.is_empty()
114            || password.len() > 1024
115        {
116            return Err("Identifiants invalides.".into());
117        }
118        let data = json!({"username":username,"password":password}).to_string();
119        let response = agent
120            .post(format!("{server}/api/auth/v1/session"))
121            .header("Content-Type", "application/json")
122            .send(data.as_bytes())
123            .map_err(|_| "Impossible de joindre Aède.".to_string())?;
124        let answer = decode(&bytes(response, 64 * 1024)?)?;
125        let token = answer["token"]
126            .as_str()
127            .filter(|value| value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit()))
128            .ok_or("Session Aède invalide.")?
129            .to_owned();
130        let account = answer["account"].clone();
131        if !account.is_object() || account["id"].as_str().is_none() {
132            return Err("Compte Aède invalide.".into());
133        }
134        Ok(Self {
135            agent,
136            server,
137            token,
138            account,
139            artwork_parental: Arc::default(),
140            playback_tls,
141        })
142    }
143    /// Compare native session identity without exposing its bearer credential.
144    pub fn same_session(&self, other: &Self) -> bool {
145        self.server == other.server && self.token == other.token
146    }
147    pub(crate) fn playback_connector(&self) -> Option<tungstenite::Connector> {
148        self.playback_tls
149            .as_ref()
150            .map(|tls| tungstenite::Connector::Rustls(tls.clone()))
151    }
152    /// Public account metadata excludes the bearer credential.
153    pub fn account(&self) -> Value {
154        json!({"account":self.account,"server":self.server})
155    }
156    /// Fetch only catalog-selected local artwork, never an external image URL.
157    pub fn artwork(&self, reference: &str, kind: &str) -> Result<Vec<u8>, String> {
158        self.artwork_admitted(reference, kind, || Ok(()))
159    }
160    /// Admit each artwork attempt to its captured native session, including busy retries.
161    pub fn artwork_admitted(
162        &self,
163        reference: &str,
164        kind: &str,
165        current: impl Fn() -> Result<(), String>,
166    ) -> Result<Vec<u8>, String> {
167        if reference.len() > 16 * 1024
168            || !matches!(
169                kind,
170                "cover" | "banner" | "background" | "portrait" | "logo" | "spectrum"
171            )
172        {
173            return Err("Image non prise en charge.".into());
174        }
175        let size = if matches!(kind, "cover" | "portrait" | "logo") {
176            "512"
177        } else {
178            "1920"
179        };
180        self.read_artwork(reference, kind, Some(size), &current)
181    }
182    /// Read an unowned group's cached cover or explicitly request archive acquisition on Aède.
183    /// Retain the session's TLS identity while allowing a longer per-request timeout.
184    pub fn missing_cover(
185        &self,
186        artist: &str,
187        group: &str,
188        download: bool,
189    ) -> Result<Vec<u8>, String> {
190        if !artist.starts_with("artist:")
191            || artist.len() > 4096
192            || group.len() != 36
193            || !group.bytes().enumerate().all(|(i, b)| {
194                if [8, 13, 18, 23].contains(&i) {
195                    b == b'-'
196                } else {
197                    b.is_ascii_hexdigit()
198                }
199            })
200        {
201            return Err("Album manquant Aède invalide.".into());
202        }
203        let url = format!("{}/api/me/v1/missing/cover", self.server);
204        let response = if download {
205            self.agent
206                .post(url)
207                .query("artist", artist)
208                .query("release_group", group)
209                .header("Authorization", format!("Bearer {}", self.token))
210                .config()
211                .timeout_global(Some(Duration::from_secs(60)))
212                .build()
213                .send_empty()
214        } else {
215            self.agent
216                .get(url)
217                .query("artist", artist)
218                .query("release_group", group)
219                .header("Authorization", format!("Bearer {}", self.token))
220                .config()
221                .timeout_global(Some(Duration::from_secs(60)))
222                .build()
223                .call()
224        }
225        .map_err(|_| "Image Aède inaccessible.".to_string())?;
226        let data = bytes(response, 32 * 1024 * 1024)?;
227        if !data.is_empty()
228            && !data.starts_with(&[0xff, 0xd8, 0xff])
229            && !data.starts_with(b"\x89PNG\r\n\x1a\n")
230        {
231            return Err("Format d’image Aède invalide.".into());
232        }
233        Ok(data)
234    }
235    /// Export a retained track spectrum at its original dimensions, without thumbnail rendering.
236    pub fn spectrum_download(&self, reference: &str) -> Result<Vec<u8>, String> {
237        self.spectrum_download_admitted(reference, || Ok(()))
238    }
239    /// Retire pending spectrum retries without publishing under a replacement session.
240    pub fn spectrum_download_admitted(
241        &self,
242        reference: &str,
243        current: impl Fn() -> Result<(), String>,
244    ) -> Result<Vec<u8>, String> {
245        if !reference.starts_with("track:") || reference.len() > 4096 {
246            return Err("Référence de spectre invalide.".into());
247        }
248        self.read_artwork(reference, "spectrum", None, &current)
249    }
250    fn read_artwork(
251        &self,
252        reference: &str,
253        kind: &str,
254        size: Option<&str>,
255        current: &impl Fn() -> Result<(), String>,
256    ) -> Result<Vec<u8>, String> {
257        let response = request::read_response(
258            || {
259                let mut request = self
260                    .agent
261                    .get(format!("{}/api/v1/artwork", self.server))
262                    .query("ref", reference)
263                    .query("kind", kind);
264                if let Some(size) = size {
265                    request = request.query("size", size);
266                }
267                request
268                    .header("Authorization", format!("Bearer {}", self.token))
269                    .call()
270                    .map_err(|_| "Image Aède inaccessible.".to_string())
271            },
272            current,
273        )?;
274        let data = bytes(response, 32 * 1024 * 1024)?;
275        current()?;
276        if !data.starts_with(&[0xff, 0xd8, 0xff]) && !data.starts_with(b"\x89PNG\r\n\x1a\n") {
277            return Err("Format d’image Aède invalide.".into());
278        }
279        Ok(data)
280    }
281}
282/// Protect the application-owned principal on the managed-local source only.
283/// Server accounts remain governed by the selected server's own administration rules.
284pub fn managed_account_request(method: &str, path: &str) -> Result<(), String> {
285    let managed = path
286        .strip_prefix("/api/admin/v1/accounts/")
287        .and_then(|suffix| suffix.split('/').next())
288        .is_some_and(|name| name.eq_ignore_ascii_case("phemios-local"));
289    if managed && method != "GET" {
290        return Err("Le compte du moteur local est géré par Phémios.".into());
291    }
292    if path == "/api/auth/v1/password" {
293        return Err("Le secret du moteur local est géré par Phémios.".into());
294    }
295    Ok(())
296}
297
298fn parental_account_path(path: &str) -> bool {
299    path.strip_prefix("/api/admin/v1/accounts/")
300        .and_then(|p| p.strip_suffix("/parental"))
301        .is_some_and(|name| {
302            !name.is_empty()
303                && name.len() <= 64
304                && name
305                    .bytes()
306                    .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
307        })
308}
309fn allowed(method: &str, path: &str) -> bool {
310    match method {
311        "GET" => {
312            matches!(
313                path,
314                "/api/auth/v1/session"
315                    | "/api/v1/status"
316                    | "/api/v1/roots"
317                    | "/api/admin/v1/accounts"
318                    | "/api/admin/v1/tasks"
319                    | "/api/admin/v1/source-review"
320                    | "/api/admin/v1/network-audio"
321                    | "/api/v1/presentation/tracks"
322                    | "/api/v1/presentation/analysis"
323                    | "/api/v1/presentation/artists"
324                    | "/api/v1/presentation/albums"
325                    | "/api/v1/presentation/playback"
326                    | "/api/v1/stats"
327                    | "/api/v1/doctor"
328                    | "/api/v1/analysis/report"
329                    | "/api/v1/album"
330                    | "/api/v1/artist"
331                    | "/api/v1/track"
332                    | "/api/v1/lyrics"
333                    | "/api/v1/waveform"
334                    | "/api/v1/query"
335                    | "/api/v1/labels"
336                    | "/api/v1/label"
337                    | "/api/me/v1/missing"
338                    | "/api/me/v1/parental"
339                    | "/api/me/v1/annotation"
340                    | "/api/me/v1/annotations"
341                    | "/api/me/v1/preferences"
342                    | "/api/me/v1/moods"
343                    | "/api/me/v1/m3u"
344                    | "/api/me/v1/m3u-files"
345                    | "/api/me/v1/collections"
346                    | "/api/me/v1/collection"
347                    | "/api/me/v1/collection/albums"
348                    | "/api/me/v1/search"
349                    | "/api/me/v1/achievements"
350                    | "/api/me/v1/album-listening"
351                    | "/api/me/v1/history"
352            ) || task_path(path, false)
353                || playlist_path(path)
354                || library_playlist_path(path)
355                || m3u_playback_path(path)
356                || account_path(path, false)
357                || parental_account_path(path)
358        }
359        "PUT" => {
360            parental_account_path(path)
361                || matches!(
362                    path,
363                    "/api/me/v1/annotation"
364                        | "/api/me/v1/moods"
365                        | "/api/me/v1/collection"
366                        | "/api/auth/v1/password"
367                        | "/api/me/v1/missing"
368                )
369        }
370        "PATCH" => {
371            path == "/api/admin/v1/source-review"
372                || path == "/api/me/v1/collection"
373                || path == "/api/me/v1/preferences"
374                || account_path(path, false)
375        }
376        "DELETE" => {
377            account_path(path, true)
378                || matches!(
379                    path,
380                    "/api/auth/v1/session" | "/api/me/v1/collection" | "/api/me/v1/history"
381                )
382        }
383        "POST" => {
384            matches!(
385                path,
386                "/api/me/v1/achievements"
387                    | "/api/me/v1/parental/lease"
388                    | "/api/me/v1/background-priority"
389                    | "/api/admin/v1/network-audio/discover"
390                    | "/api/admin/v1/network-audio/inspect"
391                    | "/api/admin/v1/network-audio/play"
392                    | "/api/admin/v1/network-audio/stop"
393                    | "/api/admin/v1/mood-features"
394                    | "/api/admin/v1/check"
395                    | "/api/admin/v1/analyze"
396                    | "/api/admin/v1/generate"
397                    | "/api/admin/v1/spectrum"
398                    | "/api/me/v1/m3u"
399                    | "/api/admin/v1/scan"
400                    | "/api/admin/v1/fetch"
401                    | "/api/admin/v1/lyrics"
402                    | "/api/me/v1/exports/plan"
403                    | "/api/admin/v1/accounts"
404            ) || task_path(path, true)
405        }
406        _ => false,
407    }
408}
409fn task_path(path: &str, cancel: bool) -> bool {
410    let Some(id) = path.strip_prefix("/api/admin/v1/tasks/") else {
411        return false;
412    };
413    let id = if cancel {
414        match id.strip_suffix("/cancel") {
415            Some(id) => id,
416            None => return false,
417        }
418    } else {
419        id
420    };
421    !id.is_empty() && id.len() <= 20 && id.bytes().all(|b| b.is_ascii_digit())
422}
423
424#[cfg(test)]
425#[path = "network_tests.rs"]
426mod tests;
427
428fn playlist_path(path: &str) -> bool {
429    path.strip_prefix("/api/me/v1/m3u/playlist-")
430        .is_some_and(|id| id.len() == 64 && id.bytes().all(|b| b.is_ascii_hexdigit()))
431}
432
433fn account_path(path: &str, sessions: bool) -> bool {
434    let Some(name) = path.strip_prefix("/api/admin/v1/accounts/") else {
435        return false;
436    };
437    let name = if sessions {
438        match name.strip_suffix("/sessions") {
439            Some(name) => name,
440            None => return false,
441        }
442    } else {
443        name
444    };
445    !name.is_empty()
446        && name.len() <= 128
447        && name
448            .bytes()
449            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
450}
451
452#[cfg(test)]
453#[path = "network_test_support.rs"]
454pub(crate) mod test_support;
455
456fn library_playlist_path(path: &str) -> bool {
457    path.strip_prefix("/api/me/v1/m3u-files/m3u-file-")
458        .is_some_and(|id| id.len() == 64 && id.bytes().all(|b| b.is_ascii_hexdigit()))
459}
460
461fn m3u_playback_path(path: &str) -> bool {
462    path.strip_suffix("/playback")
463        .is_some_and(|base| playlist_path(base) || library_playlist_path(base))
464}
465
466#[path = "network_export.rs"]
467mod exporter;