use axum::body::Body; use axum::http::header; use models::chat::attachments::{self, AttachmentStatus}; use sea_orm::{ ActiveModelTrait, ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect, Set, }; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::ToSchema; use uuid::Uuid; use crate::errors::ChatError; use crate::services::service::ChatService; pub const ATTACHMENT_PREFIX: &str = "chat/attachments"; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct AttachmentDto { pub id: Uuid, pub user_id: Uuid, pub room_id: Option, pub message_id: Option, pub filename: String, pub description: Option, pub content_type: String, pub size: i64, pub status: AttachmentStatus, pub width: Option, pub height: Option, pub duration_secs: Option, pub created_at: OffsetDateTime, } impl From for AttachmentDto { fn from(a: attachments::Model) -> Self { Self { id: a.id, user_id: a.user_id, room_id: a.room_id, message_id: a.message_id, filename: a.filename, description: a.description, content_type: a.content_type, size: a.size, status: a.status, width: a.width, height: a.height, duration_secs: a.duration_secs, created_at: a.created_at, } } } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateAttachmentCmd { pub filename: String, pub content_type: String, pub size: i64, #[serde(default)] pub description: Option, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct UploadIntentDto { pub id: Uuid, pub upload_url: String, pub expires_secs: u64, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct FinalizeAttachmentCmd { #[serde(default)] pub width: Option, #[serde(default)] pub height: Option, #[serde(default)] pub duration_secs: Option, } fn sanitize_filename(name: &str) -> String { let base = name.rsplit(['/', '\\']).next().unwrap_or("file"); let cleaned: String = base .chars() .map(|c| match c { 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => c, _ => '_', }) .collect(); if cleaned.is_empty() || cleaned.chars().all(|c| c == '.') { "file".to_string() } else { cleaned } } /// Content types that browsers may execute/script inline; never accepted or /// served embedded. const UNSAFE_INLINE_TYPES: &[&str] = &[ "text/html", "application/xhtml+xml", "image/svg+xml", "text/xml", "application/xml", "text/javascript", "application/javascript", "application/x-javascript", "application/ecmascript", ]; fn is_unsafe_inline(content_type: &str) -> bool { let main = content_type.split(';').next().unwrap_or("").trim(); UNSAFE_INLINE_TYPES .iter() .any(|blocked| main.eq_ignore_ascii_case(blocked)) } fn is_inline_renderable(content_type: &str) -> bool { let main = content_type.split(';').next().unwrap_or("").trim(); main.starts_with("image/") || main.starts_with("video/") || main.starts_with("audio/") || main.eq_ignore_ascii_case("text/plain") || main.eq_ignore_ascii_case("text/markdown") || main.eq_ignore_ascii_case("application/pdf") } impl ChatService { pub async fn create_attachment( &self, caller: Uuid, cmd: CreateAttachmentCmd, ) -> Result { let max_size = self.config().chat.upload.max_size; if cmd.size <= 0 || cmd.size > max_size { return Err(ChatError::BadRequest(format!( "file size must be within 1..{max_size} bytes" ))); } if cmd.filename.trim().is_empty() || cmd.filename.chars().count() > 255 { return Err(ChatError::BadRequest("invalid filename".to_string())); } if cmd.content_type.trim().is_empty() || is_unsafe_inline(&cmd.content_type) { return Err(ChatError::BadRequest( "unsupported or unsafe content_type".to_string(), )); } let unattached = attachments::Entity::find() .filter(attachments::Column::UserId.eq(caller)) .filter(attachments::Column::MessageId.is_null()) .count(self.db().read()) .await?; if unattached >= self.config().chat.max_pending_attachments_per_user as u64 { return Err(ChatError::RateLimited(format!( "too many unused attachments (max {}), finalize or reference them in a message first", self.config().chat.max_pending_attachments_per_user ))); } let now = OffsetDateTime::now_utc(); let id = Uuid::now_v7(); let filename = sanitize_filename(&cmd.filename); let key = format!("{ATTACHMENT_PREFIX}/{caller}/{id}/{filename}"); let bucket = self.storage().client().bucket_name().to_string(); let row = attachments::ActiveModel { id: Set(id), user_id: Set(caller), room_id: Set(None), message_id: Set(None), filename: Set(filename.clone()), description: Set(cmd.description.clone()), content_type: Set(cmd.content_type.clone()), size: Set(cmd.size), bucket: Set(bucket), s3_key: Set(key.clone()), status: Set(AttachmentStatus::Pending), width: Set(None), height: Set(None), duration_secs: Set(None), variants: Set(serde_json::json!([])), created_at: Set(now), updated_at: Set(now), } .insert(self.db().write()) .await?; let ttl = self.config().chat.upload.presign_ttl_secs.min(604_800); let mut put_headers = http::HeaderMap::new(); if let Ok(value) = cmd.content_type.parse() { put_headers.insert(http::header::CONTENT_TYPE, value); } let upload_url = self .storage() .client() .presign_put_with_headers(&key, ttl as u32, put_headers) .map_err(ChatError::from)?; let _ = row; Ok(UploadIntentDto { id, upload_url, expires_secs: ttl, }) } pub async fn finalize_attachment( &self, caller: Uuid, attachment_id: Uuid, cmd: FinalizeAttachmentCmd, ) -> Result { let row = attachments::Entity::find_by_id(attachment_id) .one(self.db().write()) .await? .filter(|a| a.user_id == caller) .ok_or(ChatError::NotFound)?; if row.status != AttachmentStatus::Pending { return Err(ChatError::Conflict( "attachment already finalized".to_string(), )); } let head = self.storage().client().head(&row.s3_key).await?; let Some((actual_size, actual_content_type)) = head else { return Err(ChatError::BadRequest("object not uploaded yet".to_string())); }; let max_size = self.config().chat.upload.max_size; if actual_size == 0 || actual_size > max_size as u64 { return Err(ChatError::BadRequest(format!( "uploaded object size {actual_size} outside 1..{max_size} bytes" ))); } let stored_content_type = if actual_content_type.is_empty() { row.content_type.clone() } else { actual_content_type.clone() }; if is_unsafe_inline(&stored_content_type) { return Err(ChatError::BadRequest( "uploaded object has an unsafe content_type".to_string(), )); } let now = OffsetDateTime::now_utc(); let mut am: attachments::ActiveModel = row.into(); am.size = Set(actual_size as i64); am.content_type = Set(stored_content_type); am.status = Set(AttachmentStatus::Uploaded); am.width = Set(cmd.width); am.height = Set(cmd.height); am.duration_secs = Set(cmd.duration_secs); am.updated_at = Set(now); let updated = am.update(self.db().write()).await?; Ok(AttachmentDto::from(updated)) } pub async fn cleanup_stale_attachments(&self) -> Result { let now = OffsetDateTime::now_utc(); let pending_cutoff = now - time::Duration::hours(24); let uploaded_cutoff = now - time::Duration::days(7); let stale = attachments::Entity::find() .filter( Condition::any() .add( Condition::all() .add(attachments::Column::Status.eq(AttachmentStatus::Pending)) .add(attachments::Column::CreatedAt.lt(pending_cutoff)), ) .add( Condition::all() .add(attachments::Column::Status.eq(AttachmentStatus::Uploaded)) .add(attachments::Column::MessageId.is_null()) .add(attachments::Column::CreatedAt.lt(uploaded_cutoff)), ), ) .limit(200) .all(self.db().write()) .await?; let count = stale.len() as u64; for row in stale { let shared = attachments::Entity::find() .filter(attachments::Column::S3Key.eq(row.s3_key.clone())) .filter(attachments::Column::Id.ne(row.id)) .count(self.db().read()) .await .unwrap_or(1); let _ = attachments::Entity::delete_by_id(row.id) .exec(self.db().write()) .await; if shared == 0 { let _ = self.storage().client().delete(&row.s3_key).await; } } Ok(count) } pub async fn get_attachment_meta( &self, caller: Uuid, attachment_id: Uuid, ) -> Result { let row = attachments::Entity::find_by_id(attachment_id) .one(self.db().read()) .await? .ok_or(ChatError::NotFound)?; self.check_attachment_access(caller, &row).await?; Ok(AttachmentDto::from(row)) } pub async fn download_attachment( &self, caller: Uuid, attachment_id: Uuid, ) -> Result<(attachments::Model, Body), ChatError> { let row = attachments::Entity::find_by_id(attachment_id) .one(self.db().read()) .await? .ok_or(ChatError::NotFound)?; self.check_attachment_access(caller, &row).await?; if row.status == AttachmentStatus::Pending { return Err(ChatError::NotFound); } let stream = self.storage().client().get_stream(&row.s3_key).await?; let stream = futures::StreamExt::map(stream, |item| { item.map_err(|e| std::io::Error::other(e.to_string())) }); let body = Body::from_stream(stream); Ok((row, body)) } async fn check_attachment_access( &self, caller: Uuid, row: &attachments::Model, ) -> Result<(), ChatError> { if row.user_id == caller { return Ok(()); } if let Some(room_id) = row.room_id { // Mirror message visibility: anyone who can read the room can read // its attachments (public rooms do not require a membership row). if crate::share::lookup::require_room_readable(self, room_id, caller) .await .is_ok() { return Ok(()); } } Err(ChatError::Forbidden) } pub fn attachment_content_headers( row: &attachments::Model, ) -> Vec<(header::HeaderName, String)> { let mut headers = Vec::new(); let content_type = if is_unsafe_inline(&row.content_type) { "application/octet-stream".to_string() } else { row.content_type.clone() }; headers.push((header::CONTENT_TYPE, content_type)); headers.push(( header::HeaderName::from_static("x-content-type-options"), "nosniff".to_string(), )); let disposition = if is_inline_renderable(&row.content_type) { format!("inline; filename=\"{}\"", row.filename.replace('"', "_")) } else { format!( "attachment; filename=\"{}\"", row.filename.replace('"', "_") ) }; headers.push((header::CONTENT_DISPOSITION, disposition)); headers } }