Skip to main content

max / mnw-cli

10.5 KB · 366 lines History Blame Raw
1 //! Staging directory management for SFTP uploads.
2 //!
3 //! Files uploaded via SFTP land in a per-user staging directory on disk.
4 //! From there, the TUI publish flow uploads them to S3 via MNW internal API.
5
6 use std::path::{Path, PathBuf};
7
8 use tokio::fs;
9
10 /// A file in the staging directory waiting to be published.
11 #[derive(Debug, Clone)]
12 pub struct StagedFile {
13 pub filename: String,
14 pub size: u64,
15 pub modified: std::time::SystemTime,
16 /// Derived from extension.
17 pub classification: Option<FileClassification>,
18 }
19
20 /// Classification of a staged file based on its extension.
21 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
22 pub struct FileClassification {
23 pub item_type: &'static str,
24 pub file_type: &'static str,
25 pub content_type: &'static str,
26 }
27
28 /// 1 GB staging quota per user.
29 pub const STAGING_QUOTA_BYTES: u64 = 1024 * 1024 * 1024;
30
31 /// Get the staging directory path for a user.
32 pub fn user_staging_dir(base: &Path, user_id: &str) -> PathBuf {
33 base.join(user_id)
34 }
35
36 /// List all staged files in a user's staging directory.
37 pub async fn list_staged_files(dir: &Path) -> Vec<StagedFile> {
38 let mut files = Vec::new();
39 let Ok(mut entries) = fs::read_dir(dir).await else {
40 return files;
41 };
42
43 while let Ok(Some(entry)) = entries.next_entry().await {
44 let Ok(metadata) = entry.metadata().await else {
45 continue;
46 };
47 if !metadata.is_file() {
48 continue;
49 }
50
51 let filename = entry.file_name().to_string_lossy().to_string();
52 let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase();
53 let classification = classify_extension(&ext);
54
55 files.push(StagedFile {
56 filename,
57 size: metadata.len(),
58 modified: metadata.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH),
59 classification,
60 });
61 }
62
63 files.sort_by(|a, b| b.modified.cmp(&a.modified));
64 files
65 }
66
67 /// Calculate total bytes used in a staging directory.
68 pub async fn staging_usage(dir: &Path) -> u64 {
69 let mut total = 0u64;
70 let Ok(mut entries) = fs::read_dir(dir).await else {
71 return 0;
72 };
73
74 while let Ok(Some(entry)) = entries.next_entry().await {
75 if let Ok(metadata) = entry.metadata().await
76 && metadata.is_file()
77 {
78 total += metadata.len();
79 }
80 }
81
82 total
83 }
84
85 /// Remove staged files older than `ttl` across all user directories.
86 pub async fn cleanup_stale(base: &Path, ttl: std::time::Duration) {
87 let Ok(mut users) = fs::read_dir(base).await else {
88 return;
89 };
90
91 let now = std::time::SystemTime::now();
92 let mut removed = 0u64;
93
94 while let Ok(Some(user_dir)) = users.next_entry().await {
95 let Ok(metadata) = user_dir.metadata().await else {
96 continue;
97 };
98 if !metadata.is_dir() {
99 continue;
100 }
101
102 let Ok(mut files) = fs::read_dir(user_dir.path()).await else {
103 continue;
104 };
105
106 let mut dir_empty = true;
107 while let Ok(Some(file)) = files.next_entry().await {
108 let Ok(fmeta) = file.metadata().await else {
109 dir_empty = false;
110 continue;
111 };
112
113 let age = fmeta
114 .modified()
115 .ok()
116 .and_then(|m| now.duration_since(m).ok())
117 .unwrap_or_default();
118
119 if age > ttl {
120 if fs::remove_file(file.path()).await.is_ok() {
121 removed += 1;
122 }
123 } else {
124 dir_empty = false;
125 }
126 }
127
128 // Remove empty user dirs
129 if dir_empty {
130 let _ = fs::remove_dir(user_dir.path()).await;
131 }
132 }
133
134 if removed > 0 {
135 tracing::info!(removed, "cleaned up stale staging files");
136 }
137 }
138
139 /// Sanitize a filename: keep only safe characters, prevent path traversal.
140 pub fn sanitize_filename(name: &str) -> String {
141 // Take only the final path component
142 let base = name.rsplit('/').next().unwrap_or(name);
143 let base = base.rsplit('\\').next().unwrap_or(base);
144
145 let sanitized: String = base
146 .chars()
147 .filter(|c| c.is_alphanumeric() || *c == '.' || *c == '-' || *c == '_' || *c == ' ')
148 .collect();
149
150 let sanitized = sanitized.trim().to_string();
151
152 if sanitized.is_empty() || sanitized == "." || sanitized == ".." {
153 return "upload".to_string();
154 }
155
156 // Limit length
157 if sanitized.len() > 200 {
158 sanitized[..200].to_string()
159 } else {
160 sanitized
161 }
162 }
163
164 /// Classify a file extension into item type, file type, and content type.
165 pub fn classify_extension(ext: &str) -> Option<FileClassification> {
166 match ext {
167 // Audio
168 "mp3" => Some(FileClassification {
169 item_type: "audio",
170 file_type: "audio",
171 content_type: "audio/mpeg",
172 }),
173 "wav" => Some(FileClassification {
174 item_type: "audio",
175 file_type: "audio",
176 content_type: "audio/wav",
177 }),
178 "flac" => Some(FileClassification {
179 item_type: "audio",
180 file_type: "audio",
181 content_type: "audio/flac",
182 }),
183 "ogg" => Some(FileClassification {
184 item_type: "audio",
185 file_type: "audio",
186 content_type: "audio/ogg",
187 }),
188 "m4a" => Some(FileClassification {
189 item_type: "audio",
190 file_type: "audio",
191 content_type: "audio/mp4",
192 }),
193 "aac" => Some(FileClassification {
194 item_type: "audio",
195 file_type: "audio",
196 content_type: "audio/aac",
197 }),
198 // Digital downloads
199 "zip" => Some(FileClassification {
200 item_type: "digital",
201 file_type: "download",
202 content_type: "application/zip",
203 }),
204 "dmg" => Some(FileClassification {
205 item_type: "digital",
206 file_type: "download",
207 content_type: "application/x-apple-diskimage",
208 }),
209 "exe" => Some(FileClassification {
210 item_type: "digital",
211 file_type: "download",
212 content_type: "application/vnd.microsoft.portable-executable",
213 }),
214 "appimage" => Some(FileClassification {
215 item_type: "digital",
216 file_type: "download",
217 content_type: "application/octet-stream",
218 }),
219 "deb" => Some(FileClassification {
220 item_type: "digital",
221 file_type: "download",
222 content_type: "application/vnd.debian.binary-package",
223 }),
224 // Plugins
225 "clap" => Some(FileClassification {
226 item_type: "plugin",
227 file_type: "download",
228 content_type: "application/octet-stream",
229 }),
230 "vst3" => Some(FileClassification {
231 item_type: "plugin",
232 file_type: "download",
233 content_type: "application/octet-stream",
234 }),
235 _ => None,
236 }
237 }
238
239 /// Derive a human-readable title from a filename.
240 /// Strips extension, replaces `-` and `_` with spaces, title-cases words.
241 pub fn derive_title(filename: &str) -> String {
242 let without_ext = match filename.rfind('.') {
243 Some(pos) if pos > 0 => &filename[..pos],
244 _ => filename,
245 };
246
247 without_ext
248 .replace(['-', '_'], " ")
249 .split_whitespace()
250 .map(title_case_word)
251 .collect::<Vec<_>>()
252 .join(" ")
253 }
254
255 fn title_case_word(word: &str) -> String {
256 let mut chars = word.chars();
257 match chars.next() {
258 None => String::new(),
259 Some(first) => {
260 let mut s = first.to_uppercase().to_string();
261 s.extend(chars.map(|c| c.to_ascii_lowercase()));
262 s
263 }
264 }
265 }
266
267 /// Check if an extension is allowed for upload.
268 pub fn is_allowed_extension(ext: &str) -> bool {
269 classify_extension(&ext.to_lowercase()).is_some()
270 }
271
272 /// Format bytes into a human-readable string.
273 pub fn format_bytes(bytes: u64) -> String {
274 if bytes < 1024 {
275 format!("{} B", bytes)
276 } else if bytes < 1024 * 1024 {
277 format!("{:.1} KB", bytes as f64 / 1024.0)
278 } else if bytes < 1024 * 1024 * 1024 {
279 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
280 } else {
281 format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
282 }
283 }
284
285 #[cfg(test)]
286 mod tests {
287 use super::*;
288
289 #[test]
290 fn sanitize_filename_basic() {
291 assert_eq!(sanitize_filename("song.mp3"), "song.mp3");
292 }
293
294 #[test]
295 fn sanitize_filename_path_traversal() {
296 assert_eq!(sanitize_filename("../../etc/passwd"), "passwd");
297 assert_eq!(sanitize_filename("..\\..\\secret.txt"), "secret.txt");
298 }
299
300 #[test]
301 fn sanitize_filename_strips_special_chars() {
302 assert_eq!(sanitize_filename("my<>file|name.zip"), "myfilename.zip");
303 }
304
305 #[test]
306 fn sanitize_filename_empty_and_dots() {
307 assert_eq!(sanitize_filename(""), "upload");
308 assert_eq!(sanitize_filename("."), "upload");
309 assert_eq!(sanitize_filename(".."), "upload");
310 }
311
312 #[test]
313 fn sanitize_filename_length_limit() {
314 let long_name = "a".repeat(300);
315 assert_eq!(sanitize_filename(&long_name).len(), 200);
316 }
317
318 #[test]
319 fn classify_extension_audio() {
320 let c = classify_extension("mp3").unwrap();
321 assert_eq!(c.item_type, "audio");
322 assert_eq!(c.content_type, "audio/mpeg");
323 }
324
325 #[test]
326 fn classify_extension_digital() {
327 let c = classify_extension("zip").unwrap();
328 assert_eq!(c.item_type, "digital");
329 assert_eq!(c.file_type, "download");
330 }
331
332 #[test]
333 fn classify_extension_unknown() {
334 assert!(classify_extension("txt").is_none());
335 assert!(classify_extension("").is_none());
336 }
337
338 #[test]
339 fn derive_title_basic() {
340 assert_eq!(derive_title("my-cool-song.mp3"), "My Cool Song");
341 assert_eq!(derive_title("hello_world.zip"), "Hello World");
342 }
343
344 #[test]
345 fn derive_title_no_extension() {
346 assert_eq!(derive_title("readme"), "Readme");
347 }
348
349 #[test]
350 fn is_allowed_extension_cases() {
351 assert!(is_allowed_extension("MP3"));
352 assert!(is_allowed_extension("wav"));
353 assert!(!is_allowed_extension("txt"));
354 assert!(!is_allowed_extension(""));
355 }
356
357 #[test]
358 fn format_bytes_ranges() {
359 assert_eq!(format_bytes(0), "0 B");
360 assert_eq!(format_bytes(512), "512 B");
361 assert_eq!(format_bytes(1024), "1.0 KB");
362 assert_eq!(format_bytes(1_048_576), "1.0 MB");
363 assert_eq!(format_bytes(1_073_741_824), "1.00 GB");
364 }
365 }
366