Skip to main content

rusmes_jmap/
blob.rs

1//! Blob upload/download endpoints for JMAP
2//!
3//! Implements RFC 8620 Section 6.1 and 6.2:
4//! - GET /download/:account/:blob/:name - download blobs
5//! - POST /upload/:account - upload blobs
6//! - Blob size limits and validation
7
8use axum::{
9    body::Body,
10    extract::{Path, State},
11    http::{header, StatusCode},
12    response::{IntoResponse, Response},
13    routing::{get, post},
14    Router,
15};
16use serde::Serialize;
17use std::collections::HashMap;
18use std::sync::{Arc, RwLock};
19
20/// Maximum blob size (10MB)
21const MAX_BLOB_SIZE: usize = 10 * 1024 * 1024;
22
23/// Blob storage (in-memory for now)
24#[derive(Clone)]
25pub struct BlobStorage {
26    blobs: Arc<RwLock<HashMap<String, BlobData>>>,
27}
28
29/// Blob data
30#[derive(Clone)]
31pub struct BlobData {
32    data: Vec<u8>,
33    content_type: String,
34}
35
36impl BlobStorage {
37    /// Create new blob storage
38    pub fn new() -> Self {
39        Self {
40            blobs: Arc::new(RwLock::new(HashMap::new())),
41        }
42    }
43
44    /// Store a blob
45    pub fn store(&self, blob_id: String, data: Vec<u8>, content_type: String) {
46        let blob_data = BlobData { data, content_type };
47        if let Ok(mut blobs) = self.blobs.write() {
48            blobs.insert(blob_id, blob_data);
49        }
50    }
51
52    /// Retrieve a blob
53    pub fn get(&self, blob_id: &str) -> Option<BlobData> {
54        self.blobs
55            .read()
56            .ok()
57            .and_then(|blobs| blobs.get(blob_id).cloned())
58    }
59
60    /// Get blob size
61    pub fn size(&self, blob_id: &str) -> Option<usize> {
62        self.blobs
63            .read()
64            .ok()
65            .and_then(|blobs| blobs.get(blob_id).map(|b| b.data.len()))
66    }
67}
68
69impl Default for BlobStorage {
70    fn default() -> Self {
71        Self::new()
72    }
73}
74
75/// Upload response
76#[derive(Debug, Serialize)]
77#[serde(rename_all = "camelCase")]
78pub struct UploadResponse {
79    pub account_id: String,
80    pub blob_id: String,
81    #[serde(rename = "type")]
82    pub content_type: String,
83    pub size: usize,
84}
85
86/// Upload error response
87#[derive(Debug, Serialize)]
88#[serde(rename_all = "camelCase")]
89pub struct UploadError {
90    #[serde(rename = "type")]
91    pub error_type: String,
92    pub status: u16,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub detail: Option<String>,
95}
96
97/// Create blob router
98pub fn blob_routes() -> Router<BlobStorage> {
99    Router::new()
100        .route("/download/:account/:blob/:name", get(download_blob))
101        .route("/upload/:account", post(upload_blob))
102}
103
104/// Download blob endpoint
105async fn download_blob(
106    Path((account, blob_id, name)): Path<(String, String, String)>,
107    State(storage): State<BlobStorage>,
108) -> Response {
109    // Validate account (in production, check authentication)
110    if account.is_empty() {
111        return (StatusCode::BAD_REQUEST, "Invalid account ID".to_string()).into_response();
112    }
113
114    // Retrieve blob
115    match storage.get(&blob_id) {
116        Some(blob_data) => {
117            // Return blob with appropriate headers
118            match Response::builder()
119                .status(StatusCode::OK)
120                .header(header::CONTENT_TYPE, blob_data.content_type)
121                .header(
122                    header::CONTENT_DISPOSITION,
123                    format!("attachment; filename=\"{}\"", name),
124                )
125                .header(header::CONTENT_LENGTH, blob_data.data.len())
126                .body(Body::from(blob_data.data))
127            {
128                Ok(response) => response,
129                Err(e) => (
130                    StatusCode::INTERNAL_SERVER_ERROR,
131                    format!("Failed to build response: {}", e),
132                )
133                    .into_response(),
134            }
135        }
136        None => (StatusCode::NOT_FOUND, "Blob not found".to_string()).into_response(),
137    }
138}
139
140/// Upload blob endpoint
141async fn upload_blob(
142    Path(account): Path<String>,
143    State(storage): State<BlobStorage>,
144    headers: axum::http::HeaderMap,
145    body: axum::body::Bytes,
146) -> Response {
147    // Validate account
148    if account.is_empty() {
149        let error = UploadError {
150            error_type: "urn:ietf:params:jmap:error:invalidArguments".to_string(),
151            status: 400,
152            detail: Some("Invalid account ID".to_string()),
153        };
154        return (StatusCode::BAD_REQUEST, axum::Json(error)).into_response();
155    }
156
157    // Check size limit
158    if body.len() > MAX_BLOB_SIZE {
159        let error = UploadError {
160            error_type: "urn:ietf:params:jmap:error:tooLarge".to_string(),
161            status: 413,
162            detail: Some(format!(
163                "Blob size {} exceeds maximum of {}",
164                body.len(),
165                MAX_BLOB_SIZE
166            )),
167        };
168        return (StatusCode::PAYLOAD_TOO_LARGE, axum::Json(error)).into_response();
169    }
170
171    // Get content type
172    let content_type = headers
173        .get(header::CONTENT_TYPE)
174        .and_then(|v| v.to_str().ok())
175        .unwrap_or("application/octet-stream")
176        .to_string();
177
178    // Generate blob ID
179    let blob_id = generate_blob_id(&body);
180
181    // Store blob
182    let data = body.to_vec();
183    let size = data.len();
184    storage.store(blob_id.clone(), data, content_type.clone());
185
186    // Return upload response
187    let response = UploadResponse {
188        account_id: account,
189        blob_id,
190        content_type,
191        size,
192    };
193
194    (StatusCode::CREATED, axum::Json(response)).into_response()
195}
196
197/// Generate blob ID from data
198fn generate_blob_id(data: &[u8]) -> String {
199    use sha2::{Digest, Sha256};
200
201    let mut hasher = Sha256::new();
202    hasher.update(data);
203    let result = hasher.finalize();
204    format!("G{:x}", result)
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn test_blob_storage_store_and_get() {
213        let storage = BlobStorage::new();
214        let data = b"test data".to_vec();
215        let blob_id = "blob123".to_string();
216
217        storage.store(blob_id.clone(), data.clone(), "text/plain".to_string());
218
219        let retrieved = storage.get(&blob_id).unwrap();
220        assert_eq!(retrieved.data, data);
221        assert_eq!(retrieved.content_type, "text/plain");
222    }
223
224    #[test]
225    fn test_blob_storage_size() {
226        let storage = BlobStorage::new();
227        let data = b"test data".to_vec();
228        let blob_id = "blob123".to_string();
229
230        storage.store(blob_id.clone(), data.clone(), "text/plain".to_string());
231
232        assert_eq!(storage.size(&blob_id), Some(9));
233    }
234
235    #[test]
236    fn test_blob_storage_get_nonexistent() {
237        let storage = BlobStorage::new();
238        assert!(storage.get("nonexistent").is_none());
239    }
240
241    #[test]
242    fn test_generate_blob_id() {
243        let data1 = b"test data";
244        let data2 = b"test data";
245        let data3 = b"different data";
246
247        let id1 = generate_blob_id(data1);
248        let id2 = generate_blob_id(data2);
249        let id3 = generate_blob_id(data3);
250
251        // Same data should produce same ID
252        assert_eq!(id1, id2);
253
254        // Different data should produce different ID
255        assert_ne!(id1, id3);
256
257        // Should start with 'G'
258        assert!(id1.starts_with('G'));
259    }
260
261    #[test]
262    fn test_blob_id_length() {
263        let data = b"test data";
264        let blob_id = generate_blob_id(data);
265
266        // SHA256 hash is 64 hex chars + 1 for 'G' prefix
267        assert_eq!(blob_id.len(), 65);
268    }
269
270    #[test]
271    fn test_blob_storage_multiple_blobs() {
272        let storage = BlobStorage::new();
273
274        for i in 0..10 {
275            let data = format!("data{}", i).into_bytes();
276            let blob_id = format!("blob{}", i);
277            storage.store(blob_id.clone(), data.clone(), "text/plain".to_string());
278        }
279
280        // Verify all blobs exist
281        for i in 0..10 {
282            let blob_id = format!("blob{}", i);
283            assert!(storage.get(&blob_id).is_some());
284        }
285    }
286
287    #[test]
288    fn test_blob_storage_overwrite() {
289        let storage = BlobStorage::new();
290        let blob_id = "blob123".to_string();
291
292        storage.store(
293            blob_id.clone(),
294            b"original".to_vec(),
295            "text/plain".to_string(),
296        );
297        storage.store(
298            blob_id.clone(),
299            b"updated".to_vec(),
300            "text/html".to_string(),
301        );
302
303        let retrieved = storage.get(&blob_id).unwrap();
304        assert_eq!(retrieved.data, b"updated");
305        assert_eq!(retrieved.content_type, "text/html");
306    }
307
308    #[test]
309    fn test_blob_storage_empty_data() {
310        let storage = BlobStorage::new();
311        let blob_id = "empty".to_string();
312
313        storage.store(
314            blob_id.clone(),
315            vec![],
316            "application/octet-stream".to_string(),
317        );
318
319        let retrieved = storage.get(&blob_id).unwrap();
320        assert_eq!(retrieved.data.len(), 0);
321    }
322
323    #[test]
324    fn test_blob_storage_large_data() {
325        let storage = BlobStorage::new();
326        let data = vec![0u8; 1024 * 1024]; // 1MB
327        let blob_id = "large".to_string();
328
329        storage.store(
330            blob_id.clone(),
331            data.clone(),
332            "application/octet-stream".to_string(),
333        );
334
335        assert_eq!(storage.size(&blob_id), Some(1024 * 1024));
336    }
337
338    #[test]
339    fn test_upload_response_serialization() {
340        let response = UploadResponse {
341            account_id: "acc1".to_string(),
342            blob_id: "blob123".to_string(),
343            content_type: "image/png".to_string(),
344            size: 1024,
345        };
346
347        let json = serde_json::to_string(&response).unwrap();
348        assert!(json.contains("blob123"));
349        assert!(json.contains("image/png"));
350    }
351
352    #[test]
353    fn test_upload_error_serialization() {
354        let error = UploadError {
355            error_type: "urn:ietf:params:jmap:error:tooLarge".to_string(),
356            status: 413,
357            detail: Some("Too large".to_string()),
358        };
359
360        let json = serde_json::to_string(&error).unwrap();
361        assert!(json.contains("tooLarge"));
362        assert!(json.contains("413"));
363    }
364
365    #[test]
366    fn test_max_blob_size_constant() {
367        assert_eq!(MAX_BLOB_SIZE, 10 * 1024 * 1024);
368    }
369
370    #[test]
371    fn test_blob_id_deterministic() {
372        let data = b"consistent data";
373        let id1 = generate_blob_id(data);
374        let id2 = generate_blob_id(data);
375        let id3 = generate_blob_id(data);
376
377        assert_eq!(id1, id2);
378        assert_eq!(id2, id3);
379    }
380
381    #[test]
382    fn test_blob_storage_clone() {
383        let storage1 = BlobStorage::new();
384        storage1.store(
385            "blob1".to_string(),
386            b"data".to_vec(),
387            "text/plain".to_string(),
388        );
389
390        let storage2 = storage1.clone();
391        assert!(storage2.get("blob1").is_some());
392    }
393
394    #[test]
395    fn test_blob_data_clone() {
396        let data1 = BlobData {
397            data: b"test".to_vec(),
398            content_type: "text/plain".to_string(),
399        };
400
401        let data2 = data1.clone();
402        assert_eq!(data1.data, data2.data);
403        assert_eq!(data1.content_type, data2.content_type);
404    }
405
406    #[test]
407    fn test_blob_storage_default() {
408        let storage = BlobStorage::default();
409        assert!(storage.get("any").is_none());
410    }
411
412    #[test]
413    fn test_blob_id_uniqueness() {
414        let mut ids = std::collections::HashSet::new();
415
416        for i in 0..100 {
417            let data = format!("unique data {}", i).into_bytes();
418            let id = generate_blob_id(&data);
419            assert!(ids.insert(id), "Duplicate blob ID generated");
420        }
421    }
422
423    #[test]
424    fn test_blob_storage_concurrent_access() {
425        let storage = BlobStorage::new();
426
427        // Store from one reference
428        storage.store(
429            "blob1".to_string(),
430            b"data1".to_vec(),
431            "text/plain".to_string(),
432        );
433
434        // Clone and access from another
435        let storage2 = storage.clone();
436        storage2.store(
437            "blob2".to_string(),
438            b"data2".to_vec(),
439            "text/html".to_string(),
440        );
441
442        // Both should see both blobs
443        assert!(storage.get("blob1").is_some());
444        assert!(storage.get("blob2").is_some());
445        assert!(storage2.get("blob1").is_some());
446        assert!(storage2.get("blob2").is_some());
447    }
448
449    #[test]
450    fn test_blob_storage_size_nonexistent() {
451        let storage = BlobStorage::new();
452        assert_eq!(storage.size("nonexistent"), None);
453    }
454
455    #[test]
456    fn test_blob_id_format() {
457        let data = b"test";
458        let blob_id = generate_blob_id(data);
459
460        // Should be hex characters after 'G'
461        assert!(blob_id.chars().skip(1).all(|c| c.is_ascii_hexdigit()));
462    }
463
464    #[test]
465    fn test_upload_error_without_detail() {
466        let error = UploadError {
467            error_type: "urn:ietf:params:jmap:error:serverFail".to_string(),
468            status: 500,
469            detail: None,
470        };
471
472        let json = serde_json::to_string(&error).unwrap();
473        assert!(!json.contains("detail"));
474    }
475}