shaperail_runtime/storage/
upload.rs1use super::backend::{FileMetadata, StorageBackend, StorageError};
2use std::sync::Arc;
3
4pub 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 (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
27pub 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 if pattern == mime_type {
41 return Ok(());
42 }
43 if let Some(prefix) = pattern.strip_suffix("/*") {
45 if mime_type.starts_with(prefix) {
46 return Ok(());
47 }
48 }
49 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
62fn 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
81pub struct UploadHandler {
83 backend: Arc<StorageBackend>,
84}
85
86impl UploadHandler {
87 pub fn new(backend: Arc<StorageBackend>) -> Self {
89 Self { backend }
90 }
91
92 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 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 if let Some(types) = allowed_types {
116 validate_mime_type(mime_type, types)?;
117 }
118
119 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 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 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 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 pub async fn delete(&self, path: &str) -> Result<(), StorageError> {
196 self.backend.delete(path).await
197 }
198
199 pub fn backend(&self) -> &StorageBackend {
201 &self.backend
202 }
203}
204
205fn 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 let result = handler.backend().download(&meta.path).await;
404 assert!(matches!(result, Err(StorageError::NotFound(_))));
405 }
406}