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