use axum::{ Json, extract::{Multipart, Path, Query, State}, http::{HeaderMap, StatusCode}, }; use serde::Deserialize; use std::sync::Arc; use tokio::fs; use tracing::{error, info, warn}; use crate::{ AppState, auth::check_auth, error::AppError, models::{FileInfo, UploadResponse}, }; #[derive(Deserialize)] pub struct UploadQuery { pub replace: Option, } /// Allowed upload extensions. SVG, HTML, JS, executables intentionally absent — /// /uploads/* is served as-is, so any active content there is XSS waiting to happen. const ALLOWED_EXTS: &[&str] = &[ "jpg", "jpeg", "png", "webp", "gif", "avif", "pdf", "txt", "md", "mp3", "wav", "ogg", "mp4", "webm", "mov", ]; fn validate_filename(name: &str) -> Result<(), AppError> { if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") || name.contains('\0') || name.starts_with('.') { return Err(AppError::BadRequest("Invalid filename".to_string())); } Ok(()) } fn check_mime_matches_ext(bytes: &[u8], ext: &str) -> bool { let Some(kind) = infer::get(bytes) else { // Plain text formats (txt, md) won't be detected by magic bytes. return matches!(ext, "txt" | "md"); }; let mime = kind.mime_type(); match ext { "jpg" | "jpeg" => mime == "image/jpeg", "png" => mime == "image/png", "webp" => mime == "image/webp", "gif" => mime == "image/gif", "avif" => mime == "image/avif", "pdf" => mime == "application/pdf", "mp3" => mime == "audio/mpeg", "wav" => mime == "audio/x-wav" || mime == "audio/wav", "ogg" => mime == "audio/ogg" || mime == "video/ogg", "mp4" => mime == "video/mp4", "webm" => mime == "video/webm", "mov" => mime == "video/quicktime", "txt" | "md" => true, _ => false, } } pub async fn delete_upload( State(state): State>, headers: HeaderMap, Path(filename): Path, ) -> Result { check_auth(&headers, &state.admin_token)?; validate_filename(&filename)?; let uploads_dir = state.data_dir.join("uploads"); let file_path = uploads_dir.join(&filename); let canonical_dir = fs::canonicalize(&uploads_dir) .await .map_err(|e| AppError::Internal("Path resolution".to_string(), Some(e.to_string())))?; if let Ok(canonical_file) = fs::canonicalize(&file_path).await { if !canonical_file.starts_with(&canonical_dir) { warn!("Refused delete outside uploads dir: {}", filename); return Err(AppError::BadRequest("Invalid filename".to_string())); } fs::remove_file(canonical_file).await.map_err(|e| { error!("Delete error for file {}: {}", filename, e); AppError::Internal("Delete error".to_string(), Some(e.to_string())) })?; state .image_dims_cache .write() .await .remove(&format!("/uploads/{}", filename)); info!("Deleted file: {}", filename); Ok(StatusCode::NO_CONTENT) } else { Err(AppError::NotFound("File not found".to_string())) } } pub async fn list_uploads( State(state): State>, headers: HeaderMap, ) -> Result>, AppError> { check_auth(&headers, &state.admin_token)?; let uploads_dir = state.data_dir.join("uploads"); let mut files = Vec::new(); if let Ok(mut rd) = fs::read_dir(&uploads_dir).await { loop { match rd.next_entry().await { Ok(Some(entry)) => { let path = entry.path(); let is_file = entry .file_type() .await .map(|t| t.is_file()) .unwrap_or(false); if !is_file { continue; } if let Some(name) = path.file_name().and_then(|n| n.to_str()) { files.push(FileInfo { name: name.to_string(), url: format!("/uploads/{}", name), }); } } Ok(None) => break, Err(e) => { warn!("Error iterating uploads dir: {}", e); break; } } } } Ok(Json(files)) } pub async fn upload_file( State(state): State>, headers: HeaderMap, Query(query): Query, mut multipart: Multipart, ) -> Result, AppError> { check_auth(&headers, &state.admin_token)?; info!("Upload requested"); while let Ok(Some(field)) = multipart.next_field().await { let original_name = match field.file_name() { Some(name) => name.to_string(), None => continue, }; info!("Processing upload for: {}", original_name); let extension = std::path::Path::new(&original_name) .extension() .and_then(|e| e.to_str()) .map(|e| e.to_ascii_lowercase()) .unwrap_or_default(); if !ALLOWED_EXTS.contains(&extension.as_str()) { warn!("Upload rejected: extension '{}' not allowed", extension); return Err(AppError::BadRequest(format!( "File type '.{}' not allowed", extension ))); } let stem = std::path::Path::new(&original_name) .file_stem() .and_then(|s| s.to_str()) .unwrap_or("file"); let slugified = slug::slugify(stem); let final_name = format!("{}.{}", slugified, extension); let data = field.bytes().await.map_err(|e| { error!("Failed to read multipart bytes: {}", e); AppError::BadRequest(format!("Read error: {}", e)) })?; if !check_mime_matches_ext(&data, &extension) { warn!( "Upload rejected: magic bytes don't match extension '{}'", extension ); return Err(AppError::BadRequest( "File contents don't match extension".to_string(), )); } let uploads_dir = state.data_dir.join("uploads"); let target_path = uploads_dir.join(&final_name); let final_path = if fs::try_exists(&target_path).await.unwrap_or(false) && !query.replace.unwrap_or(false) { let timestamp = chrono::Utc::now().timestamp(); uploads_dir.join(format!("{}_{}", timestamp, final_name)) } else { target_path }; // Final containment check. let canonical_dir = fs::canonicalize(&uploads_dir) .await .map_err(|e| AppError::Internal("Path resolution".to_string(), Some(e.to_string())))?; if let Some(parent) = final_path.parent() { let canonical_parent = fs::canonicalize(parent).await.map_err(|e| { AppError::Internal("Path resolution".to_string(), Some(e.to_string())) })?; if canonical_parent != canonical_dir { return Err(AppError::BadRequest("Invalid filename".to_string())); } } let final_name_str = final_path .file_name() .and_then(|n| n.to_str()) .unwrap_or(&final_name) .to_string(); fs::write(&final_path, &data).await.map_err(|e| { error!("Failed to write file to {:?}: {}", final_path, e); AppError::Internal("Write error".to_string(), Some(e.to_string())) })?; let url = format!("/uploads/{}", final_name_str); // Invalidate any stale dim cache entry (matters when replacing an existing file). state.image_dims_cache.write().await.remove(&url); info!("File uploaded successfully to {:?}", final_path); return Ok(Json(UploadResponse { url })); } warn!("Upload failed: no file found in multipart stream"); Err(AppError::BadRequest("No file found".to_string())) }