Skip to main content

karbon_framework/storage/
img_resizer.rs

1use 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
12/// ImgResizer — on-the-fly image resizing service with disk cache.
13///
14/// # Usage
15///
16/// ```rust
17/// use karbon::storage::ImgResizer;
18///
19/// let app = Router::new()
20///     .nest_service("/files", ImgResizer::serve("./storage", "./cache/img"));
21/// ```
22///
23/// URLs:
24/// - `/files/r/320x180/uploads/photo.jpg` → resize fit 320×180
25/// - `/files/r/640x0/uploads/photo.jpg` → resize width 640, auto height
26/// - `/files/r/0x400/uploads/photo.jpg` → resize height 400, auto width
27/// - `/files/r/320x180_cover/uploads/photo.jpg` → cover crop
28/// - `/files/r/320x180_stretch/uploads/photo.jpg` → stretch
29/// - `/files/r/800x600.webp/uploads/photo.jpg` → convert to WebP
30/// - `/files/r/400x400_cover.webp/uploads/photo.jpg` → cover + WebP
31/// - `/files/r/320x180_q75/uploads/photo.jpg` → JPEG quality 75
32/// - `/files/r/320x180_gray/uploads/photo.jpg` → grayscale
33/// - `/files/r/320x180_blur3/uploads/photo.jpg` → blur sigma 3
34/// - `/files/original/uploads/photo.jpg` → serve original (passthrough)
35///
36/// Query params (advanced):
37/// - `?anchor=top-left` → crop anchor for cover mode
38/// - `?watermark=/path/to/logo.png&wpos=bottom-right&wopacity=50&wscale=20`
39pub struct ImgResizer;
40
41/// Internal state shared across requests
42struct 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
51/// Configuration builder for ImgResizer
52pub 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    /// Max allowed resize width (default 3840)
72    pub fn max_width(mut self, w: u32) -> Self {
73        self.max_width = w;
74        self
75    }
76
77    /// Max allowed resize height (default 2160)
78    pub fn max_height(mut self, h: u32) -> Self {
79        self.max_height = h;
80        self
81    }
82
83    /// Default JPEG quality 1-100 (default 85)
84    pub fn default_quality(mut self, q: u8) -> Self {
85        self.default_quality = q.clamp(1, 100);
86        self
87    }
88
89    /// Build the Axum router
90    pub fn build(self) -> Router {
91        // Ensure cache dir exists
92        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: serve original files via tower-http ServeDir
112            .fallback_service(tower_http::services::ServeDir::new(&state.source_dir))
113    }
114}
115
116impl ImgResizer {
117    /// Quick setup: serve images from `source_dir` with cache in `cache_dir`
118    ///
119    /// ```rust
120    /// app.nest_service("/files", ImgResizer::serve("./storage", "./cache/img"));
121    /// ```
122    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    /// Advanced setup with config builder
127    ///
128    /// ```rust
129    /// app.nest_service("/files", ImgResizer::config("./storage", "./cache/img")
130    ///     .max_width(2560)
131    ///     .default_quality(90)
132    ///     .build());
133    /// ```
134    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// ── Resize spec parsing ──
140
141/// Parsed resize specification from URL
142#[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
153/// Parse spec like "320x180", "320x180_cover", "640x0.webp", "320x180_q75_gray"
154fn parse_spec(spec: &str) -> Option<ResizeSpec> {
155    if spec == "original" {
156        return None; // passthrough
157    }
158
159    // Split format extension: "320x180.webp" → ("320x180", Some("webp"))
160    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    // Split by underscore for modifiers: "320x180_cover_q75_gray"
168    let parts: Vec<&str> = spec_part.split('_').collect();
169    let dims = parts.first()?;
170
171    // Parse dimensions: "320x180"
172    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    // Parse modifiers
193    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
220/// Parse crop anchor from query param
221fn 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// ── Query params ──
237
238#[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
247// ── Request handler ──
248
249async fn handle_resize(
250    Path((spec, file_path)): Path<(String, String)>,
251    Query(query): Query<ResizeQuery>,
252    state: Arc<ResizerState>,
253) -> Response {
254    // Parse spec
255    let resize_spec = match parse_spec(&spec) {
256        Some(s) => s,
257        None => {
258            // "original" or invalid spec → serve original
259            return serve_file(&state.source_dir.join(&file_path)).await;
260        }
261    };
262
263    // Validate dimensions
264    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    // Validate source file exists and is an allowed format
269    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    // Security: verify path doesn't escape source dir
275    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    // Check file extension is allowed
288    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    // Determine output extension
297    let out_ext = resize_spec.format.as_deref().unwrap_or(&ext);
298
299    // Build cache path: cache_dir/spec/path.ext
300    let cache_path = state.cache_dir
301        .join(&spec)
302        .join(format!("{}.{}", file_path.replace('/', "_"), out_ext));
303
304    // Serve from cache if exists and newer than source
305    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    // Process image
316    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    // Width-only or height-only
324    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    // Anchor
335    if let Some(anchor) = &query.anchor {
336        processor = processor.anchor(parse_anchor(anchor));
337    }
338
339    // Filters
340    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    // Output format
348    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    // Ensure cache directory exists
357    if let Some(parent) = cache_path.parent() {
358        std::fs::create_dir_all(parent).ok();
359    }
360
361    // Process in a blocking thread to avoid starving the Tokio runtime
362    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            // Fallback: serve original
375            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
384/// Serve a file with proper content-type and cache headers
385async 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}