diff --git a/libs/api/room/mod.rs b/libs/api/room/mod.rs index 8d1cdc9..035aed6 100644 --- a/libs/api/room/mod.rs +++ b/libs/api/room/mod.rs @@ -8,6 +8,7 @@ pub mod pin; pub mod reaction; pub mod room; pub mod thread; +pub mod upload; pub mod ws; pub mod ws_handler; pub mod ws_types; @@ -173,6 +174,11 @@ pub fn init_room_routes(cfg: &mut web::ServiceConfig) { .route( "/me/notifications/{notification_id}/archive", web::post().to(notification::notification_archive), + ) + // file upload + .route( + "/rooms/{room_id}/upload", + web::post().to(upload::upload), ), ); } diff --git a/libs/api/room/upload.rs b/libs/api/room/upload.rs new file mode 100644 index 0000000..4757a75 --- /dev/null +++ b/libs/api/room/upload.rs @@ -0,0 +1,140 @@ +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()) +} diff --git a/libs/api/room/ws.rs b/libs/api/room/ws.rs index 356fefe..79e197b 100644 --- a/libs/api/room/ws.rs +++ b/libs/api/room/ws.rs @@ -98,6 +98,8 @@ pub struct AiStreamChunkPayload { pub content: String, pub done: bool, pub error: Option, + /// Human-readable AI model name for display in the UI. + pub display_name: Option, } impl From for AiStreamChunkPayload { @@ -108,6 +110,7 @@ impl From for AiStreamChunkPayload { content: e.content, done: e.done, error: e.error, + display_name: e.display_name, } } }