feat(api/room): add upload handler and update websocket handler
This commit is contained in:
parent
cec8d486f1
commit
26c86f0796
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
140
libs/api/room/upload.rs
Normal file
140
libs/api/room/upload.rs
Normal file
@ -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<String> {
|
||||
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<AppService>,
|
||||
session: Session,
|
||||
path: web::Path<Uuid>,
|
||||
mut payload: Multipart,
|
||||
) -> Result<HttpResponse, crate::error::ApiError> {
|
||||
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<u8> = 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())
|
||||
}
|
||||
@ -98,6 +98,8 @@ pub struct AiStreamChunkPayload {
|
||||
pub content: String,
|
||||
pub done: bool,
|
||||
pub error: Option<String>,
|
||||
/// Human-readable AI model name for display in the UI.
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
impl From<RoomMessageStreamChunkEvent> for AiStreamChunkPayload {
|
||||
@ -108,6 +110,7 @@ impl From<RoomMessageStreamChunkEvent> for AiStreamChunkPayload {
|
||||
content: e.content,
|
||||
done: e.done,
|
||||
error: e.error,
|
||||
display_name: e.display_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user