Skip to main content

phemios_core/
download.rs

1//! Publish server-authored M3U and analysis reports under a native-selected download directory.
2use serde_json::Value;
3use std::fs::{self, OpenOptions};
4use std::io::Write;
5use std::path::{Path, PathBuf};
6/// No client path or filename is accepted; repeats never replace an existing file.
7pub fn publish(folder: &Path, id: &str, response: &Value) -> Result<PathBuf, String> {
8    if !id.strip_prefix("playlist-").is_some_and(|value| {
9        value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
10    }) || response["id"].as_str() != Some(id)
11    {
12        return Err("Identité de M3U invalide.".into());
13    }
14    let content = response["content"]
15        .as_str()
16        .filter(|value| value.starts_with("#EXTM3U") && value.len() <= 8 * 1024 * 1024)
17        .ok_or("Contenu M3U invalide.")?;
18    publish_named(folder, id, "m3u", content.as_bytes())
19}
20/// Save an existing folder M3U verbatim under an opaque native-selected name.
21/// References remain inert; even an unsafe server filename cannot choose the destination.
22pub fn publish_library_m3u(folder: &Path, id: &str, response: &Value) -> Result<PathBuf, String> {
23    if !id.strip_prefix("m3u-file-").is_some_and(|value| {
24        value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
25    }) || response["id"].as_str() != Some(id)
26    {
27        return Err("Identité de M3U invalide.".into());
28    }
29    let filename = response["filename"]
30        .as_str()
31        .ok_or("Contenu M3U invalide.")?
32        .to_ascii_lowercase();
33    let extension = if filename.ends_with(".m3u8") {
34        "m3u8"
35    } else if filename.ends_with(".m3u") {
36        "m3u"
37    } else {
38        return Err("Contenu M3U invalide.".into());
39    };
40    let content = response["content"]
41        .as_str()
42        .filter(|value| value.len() <= 8 * 1024 * 1024 && !value.contains('\0'))
43        .ok_or("Contenu M3U invalide.")?;
44    let entries = content
45        .trim_start_matches('\u{feff}')
46        .lines()
47        .map(str::trim)
48        .filter(|line| !line.is_empty() && !line.starts_with('#'))
49        .count();
50    if entries > 4096 || response["track_count"].as_u64() != Some(entries as u64) {
51        return Err("Contenu M3U invalide.".into());
52    }
53    publish_named(folder, id, extension, content.as_bytes())
54}
55
56fn publish_named(
57    folder: &Path,
58    stem: &str,
59    extension: &str,
60    content: &[u8],
61) -> Result<PathBuf, String> {
62    fs::create_dir_all(folder)
63        .map_err(|_| "Dossier de téléchargement inaccessible.".to_string())?;
64    for attempt in 0..1000 {
65        let filename = if attempt == 0 {
66            format!("{stem}.{extension}")
67        } else {
68            format!("{stem}-{attempt}.{extension}")
69        };
70        let path = folder.join(filename);
71        let mut options = OpenOptions::new();
72        options.write(true).create_new(true);
73        #[cfg(unix)]
74        {
75            use std::os::unix::fs::OpenOptionsExt;
76            options.mode(0o600);
77        }
78        let mut file = match options.open(&path) {
79            Ok(file) => file,
80            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
81            Err(_) => return Err("Téléchargement impossible.".into()),
82        };
83        if file
84            .write_all(content)
85            .and_then(|()| file.sync_all())
86            .is_err()
87        {
88            drop(file);
89            let _ = fs::remove_file(&path);
90            return Err("Téléchargement interrompu.".into());
91        }
92        return Ok(path);
93    }
94    Err("Trop de copies de ce fichier existent dans les téléchargements.".into())
95}
96/// Save a validated server-authored FlacCompagnon report; paths in the JSON are data only.
97pub fn publish_analysis(
98    folder: &Path,
99    reference: &str,
100    response: &Value,
101) -> Result<PathBuf, String> {
102    use std::hash::{Hash, Hasher};
103    if reference.len() > 4096
104        || !["track:", "release:"].iter().any(|kind| {
105            reference
106                .strip_prefix(kind)
107                .is_some_and(|value| !value.is_empty())
108        })
109    {
110        return Err("Référence d’analyse invalide.".into());
111    }
112    let files = response["report"]["files"]
113        .as_array()
114        .filter(|files| !files.is_empty() && files.len() <= 2048)
115        .ok_or("Rapport d’analyse incomplet.")?;
116    if response["format"] != "flaccompagnon-report"
117        || !response["version"]
118            .as_u64()
119            .is_some_and(|version| version <= 1)
120        || !response["report"]["root"].is_string()
121        || !response["report"]["has_flac"].is_boolean()
122        || files.iter().any(|file| !file["path"].is_string())
123    {
124        return Err("Rapport FlacCompagnon invalide.".into());
125    }
126    let content = serde_json::to_vec_pretty(response).map_err(|_| "Rapport d’analyse invalide.")?;
127    if content.len() > 16 * 1024 * 1024 {
128        return Err("Rapport d’analyse trop volumineux.".into());
129    }
130    let mut hash = std::collections::hash_map::DefaultHasher::new();
131    reference.hash(&mut hash);
132    let kind = if reference.starts_with("track:") {
133        "titre"
134    } else {
135        "album"
136    };
137    publish_named(
138        folder,
139        &format!("flaccompagnon-{kind}-{:016x}", hash.finish()),
140        "json",
141        &content,
142    )
143}
144/// Save only server-selected spectrum bytes; the webview cannot choose a filename or path.
145pub fn publish_spectrum(folder: &Path, reference: &str, bytes: &[u8]) -> Result<PathBuf, String> {
146    use std::hash::{Hash, Hasher};
147    if reference.len() > 4096
148        || !reference
149            .strip_prefix("track:")
150            .is_some_and(|s| !s.is_empty())
151    {
152        return Err("Référence de spectre invalide.".into());
153    }
154    if bytes.len() > 8 * 1024 * 1024 {
155        return Err("Spectre trop volumineux.".into());
156    }
157    let extension = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
158        "png"
159    } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
160        "jpg"
161    } else {
162        return Err("Format de spectre invalide.".into());
163    };
164    let mut hash = std::collections::hash_map::DefaultHasher::new();
165    reference.hash(&mut hash);
166    publish_named(
167        folder,
168        &format!("spectre-{:016x}", hash.finish()),
169        extension,
170        bytes,
171    )
172}
173#[cfg(test)]
174#[path = "download_tests.rs"]
175mod tests;
176
177/// Publish one attributed Wikipedia text document under a fixed language/page identity.
178/// No executable format, caller-selected path or filename, or overwrite is admitted.
179pub fn publish_wikipedia(
180    folder: &Path,
181    language: &str,
182    page_id: u64,
183    document: &str,
184) -> Result<PathBuf, String> {
185    if !matches!(language, "fr" | "en")
186        || page_id == 0
187        || page_id > 9_007_199_254_740_991
188        || document.len() > 1024 * 1024
189        || !document.starts_with(&format!(
190            "\u{feff}Wikipédia · {} · {page_id}\n",
191            language.to_uppercase()
192        ))
193        || document
194            .chars()
195            .any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t'))
196    {
197        return Err("Document Wikipédia invalide ou trop volumineux.".into());
198    }
199    publish_named(
200        folder,
201        &format!("wikipedia-{language}-{page_id}"),
202        "txt",
203        document.as_bytes(),
204    )
205}
206
207/// Export only the exact owner's server-confirmed Markdown note with collision-safe publication.
208/// Caller titles and paths never choose the destination; unsaved UI drafts are not accepted.
209pub fn publish_note(
210    folder: &Path,
211    reference: &str,
212    response: &Value,
213    detail: &Value,
214) -> Result<PathBuf, String> {
215    if reference.len() > 4096
216        || !["artist:", "release:", "track:"].iter().any(|prefix| {
217            reference
218                .strip_prefix(prefix)
219                .is_some_and(|value| !value.is_empty())
220        })
221        || response["reference"].as_str() != Some(reference)
222        || response["annotation"]["reference"].as_str() != Some(reference)
223        || detail["reference"].as_str() != Some(reference)
224    {
225        return Err("Identité de note personnelle invalide.".into());
226    }
227    let note = response["annotation"]["note"]
228        .as_str()
229        .filter(|text| !text.trim().is_empty() && text.len() <= 1024 * 1024 && !text.contains('\0'))
230        .ok_or("Aucune note personnelle disponible à télécharger.")?;
231    let field = if reference.starts_with("artist:") {
232        "name"
233    } else {
234        "title"
235    };
236    let title = detail[field]
237        .as_str()
238        .filter(|title| !title.trim().is_empty() && title.len() <= 4096)
239        .ok_or("Nom de note personnelle invalide.")?;
240    // Bound UTF-8 bytes rather than characters, including non-Latin catalogue names.
241    let mut safe_title = String::new();
242    for character in title.chars() {
243        let character = if character.is_alphanumeric()
244            || matches!(character, ' ' | '-' | '_' | '(' | ')' | '\'')
245        {
246            character
247        } else {
248            '-'
249        };
250        if safe_title.len() + character.len_utf8() > 180 {
251            break;
252        }
253        safe_title.push(character);
254    }
255    let title = safe_title.trim_matches([' ', '-']);
256    if title.is_empty() {
257        return Err("Nom de note personnelle invalide.".into());
258    }
259    publish_named(folder, &format!("Note-{title}"), "md", note.as_bytes())
260}