tail_fin_common/
attachments.rs1use std::path::Path;
2
3use base64::Engine as _;
4use image::codecs::jpeg::JpegEncoder;
5use image::imageops::FilterType;
6
7use crate::TailFinError;
8
9const DEFAULT_MAX_IMAGE_COUNT: usize = 4;
10const DEFAULT_MAX_IMAGE_BYTES: usize = 6 * 1024 * 1024;
11const DEFAULT_MAX_TOTAL_IMAGE_BYTES: usize = 12 * 1024 * 1024;
12const COMPRESS_TRIGGER_BYTES: usize = 1_500_000;
13const TARGET_COMPRESSED_BYTES: usize = 1_200_000;
14const MAX_EDGE_PIXELS: u32 = 2048;
15
16#[derive(Debug, Clone, Copy)]
17pub struct AttachmentLimits {
18 pub max_image_count: usize,
19 pub max_image_bytes: usize,
20 pub max_total_image_bytes: usize,
21}
22
23impl Default for AttachmentLimits {
24 fn default() -> Self {
25 Self {
26 max_image_count: DEFAULT_MAX_IMAGE_COUNT,
27 max_image_bytes: DEFAULT_MAX_IMAGE_BYTES,
28 max_total_image_bytes: DEFAULT_MAX_TOTAL_IMAGE_BYTES,
29 }
30 }
31}
32
33impl AttachmentLimits {
34 pub fn with_overrides(
35 max_image_mb: Option<usize>,
36 max_total_image_mb: Option<usize>,
37 ) -> Result<Self, TailFinError> {
38 let mut v = Self::default();
39 if let Some(mb) = max_image_mb {
40 if mb == 0 {
41 return Err(TailFinError::Api("max_image_mb must be >= 1".into()));
42 }
43 v.max_image_bytes = mb.saturating_mul(1024 * 1024);
44 v.max_total_image_bytes = v.max_image_bytes.saturating_mul(v.max_image_count);
45 }
46 if let Some(total_mb) = max_total_image_mb {
47 if total_mb == 0 {
48 return Err(TailFinError::Api("max_total_image_mb must be >= 1".into()));
49 }
50 v.max_total_image_bytes = total_mb.saturating_mul(1024 * 1024);
51 }
52 if v.max_total_image_bytes < v.max_image_bytes {
53 return Err(TailFinError::Api(
54 "max_total_image_mb must be >= max_image_mb".into(),
55 ));
56 }
57 Ok(v)
58 }
59}
60
61#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
62pub struct ImageAttachmentDebug {
63 pub original_path: String,
64 pub sent_name: String,
65 pub original_bytes: usize,
66 pub sent_bytes: usize,
67 pub mime: String,
68 pub compressed: bool,
69}
70
71#[derive(Debug, Clone)]
72pub struct PreparedImageAttachment {
73 pub data_url: String,
74 pub debug: ImageAttachmentDebug,
75}
76
77pub fn prepare_image_data_urls(
78 paths: &[String],
79 limits: AttachmentLimits,
80) -> Result<Vec<PreparedImageAttachment>, TailFinError> {
81 if paths.len() > limits.max_image_count {
82 return Err(TailFinError::Api(format!(
83 "too many images: {} (limit {})",
84 paths.len(),
85 limits.max_image_count
86 )));
87 }
88
89 let mut out = Vec::with_capacity(paths.len());
90 let mut total = 0usize;
91 for p in paths {
92 let prepared = prepare_one_image(p, limits)?;
93 total += prepared.debug.sent_bytes;
94 if total > limits.max_total_image_bytes {
95 return Err(TailFinError::Api(format!(
96 "total image payload too large: {} (limit {})",
97 human_bytes(total),
98 human_bytes(limits.max_total_image_bytes)
99 )));
100 }
101 out.push(prepared);
102 }
103 Ok(out)
104}
105
106fn prepare_one_image(
107 path: &str,
108 limits: AttachmentLimits,
109) -> Result<PreparedImageAttachment, TailFinError> {
110 let src =
111 std::fs::read(path).map_err(|e| TailFinError::Io(format!("read image '{}': {e}", path)))?;
112
113 let original_bytes = src.len();
114 let mut sent = src;
115 let mut mime = mime_from_path(path).to_string();
116 let mut sent_name = Path::new(path)
117 .file_name()
118 .and_then(|s| s.to_str())
119 .unwrap_or("image")
120 .to_string();
121 let mut compressed = false;
122
123 if original_bytes > COMPRESS_TRIGGER_BYTES {
124 if let Ok(reencoded) = compress_image_to_jpeg(&sent) {
125 if reencoded.len() < sent.len() {
126 sent = reencoded;
127 mime = "image/jpeg".to_string();
128 let stem = Path::new(path)
129 .file_stem()
130 .and_then(|s| s.to_str())
131 .unwrap_or("image");
132 sent_name = format!("{stem}.jpg");
133 compressed = true;
134 }
135 }
136 }
137
138 if sent.len() > limits.max_image_bytes {
139 return Err(TailFinError::Api(format!(
140 "image too large: '{}' is {} (limit {}). Try a smaller image.",
141 path,
142 human_bytes(sent.len()),
143 human_bytes(limits.max_image_bytes)
144 )));
145 }
146
147 let b64 = base64::engine::general_purpose::STANDARD.encode(&sent);
148 let data_url = format!("data:{mime};base64,{b64}");
149 Ok(PreparedImageAttachment {
150 data_url,
151 debug: ImageAttachmentDebug {
152 original_path: path.to_string(),
153 sent_name,
154 original_bytes,
155 sent_bytes: sent.len(),
156 mime,
157 compressed,
158 },
159 })
160}
161
162fn compress_image_to_jpeg(src: &[u8]) -> Result<Vec<u8>, TailFinError> {
163 let decoded = image::load_from_memory(src)
164 .map_err(|e| TailFinError::Api(format!("decode image for compression: {e}")))?;
165
166 let resized = if decoded.width() > MAX_EDGE_PIXELS || decoded.height() > MAX_EDGE_PIXELS {
167 decoded.resize(MAX_EDGE_PIXELS, MAX_EDGE_PIXELS, FilterType::Lanczos3)
168 } else {
169 decoded
170 };
171
172 let rgb = resized.to_rgb8();
173 let (w, h) = rgb.dimensions();
174
175 let mut best = Vec::new();
176 for quality in [82u8, 72, 62, 52] {
177 let mut buf = Vec::new();
178 let mut enc = JpegEncoder::new_with_quality(&mut buf, quality);
179 enc.encode(&rgb, w, h, image::ColorType::Rgb8.into())
180 .map_err(|e| TailFinError::Api(format!("encode jpeg for compression: {e}")))?;
181
182 if best.is_empty() || buf.len() < best.len() {
183 best = buf;
184 }
185 if best.len() <= TARGET_COMPRESSED_BYTES {
186 break;
187 }
188 }
189
190 if best.is_empty() {
191 return Err(TailFinError::Api("image compression failed".into()));
192 }
193 Ok(best)
194}
195
196pub fn human_bytes(n: usize) -> String {
197 const KB: f64 = 1024.0;
198 const MB: f64 = KB * 1024.0;
199 let v = n as f64;
200 if v >= MB {
201 format!("{:.2} MB", v / MB)
202 } else if v >= KB {
203 format!("{:.1} KB", v / KB)
204 } else {
205 format!("{} B", n)
206 }
207}
208
209pub fn mime_from_path(path: &str) -> &'static str {
210 let ext = Path::new(path)
211 .extension()
212 .and_then(|s| s.to_str())
213 .unwrap_or("")
214 .to_ascii_lowercase();
215 match ext.as_str() {
216 "png" => "image/png",
217 "jpg" | "jpeg" => "image/jpeg",
218 "webp" => "image/webp",
219 "gif" => "image/gif",
220 "bmp" => "image/bmp",
221 _ => "application/octet-stream",
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn limits_with_overrides_validate_bounds() {
231 assert!(AttachmentLimits::with_overrides(Some(0), None).is_err());
232 assert!(AttachmentLimits::with_overrides(None, Some(0)).is_err());
233 }
234
235 #[test]
236 fn prepare_image_data_urls_rejects_too_many_images() {
237 let limits = AttachmentLimits::default();
238 let paths: Vec<String> = (0..(limits.max_image_count + 1))
239 .map(|i| format!("/tmp/fake-{i}.png"))
240 .collect();
241 let err = prepare_image_data_urls(&paths, limits).unwrap_err();
242 assert!(err.to_string().contains("too many images"));
243 }
244
245 #[test]
246 fn human_bytes_formats_units() {
247 assert_eq!(human_bytes(512), "512 B");
248 assert!(human_bytes(2048).contains("KB"));
249 assert!(human_bytes(2 * 1024 * 1024).contains("MB"));
250 }
251}