diff --git a/libs/api/room/mod.rs b/libs/api/room/mod.rs index 035aed6..ec8251e 100644 --- a/libs/api/room/mod.rs +++ b/libs/api/room/mod.rs @@ -179,6 +179,10 @@ pub fn init_room_routes(cfg: &mut web::ServiceConfig) { .route( "/rooms/{room_id}/upload", web::post().to(upload::upload), + ) + .route( + "/rooms/{room_id}/attachments/{attachment_id}", + web::get().to(upload::get_attachment), ), ); } diff --git a/libs/api/room/reaction.rs b/libs/api/room/reaction.rs index caa3aab..e6df3a3 100644 --- a/libs/api/room/reaction.rs +++ b/libs/api/room/reaction.rs @@ -189,9 +189,18 @@ pub async fn message_search( .user() .ok_or_else(|| ApiError::from(service::error::AppError::Unauthorized))?; let ctx = WsUserContext::new(user_id); + let req = room::types::RoomMessageSearchRequest { + q: query.q.clone(), + start_time: None, + end_time: None, + sender_id: None, + content_type: None, + limit: query.limit, + offset: query.offset, + }; let resp = service .room - .message_search(room_id, &query.q, query.limit, query.offset, &ctx) + .room_message_search(room_id, req, &ctx) .await .map_err(ApiError::from)?; Ok(ApiResponse::ok(resp).to_response()) diff --git a/libs/api/room/upload.rs b/libs/api/room/upload.rs index 4757a75..f1dfb84 100644 --- a/libs/api/room/upload.rs +++ b/libs/api/room/upload.rs @@ -1,13 +1,17 @@ use actix_multipart::Multipart; use actix_web::{HttpResponse, Result, web}; use actix_web::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE}; +use chrono::Utc; use futures_util::StreamExt; +use models::rooms::room_attachment; +use sea_orm::{ActiveModelTrait, EntityTrait, Set}; use service::AppService; use session::Session; use uuid::Uuid; #[derive(Debug, serde::Serialize, utoipa::ToSchema)] pub struct UploadResponse { + pub id: String, pub url: String, pub file_name: String, pub file_size: i64, @@ -130,11 +134,104 @@ pub async fn upload( )) })?; + // Write to room_attachment table (message will be linked when message is created) + let attachment_id = Uuid::now_v7(); + let attachment: room_attachment::ActiveModel = room_attachment::ActiveModel { + id: Set(attachment_id), + room: Set(room_id), + message: Set(None), + uploader: Set(user_id), + file_name: Set(file_name.clone()), + file_size: Set(file_size), + content_type: Set(content_type.clone()), + s3_key: Set(key), + created_at: Set(Utc::now()), + }; + attachment + .insert(&service.db) + .await + .map_err(|e| { + crate::error::ApiError(service::error::AppError::InternalServerError( + e.to_string(), + )) + })?; + + // Return the structured attachment URL instead of the /files/... path + // (the /files/... path has no handler on the API server) + let attachment_url = format!("/api/rooms/{}/attachments/{}", room_id, attachment_id); + Ok(crate::ApiResponse::ok(UploadResponse { - url, + id: attachment_id.to_string(), + url: attachment_url, file_name, file_size, content_type, }) .to_response()) } + +#[utoipa::path( + get, + path = "/api/rooms/{room_id}/attachments/{attachment_id}", + params( + ("room_id" = Uuid, Path, description = "Room ID"), + ("attachment_id" = Uuid, Path, description = "Attachment ID"), + ), + responses( + (status = 200, description = "Download file"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Not a room member"), + (status = 404, description = "Not found"), + ), + tag = "Room" +)] +pub async fn get_attachment( + service: web::Data, + session: Session, + path: web::Path<(Uuid, Uuid)>, +) -> Result { + let user_id = session + .user() + .ok_or_else(|| crate::error::ApiError(service::error::AppError::Unauthorized))?; + + let (room_id, attachment_id) = path.into_inner(); + + service + .room + .require_room_member(room_id, user_id) + .await + .map_err(crate::error::ApiError::from)?; + + let attachment = room_attachment::Entity::find_by_id(attachment_id) + .one(&service.db) + .await + .map_err(|e| crate::error::ApiError(service::error::AppError::InternalServerError(e.to_string())))? + .ok_or_else(|| crate::error::ApiError(service::error::AppError::NotFound))?; + + // Ensure the attachment belongs to the requested room + if attachment.room != room_id { + return Err(crate::error::ApiError(service::error::AppError::NotFound)); + } + + let storage = service + .storage + .as_ref() + .ok_or_else(|| crate::error::ApiError(service::error::AppError::InternalServerError("Storage not configured".to_string())))?; + + let (data, content_type) = storage + .read(&attachment.s3_key) + .await + .map_err(|e| crate::error::ApiError(service::error::AppError::InternalServerError(e.to_string())))?; + + HttpResponse::Ok() + .content_type(content_type) + .insert_header(( + CONTENT_TYPE, + content_type, + )) + .insert_header(( + CONTENT_DISPOSITION, + format!("inline; filename=\"{}\"", attachment.file_name), + )) + .body(data) +} diff --git a/libs/api/room/ws_handler.rs b/libs/api/room/ws_handler.rs index dcf9c6e..b4a5447 100644 --- a/libs/api/room/ws_handler.rs +++ b/libs/api/room/ws_handler.rs @@ -222,6 +222,7 @@ impl WsRequestHandler { content_type: params.content_type.clone(), thread: params.thread_id, in_reply_to: params.in_reply_to, + attachment_ids: params.attachment_ids.clone().unwrap_or_default(), }, &ctx, ) diff --git a/libs/api/room/ws_types.rs b/libs/api/room/ws_types.rs index 020ed80..885be30 100644 --- a/libs/api/room/ws_types.rs +++ b/libs/api/room/ws_types.rs @@ -210,6 +210,7 @@ pub struct WsRequestParams { pub stream: Option, pub min_score: Option, pub query: Option, + pub attachment_ids: Option>, } #[derive(Debug, Clone, Serialize)]