Skip to main content

shaperail_runtime/storage/
upload.rs

1use super::backend::{FileMetadata, StorageBackend, StorageError};
2use std::sync::Arc;
3
4/// Parses a human-readable size string (e.g., "5mb", "100kb") into bytes.
5pub fn parse_max_size(size_str: &str) -> Result<u64, StorageError> {
6    let s = size_str.trim().to_lowercase();
7    let (num_part, multiplier) = if let Some(n) = s.strip_suffix("gb") {
8        (n, 1024 * 1024 * 1024)
9    } else if let Some(n) = s.strip_suffix("mb") {
10        (n, 1024 * 1024)
11    } else if let Some(n) = s.strip_suffix("kb") {
12        (n, 1024)
13    } else if let Some(n) = s.strip_suffix('b') {
14        (n, 1)
15    } else {
16        // Assume bytes if no suffix
17        (s.as_str(), 1)
18    };
19
20    num_part
21        .trim()
22        .parse::<u64>()
23        .map(|n| n * multiplier)
24        .map_err(|_| StorageError::Backend(format!("Invalid size format: '{size_str}'")))
25}
26
27/// Validates a MIME type against an allowed list of extensions/types.
28///
29/// The `allowed` list can contain:
30/// - Extensions: "jpg", "png", "pdf"
31/// - MIME types: "image/png", "application/pdf"
32/// - Wildcards: "image/*"
33pub fn validate_mime_type(mime_type: &str, allowed: &[String]) -> Result<(), StorageError> {
34    if allowed.is_empty() {
35        return Ok(());
36    }
37
38    for pattern in allowed {
39        // Direct MIME type match
40        if pattern == mime_type {
41            return Ok(());
42        }
43        // Wildcard match (e.g., "image/*")
44        if let Some(prefix) = pattern.strip_suffix("/*") {
45            if mime_type.starts_with(prefix) {
46                return Ok(());
47            }
48        }
49        // Extension-based match
50        let ext_mime = extension_to_mime(pattern);
51        if ext_mime == mime_type {
52            return Ok(());
53        }
54    }
55
56    Err(StorageError::InvalidMimeType {
57        mime_type: mime_type.to_string(),
58        allowed: allowed.to_vec(),
59    })
60}
61
62/// Maps common file extensions to MIME types.
63fn extension_to_mime(ext: &str) -> &str {
64    match ext.to_lowercase().as_str() {
65        "jpg" | "jpeg" => "image/jpeg",
66        "png" => "image/png",
67        "gif" => "image/gif",
68        "webp" => "image/webp",
69        "svg" => "image/svg+xml",
70        "pdf" => "application/pdf",
71        "json" => "application/json",
72        "csv" => "text/csv",
73        "txt" => "text/plain",
74        "zip" => "application/zip",
75        "mp4" => "video/mp4",
76        "mp3" => "audio/mpeg",
77        _ => "",
78    }
79}
80
81/// Handles file uploads with validation, storage, and optional image processing.
82pub struct UploadHandler {
83    backend: Arc<StorageBackend>,
84}
85
86impl UploadHandler {
87    /// Create a new upload handler with the given storage backend.
88    pub fn new(backend: Arc<StorageBackend>) -> Self {
89        Self { backend }
90    }
91
92    /// Process an upload: validate size and MIME type, store the file.
93    ///
94    /// Returns the file metadata on success.
95    pub async fn process_upload(
96        &self,
97        filename: &str,
98        data: &[u8],
99        mime_type: &str,
100        max_size: Option<u64>,
101        allowed_types: Option<&[String]>,
102        storage_prefix: &str,
103    ) -> Result<FileMetadata, StorageError> {
104        // Validate file size
105        if let Some(max) = max_size {
106            if data.len() as u64 > max {
107                return Err(StorageError::FileTooLarge {
108                    max_bytes: max,
109                    actual_bytes: data.len() as u64,
110                });
111            }
112        }
113
114        // Validate MIME type
115        if let Some(types) = allowed_types {
116            validate_mime_type(mime_type, types)?;
117        }
118
119        // Generate storage path: prefix/uuid-filename
120        let file_id = uuid::Uuid::new_v4();
121        let safe_filename = sanitize_filename(filename);
122        let path = format!("{storage_prefix}/{file_id}-{safe_filename}");
123
124        let mut metadata = self.backend.upload(&path, data, mime_type).await?;
125        metadata.filename = filename.to_string();
126        Ok(metadata)
127    }
128
129    /// Generate a thumbnail for an image file.
130    ///
131    /// Returns the metadata of the stored thumbnail.
132    pub async fn create_thumbnail(
133        &self,
134        original_path: &str,
135        max_width: u32,
136        max_height: u32,
137        storage_prefix: &str,
138    ) -> Result<FileMetadata, StorageError> {
139        let data = self.backend.download(original_path).await?;
140
141        let img = image::load_from_memory(&data)
142            .map_err(|e| StorageError::Backend(format!("Failed to decode image: {e}")))?;
143
144        let thumbnail = img.thumbnail(max_width, max_height);
145
146        let mut buf = std::io::Cursor::new(Vec::new());
147        thumbnail
148            .write_to(&mut buf, image::ImageFormat::Png)
149            .map_err(|e| StorageError::Backend(format!("Failed to encode thumbnail: {e}")))?;
150        let thumb_data = buf.into_inner();
151
152        let file_id = uuid::Uuid::new_v4();
153        let thumb_path = format!("{storage_prefix}/thumb-{file_id}.png");
154
155        self.backend
156            .upload(&thumb_path, &thumb_data, "image/png")
157            .await
158    }
159
160    /// Resize an image to fit within the given dimensions.
161    pub async fn resize_image(
162        &self,
163        original_path: &str,
164        width: u32,
165        height: u32,
166        storage_prefix: &str,
167    ) -> Result<FileMetadata, StorageError> {
168        let data = self.backend.download(original_path).await?;
169
170        let img = image::load_from_memory(&data)
171            .map_err(|e| StorageError::Backend(format!("Failed to decode image: {e}")))?;
172
173        let resized = img.resize(width, height, image::imageops::FilterType::Lanczos3);
174
175        let mut buf = std::io::Cursor::new(Vec::new());
176        resized
177            .write_to(&mut buf, image::ImageFormat::Png)
178            .map_err(|e| StorageError::Backend(format!("Failed to encode resized image: {e}")))?;
179        let resized_data = buf.into_inner();
180
181        let file_id = uuid::Uuid::new_v4();
182        let resized_path = format!("{storage_prefix}/resized-{file_id}.png");
183
184        self.backend
185            .upload(&resized_path, &resized_data, "image/png")
186            .await
187    }
188
189    /// Generate a time-limited signed URL for a file.
190    pub async fn signed_url(&self, path: &str, expires_secs: u64) -> Result<String, StorageError> {
191        self.backend.signed_url(path, expires_secs).await
192    }
193
194    /// Delete a file from storage (used for orphan cleanup).
195    pub async fn delete(&self, path: &str) -> Result<(), StorageError> {
196        self.backend.delete(path).await
197    }
198
199    /// Returns a reference to the underlying storage backend.
200    pub fn backend(&self) -> &StorageBackend {
201        &self.backend
202    }
203}
204
205/// Sanitize a filename to prevent directory traversal and other issues.
206fn sanitize_filename(name: &str) -> String {
207    name.chars()
208        .map(|c| {
209            if c.is_alphanumeric() || c == '.' || c == '-' || c == '_' {
210                c
211            } else {
212                '_'
213            }
214        })
215        .collect::<String>()
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn parse_size_mb() {
224        assert_eq!(parse_max_size("5mb").unwrap(), 5 * 1024 * 1024);
225    }
226
227    #[test]
228    fn parse_size_kb() {
229        assert_eq!(parse_max_size("100kb").unwrap(), 100 * 1024);
230    }
231
232    #[test]
233    fn parse_size_gb() {
234        assert_eq!(parse_max_size("1gb").unwrap(), 1024 * 1024 * 1024);
235    }
236
237    #[test]
238    fn parse_size_bytes() {
239        assert_eq!(parse_max_size("1024b").unwrap(), 1024);
240        assert_eq!(parse_max_size("512").unwrap(), 512);
241    }
242
243    #[test]
244    fn parse_size_invalid() {
245        assert!(parse_max_size("abc").is_err());
246    }
247
248    #[test]
249    fn validate_mime_extension_match() {
250        let allowed = vec!["jpg".to_string(), "png".to_string()];
251        assert!(validate_mime_type("image/jpeg", &allowed).is_ok());
252        assert!(validate_mime_type("image/png", &allowed).is_ok());
253        assert!(validate_mime_type("application/pdf", &allowed).is_err());
254    }
255
256    #[test]
257    fn validate_mime_wildcard() {
258        let allowed = vec!["image/*".to_string()];
259        assert!(validate_mime_type("image/jpeg", &allowed).is_ok());
260        assert!(validate_mime_type("image/png", &allowed).is_ok());
261        assert!(validate_mime_type("application/pdf", &allowed).is_err());
262    }
263
264    #[test]
265    fn validate_mime_direct_match() {
266        let allowed = vec!["application/pdf".to_string()];
267        assert!(validate_mime_type("application/pdf", &allowed).is_ok());
268        assert!(validate_mime_type("image/png", &allowed).is_err());
269    }
270
271    #[test]
272    fn validate_mime_empty_allows_all() {
273        assert!(validate_mime_type("anything/here", &[]).is_ok());
274    }
275
276    #[test]
277    fn sanitize_filename_safe() {
278        assert_eq!(sanitize_filename("file.txt"), "file.txt");
279        assert_eq!(sanitize_filename("my-file_v2.jpg"), "my-file_v2.jpg");
280    }
281
282    #[test]
283    fn sanitize_filename_unsafe() {
284        assert_eq!(
285            sanitize_filename("../../../etc/passwd"),
286            ".._.._.._etc_passwd"
287        );
288        assert_eq!(sanitize_filename("file name.txt"), "file_name.txt");
289    }
290
291    #[tokio::test]
292    async fn upload_handler_process() {
293        let dir = tempfile::TempDir::new().unwrap();
294        let local = super::super::LocalStorage::new(dir.path().to_path_buf());
295        let backend = Arc::new(StorageBackend::Local(local));
296        let handler = UploadHandler::new(backend);
297
298        let meta = handler
299            .process_upload(
300                "test.txt",
301                b"hello world",
302                "text/plain",
303                Some(1024 * 1024),
304                None,
305                "uploads",
306            )
307            .await
308            .unwrap();
309
310        assert_eq!(meta.mime_type, "text/plain");
311        assert_eq!(meta.size, 11);
312        assert!(meta.path.starts_with("uploads/"));
313    }
314
315    #[tokio::test]
316    async fn upload_handler_rejects_too_large() {
317        let dir = tempfile::TempDir::new().unwrap();
318        let local = super::super::LocalStorage::new(dir.path().to_path_buf());
319        let backend = Arc::new(StorageBackend::Local(local));
320        let handler = UploadHandler::new(backend);
321
322        let result = handler
323            .process_upload(
324                "big.txt",
325                &[0u8; 2000],
326                "text/plain",
327                Some(1000),
328                None,
329                "uploads",
330            )
331            .await;
332
333        assert!(matches!(result, Err(StorageError::FileTooLarge { .. })));
334    }
335
336    #[tokio::test]
337    async fn upload_handler_rejects_invalid_mime() {
338        let dir = tempfile::TempDir::new().unwrap();
339        let local = super::super::LocalStorage::new(dir.path().to_path_buf());
340        let backend = Arc::new(StorageBackend::Local(local));
341        let handler = UploadHandler::new(backend);
342
343        let allowed = vec!["jpg".to_string(), "png".to_string()];
344        let result = handler
345            .process_upload(
346                "doc.pdf",
347                b"pdf data",
348                "application/pdf",
349                None,
350                Some(&allowed),
351                "uploads",
352            )
353            .await;
354
355        assert!(matches!(result, Err(StorageError::InvalidMimeType { .. })));
356    }
357
358    #[tokio::test]
359    async fn upload_handler_signed_url() {
360        let dir = tempfile::TempDir::new().unwrap();
361        let local = super::super::LocalStorage::new(dir.path().to_path_buf());
362        let backend = Arc::new(StorageBackend::Local(local));
363        let handler = UploadHandler::new(backend);
364
365        let meta = handler
366            .process_upload(
367                "sign_test.txt",
368                b"data",
369                "text/plain",
370                None,
371                None,
372                "uploads",
373            )
374            .await
375            .unwrap();
376
377        let url = handler.signed_url(&meta.path, 3600).await.unwrap();
378        assert!(url.starts_with("file://"));
379    }
380
381    #[tokio::test]
382    async fn upload_handler_delete() {
383        let dir = tempfile::TempDir::new().unwrap();
384        let local = super::super::LocalStorage::new(dir.path().to_path_buf());
385        let backend = Arc::new(StorageBackend::Local(local));
386        let handler = UploadHandler::new(backend);
387
388        let meta = handler
389            .process_upload(
390                "delete_me.txt",
391                b"data",
392                "text/plain",
393                None,
394                None,
395                "uploads",
396            )
397            .await
398            .unwrap();
399
400        handler.delete(&meta.path).await.unwrap();
401
402        // Downloading after delete should fail
403        let result = handler.backend().download(&meta.path).await;
404        assert!(matches!(result, Err(StorageError::NotFound(_))));
405    }
406}