use config::AppConfig; use serde::{Deserialize, Serialize}; use std::path::PathBuf; #[derive(Clone, Debug)] pub struct AppAvatar { pub basic_path: PathBuf, } #[derive(Clone, Debug, Deserialize, Serialize)] pub struct AvatarLoad { w: Option, h: Option, } impl AppAvatar { pub async fn init(cfg: &AppConfig) -> anyhow::Result { let path = cfg.avatar_path()?; if std::fs::read_dir(&path).is_err() { if let Err(e) = std::fs::create_dir_all(&path) { tracing::warn!(path = %path, error = %e, "Avatar directory not available, avatars disabled"); } } let basic_path = PathBuf::from(path); Ok(Self { basic_path }) } pub async fn upload(&self, file: Vec, file_name: String, ext: &str) -> anyhow::Result<()> { // Validate file size (max 5MB) if file.len() > 5 * 1024 * 1024 { anyhow::bail!("File size exceeds 5MB limit"); } // Validate extension let allowed_exts = ["png", "jpg", "jpeg", "gif", "webp"]; if !allowed_exts.contains(&ext) { anyhow::bail!("Invalid file extension: {}", ext); } // Sanitize filename to prevent path traversal let sanitized_name = file_name.replace(['/', '\\', '.', ':'], "_"); if sanitized_name.is_empty() || sanitized_name.len() > 255 { anyhow::bail!("Invalid filename"); } let image = image::load_from_memory(&*file)?; image.save(self.basic_path.join(format!("{}.{}", sanitized_name, ext)))?; Ok(()) } pub async fn load(&self, file_name: String, load: AvatarLoad) -> anyhow::Result> { // Sanitize filename to prevent path traversal let sanitized_name = file_name.replace(['/', '\\', '.', ':'], "_"); if sanitized_name.is_empty() || sanitized_name.len() > 255 { anyhow::bail!("Invalid filename"); } let path = self.basic_path.join(format!("{}.png", sanitized_name)); // Verify path is within basic_path let canonical_path = path.canonicalize().unwrap_or(path.clone()); if !canonical_path.starts_with(&self.basic_path) { anyhow::bail!("Path traversal detected"); } let image = image::open(canonical_path)?; let (w, h) = ( load.w.unwrap_or(image.width()), load.h.unwrap_or(image.height()), ); let image = image.resize(w, h, image::imageops::FilterType::Nearest); Ok(image.as_bytes().to_vec()) } pub async fn delete(&self, file_name: String, ext: &str) -> anyhow::Result<()> { // Validate extension let allowed_exts = ["png", "jpg", "jpeg", "gif", "webp"]; if !allowed_exts.contains(&ext) { anyhow::bail!("Invalid file extension: {}", ext); } // Sanitize filename to prevent path traversal let sanitized_name = file_name.replace(['/', '\\', '.', ':'], "_"); if sanitized_name.is_empty() || sanitized_name.len() > 255 { anyhow::bail!("Invalid filename"); } let path = self.basic_path.join(format!("{}.{}", sanitized_name, ext)); // Verify path is within basic_path let canonical_path = path.canonicalize().unwrap_or(path.clone()); if !canonical_path.starts_with(&self.basic_path) { anyhow::bail!("Path traversal detected"); } std::fs::remove_file(canonical_path)?; Ok(()) } }