Skip to main content

teamcraft/services/requests/
attachment_files.rs

1//! Attachments (screenshots, mark-ups, PDF reports).
2
3use super::*;
4
5/// The content type of an allowed attachment, decided by its first bytes
6/// (never by the name or the type the client claims): PNG, JPEG, GIF, WebP
7/// or PDF. SVG and everything else are refused.
8pub 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
24/// A safe display name: the last path component without control characters.
25pub 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
39/// Attaches an image or PDF to an open request. Everybody who may comment
40/// may attach.
41pub 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    // check before the bytes go into the vault …
65    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    // … and again under the lock
71    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
118/// Removes an attachment (its uploader or an admin, while the request is open).
119pub 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}