karbon_framework/storage/
img_resizer.rs1use axum::extract::{Path, Query, Request};
2use axum::http::{header, StatusCode};
3use axum::response::{IntoResponse, Response};
4use axum::routing::get;
5use axum::Router;
6use serde::Deserialize;
7use std::path::{Path as StdPath, PathBuf};
8use std::sync::Arc;
9
10use super::thumbnail::{ImageProcessor, ResizeMode, CropAnchor, OutputFormat, PngCompression};
11
12pub struct ImgResizer;
40
41struct ResizerState {
43 source_dir: PathBuf,
44 cache_dir: PathBuf,
45 max_width: u32,
46 max_height: u32,
47 default_quality: u8,
48 allowed_formats: Vec<String>,
49}
50
51pub struct ImgResizerConfig {
53 source_dir: PathBuf,
54 cache_dir: PathBuf,
55 max_width: u32,
56 max_height: u32,
57 default_quality: u8,
58}
59
60impl ImgResizerConfig {
61 pub fn new(source_dir: impl Into<PathBuf>, cache_dir: impl Into<PathBuf>) -> Self {
62 Self {
63 source_dir: source_dir.into(),
64 cache_dir: cache_dir.into(),
65 max_width: 3840,
66 max_height: 2160,
67 default_quality: 85,
68 }
69 }
70
71 pub fn max_width(mut self, w: u32) -> Self {
73 self.max_width = w;
74 self
75 }
76
77 pub fn max_height(mut self, h: u32) -> Self {
79 self.max_height = h;
80 self
81 }
82
83 pub fn default_quality(mut self, q: u8) -> Self {
85 self.default_quality = q.clamp(1, 100);
86 self
87 }
88
89 pub fn build(self) -> Router {
91 std::fs::create_dir_all(&self.cache_dir).ok();
93
94 let state = Arc::new(ResizerState {
95 source_dir: self.source_dir,
96 cache_dir: self.cache_dir,
97 max_width: self.max_width,
98 max_height: self.max_height,
99 default_quality: self.default_quality,
100 allowed_formats: vec![
101 "jpg".into(), "jpeg".into(), "png".into(),
102 "gif".into(), "webp".into(), "avif".into(),
103 ],
104 });
105
106 Router::new()
107 .route("/r/{spec}/{*path}", get({
108 let state = Arc::clone(&state);
109 move |path, query| handle_resize(path, query, state)
110 }))
111 .fallback_service(tower_http::services::ServeDir::new(&state.source_dir))
113 }
114}
115
116impl ImgResizer {
117 pub fn serve(source_dir: impl Into<PathBuf>, cache_dir: impl Into<PathBuf>) -> Router {
123 ImgResizerConfig::new(source_dir, cache_dir).build()
124 }
125
126 pub fn config(source_dir: impl Into<PathBuf>, cache_dir: impl Into<PathBuf>) -> ImgResizerConfig {
135 ImgResizerConfig::new(source_dir, cache_dir)
136 }
137}
138
139#[derive(Debug)]
143struct ResizeSpec {
144 width: u32,
145 height: u32,
146 mode: ResizeMode,
147 format: Option<String>,
148 quality: Option<u8>,
149 grayscale: bool,
150 blur: Option<f32>,
151}
152
153fn parse_spec(spec: &str) -> Option<ResizeSpec> {
155 if spec == "original" {
156 return None; }
158
159 let (spec_part, format) = if let Some(dot_pos) = spec.rfind('.') {
161 let fmt = &spec[dot_pos + 1..];
162 (&spec[..dot_pos], Some(fmt.to_lowercase()))
163 } else {
164 (spec, None)
165 };
166
167 let parts: Vec<&str> = spec_part.split('_').collect();
169 let dims = parts.first()?;
170
171 let (w_str, h_str) = dims.split_once('x')?;
173 let width = w_str.parse::<u32>().ok()?;
174 let height = h_str.parse::<u32>().ok()?;
175
176 if width == 0 && height == 0 {
177 return None;
178 }
179
180 let mut mode = if width == 0 {
181 ResizeMode::Height
182 } else if height == 0 {
183 ResizeMode::Width
184 } else {
185 ResizeMode::Fit
186 };
187
188 let mut quality = None;
189 let mut grayscale = false;
190 let mut blur = None;
191
192 for part in parts.iter().skip(1) {
194 match *part {
195 "cover" => mode = ResizeMode::Cover,
196 "fit" => mode = ResizeMode::Fit,
197 "stretch" => mode = ResizeMode::Stretch,
198 "gray" | "grayscale" => grayscale = true,
199 p if p.starts_with('q') => {
200 quality = p[1..].parse::<u8>().ok().map(|q| q.clamp(1, 100));
201 }
202 p if p.starts_with("blur") => {
203 blur = p[4..].parse::<f32>().ok().map(|b| b.clamp(0.1, 20.0));
204 }
205 _ => {}
206 }
207 }
208
209 Some(ResizeSpec {
210 width,
211 height,
212 mode,
213 format,
214 quality,
215 grayscale,
216 blur,
217 })
218}
219
220fn parse_anchor(anchor: &str) -> CropAnchor {
222 match anchor {
223 "top-left" => CropAnchor::TopLeft,
224 "top-center" | "top" => CropAnchor::TopCenter,
225 "top-right" => CropAnchor::TopRight,
226 "center-left" | "left" => CropAnchor::CenterLeft,
227 "center" => CropAnchor::Center,
228 "center-right" | "right" => CropAnchor::CenterRight,
229 "bottom-left" => CropAnchor::BottomLeft,
230 "bottom-center" | "bottom" => CropAnchor::BottomCenter,
231 "bottom-right" => CropAnchor::BottomRight,
232 _ => CropAnchor::Center,
233 }
234}
235
236#[derive(Debug, Deserialize, Default)]
239struct ResizeQuery {
240 anchor: Option<String>,
241 watermark: Option<String>,
242 wpos: Option<String>,
243 wopacity: Option<u8>,
244 wscale: Option<u32>,
245}
246
247async fn handle_resize(
250 Path((spec, file_path)): Path<(String, String)>,
251 Query(query): Query<ResizeQuery>,
252 state: Arc<ResizerState>,
253) -> Response {
254 let resize_spec = match parse_spec(&spec) {
256 Some(s) => s,
257 None => {
258 return serve_file(&state.source_dir.join(&file_path)).await;
260 }
261 };
262
263 if resize_spec.width > state.max_width || resize_spec.height > state.max_height {
265 return (StatusCode::BAD_REQUEST, "Dimensions too large").into_response();
266 }
267
268 let source_path = state.source_dir.join(&file_path);
270 if !source_path.exists() || !source_path.is_file() {
271 return StatusCode::NOT_FOUND.into_response();
272 }
273
274 let canonical_source = match source_path.canonicalize() {
276 Ok(p) => p,
277 Err(_) => return StatusCode::NOT_FOUND.into_response(),
278 };
279 let canonical_base = match state.source_dir.canonicalize() {
280 Ok(p) => p,
281 Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
282 };
283 if !canonical_source.starts_with(&canonical_base) {
284 return StatusCode::FORBIDDEN.into_response();
285 }
286
287 let ext = source_path.extension()
289 .and_then(|e| e.to_str())
290 .map(|e| e.to_lowercase())
291 .unwrap_or_default();
292 if !state.allowed_formats.contains(&ext) {
293 return (StatusCode::BAD_REQUEST, "Unsupported format").into_response();
294 }
295
296 let out_ext = resize_spec.format.as_deref().unwrap_or(&ext);
298
299 let cache_path = state.cache_dir
301 .join(&spec)
302 .join(format!("{}.{}", file_path.replace('/', "_"), out_ext));
303
304 if cache_path.exists() {
306 if let (Ok(cache_meta), Ok(src_meta)) = (cache_path.metadata(), source_path.metadata()) {
307 if let (Ok(cache_mod), Ok(src_mod)) = (cache_meta.modified(), src_meta.modified()) {
308 if cache_mod >= src_mod {
309 return serve_file(&cache_path).await;
310 }
311 }
312 }
313 }
314
315 let mut processor = ImageProcessor::new()
317 .resize(
318 if resize_spec.width > 0 { resize_spec.width } else { 1 },
319 if resize_spec.height > 0 { resize_spec.height } else { 1 },
320 )
321 .mode(resize_spec.mode);
322
323 if resize_spec.width == 0 {
325 processor = ImageProcessor::new()
326 .height(resize_spec.height)
327 .mode(resize_spec.mode);
328 } else if resize_spec.height == 0 {
329 processor = ImageProcessor::new()
330 .width(resize_spec.width)
331 .mode(resize_spec.mode);
332 }
333
334 if let Some(anchor) = &query.anchor {
336 processor = processor.anchor(parse_anchor(anchor));
337 }
338
339 if resize_spec.grayscale {
341 processor = processor.grayscale();
342 }
343 if let Some(sigma) = resize_spec.blur {
344 processor = processor.blur(sigma);
345 }
346
347 let quality = resize_spec.quality.unwrap_or(state.default_quality);
349 processor = match out_ext {
350 "webp" => processor.webp(),
351 "png" => processor.png(PngCompression::Default),
352 "gif" => processor.format(OutputFormat::Gif),
353 _ => processor.jpeg(quality),
354 };
355
356 if let Some(parent) = cache_path.parent() {
358 std::fs::create_dir_all(parent).ok();
359 }
360
361 let cache_path_clone = cache_path.clone();
363 let source_clone = source_path.clone();
364 let file_path_clone = file_path.clone();
365
366 let result = tokio::task::spawn_blocking(move || {
367 processor.process(&source_clone, &cache_path_clone)
368 }).await;
369
370 match result {
371 Ok(Ok(())) => serve_file(&cache_path).await,
372 Ok(Err(e)) => {
373 tracing::error!("ImgResizer: failed to process {}: {}", file_path, e);
374 serve_file(&source_path).await
376 }
377 Err(e) => {
378 tracing::error!("ImgResizer: task panicked for {}: {}", file_path, e);
379 serve_file(&source_path).await
380 }
381 }
382}
383
384async fn serve_file(path: &StdPath) -> Response {
386 let data = match tokio::fs::read(path).await {
387 Ok(d) => d,
388 Err(_) => return StatusCode::NOT_FOUND.into_response(),
389 };
390
391 let ext = path.extension()
392 .and_then(|e| e.to_str())
393 .unwrap_or("bin");
394
395 let content_type = match ext {
396 "jpg" | "jpeg" => "image/jpeg",
397 "png" => "image/png",
398 "webp" => "image/webp",
399 "gif" => "image/gif",
400 "avif" => "image/avif",
401 "svg" => "image/svg+xml",
402 _ => "application/octet-stream",
403 };
404
405 (
406 StatusCode::OK,
407 [
408 (header::CONTENT_TYPE, content_type),
409 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
410 ],
411 data,
412 ).into_response()
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
420 fn test_parse_spec_basic() {
421 let spec = parse_spec("320x180").unwrap();
422 assert_eq!(spec.width, 320);
423 assert_eq!(spec.height, 180);
424 assert!(matches!(spec.mode, ResizeMode::Fit));
425 }
426
427 #[test]
428 fn test_parse_spec_width_only() {
429 let spec = parse_spec("640x0").unwrap();
430 assert_eq!(spec.width, 640);
431 assert_eq!(spec.height, 0);
432 assert!(matches!(spec.mode, ResizeMode::Width));
433 }
434
435 #[test]
436 fn test_parse_spec_height_only() {
437 let spec = parse_spec("0x400").unwrap();
438 assert_eq!(spec.width, 0);
439 assert_eq!(spec.height, 400);
440 assert!(matches!(spec.mode, ResizeMode::Height));
441 }
442
443 #[test]
444 fn test_parse_spec_cover() {
445 let spec = parse_spec("320x180_cover").unwrap();
446 assert!(matches!(spec.mode, ResizeMode::Cover));
447 }
448
449 #[test]
450 fn test_parse_spec_with_format() {
451 let spec = parse_spec("320x180.webp").unwrap();
452 assert_eq!(spec.format, Some("webp".into()));
453 }
454
455 #[test]
456 fn test_parse_spec_quality() {
457 let spec = parse_spec("320x180_q75").unwrap();
458 assert_eq!(spec.quality, Some(75));
459 }
460
461 #[test]
462 fn test_parse_spec_grayscale() {
463 let spec = parse_spec("320x180_gray").unwrap();
464 assert!(spec.grayscale);
465 }
466
467 #[test]
468 fn test_parse_spec_blur() {
469 let spec = parse_spec("320x180_blur3").unwrap();
470 assert_eq!(spec.blur, Some(3.0));
471 }
472
473 #[test]
474 fn test_parse_spec_combined() {
475 let spec = parse_spec("800x600_cover_q90_gray.webp").unwrap();
476 assert_eq!(spec.width, 800);
477 assert_eq!(spec.height, 600);
478 assert!(matches!(spec.mode, ResizeMode::Cover));
479 assert_eq!(spec.quality, Some(90));
480 assert!(spec.grayscale);
481 assert_eq!(spec.format, Some("webp".into()));
482 }
483
484 #[test]
485 fn test_parse_spec_original() {
486 assert!(parse_spec("original").is_none());
487 }
488
489 #[test]
490 fn test_parse_spec_invalid() {
491 assert!(parse_spec("0x0").is_none());
492 assert!(parse_spec("abc").is_none());
493 }
494
495 #[test]
496 fn test_parse_anchor() {
497 assert!(matches!(parse_anchor("top-left"), CropAnchor::TopLeft));
498 assert!(matches!(parse_anchor("center"), CropAnchor::Center));
499 assert!(matches!(parse_anchor("bottom-right"), CropAnchor::BottomRight));
500 assert!(matches!(parse_anchor("top"), CropAnchor::TopCenter));
501 assert!(matches!(parse_anchor("invalid"), CropAnchor::Center));
502 }
503}