15c35c06cdea1711d13d2bf9060bc4610e4b22c2 / frogue / src / attachments / file_store.rs
Raw
use std::path::PathBuf;
use mime_guess::Mime;
use tracing::{debug, error};
use uuid::Uuid;
/// Manages attachment files on disk.
///
/// File layout: `<root>/uploads/<first 2 hex chars of uuid>/<remainder of
/// uuid>.<ext>` Extension is derived from content_type via mime_guess,
/// sanitized to [a-z0-9]+.
#[derive(Clone)]
pub struct FileStore {
root: PathBuf,
}
impl FileStore {
/// Create a new FileStore rooted at the given directory.
/// The `uploads` subdirectory will be created if it doesn't exist.
pub async fn new(root: PathBuf) -> std::io::Result<Self> {
let uploads_dir = root.join("uploads");
tokio::fs::create_dir_all(&uploads_dir).await?;
debug!(?uploads_dir, "FileStore initialized");
Ok(Self { root: uploads_dir })
}
/// Write attachment bytes to disk.
pub async fn write(&self, uuid: Uuid, content_type: &str, data: &[u8]) -> std::io::Result<()> {
let path = self.path(uuid, content_type);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(&path, data).await?;
debug!(?path, size = data.len(), "Wrote attachment file");
Ok(())
}
/// Open an attachment file for streaming.
/// Returns None if the file doesn't exist (row exists but file lost).
pub async fn open(
&self,
uuid: Uuid,
content_type: &str,
) -> std::io::Result<Option<tokio::fs::File>> {
let path = self.path(uuid, content_type);
match tokio::fs::File::open(&path).await {
Ok(file) => {
debug!(?path, "Opened attachment file");
Ok(Some(file))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
error!(?path, "Attachment file missing on disk");
Ok(None)
}
Err(e) => {
error!(?path, error = %e, "Failed to open attachment file");
Err(e)
}
}
}
/// Delete an attachment file. Missing files are Ok (idempotent).
pub async fn delete(&self, uuid: Uuid, content_type: &str) -> std::io::Result<()> {
let path = self.path(uuid, content_type);
match tokio::fs::remove_file(&path).await {
Ok(()) => {
debug!(?path, "Deleted attachment file");
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
debug!(?path, "Attachment file already missing");
Ok(())
}
Err(e) => {
error!(?path, error = %e, "Failed to delete attachment file");
Err(e)
}
}
}
/// Derive the file path from uuid and content_type.
/// Layout: `<root>/<first 2 hex>/<remainder>.<ext>`
fn path(&self, uuid: Uuid, content_type: &str) -> PathBuf {
let hex = uuid.simple().to_string();
let shard = &hex[..2];
let remainder = &hex[2..];
let ext = Self::sanitize_extension(content_type);
if ext.is_empty() {
self.root.join(shard).join(remainder)
} else {
self.root.join(shard).join(format!("{}.{}", remainder, ext))
}
}
/// Sanitize content_type into a file extension.
/// Uses a preference map for common types, then falls back to mime_guess.
fn sanitize_extension(content_type: &str) -> String {
// Preference map for common MIME types where we know the standard
// extension
let preferred = match content_type {
"image/png" => "png",
"image/jpeg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
"text/plain" => "txt",
"text/html" => "html",
"text/css" => "css",
"text/javascript" => "js",
"application/json" => "json",
"application/pdf" => "pdf",
"application/zip" => "zip",
"application/gzip" => "gz",
_ => {
// Fall back to mime_guess for unknown types
let mime: Mime = match content_type.parse() {
Ok(m) => m,
Err(_) => return String::new(),
};
return mime_guess::get_mime_extensions(&mime)
.and_then(|exts| exts.first())
.map(|s| s.to_string())
.unwrap_or_default()
.chars()
.filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
.collect();
}
};
preferred.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_extension_common_types() {
assert_eq!(FileStore::sanitize_extension("image/png"), "png");
assert_eq!(FileStore::sanitize_extension("image/jpeg"), "jpg");
assert_eq!(FileStore::sanitize_extension("text/plain"), "txt");
assert_eq!(FileStore::sanitize_extension("application/pdf"), "pdf");
}
#[test]
fn sanitize_extension_unknown_type() {
assert_eq!(FileStore::sanitize_extension("application/x-unknown"), "");
}
#[test]
fn sanitize_extension_filters_chars() {
// Even if mime_guess returns something weird, we filter
// This is defensive since mime_guess should return clean extensions
let ext = FileStore::sanitize_extension("text/plain");
assert!(
ext.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
);
}
#[test]
fn path_derivation() {
let store = FileStore {
root: PathBuf::from("/data/uploads"),
};
let uuid = Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap();
let path = store.path(uuid, "image/png");
assert_eq!(
path,
PathBuf::from("/data/uploads/12/345678123456781234567812345678.png")
);
}
#[test]
fn path_derivation_no_extension() {
let store = FileStore {
root: PathBuf::from("/data/uploads"),
};
let uuid = Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap();
let path = store.path(uuid, "application/x-unknown");
assert_eq!(
path,
PathBuf::from("/data/uploads/12/345678123456781234567812345678")
);
}
}