//! S3 storage client for image uploads. //! Delegates S3 operations to the shared `s3_storage` crate. use uuid::Uuid; use crate::config::S3Config; /// S3 client wrapper for image storage. #[derive(Clone)] pub struct S3Storage { inner: s3_storage::S3Client, } /// Maximum image size: 5 MB. pub const MAX_IMAGE_SIZE: usize = 5 * 1024 * 1024; /// Allowed image content types. const ALLOWED_CONTENT_TYPES: &[&str] = &[ "image/png", "image/jpeg", "image/gif", "image/webp", ]; /// Allowed file extensions (lowercase). #[cfg(test)] const ALLOWED_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp"]; impl S3Storage { /// Create a new S3 client from configuration. pub async fn new(config: &S3Config) -> Result { let s3_config = s3_storage::S3Config { endpoint: config.endpoint.clone(), bucket: config.bucket.clone(), access_key: config.access_key.clone(), secret_key: config.secret_key.clone(), region: config.region.clone(), }; let inner = s3_storage::S3Client::new(&s3_config).await?; Ok(Self { inner }) } /// Upload bytes to S3. #[tracing::instrument(skip_all)] pub async fn upload(&self, s3_key: &str, content_type: &str, data: Vec) -> Result<(), String> { self.inner.upload(s3_key, content_type, data, None).await } /// Download bytes from S3. #[tracing::instrument(skip_all)] pub async fn download(&self, s3_key: &str) -> Result<(Vec, String), String> { self.inner.download(s3_key).await } /// Delete an object from S3. #[tracing::instrument(skip_all)] pub async fn delete(&self, s3_key: &str) -> Result<(), String> { self.inner.delete(s3_key).await } } /// Generate an S3 key for a forum image. /// Format: `mt/{community_slug}/{uuid}.{ext}` pub fn generate_image_key(community_slug: &str, ext: &str) -> String { let id = Uuid::new_v4(); format!("mt/{community_slug}/{id}.{ext}") } /// Validate an uploaded file. Returns the sanitized extension and content type. pub fn validate_image( filename: &str, content_type: &str, size: usize, ) -> Result<(&'static str, &'static str), &'static str> { if size > MAX_IMAGE_SIZE { return Err("Image exceeds 5 MB limit."); } if size == 0 { return Err("Empty file."); } // Check extension let ext = filename .rsplit('.') .next() .map(|e| e.to_lowercase()) .unwrap_or_default(); let ext_str: &'static str = match ext.as_str() { "png" => "png", "jpg" | "jpeg" => "jpg", "gif" => "gif", "webp" => "webp", _ => return Err("Allowed types: png, jpg, gif, webp."), }; // Check content type let ct: &'static str = ALLOWED_CONTENT_TYPES .iter() .find(|&&ct| ct == content_type) .copied() .ok_or("Invalid image content type.")?; // Cross-validate: extension should match content type let ext_matches = match ext_str { "png" => ct == "image/png", "jpg" => ct == "image/jpeg", "gif" => ct == "image/gif", "webp" => ct == "image/webp", _ => false, }; if !ext_matches { return Err("File extension does not match content type."); } Ok((ext_str, ct)) } /// Strip EXIF metadata from JPEG data. /// Works by copying all JPEG segments except APP1 (EXIF) and APP13 (IPTC). /// Returns the cleaned data, or the original data if parsing fails. pub fn strip_exif_jpeg(data: &[u8]) -> Vec { if data.len() < 4 || data[0] != 0xFF || data[1] != 0xD8 { return data.to_vec(); // Not a valid JPEG } let mut out = Vec::with_capacity(data.len()); out.extend_from_slice(&[0xFF, 0xD8]); // SOI marker let mut i = 2; while i + 1 < data.len() { if data[i] != 0xFF { // Not a marker — copy rest as-is (image data) out.extend_from_slice(&data[i..]); break; } let marker = data[i + 1]; // SOS (Start of Scan) — copy the rest verbatim (compressed data follows) if marker == 0xDA { out.extend_from_slice(&data[i..]); break; } // Markers without length (RST0-RST7, SOI, EOI, TEM) if marker == 0x00 || marker == 0x01 || (0xD0..=0xD9).contains(&marker) { out.extend_from_slice(&data[i..i + 2]); i += 2; continue; } // Read segment length if i + 3 >= data.len() { out.extend_from_slice(&data[i..]); break; } let seg_len = ((data[i + 2] as usize) << 8) | (data[i + 3] as usize); let total = 2 + seg_len; // marker (2) + length includes itself if i + total > data.len() { out.extend_from_slice(&data[i..]); break; } // Skip APP1 (0xE1 = EXIF) and APP13 (0xED = IPTC/Photoshop) if marker == 0xE1 || marker == 0xED { i += total; continue; } out.extend_from_slice(&data[i..i + total]); i += total; } out } #[cfg(test)] mod tests { use super::*; #[test] fn validate_valid_png() { let (ext, ct) = validate_image("photo.png", "image/png", 1024).unwrap(); assert_eq!(ext, "png"); assert_eq!(ct, "image/png"); } #[test] fn validate_valid_jpeg() { let (ext, _) = validate_image("photo.jpg", "image/jpeg", 1024).unwrap(); assert_eq!(ext, "jpg"); } #[test] fn validate_rejects_oversized() { let err = validate_image("big.png", "image/png", 6 * 1024 * 1024).unwrap_err(); assert!(err.contains("5 MB")); } #[test] fn validate_rejects_bad_extension() { let err = validate_image("file.exe", "application/octet-stream", 1024).unwrap_err(); assert!(err.contains("Allowed types")); } #[test] fn validate_rejects_mismatched_type() { let err = validate_image("photo.png", "image/jpeg", 1024).unwrap_err(); assert!(err.contains("does not match")); } #[test] fn validate_rejects_empty() { let err = validate_image("photo.png", "image/png", 0).unwrap_err(); assert!(err.contains("Empty")); } #[test] fn strip_exif_preserves_non_jpeg() { let data = b"not a jpeg"; let result = strip_exif_jpeg(data); assert_eq!(result, data); } #[test] fn strip_exif_minimal_jpeg() { // Minimal JPEG: SOI + APP0 (JFIF) + SOS + EOI let mut jpeg = vec![0xFF, 0xD8]; // SOI // APP0 segment (marker + length + data) jpeg.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x04, 0x00, 0x00]); // APP0, len=4 // APP1 segment (EXIF — should be stripped) jpeg.extend_from_slice(&[0xFF, 0xE1, 0x00, 0x04, 0xAA, 0xBB]); // APP1, len=4 // SOS + fake data jpeg.extend_from_slice(&[0xFF, 0xDA, 0x00, 0x02]); jpeg.extend_from_slice(&[0xFF, 0xD9]); // EOI let result = strip_exif_jpeg(&jpeg); // Should contain SOI, APP0, SOS+data — but NOT APP1 assert!(result.len() < jpeg.len(), "EXIF should be stripped"); // Check APP1 marker is absent let has_app1 = result.windows(2).any(|w| w == [0xFF, 0xE1]); assert!(!has_app1, "APP1 should be removed"); // Check APP0 is preserved let has_app0 = result.windows(2).any(|w| w == [0xFF, 0xE0]); assert!(has_app0, "APP0 should be preserved"); } #[test] fn generate_key_format() { let key = generate_image_key("test-comm", "png"); assert!(key.starts_with("mt/test-comm/")); assert!(key.ends_with(".png")); } #[test] fn allowed_extensions_match() { // Verify our ALLOWED_EXTENSIONS list matches validate_image behavior for ext in ALLOWED_EXTENSIONS { let filename = format!("test.{ext}"); let ct = match *ext { "png" => "image/png", "jpg" | "jpeg" => "image/jpeg", "gif" => "image/gif", "webp" => "image/webp", _ => continue, }; assert!(validate_image(&filename, ct, 1024).is_ok(), "Extension {ext} should be valid"); } } }