1#![expect(
2 clippy::indexing_slicing,
3 reason = "Image signatures are checked for minimum length before fixed-format byte access."
4)]
5
6use anyhow::{Context, Result};
9use base64::Engine;
10use std::path::Path;
11
12#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
14pub struct ImageData {
15 pub base64_data: String,
17
18 pub mime_type: String,
20
21 pub file_path: String,
23
24 pub size: u64,
26}
27
28pub fn detect_mime_type_from_content_type(content_type: &str) -> Option<String> {
30 let content_type = content_type.to_lowercase();
31 if content_type.starts_with("image/png") {
32 Some("image/png".to_string())
33 } else if content_type.starts_with("image/jpeg") || content_type.starts_with("image/jpg") {
34 Some("image/jpeg".to_string())
35 } else if content_type.starts_with("image/gif") {
36 Some("image/gif".to_string())
37 } else if content_type.starts_with("image/webp") {
38 Some("image/webp".to_string())
39 } else if content_type.starts_with("image/bmp") {
40 Some("image/bmp".to_string())
41 } else if content_type.starts_with("image/tiff") || content_type.starts_with("image/tif") {
42 Some("image/tiff".to_string())
43 } else if content_type.starts_with("image/svg") {
44 Some("image/svg+xml".to_string())
45 } else {
46 None
47 }
48}
49
50pub fn detect_mime_type_from_data(data: &[u8]) -> String {
52 if data.len() >= 2 && data[0] == 0xFF && data[1] == 0xD8 {
54 return "image/jpeg".to_string();
55 }
56
57 if data.len() < 8 {
59 return "image/png".to_string();
60 }
61
62 match &data[..8] {
63 [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] => "image/png".to_string(),
64 [0x47, 0x49, 0x46, 0x38, _, _, _, _] => {
65 if data.len() >= 12 && &data[8..12] == b"WEBP" {
66 "image/webp".to_string()
67 } else {
68 "image/gif".to_string()
69 }
70 }
71 [0x52, 0x49, 0x46, 0x46, _, _, _, _] => {
72 if data.len() >= 12 && &data[8..12] == b"WEBP" {
73 "image/webp".to_string()
74 } else {
75 "image/png".to_string()
76 }
77 }
78 [0x42, 0x4D, _, _] => "image/bmp".to_string(),
79 _ => "image/png".to_string(),
80 }
81}
82
83fn detect_mime_type_from_extension(path: &Path) -> Result<String> {
85 let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("").to_lowercase();
86
87 let mime_type = match extension.as_str() {
88 "png" => "image/png",
89 "jpg" | "jpeg" => "image/jpeg",
90 "gif" => "image/gif",
91 "webp" => "image/webp",
92 "bmp" => "image/bmp",
93 "tiff" | "tif" => "image/tiff",
94 "svg" => "image/svg+xml",
95 _ => return Err(anyhow::anyhow!("Unsupported image format: {extension}")),
96 };
97
98 Ok(mime_type.to_string())
99}
100
101pub fn has_supported_image_extension(path: &Path) -> bool {
103 let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("").to_lowercase();
104
105 const VALID_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "svg"];
106 VALID_EXTENSIONS.contains(&extension.as_str())
107}
108
109pub fn encode_to_base64(data: &[u8]) -> String {
111 base64::engine::general_purpose::STANDARD.encode(data)
112}
113
114pub async fn read_image_file<P: AsRef<Path>>(file_path: P) -> Result<ImageData> {
119 use crate::paths::is_safe_relative_path;
120
121 let path = file_path.as_ref();
122
123 if !is_safe_relative_path(&path.to_string_lossy()) {
124 return Err(anyhow::anyhow!("Unsafe or traversal detected in image path: {}", path.display()));
125 }
126
127 if !has_supported_image_extension(path) {
128 return Err(anyhow::anyhow!("Unsupported image extension for path: {}", path.display()));
129 }
130
131 let file_contents = tokio::fs::read(path)
132 .await
133 .with_context(|| format!("Failed to read image file: {}", path.display()))?;
134
135 if file_contents.len() > 20 * 1024 * 1024 {
136 return Err(anyhow::anyhow!("Image file too large: {} bytes (max 20MB)", file_contents.len()));
137 }
138
139 let mime_type = detect_mime_type_from_extension(path)?;
140 let base64_data = encode_to_base64(&file_contents);
141
142 Ok(ImageData {
143 base64_data,
144 mime_type,
145 file_path: path.display().to_string(),
146 size: file_contents.len() as u64,
147 })
148}
149
150pub async fn read_image_file_any_path<P: AsRef<Path>>(file_path: P) -> Result<ImageData> {
156 let path = file_path.as_ref();
157
158 if !has_supported_image_extension(path) {
159 return Err(anyhow::anyhow!("Unsupported image extension for path: {}", path.display()));
160 }
161
162 let file_contents = tokio::fs::read(path)
163 .await
164 .with_context(|| format!("Failed to read image file: {}", path.display()))?;
165
166 if file_contents.len() > 20 * 1024 * 1024 {
167 return Err(anyhow::anyhow!("Image file too large: {} bytes (max 20MB)", file_contents.len()));
168 }
169
170 let mime_type = detect_mime_type_from_extension(path)?;
171 let base64_data = encode_to_base64(&file_contents);
172
173 Ok(ImageData {
174 base64_data,
175 mime_type,
176 file_path: path.display().to_string(),
177 size: file_contents.len() as u64,
178 })
179}