1use 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#[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}
27pub 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}
54mod 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 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 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 pub fn account(&self) -> Value {
154 json!({"account":self.account,"server":self.server})
155 }
156 pub fn artwork(&self, reference: &str, kind: &str) -> Result<Vec<u8>, String> {
158 self.artwork_admitted(reference, kind, || Ok(()))
159 }
160 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), ¤t)
181 }
182 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 pub fn spectrum_download(&self, reference: &str) -> Result<Vec<u8>, String> {
237 self.spectrum_download_admitted(reference, || Ok(()))
238 }
239 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, ¤t)
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}
282pub 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;