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 if let Err(e) = processor.process(&source_path, &cache_path) {
363 tracing::error!("ImgResizer: failed to process {}: {}", file_path, e);
364 return (StatusCode::INTERNAL_SERVER_ERROR, "Processing failed").into_response();
365 }
366
367 serve_file(&cache_path).await
368}
369
370async fn serve_file(path: &StdPath) -> Response {
372 let data = match tokio::fs::read(path).await {
373 Ok(d) => d,
374 Err(_) => return StatusCode::NOT_FOUND.into_response(),
375 };
376
377 let ext = path.extension()
378 .and_then(|e| e.to_str())
379 .unwrap_or("bin");
380
381 let content_type = match ext {
382 "jpg" | "jpeg" => "image/jpeg",
383 "png" => "image/png",
384 "webp" => "image/webp",
385 "gif" => "image/gif",
386 "avif" => "image/avif",
387 "svg" => "image/svg+xml",
388 _ => "application/octet-stream",
389 };
390
391 (
392 StatusCode::OK,
393 [
394 (header::CONTENT_TYPE, content_type),
395 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
396 ],
397 data,
398 ).into_response()
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 #[test]
406 fn test_parse_spec_basic() {
407 let spec = parse_spec("320x180").unwrap();
408 assert_eq!(spec.width, 320);
409 assert_eq!(spec.height, 180);
410 assert!(matches!(spec.mode, ResizeMode::Fit));
411 }
412
413 #[test]
414 fn test_parse_spec_width_only() {
415 let spec = parse_spec("640x0").unwrap();
416 assert_eq!(spec.width, 640);
417 assert_eq!(spec.height, 0);
418 assert!(matches!(spec.mode, ResizeMode::Width));
419 }
420
421 #[test]
422 fn test_parse_spec_height_only() {
423 let spec = parse_spec("0x400").unwrap();
424 assert_eq!(spec.width, 0);
425 assert_eq!(spec.height, 400);
426 assert!(matches!(spec.mode, ResizeMode::Height));
427 }
428
429 #[test]
430 fn test_parse_spec_cover() {
431 let spec = parse_spec("320x180_cover").unwrap();
432 assert!(matches!(spec.mode, ResizeMode::Cover));
433 }
434
435 #[test]
436 fn test_parse_spec_with_format() {
437 let spec = parse_spec("320x180.webp").unwrap();
438 assert_eq!(spec.format, Some("webp".into()));
439 }
440
441 #[test]
442 fn test_parse_spec_quality() {
443 let spec = parse_spec("320x180_q75").unwrap();
444 assert_eq!(spec.quality, Some(75));
445 }
446
447 #[test]
448 fn test_parse_spec_grayscale() {
449 let spec = parse_spec("320x180_gray").unwrap();
450 assert!(spec.grayscale);
451 }
452
453 #[test]
454 fn test_parse_spec_blur() {
455 let spec = parse_spec("320x180_blur3").unwrap();
456 assert_eq!(spec.blur, Some(3.0));
457 }
458
459 #[test]
460 fn test_parse_spec_combined() {
461 let spec = parse_spec("800x600_cover_q90_gray.webp").unwrap();
462 assert_eq!(spec.width, 800);
463 assert_eq!(spec.height, 600);
464 assert!(matches!(spec.mode, ResizeMode::Cover));
465 assert_eq!(spec.quality, Some(90));
466 assert!(spec.grayscale);
467 assert_eq!(spec.format, Some("webp".into()));
468 }
469
470 #[test]
471 fn test_parse_spec_original() {
472 assert!(parse_spec("original").is_none());
473 }
474
475 #[test]
476 fn test_parse_spec_invalid() {
477 assert!(parse_spec("0x0").is_none());
478 assert!(parse_spec("abc").is_none());
479 }
480
481 #[test]
482 fn test_parse_anchor() {
483 assert!(matches!(parse_anchor("top-left"), CropAnchor::TopLeft));
484 assert!(matches!(parse_anchor("center"), CropAnchor::Center));
485 assert!(matches!(parse_anchor("bottom-right"), CropAnchor::BottomRight));
486 assert!(matches!(parse_anchor("top"), CropAnchor::TopCenter));
487 assert!(matches!(parse_anchor("invalid"), CropAnchor::Center));
488 }
489}