use actix_web::http::header; use actix_web::web::Bytes; use futures::stream::BoxStream; use imaging::layout::{ImageVariant, PreviewState}; use models::chat::attachments::{self, AttachmentStatus}; use sea_orm::sea_query::Expr; 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: i64, 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)) } /// Whether the attachment is an inline image, i.e. something worth deriving a /// preview from. Kept separate from [`is_inline_renderable`] so the preview /// machinery never touches video/audio/PDF. pub(crate) fn is_inline_image(content_type: &str) -> bool { let main = content_type.split(';').next().unwrap_or("").trim(); main.to_ascii_lowercase().starts_with("image/") } /// Content type for a streamed original, mirroring the safety rule applied to /// response headers: an unsafe inline type is served as a plain download. pub(crate) fn attachment_stream_content_type(row: &attachments::Model) -> String { if is_unsafe_inline(&row.content_type) { "application/octet-stream".to_string() } else { row.content_type.clone() } } 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: i64, cmd: CreateAttachmentCmd, ) -> Result { let max_size = self.settings.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.database.read()) .await?; if unattached >= self.settings.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.settings.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.media.client.as_ref().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.database.write()) .await?; let ttl = self.settings.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 .media .client .as_ref() .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: i64, attachment_id: Uuid, cmd: FinalizeAttachmentCmd, ) -> Result { let row = attachments::Entity::find_by_id(attachment_id) .one(self.database.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.media.client.as_ref().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.settings.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.clone()); 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.database.write()).await?; // Build the list-view derivative while the bytes are still hot. A // failure here must never fail the upload: the attachment is already // valid, and the read path falls back to the original. if is_inline_image(&stored_content_type) && let Err(error) = self.generate_attachment_preview(&updated).await { tracing::warn!(%error, attachment_id = %updated.id, "attachment preview generation failed"); } Ok(AttachmentDto::from(updated)) } /// Render and store the derivative for one uploaded attachment, recording /// the outcome (including "skipped") in `variants` so a read never repeats /// the decision. async fn generate_attachment_preview(&self, row: &attachments::Model) -> Result<(), ChatError> { let preview = self.media.preview(row, None).await; let Some(state) = preview else { tracing::debug!(attachment_id = %row.id, "attachment preview unavailable; serving the original"); return Ok(()); }; self.record_attachment_preview(row.id, row.variants.clone(), &state) .await } /// Persist which derivative (or skip reason) belongs to one attachment. async fn record_attachment_preview( &self, attachment_id: Uuid, mut variants: serde_json::Value, state: &PreviewState, ) -> Result<(), ChatError> { imaging::layout::preview_into_variants(&mut variants, state); attachments::Entity::update_many() .filter(attachments::Column::Id.eq(attachment_id)) .col_expr(attachments::Column::Variants, Expr::value(variants)) .exec(self.database.write()) .await?; Ok(()) } /// Remove both encodings of an attachment's derivative. Safe to call for any /// key: a non-image attachment has nothing stored under these names. async fn delete_attachment_previews(&self, original_key: &str) { let key_base = ::media::preview_key_base_of(original_key); for candidate in imaging::layout::preview_candidate_keys(&key_base) { if let Err(error) = self.media.client.as_ref().delete(&candidate).await { tracing::warn!(%error, key = %candidate, "failed to delete attachment preview"); } } } /// Locator of the representation to stream: the derivative when it exists, /// the original otherwise. Legacy rows (finalized before previews existed) /// are healed here on first demand. 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.database.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.database.read()) .await .unwrap_or(1); let _ = attachments::Entity::delete_by_id(row.id) .exec(self.database.write()) .await; if shared == 0 { let _ = self.media.client.as_ref().delete(&row.s3_key).await; self.delete_attachment_previews(&row.s3_key).await; } } Ok(count) } pub async fn get_attachment_meta( &self, caller: i64, attachment_id: Uuid, ) -> Result { let row = attachments::Entity::find_by_id(attachment_id) .one(self.database.read()) .await? .ok_or(ChatError::NotFound)?; self.check_attachment_access(caller, &row).await?; Ok(AttachmentDto::from(row)) } /// Stream one representation of an attachment. /// /// `ImageVariant::Original` is what the download link and the lightbox use; /// `ImageVariant::Preview` is what the transcript grid loads. pub async fn download_attachment( &self, caller: i64, attachment_id: Uuid, variant: ImageVariant, ) -> Result< ( attachments::Model, BoxStream<'static, Result>, String, ), ChatError, > { let row = attachments::Entity::find_by_id(attachment_id) .one(self.database.read()) .await? .ok_or(ChatError::NotFound)?; self.check_attachment_access(caller, &row).await?; if row.status == AttachmentStatus::Pending { return Err(ChatError::NotFound); } let (key, content_type) = self.media.attachment_variant_locator(&row, variant).await; let stream = self.media.client.as_ref().get_stream(&key).await?; let body = Box::pin(futures::StreamExt::map(stream, |item| { item.map(Bytes::from) .map_err(|e| std::io::Error::other(e.to_string())) })) as BoxStream<'static, Result>; Ok((row, body, content_type)) } async fn check_attachment_access( &self, caller: i64, 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) } /// Response headers for one streamed representation. `content_type` is what /// is actually being served (the derivative may be JPEG where the original /// was PNG), so it is passed in rather than re-read from the row. pub fn attachment_content_headers( row: &attachments::Model, content_type: String, ) -> Vec<(header::HeaderName, String)> { let mut headers = Vec::new(); 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 } }