use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; use tokio::fs::{self, File}; use tokio::io::AsyncWriteExt; use uuid::Uuid; pub struct PendingMediaFile { file: File, temporary_path: PathBuf, final_path: PathBuf, relative_final_path: String, } impl PendingMediaFile { pub async fn begin( root: &Path, directory: &str, id: Uuid, extension: Option<&str>, ) -> std::io::Result { let relative_directory = PathBuf::from(directory); let directory_path = root.join(&relative_directory); fs::create_dir_all(&directory_path).await?; let uuid_name = id.to_string(); let temporary_name = format!("~{uuid_name}.part"); let final_name = match extension.filter(|value| !value.is_empty()) { Some(extension) => format!("{uuid_name}.{extension}"), None => uuid_name, }; let temporary_path = directory_path.join(temporary_name); let final_path = directory_path.join(&final_name); let file = File::create(&temporary_path).await?; Ok(Self { file, temporary_path, final_path, relative_final_path: relative_directory .join(final_name) .to_string_lossy() .into_owned(), }) } pub async fn write(&mut self, chunk: &[u8]) -> std::io::Result<()> { self.file.write_all(chunk).await } pub async fn finish(mut self) -> std::io::Result { self.file.flush().await?; self.file.sync_all().await?; drop(self.file); fs::rename(&self.temporary_path, &self.final_path).await?; Ok(self.relative_final_path) } pub async fn remove_final(path: &Path) { let _ = fs::remove_file(path).await; } } pub fn extension_from_filename(filename: &str) -> Option { let name = Path::new(filename).file_name()?.to_str()?; let lower = name.to_ascii_lowercase(); let extension = ["tar.gz", "tar.bz2", "tar.xz", "tar.zst"] .iter() .find(|candidate| lower.ends_with(&format!(".{candidate}"))) .map(|candidate| (*candidate).to_string()) .or_else(|| { Path::new(name) .extension() .and_then(|value| value.to_str()) .map(str::to_ascii_lowercase) })?; if extension .chars() .all(|character| character.is_ascii_alphanumeric() || character == '.') { Some(extension) } else { None } } pub fn extension_from_mime(mime_type: &str) -> Option<&'static str> { match mime_type { "image/png" => Some("png"), "image/gif" => Some("gif"), "image/webp" => Some("webp"), "image/jpeg" => Some("jpg"), _ => None, } } pub async fn cleanup_temporary_files(root: &Path, max_age: Duration) -> std::io::Result<()> { let mut directories = vec![root.to_path_buf()]; let mut root_entries = match fs::read_dir(root).await { Ok(entries) => entries, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), Err(error) => return Err(error), }; while let Some(entry) = root_entries.next_entry().await? { if entry.file_type().await?.is_dir() { directories.push(entry.path()); } } let now = SystemTime::now(); for directory in directories { let mut entries = match fs::read_dir(&directory).await { Ok(entries) => entries, Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, Err(error) => return Err(error), }; while let Some(entry) = entries.next_entry().await? { let name = entry.file_name().to_string_lossy().into_owned(); if !name.starts_with('~') || !name.ends_with(".part") { continue; } let modified = entry.metadata().await?.modified().unwrap_or(now); if now.duration_since(modified).unwrap_or_default() > max_age { let _ = fs::remove_file(entry.path()).await; } } } Ok(()) } #[cfg(test)] mod tests { use super::extension_from_filename; #[test] fn preserves_compound_extensions() { assert_eq!( extension_from_filename("archive.tar.gz").as_deref(), Some("tar.gz") ); } #[test] fn extracts_simple_extensions() { assert_eq!(extension_from_filename("photo.PNG").as_deref(), Some("png")); } #[test] fn returns_none_without_an_extension() { assert_eq!(extension_from_filename("README").as_deref(), None); } }