Skip to main content

minco_interaction/
attachment.rs

1use chrono::{DateTime, Utc};
2use minco_plugin_object_storage::{
3    IssueObjectUpload, IssuedObjectUpload, ObjectKey, ObjectStoreError, ObjectStoreService,
4    ObjectUploadError, ObjectUploadService, PendingObjectUpload, PutObject, VerifiedObjectUpload,
5};
6use serde::{Deserialize, Serialize};
7use std::{
8    collections::{BTreeMap, BTreeSet},
9    fmt,
10};
11use uuid::Uuid;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum AttachmentKind {
16    Screenshot,
17    Audio,
18    File,
19}
20
21#[derive(Clone, PartialEq, Eq)]
22pub struct AttachmentUpload {
23    pub kind: AttachmentKind,
24    pub file_name: String,
25    pub content_type: String,
26    pub bytes: Vec<u8>,
27}
28
29impl fmt::Debug for AttachmentUpload {
30    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31        formatter
32            .debug_struct("AttachmentUpload")
33            .field("kind", &self.kind)
34            .field("file_name", &"[REDACTED]")
35            .field("content_type", &self.content_type)
36            .field("size_bytes", &self.bytes.len())
37            .finish()
38    }
39}
40
41#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct AttachmentMetadata {
43    pub id: Uuid,
44    pub kind: AttachmentKind,
45    pub object_key: ObjectKey,
46    pub file_name: String,
47    pub content_type: String,
48    pub size_bytes: u64,
49    pub sha256: String,
50    pub created_at: DateTime<Utc>,
51}
52
53impl fmt::Debug for AttachmentMetadata {
54    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
55        formatter
56            .debug_struct("AttachmentMetadata")
57            .field("id", &self.id)
58            .field("kind", &self.kind)
59            .field("object_key", &"[REDACTED]")
60            .field("file_name", &"[REDACTED]")
61            .field("content_type", &self.content_type)
62            .field("size_bytes", &self.size_bytes)
63            .field("sha256", &self.sha256)
64            .field("created_at", &self.created_at)
65            .finish()
66    }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70pub struct AttachmentLimits {
71    pub count: usize,
72    pub screenshot_bytes: u64,
73    pub audio_bytes: u64,
74    pub file_bytes: u64,
75    pub aggregate_bytes: u64,
76}
77
78impl AttachmentLimits {
79    #[must_use]
80    pub const fn maximum_for(self, kind: AttachmentKind) -> u64 {
81        match kind {
82            AttachmentKind::Screenshot => self.screenshot_bytes,
83            AttachmentKind::Audio => self.audio_bytes,
84            AttachmentKind::File => self.file_bytes,
85        }
86    }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct AttachmentPolicy {
91    limits: AttachmentLimits,
92    allowed_content_types: BTreeMap<AttachmentKind, BTreeSet<String>>,
93}
94
95impl AttachmentPolicy {
96    pub fn new<I, S>(limits: AttachmentLimits, content_types: I) -> Result<Self, AttachmentError>
97    where
98        I: IntoIterator<Item = (AttachmentKind, S)>,
99        S: AsRef<str>,
100    {
101        if limits.count > 64
102            || limits.aggregate_bytes == 0
103            || [
104                limits.screenshot_bytes,
105                limits.audio_bytes,
106                limits.file_bytes,
107            ]
108            .contains(&0)
109        {
110            return Err(AttachmentError::InvalidLimits);
111        }
112        let mut allowed_content_types = BTreeMap::<AttachmentKind, BTreeSet<String>>::new();
113        for (kind, value) in content_types {
114            let value = normalize_content_type(value.as_ref())?;
115            allowed_content_types.entry(kind).or_default().insert(value);
116        }
117        if [
118            AttachmentKind::Screenshot,
119            AttachmentKind::Audio,
120            AttachmentKind::File,
121        ]
122        .iter()
123        .any(|kind| {
124            allowed_content_types
125                .get(kind)
126                .is_none_or(BTreeSet::is_empty)
127        }) {
128            return Err(AttachmentError::EmptyContentTypeAllowlist);
129        }
130        Ok(Self {
131            limits,
132            allowed_content_types,
133        })
134    }
135
136    #[must_use]
137    pub const fn limits(&self) -> AttachmentLimits {
138        self.limits
139    }
140
141    pub fn validate_upload(
142        &self,
143        upload: &AttachmentUpload,
144    ) -> Result<ValidatedAttachment, AttachmentError> {
145        let size_bytes = u64::try_from(upload.bytes.len())
146            .map_err(|_| AttachmentError::AggregateSizeOverflow)?;
147        if size_bytes == 0 {
148            return Err(AttachmentError::EmptyAttachment);
149        }
150        let maximum = self.limits.maximum_for(upload.kind);
151        if size_bytes > maximum {
152            return Err(AttachmentError::AttachmentTooLarge {
153                kind: upload.kind,
154                actual: size_bytes,
155                maximum,
156            });
157        }
158        let content_type = normalize_content_type(&upload.content_type)?;
159        if !self
160            .allowed_content_types
161            .get(&upload.kind)
162            .is_some_and(|allowed| allowed.contains(&content_type))
163        {
164            return Err(AttachmentError::UnsupportedContentType {
165                kind: upload.kind,
166                content_type,
167            });
168        }
169        Ok(ValidatedAttachment {
170            kind: upload.kind,
171            file_name: safe_presentation_file_name(&upload.file_name)?,
172            content_type,
173            size_bytes,
174        })
175    }
176
177    pub fn validate_batch(
178        &self,
179        uploads: &[AttachmentUpload],
180    ) -> Result<Vec<ValidatedAttachment>, AttachmentError> {
181        if uploads.len() > self.limits.count {
182            return Err(AttachmentError::TooManyAttachments {
183                actual: uploads.len(),
184                maximum: self.limits.count,
185            });
186        }
187        let mut aggregate = 0_u64;
188        let mut validated = Vec::with_capacity(uploads.len());
189        for upload in uploads {
190            let item = self.validate_upload(upload)?;
191            aggregate = aggregate
192                .checked_add(item.size_bytes)
193                .ok_or(AttachmentError::AggregateSizeOverflow)?;
194            if aggregate > self.limits.aggregate_bytes {
195                return Err(AttachmentError::AggregateTooLarge {
196                    actual: aggregate,
197                    maximum: self.limits.aggregate_bytes,
198                });
199            }
200            validated.push(item);
201        }
202        Ok(validated)
203    }
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct ValidatedAttachment {
208    pub kind: AttachmentKind,
209    pub file_name: String,
210    pub content_type: String,
211    pub size_bytes: u64,
212}
213
214#[derive(Debug, Clone)]
215pub struct AttachmentService {
216    objects: ObjectStoreService,
217    policy: AttachmentPolicy,
218}
219
220impl AttachmentService {
221    pub const fn new(objects: ObjectStoreService, policy: AttachmentPolicy) -> Self {
222        Self { objects, policy }
223    }
224
225    pub async fn store_small(
226        &self,
227        namespace: &str,
228        owner_id: &str,
229        upload: &AttachmentUpload,
230        mut attributes: BTreeMap<String, String>,
231    ) -> Result<AttachmentMetadata, AttachmentError> {
232        let validated = self.policy.validate_upload(upload)?;
233        validate_key_segment(namespace)?;
234        validate_key_segment(owner_id)?;
235        let id = Uuid::now_v7();
236        let object_key = ObjectKey::parse(format!("{namespace}/{owner_id}/{id}"))?;
237        attributes
238            .entry("attachment_id".into())
239            .or_insert_with(|| id.to_string());
240        let metadata = self
241            .objects
242            .put(PutObject {
243                key: object_key.clone(),
244                bytes: upload.bytes.clone(),
245                content_type: validated.content_type.clone(),
246                attributes,
247            })
248            .await?;
249        Ok(AttachmentMetadata {
250            id,
251            kind: validated.kind,
252            object_key,
253            file_name: validated.file_name,
254            content_type: validated.content_type,
255            size_bytes: metadata.size_bytes,
256            sha256: metadata.sha256,
257            created_at: metadata.created_at,
258        })
259    }
260
261    /// Delegates capability issuance to the existing object-storage service.
262    pub async fn issue_direct(
263        uploads: &ObjectUploadService,
264        request: IssueObjectUpload,
265    ) -> Result<IssuedObjectUpload, ObjectUploadError> {
266        uploads.issue(request).await
267    }
268
269    /// Delegates provider metadata verification to the existing service.
270    pub async fn verify_direct(
271        uploads: &ObjectUploadService,
272        pending: &PendingObjectUpload,
273    ) -> Result<VerifiedObjectUpload, ObjectUploadError> {
274        uploads.verify(pending).await
275    }
276}
277
278pub fn safe_presentation_file_name(value: &str) -> Result<String, AttachmentError> {
279    if value.trim().is_empty() || value.chars().any(char::is_control) {
280        return Err(AttachmentError::InvalidFileName);
281    }
282    let safe = value
283        .chars()
284        .map(|character| {
285            if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') {
286                character
287            } else {
288                '-'
289            }
290        })
291        .take(160)
292        .collect::<String>();
293    if safe.trim_matches(['-', '.']).is_empty() {
294        Err(AttachmentError::InvalidFileName)
295    } else {
296        Ok(safe)
297    }
298}
299
300fn normalize_content_type(value: &str) -> Result<String, AttachmentError> {
301    let value = value.trim().to_ascii_lowercase();
302    if value.is_empty()
303        || value.len() > 160
304        || value.contains(';')
305        || value.chars().any(char::is_control)
306        || !value.contains('/')
307    {
308        return Err(AttachmentError::InvalidContentType);
309    }
310    Ok(value)
311}
312
313fn validate_key_segment(value: &str) -> Result<(), AttachmentError> {
314    if value.is_empty()
315        || value.len() > 200
316        || value.contains('/')
317        || matches!(value, "." | "..")
318        || value.chars().any(char::is_control)
319    {
320        Err(AttachmentError::InvalidObjectScope)
321    } else {
322        Ok(())
323    }
324}
325
326#[derive(Debug, thiserror::Error)]
327pub enum AttachmentError {
328    #[error("attachment limits are invalid")]
329    InvalidLimits,
330    #[error("every attachment kind requires an exact non-empty content-type allowlist")]
331    EmptyContentTypeAllowlist,
332    #[error("attachment is empty")]
333    EmptyAttachment,
334    #[error("attachment file name is invalid")]
335    InvalidFileName,
336    #[error("attachment content type is invalid")]
337    InvalidContentType,
338    #[error("content type {content_type:?} is not allowed for {kind:?}")]
339    UnsupportedContentType {
340        kind: AttachmentKind,
341        content_type: String,
342    },
343    #[error("attachment is {actual} bytes; maximum for {kind:?} is {maximum}")]
344    AttachmentTooLarge {
345        kind: AttachmentKind,
346        actual: u64,
347        maximum: u64,
348    },
349    #[error("attachment count is {actual}; maximum is {maximum}")]
350    TooManyAttachments { actual: usize, maximum: usize },
351    #[error("aggregate attachment size overflowed")]
352    AggregateSizeOverflow,
353    #[error("aggregate attachment size is {actual}; maximum is {maximum}")]
354    AggregateTooLarge { actual: u64, maximum: u64 },
355    #[error("attachment object scope is invalid")]
356    InvalidObjectScope,
357    #[error(transparent)]
358    ObjectStore(#[from] ObjectStoreError),
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    fn policy() -> AttachmentPolicy {
366        AttachmentPolicy::new(
367            AttachmentLimits {
368                count: 2,
369                screenshot_bytes: 10,
370                audio_bytes: 20,
371                file_bytes: 30,
372                aggregate_bytes: 30,
373            },
374            [
375                (AttachmentKind::Screenshot, "image/png"),
376                (AttachmentKind::Audio, "audio/webm"),
377                (AttachmentKind::File, "application/pdf"),
378            ],
379        )
380        .unwrap()
381    }
382
383    #[test]
384    fn validates_exact_types_sizes_and_aggregate() {
385        let upload = AttachmentUpload {
386            kind: AttachmentKind::Screenshot,
387            file_name: "screen shot.png".into(),
388            content_type: "IMAGE/PNG".into(),
389            bytes: vec![1; 10],
390        };
391        assert_eq!(
392            policy().validate_upload(&upload).unwrap().file_name,
393            "screen-shot.png"
394        );
395        let wrong = AttachmentUpload {
396            content_type: "image/jpeg".into(),
397            ..upload.clone()
398        };
399        assert!(matches!(
400            policy().validate_upload(&wrong),
401            Err(AttachmentError::UnsupportedContentType { .. })
402        ));
403        let aggregate = [
404            upload,
405            AttachmentUpload {
406                kind: AttachmentKind::Audio,
407                file_name: "voice.webm".into(),
408                content_type: "audio/webm".into(),
409                bytes: vec![1; 21],
410            },
411        ];
412        assert!(matches!(
413            policy().validate_batch(&aggregate),
414            Err(AttachmentError::AttachmentTooLarge { .. })
415        ));
416        let debug = format!("{:?}", aggregate[0]);
417        assert!(!debug.contains("screen shot.png"));
418        assert!(!debug.contains("[1, 1"));
419    }
420}