1use std::io::Cursor;
2use std::time::Duration;
3
4use image::imageops::FilterType;
5use image::{DynamicImage, ImageReader};
6use sha2::{Digest, Sha256};
7
8const MAX_DOWNLOAD_BYTES: u64 = 5 * 1024 * 1024;
10
11const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(15);
13
14const THUMB_WIDTH: u32 = 256;
16const THUMB_HEIGHT: u32 = 256;
17
18const MAX_OUTPUT_BYTES: usize = 50 * 1024;
20
21const KEY_HEX_LEN: usize = 32;
23
24#[derive(Debug, Clone)]
26pub struct ThumbnailResult {
27 pub key: String,
29 pub data: Vec<u8>,
31}
32
33#[derive(Debug)]
35pub enum Error {
36 Download(String),
37 TooLarge(u64),
38 Decode(String),
39 Encode(String),
40}
41
42impl std::fmt::Display for Error {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 match self {
45 Self::Download(msg) => write!(f, "download failed: {msg}"),
46 Self::TooLarge(size) => write!(
47 f,
48 "source too large: {size} bytes (max {MAX_DOWNLOAD_BYTES})"
49 ),
50 Self::Decode(msg) => write!(f, "image decode failed: {msg}"),
51 Self::Encode(msg) => write!(f, "webp encode failed: {msg}"),
52 }
53 }
54}
55
56impl std::error::Error for Error {}
57
58#[must_use]
62pub fn thumbnail_key(source_url: &str) -> String {
63 let mut hasher = Sha256::new();
64 hasher.update(source_url.as_bytes());
65 let hash = hasher.finalize();
66 let hex = hex_encode(&hash);
67 format!("thumbnails/{}.webp", &hex[..KEY_HEX_LEN])
68}
69
70pub fn download(url: &str) -> Result<Vec<u8>, Error> {
79 let rt = tokio::runtime::Builder::new_current_thread()
80 .enable_all()
81 .build()
82 .map_err(|e| Error::Download(e.to_string()))?;
83
84 rt.block_on(download_async(url))
85}
86
87pub async fn download_async(url: &str) -> Result<Vec<u8>, Error> {
96 let client = reqwest::Client::builder()
97 .timeout(DOWNLOAD_TIMEOUT)
98 .build()
99 .map_err(|e| Error::Download(e.to_string()))?;
100
101 let response = client
102 .get(url)
103 .send()
104 .await
105 .map_err(|e| Error::Download(e.to_string()))?;
106
107 if !response.status().is_success() {
108 return Err(Error::Download(format!("HTTP {}", response.status())));
109 }
110
111 if let Some(len) = response.content_length()
112 && len > MAX_DOWNLOAD_BYTES
113 {
114 return Err(Error::TooLarge(len));
115 }
116
117 let bytes = response
118 .bytes()
119 .await
120 .map_err(|e| Error::Download(e.to_string()))?;
121
122 if bytes.len() as u64 > MAX_DOWNLOAD_BYTES {
123 return Err(Error::TooLarge(bytes.len() as u64));
124 }
125
126 Ok(bytes.to_vec())
127}
128
129pub fn resize_to_webp(bytes: &[u8]) -> Result<Vec<u8>, Error> {
136 let img = decode_image(bytes)?;
137 let thumb = cover_crop(&img, THUMB_WIDTH, THUMB_HEIGHT);
138 encode_webp(&thumb)
139}
140
141pub fn process_thumbnail(url: &str) -> Result<ThumbnailResult, Error> {
147 let key = thumbnail_key(url);
148 let bytes = download(url)?;
149 let data = resize_to_webp(&bytes)?;
150 Ok(ThumbnailResult { key, data })
151}
152
153fn decode_image(bytes: &[u8]) -> Result<DynamicImage, Error> {
154 let cursor = Cursor::new(bytes);
155 let reader = ImageReader::new(cursor)
156 .with_guessed_format()
157 .map_err(|e| Error::Decode(e.to_string()))?;
158 reader.decode().map_err(|e| Error::Decode(e.to_string()))
159}
160
161fn cover_crop(img: &DynamicImage, width: u32, height: u32) -> DynamicImage {
163 let (iw, ih) = (img.width(), img.height());
164 let target_ratio = f64::from(width) / f64::from(height);
165 let source_ratio = f64::from(iw) / f64::from(ih);
166
167 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
168 let cropped = if source_ratio > target_ratio {
169 let new_w = (f64::from(ih) * target_ratio) as u32;
170 let x = (iw - new_w) / 2;
171 img.crop_imm(x, 0, new_w, ih)
172 } else {
173 let new_h = (f64::from(iw) / target_ratio) as u32;
174 let y = (ih - new_h) / 2;
175 img.crop_imm(0, y, iw, new_h)
176 };
177
178 cropped.resize_exact(width, height, FilterType::Lanczos3)
179}
180
181fn encode_webp(img: &DynamicImage) -> Result<Vec<u8>, Error> {
182 let mut buf = Cursor::new(Vec::new());
183 img.write_with_encoder(image::codecs::webp::WebPEncoder::new_lossless(&mut buf))
184 .map_err(|e| Error::Encode(e.to_string()))?;
185
186 let data = buf.into_inner();
187 if data.len() > MAX_OUTPUT_BYTES {
188 return Err(Error::Encode(format!(
189 "output too large: {} bytes (max {MAX_OUTPUT_BYTES})",
190 data.len()
191 )));
192 }
193
194 Ok(data)
195}
196
197fn hex_encode(bytes: &[u8]) -> String {
198 bytes
199 .iter()
200 .fold(String::with_capacity(bytes.len() * 2), |mut s, b| {
201 use std::fmt::Write;
202 let _ = write!(s, "{b:02x}");
203 s
204 })
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use image::ImageFormat;
211
212 #[test]
213 fn thumbnail_key_is_deterministic() {
214 let key1 = thumbnail_key("https://example.com/photo.jpg");
215 let key2 = thumbnail_key("https://example.com/photo.jpg");
216 assert_eq!(key1, key2);
217 }
218
219 #[test]
220 fn thumbnail_key_different_urls_differ() {
221 let key1 = thumbnail_key("https://example.com/a.jpg");
222 let key2 = thumbnail_key("https://example.com/b.jpg");
223 assert_ne!(key1, key2);
224 }
225
226 #[test]
227 fn thumbnail_key_format() {
228 let key = thumbnail_key("https://example.com/photo.jpg");
229 assert!(key.starts_with("thumbnails/"));
230 assert!(
231 std::path::Path::new(&key)
232 .extension()
233 .is_some_and(|ext| ext.eq_ignore_ascii_case("webp"))
234 );
235 let hex_part = &key["thumbnails/".len()..key.len() - ".webp".len()];
236 assert_eq!(hex_part.len(), KEY_HEX_LEN);
237 assert!(hex_part.chars().all(|c| c.is_ascii_hexdigit()));
238 }
239
240 #[test]
241 fn resize_to_webp_produces_valid_output() {
242 let img = DynamicImage::new_rgb8(800, 600);
243 let mut png_bytes = Vec::new();
244 img.write_to(&mut Cursor::new(&mut png_bytes), ImageFormat::Png)
245 .ok();
246
247 let webp = resize_to_webp(&png_bytes);
248 assert!(webp.is_ok());
249 let data = webp.ok();
250 assert!(data.is_some());
251 let data = data.unwrap_or_default();
252 assert!(!data.is_empty());
253 assert!(data.len() <= MAX_OUTPUT_BYTES);
254 }
255
256 #[test]
257 fn resize_to_webp_is_256x256() {
258 let img = DynamicImage::new_rgb8(1024, 768);
259 let mut png_bytes = Vec::new();
260 img.write_to(&mut Cursor::new(&mut png_bytes), ImageFormat::Png)
261 .ok();
262
263 let webp = resize_to_webp(&png_bytes);
264 assert!(webp.is_ok());
265 let data = webp.unwrap_or_default();
266
267 let decoded = ImageReader::new(Cursor::new(&data))
268 .with_guessed_format()
269 .ok()
270 .and_then(|r| r.decode().ok());
271 assert!(decoded.is_some());
272 let decoded = decoded.unwrap_or_else(|| DynamicImage::new_rgb8(0, 0));
273 assert_eq!(decoded.width(), THUMB_WIDTH);
274 assert_eq!(decoded.height(), THUMB_HEIGHT);
275 }
276
277 #[test]
278 fn resize_to_webp_portrait_image() {
279 let img = DynamicImage::new_rgb8(400, 1200);
280 let mut png_bytes = Vec::new();
281 img.write_to(&mut Cursor::new(&mut png_bytes), ImageFormat::Png)
282 .ok();
283
284 let webp = resize_to_webp(&png_bytes);
285 assert!(webp.is_ok());
286 }
287
288 #[test]
289 fn resize_to_webp_square_image() {
290 let img = DynamicImage::new_rgb8(500, 500);
291 let mut png_bytes = Vec::new();
292 img.write_to(&mut Cursor::new(&mut png_bytes), ImageFormat::Png)
293 .ok();
294
295 let webp = resize_to_webp(&png_bytes);
296 assert!(webp.is_ok());
297 }
298
299 #[test]
300 fn resize_to_webp_tiny_image() {
301 let img = DynamicImage::new_rgb8(16, 16);
302 let mut png_bytes = Vec::new();
303 img.write_to(&mut Cursor::new(&mut png_bytes), ImageFormat::Png)
304 .ok();
305
306 let webp = resize_to_webp(&png_bytes);
307 assert!(webp.is_ok());
308 }
309
310 #[test]
311 fn resize_to_webp_invalid_bytes_fails() {
312 let result = resize_to_webp(b"not an image");
313 assert!(result.is_err());
314 }
315
316 #[test]
317 fn download_invalid_url_fails() {
318 let result = download("not-a-url");
319 assert!(result.is_err());
320 }
321
322 #[test]
323 fn hex_encode_works() {
324 assert_eq!(hex_encode(&[0x00, 0xff, 0xab]), "00ffab");
325 }
326}