//! Issue attachments: the same presigned-upload shape the chat module uses //! (`create intent → PUT bytes → finalize`), but the rows belong to an issue. //! //! A row starts unbound (`issue_id = NULL`) so the create-issue dialog can //! upload before the issue exists; submitting the issue links it. Only the //! uploader may finalize or delete, and reading a linked file follows the //! issue's own visibility rules. use actix_web::http::header; use actix_web::web::Bytes; use futures::stream::BoxStream; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set, }; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::ToSchema; use uuid::Uuid; use models::issue::issue_attachment::{self, IssueAttachmentStatus}; use crate::errors::IssueError; use crate::services::service::IssueService; use crate::share::lookup::{issue_context_by_id, space_context}; const ATTACHMENT_PREFIX: &str = "issue-attachments"; /// Content types browsers may execute inline; never accepted, never served /// back as themselves (mirrors the chat attachment rule). const UNSAFE_INLINE_TYPES: &[&str] = &[ "text/html", "application/xhtml+xml", "image/svg+xml", "application/xml", "text/xml", "application/javascript", "text/javascript", ]; 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") } 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 } } fn storage_failure(err: impl std::fmt::Display) -> IssueError { IssueError::Internal(format!("storage error: {err}")) } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct IssueAttachmentDto { pub id: Uuid, /// `None` until the draft is submitted with this attachment attached. pub issue_id: Option, pub uploader_id: i64, pub filename: String, pub content_type: String, pub size: i64, pub status: IssueAttachmentStatus, pub created_at: OffsetDateTime, } impl From for IssueAttachmentDto { fn from(row: issue_attachment::Model) -> Self { Self { id: row.id, issue_id: row.issue_id, uploader_id: row.uploader_id, filename: row.filename, content_type: row.content_type, size: row.size, status: row.status, created_at: row.created_at, } } } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateIssueAttachmentCmd { /// Checked for membership so unbound uploads are still space-scoped. pub space_name: String, pub filename: String, pub content_type: String, pub size: i64, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct IssueUploadIntentDto { pub id: Uuid, pub upload_url: String, pub expires_secs: u64, } impl IssueService { #[tracing::instrument(name = "issues.create_issue_attachment", skip_all, fields(caller = %caller))] pub async fn create_issue_attachment( &self, caller: i64, cmd: CreateIssueAttachmentCmd, ) -> Result { let max_size = self.settings.issues.attachment.max_size; if cmd.size <= 0 || cmd.size > max_size { return Err(IssueError::BadRequest(format!( "file size must be within 1..{max_size} bytes" ))); } if cmd.filename.trim().is_empty() || cmd.filename.chars().count() > 255 { return Err(IssueError::BadRequest("invalid filename".to_string())); } if cmd.content_type.trim().is_empty() || is_unsafe_inline(&cmd.content_type) { return Err(IssueError::BadRequest( "unsupported or unsafe content_type".to_string(), )); } let space = space_context(self, caller, &cmd.space_name).await?; let pending = issue_attachment::Entity::find() .filter(issue_attachment::Column::UploaderId.eq(caller)) .filter(issue_attachment::Column::IssueId.is_null()) .count(self.database.read()) .await?; if pending >= self.settings.issues.attachment.max_pending_per_user { return Err(IssueError::RateLimited(format!( "too many unused attachments (max {})", self.settings.issues.attachment.max_pending_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}", space.id() ); let bucket = self.storage.bucket_name().to_string(); issue_attachment::ActiveModel { id: Set(id), issue_id: Set(None), uploader_id: Set(caller), filename: Set(filename), content_type: Set(cmd.content_type.clone()), size: Set(cmd.size), bucket: Set(bucket), s3_key: Set(key.clone()), status: Set(IssueAttachmentStatus::Pending), created_at: Set(now), updated_at: Set(now), } .insert(self.database.write()) .await?; let ttl = self .settings .issues .attachment .presign_ttl_secs .min(604_800); // The storage client signs with the `http` 0.2 header map (see chat's // attachment pipeline); the workspace `http` 1.x line is separate. 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 .presign_put_with_headers(&key, ttl as u32, put_headers) .map_err(storage_failure)?; Ok(IssueUploadIntentDto { id, upload_url, expires_secs: ttl, }) } #[tracing::instrument(name = "issues.finalize_issue_attachment", skip_all, fields(caller = %caller, attachment_id = %attachment_id))] pub async fn finalize_issue_attachment( &self, caller: i64, attachment_id: Uuid, ) -> Result { let row = issue_attachment::Entity::find_by_id(attachment_id) .one(self.database.write()) .await? .filter(|row| row.uploader_id == caller) .ok_or(IssueError::NotFound)?; if row.status != IssueAttachmentStatus::Pending { return Err(IssueError::Conflict( "attachment already finalized".to_string(), )); } let head = self .storage .head(&row.s3_key) .await .map_err(storage_failure)? .ok_or_else(|| IssueError::BadRequest("object not uploaded yet".to_string()))?; let (actual_size, actual_content_type) = head; let max_size = self.settings.issues.attachment.max_size; if actual_size == 0 || actual_size > max_size as u64 { return Err(IssueError::BadRequest(format!( "uploaded object size {actual_size} outside 1..{max_size} bytes" ))); } let content_type = if actual_content_type.is_empty() { row.content_type.clone() } else { actual_content_type }; if is_unsafe_inline(&content_type) { return Err(IssueError::BadRequest( "uploaded object has an unsafe content_type".to_string(), )); } let mut active = row.into_active_model(); active.size = Set(actual_size as i64); active.content_type = Set(content_type); active.status = Set(IssueAttachmentStatus::Uploaded); active.updated_at = Set(OffsetDateTime::now_utc()); Ok(IssueAttachmentDto::from( active.update(self.database.write()).await?, )) } /// Removes the row and, best effort, the stored object. #[tracing::instrument(name = "issues.delete_issue_attachment", skip_all, fields(caller = %caller, attachment_id = %attachment_id))] pub async fn delete_issue_attachment( &self, caller: i64, attachment_id: Uuid, ) -> Result<(), IssueError> { let row = issue_attachment::Entity::find_by_id(attachment_id) .one(self.database.write()) .await? .ok_or(IssueError::NotFound)?; if row.uploader_id != caller { return Err(IssueError::NotFound); } if row.status == IssueAttachmentStatus::Uploaded && let Err(err) = self.storage.delete(&row.s3_key).await { tracing::warn!(error = %err, key = %row.s3_key, "failed to delete issue attachment object"); } issue_attachment::Entity::delete_by_id(row.id) .exec(self.database.write()) .await?; Ok(()) } pub async fn list_issue_attachments( &self, caller: i64, space_name: &str, board_id: Uuid, issue_ref: &str, ) -> Result, IssueError> { let issue_id = self .resolve_issue_ref(caller, space_name, board_id, issue_ref) .await?; let rows = issue_attachment::Entity::find() .filter(issue_attachment::Column::IssueId.eq(issue_id)) .filter(issue_attachment::Column::Status.eq(IssueAttachmentStatus::Uploaded)) .order_by_asc(issue_attachment::Column::CreatedAt) .all(self.database.read()) .await?; Ok(rows.into_iter().map(IssueAttachmentDto::from).collect()) } /// Streams one stored object; the caller is either the uploader or someone /// who can read the issue the file is attached to. pub async fn download_issue_attachment( &self, caller: i64, attachment_id: Uuid, ) -> Result< ( issue_attachment::Model, BoxStream<'static, Result>, String, ), IssueError, > { let row = issue_attachment::Entity::find_by_id(attachment_id) .one(self.database.read()) .await? .ok_or(IssueError::NotFound)?; if row.uploader_id != caller { let issue_id = row.issue_id.ok_or(IssueError::NotFound)?; issue_context_by_id(self, caller, issue_id).await?; } if row.status != IssueAttachmentStatus::Uploaded { return Err(IssueError::NotFound); } let stream = self .storage .get_stream(&row.s3_key) .await .map_err(storage_failure)?; 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>; let content_type = if is_unsafe_inline(&row.content_type) { "application/octet-stream".to_string() } else { row.content_type.clone() }; Ok((row, body, content_type)) } /// Response headers for a streamed attachment: no sniffing, and unsafe /// types always download instead of rendering inline. pub fn issue_attachment_content_headers( row: &issue_attachment::Model, content_type: String, ) -> Vec<(header::HeaderName, String)> { let disposition = if is_inline_renderable(&row.content_type) { format!("inline; filename=\"{}\"", row.filename.replace('"', "_")) } else { format!( "attachment; filename=\"{}\"", row.filename.replace('"', "_") ) }; vec![ (header::CONTENT_TYPE, content_type), ( header::HeaderName::from_static("x-content-type-options"), "nosniff".to_string(), ), (header::CONTENT_DISPOSITION, disposition), ] } /// Links freshly uploaded drafts to the issue being created. Runs inside /// the create transaction so a rejected attachment rolls the issue back. pub(crate) async fn link_issue_attachments( txn: &impl sea_orm::ConnectionTrait, caller: i64, issue_id: Uuid, attachment_ids: &[Uuid], now: OffsetDateTime, ) -> Result<(), IssueError> { for id in attachment_ids { let updated = issue_attachment::Entity::update_many() .filter(issue_attachment::Column::Id.eq(*id)) .filter(issue_attachment::Column::UploaderId.eq(caller)) .filter(issue_attachment::Column::Status.eq(IssueAttachmentStatus::Uploaded)) .filter(issue_attachment::Column::IssueId.is_null()) .col_expr( issue_attachment::Column::IssueId, sea_orm::sea_query::Expr::value(Some(issue_id)), ) .col_expr( issue_attachment::Column::UpdatedAt, sea_orm::sea_query::Expr::value(now), ) .exec(txn) .await?; if updated.rows_affected != 1 { return Err(IssueError::BadRequest(format!( "attachment {id} is not an uploaded, unused attachment of yours" ))); } } Ok(()) } /// Attachment rows of one issue, for the delete cascade (`Select`-free). pub(crate) async fn issue_attachment_keys( txn: &impl sea_orm::ConnectionTrait, issue_id: Uuid, ) -> Result, IssueError> { let keys = issue_attachment::Entity::find() .select_only() .column(issue_attachment::Column::S3Key) .filter(issue_attachment::Column::IssueId.eq(issue_id)) .into_tuple::() .all(txn) .await?; Ok(keys) } }