Skip to main content

tail_fin_common/
attachments.rs

1use 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}