feat(room): add attachment upload/download API and attach files to messages
This commit is contained in:
parent
a0ab16e6ea
commit
dee79f3f7f
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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<AppService>,
|
||||
session: Session,
|
||||
path: web::Path<(Uuid, Uuid)>,
|
||||
) -> Result<HttpResponse, crate::error::ApiError> {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -210,6 +210,7 @@ pub struct WsRequestParams {
|
||||
pub stream: Option<bool>,
|
||||
pub min_score: Option<f32>,
|
||||
pub query: Option<String>,
|
||||
pub attachment_ids: Option<Vec<Uuid>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user