152 lines
4.6 KiB
Rust
152 lines
4.6 KiB
Rust
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<Self> {
|
|
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<String> {
|
|
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<String> {
|
|
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);
|
|
}
|
|
}
|