//! Staging directory management for SFTP uploads. //! //! Files uploaded via SFTP land in a per-user staging directory on disk. //! From there, the TUI publish flow uploads them to S3 via MNW internal API. use std::path::{Path, PathBuf}; use tokio::fs; /// A file in the staging directory waiting to be published. #[derive(Debug, Clone)] pub struct StagedFile { pub filename: String, pub size: u64, pub modified: std::time::SystemTime, /// Derived from extension. pub classification: Option, } /// Classification of a staged file based on its extension. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct FileClassification { pub item_type: &'static str, pub file_type: &'static str, pub content_type: &'static str, } /// 1 GB staging quota per user. pub const STAGING_QUOTA_BYTES: u64 = 1024 * 1024 * 1024; /// Get the staging directory path for a user. pub fn user_staging_dir(base: &Path, user_id: &str) -> PathBuf { base.join(user_id) } /// List all staged files in a user's staging directory. pub async fn list_staged_files(dir: &Path) -> Vec { let mut files = Vec::new(); let Ok(mut entries) = fs::read_dir(dir).await else { return files; }; while let Ok(Some(entry)) = entries.next_entry().await { let Ok(metadata) = entry.metadata().await else { continue; }; if !metadata.is_file() { continue; } let filename = entry.file_name().to_string_lossy().to_string(); let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase(); let classification = classify_extension(&ext); files.push(StagedFile { filename, size: metadata.len(), modified: metadata.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH), classification, }); } files.sort_by(|a, b| b.modified.cmp(&a.modified)); files } /// Calculate total bytes used in a staging directory. pub async fn staging_usage(dir: &Path) -> u64 { let mut total = 0u64; let Ok(mut entries) = fs::read_dir(dir).await else { return 0; }; while let Ok(Some(entry)) = entries.next_entry().await { if let Ok(metadata) = entry.metadata().await && metadata.is_file() { total += metadata.len(); } } total } /// Remove staged files older than `ttl` across all user directories. pub async fn cleanup_stale(base: &Path, ttl: std::time::Duration) { let Ok(mut users) = fs::read_dir(base).await else { return; }; let now = std::time::SystemTime::now(); let mut removed = 0u64; while let Ok(Some(user_dir)) = users.next_entry().await { let Ok(metadata) = user_dir.metadata().await else { continue; }; if !metadata.is_dir() { continue; } let Ok(mut files) = fs::read_dir(user_dir.path()).await else { continue; }; let mut dir_empty = true; while let Ok(Some(file)) = files.next_entry().await { let Ok(fmeta) = file.metadata().await else { dir_empty = false; continue; }; let age = fmeta .modified() .ok() .and_then(|m| now.duration_since(m).ok()) .unwrap_or_default(); if age > ttl { if fs::remove_file(file.path()).await.is_ok() { removed += 1; } } else { dir_empty = false; } } // Remove empty user dirs if dir_empty { let _ = fs::remove_dir(user_dir.path()).await; } } if removed > 0 { tracing::info!(removed, "cleaned up stale staging files"); } } /// Sanitize a filename: keep only safe characters, prevent path traversal. pub fn sanitize_filename(name: &str) -> String { // Take only the final path component let base = name.rsplit('/').next().unwrap_or(name); let base = base.rsplit('\\').next().unwrap_or(base); let sanitized: String = base .chars() .filter(|c| c.is_alphanumeric() || *c == '.' || *c == '-' || *c == '_' || *c == ' ') .collect(); let sanitized = sanitized.trim().to_string(); if sanitized.is_empty() || sanitized == "." || sanitized == ".." { return "upload".to_string(); } // Limit length if sanitized.len() > 200 { sanitized[..200].to_string() } else { sanitized } } /// Classify a file extension into item type, file type, and content type. pub fn classify_extension(ext: &str) -> Option { match ext { // Audio "mp3" => Some(FileClassification { item_type: "audio", file_type: "audio", content_type: "audio/mpeg", }), "wav" => Some(FileClassification { item_type: "audio", file_type: "audio", content_type: "audio/wav", }), "flac" => Some(FileClassification { item_type: "audio", file_type: "audio", content_type: "audio/flac", }), "ogg" => Some(FileClassification { item_type: "audio", file_type: "audio", content_type: "audio/ogg", }), "m4a" => Some(FileClassification { item_type: "audio", file_type: "audio", content_type: "audio/mp4", }), "aac" => Some(FileClassification { item_type: "audio", file_type: "audio", content_type: "audio/aac", }), // Digital downloads "zip" => Some(FileClassification { item_type: "digital", file_type: "download", content_type: "application/zip", }), "dmg" => Some(FileClassification { item_type: "digital", file_type: "download", content_type: "application/x-apple-diskimage", }), "exe" => Some(FileClassification { item_type: "digital", file_type: "download", content_type: "application/vnd.microsoft.portable-executable", }), "appimage" => Some(FileClassification { item_type: "digital", file_type: "download", content_type: "application/octet-stream", }), "deb" => Some(FileClassification { item_type: "digital", file_type: "download", content_type: "application/vnd.debian.binary-package", }), // Plugins "clap" => Some(FileClassification { item_type: "plugin", file_type: "download", content_type: "application/octet-stream", }), "vst3" => Some(FileClassification { item_type: "plugin", file_type: "download", content_type: "application/octet-stream", }), _ => None, } } /// Derive a human-readable title from a filename. /// Strips extension, replaces `-` and `_` with spaces, title-cases words. pub fn derive_title(filename: &str) -> String { let without_ext = match filename.rfind('.') { Some(pos) if pos > 0 => &filename[..pos], _ => filename, }; without_ext .replace(['-', '_'], " ") .split_whitespace() .map(title_case_word) .collect::>() .join(" ") } fn title_case_word(word: &str) -> String { let mut chars = word.chars(); match chars.next() { None => String::new(), Some(first) => { let mut s = first.to_uppercase().to_string(); s.extend(chars.map(|c| c.to_ascii_lowercase())); s } } } /// Check if an extension is allowed for upload. pub fn is_allowed_extension(ext: &str) -> bool { classify_extension(&ext.to_lowercase()).is_some() } /// Format bytes into a human-readable string. pub fn format_bytes(bytes: u64) -> String { if bytes < 1024 { format!("{} B", bytes) } else if bytes < 1024 * 1024 { format!("{:.1} KB", bytes as f64 / 1024.0) } else if bytes < 1024 * 1024 * 1024 { format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) } else { format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) } } #[cfg(test)] mod tests { use super::*; #[test] fn sanitize_filename_basic() { assert_eq!(sanitize_filename("song.mp3"), "song.mp3"); } #[test] fn sanitize_filename_path_traversal() { assert_eq!(sanitize_filename("../../etc/passwd"), "passwd"); assert_eq!(sanitize_filename("..\\..\\secret.txt"), "secret.txt"); } #[test] fn sanitize_filename_strips_special_chars() { assert_eq!(sanitize_filename("my<>file|name.zip"), "myfilename.zip"); } #[test] fn sanitize_filename_empty_and_dots() { assert_eq!(sanitize_filename(""), "upload"); assert_eq!(sanitize_filename("."), "upload"); assert_eq!(sanitize_filename(".."), "upload"); } #[test] fn sanitize_filename_length_limit() { let long_name = "a".repeat(300); assert_eq!(sanitize_filename(&long_name).len(), 200); } #[test] fn classify_extension_audio() { let c = classify_extension("mp3").unwrap(); assert_eq!(c.item_type, "audio"); assert_eq!(c.content_type, "audio/mpeg"); } #[test] fn classify_extension_digital() { let c = classify_extension("zip").unwrap(); assert_eq!(c.item_type, "digital"); assert_eq!(c.file_type, "download"); } #[test] fn classify_extension_unknown() { assert!(classify_extension("txt").is_none()); assert!(classify_extension("").is_none()); } #[test] fn derive_title_basic() { assert_eq!(derive_title("my-cool-song.mp3"), "My Cool Song"); assert_eq!(derive_title("hello_world.zip"), "Hello World"); } #[test] fn derive_title_no_extension() { assert_eq!(derive_title("readme"), "Readme"); } #[test] fn is_allowed_extension_cases() { assert!(is_allowed_extension("MP3")); assert!(is_allowed_extension("wav")); assert!(!is_allowed_extension("txt")); assert!(!is_allowed_extension("")); } #[test] fn format_bytes_ranges() { assert_eq!(format_bytes(0), "0 B"); assert_eq!(format_bytes(512), "512 B"); assert_eq!(format_bytes(1024), "1.0 KB"); assert_eq!(format_bytes(1_048_576), "1.0 MB"); assert_eq!(format_bytes(1_073_741_824), "1.00 GB"); } }