238 lines
7.5 KiB
Rust
238 lines
7.5 KiB
Rust
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,
|
|
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(),
|
|
))
|
|
})?;
|
|
|
|
// 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 {
|
|
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)
|
|
}
|