use actix_multipart::Multipart; use actix_web::{HttpResponse, Result, web}; use actix_web::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE}; use futures_util::StreamExt; use service::AppService; use session::Session; use uuid::Uuid; #[derive(Debug, serde::Serialize, utoipa::ToSchema)] pub struct UploadResponse { pub url: String, pub file_name: String, pub file_size: i64, pub content_type: String, } fn sanitize_key(key: &str) -> String { key.replace(['/', '\\', ':'], "_") } fn extract_filename(disposition: &actix_web::http::header::HeaderValue) -> Option { let s = disposition.to_str().ok()?; // Simple parsing: extract filename from Content-Disposition header // e.g., "form-data; filename="test.png"" for part in s.split(';') { let part = part.trim(); if part.starts_with("filename=") { let value = &part[9..].trim_matches('"'); if !value.is_empty() { return Some(value.to_string()); } } } None } pub async fn upload( service: web::Data, session: Session, path: web::Path, mut payload: Multipart, ) -> Result { let user_id = session .user() .ok_or_else(|| crate::error::ApiError(service::error::AppError::Unauthorized))?; let storage = service .storage .as_ref() .ok_or_else(|| { crate::error::ApiError(service::error::AppError::BadRequest( "Storage not configured".to_string(), )) })?; let room_id = path.into_inner(); service .room .require_room_member(room_id, user_id) .await .map_err(crate::error::ApiError::from)?; let max_size = service.config.storage_max_file_size(); let mut file_data: Vec = Vec::new(); let mut file_name = String::new(); let mut content_type = "application/octet-stream".to_string(); while let Some(item) = payload.next().await { let mut field = item.map_err(|e| { crate::error::ApiError(service::error::AppError::BadRequest(e.to_string())) })?; if let Some(disposition) = field.headers().get(&CONTENT_DISPOSITION) { if let Some(name) = extract_filename(disposition) { file_name = name; } } if let Some(ct) = field.headers().get(&CONTENT_TYPE) { if let Ok(ct_str) = ct.to_str() { if !ct_str.is_empty() && ct_str != "application/octet-stream" { content_type = ct_str.to_string(); } } } while let Some(chunk) = field.next().await { let data = chunk.map_err(|e| { crate::error::ApiError(service::error::AppError::BadRequest(e.to_string())) })?; if file_data.len() + data.len() > max_size { return Err(crate::error::ApiError( service::error::AppError::BadRequest(format!( "File exceeds maximum size of {} bytes", max_size )), )); } file_data.extend_from_slice(&data); } } if file_data.is_empty() { return Err(crate::error::ApiError( service::error::AppError::BadRequest("No file provided".to_string()), )); } if file_name.is_empty() { file_name = format!("upload_{}", Uuid::now_v7()); } // Detect content type from file extension if still octet-stream if content_type == "application/octet-stream" { content_type = mime_guess2::from_path(&file_name) .first_or_octet_stream() .to_string(); } let unique_name = format!("{}_{}", Uuid::now_v7(), sanitize_key(&file_name)); let key = format!("rooms/{}/{}", room_id, unique_name); let file_size = file_data.len() as i64; let url = storage .upload(&key, file_data) .await .map_err(|e| { crate::error::ApiError(service::error::AppError::InternalServerError( e.to_string(), )) })?; Ok(crate::ApiResponse::ok(UploadResponse { url, file_name, file_size, content_type, }) .to_response()) }