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 pub async fn issue_direct(
263 uploads: &ObjectUploadService,
264 request: IssueObjectUpload,
265 ) -> Result<IssuedObjectUpload, ObjectUploadError> {
266 uploads.issue(request).await
267 }
268
269 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}