teamcraft/services/requests/
attachment_files.rs1use super::*;
4
5pub fn sniff_attachment(bytes: &[u8]) -> Option<&'static str> {
9 if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
10 Some("image/png")
11 } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
12 Some("image/jpeg")
13 } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
14 Some("image/gif")
15 } else if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
16 Some("image/webp")
17 } else if bytes.starts_with(b"%PDF-") {
18 Some("application/pdf")
19 } else {
20 None
21 }
22}
23
24pub fn clean_filename(raw: &str) -> String {
26 let base = raw.rsplit(['/', '\\']).next().unwrap_or("");
27 let cleaned: String = base
28 .chars()
29 .filter(|c| !c.is_control() && !matches!(c, '"' | '<' | '>'))
30 .collect();
31 let cleaned = domain::truncate_chars(cleaned.trim(), 200);
32 if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
33 "attachment".into()
34 } else {
35 cleaned
36 }
37}
38
39pub async fn add_attachment(
42 pool: &PgPool,
43 vault: &Vault,
44 actor: &Actor,
45 id: Uuid,
46 filename: &str,
47 bytes: &[u8],
48) -> AppResult<RequestAttachment> {
49 if bytes.is_empty() {
50 return Err(AppError::BadRequest("The file is empty.".into()));
51 }
52 if bytes.len() > MAX_ATTACHMENT_BYTES {
53 return Err(AppError::PayloadTooLarge(format!(
54 "Attachments may be at most {} MiB.",
55 MAX_ATTACHMENT_BYTES / (1024 * 1024)
56 )));
57 }
58 let content_type = sniff_attachment(bytes).ok_or_else(|| {
59 AppError::BadRequest(
60 "Only images (PNG, JPEG, GIF, WebP) and PDF files can be attached.".into(),
61 )
62 })?;
63 let filename = clean_filename(filename);
64 let request = get(pool, actor, id).await?;
66 if !status_of(&request).is_open() {
67 return Err(not_now(&request));
68 }
69 let blob = vault.store_bytes(bytes).await?;
70 let mut tx = pool.begin().await?;
72 let request = lock(&mut tx, actor, id).await?;
73 if !status_of(&request).is_open() {
74 return Err(not_now(&request));
75 }
76 let count = sqlx::query_scalar::<_, i64>(
77 "SELECT COUNT(*) FROM change_request_attachments WHERE request_id = $1",
78 )
79 .bind(id)
80 .fetch_one(&mut *tx)
81 .await?;
82 if count >= MAX_ATTACHMENTS {
83 return Err(AppError::Conflict(format!(
84 "{} already has {MAX_ATTACHMENTS} attachments.",
85 request.number
86 )));
87 }
88 let attachment_id = Uuid::new_v4();
89 sqlx::query(
90 "INSERT INTO change_request_attachments (id, request_id, filename, content_type, size_bytes,
91 sha256, uploaded_by)
92 VALUES ($1, $2, $3, $4, $5, $6, $7)",
93 )
94 .bind(attachment_id)
95 .bind(id)
96 .bind(&filename)
97 .bind(content_type)
98 .bind(blob.size as i64)
99 .bind(&blob.sha256)
100 .bind(actor.id)
101 .execute(&mut *tx)
102 .await?;
103 touch(&mut tx, id).await?;
104 audit::record(
105 &mut tx,
106 Some(actor),
107 "change_request.attach",
108 "change_request",
109 Some(id),
110 format!("Attached {filename} to {}", request.number),
111 json!({ "attachment_id": attachment_id, "size": blob.size, "content_type": content_type }),
112 )
113 .await?;
114 tx.commit().await?;
115 attachment(pool, actor, attachment_id).await
116}
117
118pub async fn delete_attachment(
120 pool: &PgPool,
121 actor: &Actor,
122 attachment_id: Uuid,
123) -> AppResult<RequestAttachment> {
124 let att = attachment(pool, actor, attachment_id).await?;
125 let mut tx = pool.begin().await?;
126 let request = lock(&mut tx, actor, att.request_id).await?;
127 if att.uploaded_by != actor.id && !actor.is_admin() {
128 return Err(AppError::Forbidden(
129 "Only the uploader or an administrator can remove an attachment.".into(),
130 ));
131 }
132 if !status_of(&request).is_open() {
133 return Err(not_now(&request));
134 }
135 sqlx::query("DELETE FROM change_request_attachments WHERE id = $1")
136 .bind(attachment_id)
137 .execute(&mut *tx)
138 .await?;
139 touch(&mut tx, request.id).await?;
140 audit::record(
141 &mut tx,
142 Some(actor),
143 "change_request.detach",
144 "change_request",
145 Some(request.id),
146 format!("Removed {} from {}", att.filename, request.number),
147 json!({ "attachment_id": attachment_id }),
148 )
149 .await?;
150 tx.commit().await?;
151 Ok(att)
152}