eeff292
//! 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<Uuid>,
pub uploader_id: i64,
pub filename: String,
pub content_type: String,
pub size: i64,
pub status: IssueAttachmentStatus,
pub created_at: OffsetDateTime,
}
impl From<issue_attachment::Model> 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<IssueUploadIntentDto, IssueError> {
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<IssueAttachmentDto, IssueError> {
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<Vec<IssueAttachmentDto>, 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<Bytes, std::io::Error>>,
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<Bytes, std::io::Error>>;
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<Vec<String>, IssueError> {
let keys = issue_attachment::Entity::find()
.select_only()
.column(issue_attachment::Column::S3Key)
.filter(issue_attachment::Column::IssueId.eq(issue_id))
.into_tuple::<String>()
.all(txn)
.await?;
Ok(keys)
}
}