//! 图片服务的 Axum 处理器与处理流水线。 //! //! 支持按宽度/高度、缩略图、旋转角度、输出格式/质量动态处理图片, //! 使用内存(moka)+ 磁盘两级缓存加速响应。 //! WebP 编解码走 `zenwebp`(`image` crate 未启用 WebP feature)。 //! 本模块属于手动注册的 Axum 路由,仅在 `feature = "server"` 时可用。 #[cfg(feature = "server")] use axum::{ extract::{ConnectInfo, Extension, Path, Query}, http::{header, HeaderValue, StatusCode}, response::{IntoResponse, Response}, }; #[cfg(feature = "server")] use std::net::SocketAddr; #[cfg(feature = "server")] use moka::future::Cache; #[cfg(feature = "server")] use serde::Deserialize; #[cfg(feature = "server")] use std::sync::LazyLock; #[cfg(feature = "server")] use bytes::Bytes; #[cfg(feature = "server")] fn etag_for(data: &[u8]) -> String { use sha2::{Digest, Sha256}; let hash = Sha256::digest(data); format!("\"{}\"", hex::encode(&hash[..16])) } #[cfg(feature = "server")] fn etag_matches(if_none_match: &str, etag: &str) -> bool { let trimmed = if_none_match.trim(); if trimmed == "*" { return true; } trimmed .split(',') .map(|s| s.trim().trim_start_matches("W/")) .any(|candidate| candidate == etag) } #[cfg(feature = "server")] pub const MAX_IMAGE_DIMENSION: u32 = 4096; #[cfg(feature = "server")] const DEFAULT_JPEG_QUALITY: u8 = 85; #[cfg(feature = "server")] /// 允许处理的最大图片像素数(约 5k x 5k)。 pub const MAX_IMAGE_PIXELS: u32 = 25_000_000; // ~5k x 5k #[cfg(feature = "server")] #[derive(Debug, Clone)] /// 缓存条目,保存处理后的图片字节与 Content-Type。 struct CachedImage { data: Bytes, content_type: HeaderValue, } #[cfg(feature = "server")] static IMAGE_CACHE: LazyLock> = LazyLock::new(|| { Cache::builder() .max_capacity(100) .time_to_idle(std::time::Duration::from_secs(300)) .build() }); #[cfg(feature = "server")] #[derive(Debug, Deserialize, Clone, Hash, Eq, PartialEq, Default)] /// 图片处理查询参数。 pub struct ImageParams { /// 限制最大宽度。 pub w: Option, /// 限制最大高度。 pub h: Option, /// 缩略图尺寸,格式 `WxH`。 pub thumb: Option, /// 旋转角度,仅允许 0/90/180/270。 pub rotate: Option, /// 输出格式:`jpeg`/`jpg`、`png`、`webp`。 pub format: Option, /// 输出质量,范围 1-100。 pub quality: Option, } #[cfg(feature = "server")] impl ImageParams { fn is_empty(&self) -> bool { self.w.is_none() && self.h.is_none() && self.thumb.is_none() && self.rotate.is_none() && self.format.is_none() && self.quality.is_none() } fn cache_key(&self, path: &str) -> String { let mut parts = vec![path.to_string()]; if let Some(w) = self.w { parts.push(format!("w={}", w)); } if let Some(h) = self.h { parts.push(format!("h={}", h)); } if let Some(ref thumb) = self.thumb { parts.push(format!("thumb={}", thumb)); } if let Some(r) = self.rotate { parts.push(format!("rotate={}", r)); } if let Some(ref fmt) = self.format { parts.push(format!("format={}", fmt)); } if let Some(q) = self.quality { parts.push(format!("quality={}", q)); } parts.join("|") } /// 校验参数合法性,返回 HTTP 400 状态码表示非法。 fn validate(&self) -> Result<(), StatusCode> { if let Some(dim) = self.w { if dim == 0 || dim > MAX_IMAGE_DIMENSION { return Err(StatusCode::BAD_REQUEST); } } if let Some(dim) = self.h { if dim == 0 || dim > MAX_IMAGE_DIMENSION { return Err(StatusCode::BAD_REQUEST); } } if let Some(r) = self.rotate { if !matches!(r, 0 | 90 | 180 | 270) { return Err(StatusCode::BAD_REQUEST); } } if let Some(ref fmt) = self.format { if !matches!(fmt.to_lowercase().as_str(), "jpeg" | "jpg" | "png" | "webp") { return Err(StatusCode::BAD_REQUEST); } } if let Some(ref thumb) = self.thumb { let parts: Vec<&str> = thumb.split('x').collect(); if parts.len() != 2 { return Err(StatusCode::BAD_REQUEST); } let tw: u32 = parts[0].parse().map_err(|_| StatusCode::BAD_REQUEST)?; let th: u32 = parts[1].parse().map_err(|_| StatusCode::BAD_REQUEST)?; if tw == 0 || th == 0 || tw > MAX_IMAGE_DIMENSION || th > MAX_IMAGE_DIMENSION { return Err(StatusCode::BAD_REQUEST); } } if let Some(q) = self.quality { if q == 0 || q > 100 { return Err(StatusCode::BAD_REQUEST); } } Ok(()) } } #[cfg(feature = "server")] fn detect_format(path: &str) -> image::ImageFormat { let lower = path.to_lowercase(); if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { image::ImageFormat::Jpeg } else if lower.ends_with(".png") { image::ImageFormat::Png } else if lower.ends_with(".webp") { image::ImageFormat::WebP } else if lower.ends_with(".gif") { image::ImageFormat::Gif } else { image::ImageFormat::Jpeg } } #[cfg(feature = "server")] fn content_type(format: image::ImageFormat) -> HeaderValue { match format { image::ImageFormat::Jpeg => HeaderValue::from_static("image/jpeg"), image::ImageFormat::Png => HeaderValue::from_static("image/png"), image::ImageFormat::WebP => HeaderValue::from_static("image/webp"), image::ImageFormat::Gif => HeaderValue::from_static("image/gif"), _ => HeaderValue::from_static("application/octet-stream"), } } #[cfg(feature = "server")] fn image_response( data: Bytes, content_type: HeaderValue, cache_control: &'static str, headers: &HeaderMap, ) -> Response { let etag = etag_for(&data); if let Some(if_none_match) = headers .get(header::IF_NONE_MATCH) .and_then(|v| v.to_str().ok()) { if etag_matches(if_none_match, &etag) { return ( StatusCode::NOT_MODIFIED, [ (header::ETAG, HeaderValue::from_str(&etag).unwrap()), (header::CACHE_CONTROL, HeaderValue::from_static(cache_control)), (header::CONTENT_TYPE, content_type), ], ) .into_response(); } } ( StatusCode::OK, [ (header::CONTENT_TYPE, content_type), (header::CACHE_CONTROL, HeaderValue::from_static(cache_control)), (header::ETAG, HeaderValue::from_str(&etag).unwrap()), ], data, ) .into_response() } #[cfg(feature = "server")] fn check_image_dimensions(width: u32, height: u32) -> Result<(), StatusCode> { if width == 0 || height == 0 { return Err(StatusCode::BAD_REQUEST); } let pixels = u64::from(width) * u64::from(height); if pixels > u64::from(MAX_IMAGE_PIXELS) { tracing::warn!( "Image dimensions too large: {}x{} ({} pixels, max {})", width, height, pixels, MAX_IMAGE_PIXELS ); return Err(StatusCode::PAYLOAD_TOO_LARGE); } Ok(()) } #[cfg(feature = "server")] fn image_reader_limits() -> image::Limits { let mut limits = image::Limits::default(); limits.max_image_width = Some(MAX_IMAGE_DIMENSION); limits.max_image_height = Some(MAX_IMAGE_DIMENSION); limits.max_alloc = Some(MAX_IMAGE_PIXELS as u64 * 4 + 1024 * 1024); limits } #[cfg(feature = "server")] fn process_image( img: image::DynamicImage, params: &ImageParams, original_format: image::ImageFormat, ) -> Result<(Vec, HeaderValue), StatusCode> { check_image_dimensions(img.width(), img.height())?; let mut img = img; // Rotate first, then resize if let Some(degrees) = params.rotate { img = match degrees { 90 => img.rotate90(), 180 => img.rotate180(), 270 => img.rotate270(), _ => img, }; } // Resize by max dimensions (keep aspect ratio) if params.w.is_some() || params.h.is_some() { let max_w = params.w.unwrap_or(img.width()); let max_h = params.h.unwrap_or(img.height()); if img.width() > max_w || img.height() > max_h { img = img.resize(max_w, max_h, image::imageops::FilterType::Lanczos3); } } // Thumbnail: fit-in-box (same semantics as resize, but both dimensions required) if let Some(ref thumb_spec) = params.thumb { let parts: Vec<&str> = thumb_spec.split('x').collect(); if parts.len() == 2 { let tw: u32 = parts[0].parse().map_err(|_| StatusCode::BAD_REQUEST)?; let th: u32 = parts[1].parse().map_err(|_| StatusCode::BAD_REQUEST)?; if tw > 0 && th > 0 && tw <= MAX_IMAGE_DIMENSION && th <= MAX_IMAGE_DIMENSION { img = img.thumbnail(tw, th); } } } // Output format (case-insensitive) let output_format = match params.format.as_deref().map(str::to_lowercase).as_deref() { Some("webp") => image::ImageFormat::WebP, Some("png") => image::ImageFormat::Png, Some("jpeg") | Some("jpg") => image::ImageFormat::Jpeg, _ => original_format, }; let quality = params.quality.unwrap_or(DEFAULT_JPEG_QUALITY); let mut buf = std::io::Cursor::new(Vec::new()); match output_format { image::ImageFormat::Jpeg => { let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality); img.write_with_encoder(encoder) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; } image::ImageFormat::WebP => { let config = crate::webp::WEBP_CONFIG.clone(); let webp_quality = params.quality.map(|q| q as f32).unwrap_or(config.quality); let webp_data = crate::webp::encode(&img, webp_quality, config.method) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; buf = std::io::Cursor::new(webp_data); } _ => { img.write_to(&mut buf, output_format) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; } } let ct = content_type(output_format); Ok((buf.into_inner(), ct)) } #[cfg(feature = "server")] fn process_image_blocking( data: Vec, params: ImageParams, path: String, ) -> Result<(Vec, HeaderValue), StatusCode> { let original_format = detect_format(&path); let img = if original_format == image::ImageFormat::WebP { match crate::webp::decode(&data) { Ok(img) => { check_image_dimensions(img.width(), img.height())?; img } Err(e) => { tracing::warn!("WebP decode failed ({}), returning raw bytes", e); let ct = content_type(original_format); return Ok((data, ct)); } } } else { let cursor = std::io::Cursor::new(&data); let mut reader = image::ImageReader::with_format(cursor, original_format); reader.limits(image_reader_limits()); match reader.decode() { Ok(img) => img, Err(e) => { tracing::warn!("Image decode failed ({}), returning raw bytes", e); let ct = content_type(original_format); return Ok((data, ct)); } } }; process_image(img, ¶ms, original_format) } #[cfg(feature = "server")] fn is_path_safe(path: &str) -> bool { // Reject paths with parent directory references or null bytes if path.contains("..") || path.contains('\0') { return false; } // Reject absolute paths if path.starts_with('/') { return false; } true } #[cfg(feature = "server")] use axum::http::HeaderMap; #[cfg(feature = "server")] const CACHE_DIR: &str = "uploads/.cache"; #[cfg(feature = "server")] fn disk_cache_base(cache_key: &str) -> String { // 使用 SHA-256 生成稳定的磁盘缓存文件名,避免进程重启后 DefaultHasher 随机 seed // 导致旧缓存无法命中且文件无限累积。 use sha2::Digest; let hash = sha2::Sha256::digest(cache_key.as_bytes()); let hash_hex = hex::encode(hash); format!("{}/cache_{}", CACHE_DIR, hash_hex) } #[cfg(feature = "server")] async fn read_disk_cache(cache_key: &str) -> Option { let base = disk_cache_base(cache_key); let data = tokio::fs::read(format!("{}.dat", base)).await.ok()?; let ct_str = tokio::fs::read_to_string(format!("{}.ct", base)) .await .ok() .unwrap_or_else(|| "application/octet-stream".to_string()); let content_type = HeaderValue::from_str(&ct_str).ok()?; Some(CachedImage { data: Bytes::from(data), content_type, }) } #[cfg(feature = "server")] async fn write_disk_cache(cache_key: &str, cached: &CachedImage) { let base = disk_cache_base(cache_key); if let Err(e) = tokio::fs::create_dir_all(CACHE_DIR).await { tracing::warn!("Failed to create cache dir: {:?}", e); return; } let ct_str = cached .content_type .to_str() .unwrap_or("application/octet-stream"); if let Err(e) = tokio::fs::write(format!("{}.dat", base), &cached.data).await { tracing::warn!("Failed to write disk cache data: {:?}", e); } if let Err(e) = tokio::fs::write(format!("{}.ct", base), ct_str).await { tracing::warn!("Failed to write disk cache content type: {:?}", e); } } #[cfg(feature = "server")] /// 图片访问与动态处理的 Axum handler。 /// /// 依次执行:限流 → 路径安全校验 → 参数校验 → 无参数时直接返回原文件 → /// 查询内存缓存 → 查询磁盘缓存 → 读取并解码 → 处理 → 写入两级缓存 → 返回。 pub async fn serve_image( connect_info: Option>>, Path(path): Path, Query(params): Query, headers: HeaderMap, ) -> Response { let peer = connect_info.map(|Extension(ConnectInfo(addr))| addr); let ip = crate::api::rate_limit::get_client_ip_with_peer(&headers, peer); if let Err(status) = crate::api::rate_limit::check_image_limit(&ip) { return status.into_response(); } if !is_path_safe(&path) { return StatusCode::FORBIDDEN.into_response(); } let file_path = format!("uploads/{}", path); // Validate params if let Err(status) = params.validate() { return status.into_response(); } // No processing params: return raw file with long-lived cache headers. if params.is_empty() { return match tokio::fs::read(&file_path).await { Ok(data) => { let ct = content_type(detect_format(&path)); image_response(Bytes::from(data), ct, "public, max-age=31536000, immutable", &headers) } Err(_) => StatusCode::NOT_FOUND.into_response(), }; } let cache_key = params.cache_key(&path); if let Some(cached) = IMAGE_CACHE.get(&cache_key).await { return image_response( cached.data.clone(), cached.content_type, "public, max-age=86400", &headers, ); } if let Some(cached) = read_disk_cache(&cache_key).await { let data = cached.data.clone(); let content_type = cached.content_type.clone(); let _ = IMAGE_CACHE.insert(cache_key.clone(), cached).await; return image_response(data, content_type, "public, max-age=86400", &headers); } let data = match tokio::fs::read(&file_path).await { Ok(d) => d, Err(_) => return StatusCode::NOT_FOUND.into_response(), }; // Offload decode + resize + encode to the blocking pool so the async // runtime stays responsive to other requests. let path_for_blocking = path.clone(); let params_for_blocking = params.clone(); let (processed, content_type) = match tokio::task::spawn_blocking(move || { process_image_blocking(data, params_for_blocking, path_for_blocking) }) .await { Ok(Ok(r)) => r, Ok(Err(status)) => return status.into_response(), Err(_) => { tracing::error!("Image processing task panicked"); return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } }; let processed = Bytes::from(processed); let cached = CachedImage { data: processed.clone(), content_type: content_type.clone(), }; let _ = IMAGE_CACHE.insert(cache_key.clone(), cached.clone()).await; write_disk_cache(&cache_key, &cached).await; image_response(processed, content_type, "public, max-age=86400", &headers) } #[cfg(all(test, feature = "server"))] mod tests { use super::*; #[test] fn image_params_validate_valid_defaults() { let params = ImageParams::default(); assert!(params.validate().is_ok()); } #[test] fn image_params_validate_valid_width() { let params = ImageParams { w: Some(100), ..Default::default() }; assert!(params.validate().is_ok()); } #[test] fn image_params_validate_zero_width_rejected() { let params = ImageParams { w: Some(0), ..Default::default() }; assert!(params.validate().is_err()); } #[test] fn image_params_validate_oversized_width_rejected() { let params = ImageParams { w: Some(5000), ..Default::default() }; assert!(params.validate().is_err()); } #[test] fn image_params_validate_valid_rotation() { for angle in [0, 90, 180, 270] { let params = ImageParams { rotate: Some(angle), ..Default::default() }; assert!(params.validate().is_ok(), "angle {} should be valid", angle); } } #[test] fn image_params_validate_invalid_rotation_rejected() { let params = ImageParams { rotate: Some(45), ..Default::default() }; assert!(params.validate().is_err()); } #[test] fn image_params_validate_valid_format() { for fmt in &["jpeg", "jpg", "png", "webp", "JPEG", "PNG"] { let params = ImageParams { format: Some(fmt.to_string()), ..Default::default() }; assert!(params.validate().is_ok(), "format {} should be valid", fmt); } } #[test] fn image_params_validate_invalid_format_rejected() { let params = ImageParams { format: Some("gif".to_string()), ..Default::default() }; assert!(params.validate().is_err()); } #[test] fn image_params_validate_valid_thumbnail() { let params = ImageParams { thumb: Some("200x150".to_string()), ..Default::default() }; assert!(params.validate().is_ok()); } #[test] fn image_params_validate_invalid_thumbnail_rejected() { let params = ImageParams { thumb: Some("200".to_string()), ..Default::default() }; assert!(params.validate().is_err()); } #[test] fn image_params_validate_valid_quality() { let params = ImageParams { quality: Some(85), ..Default::default() }; assert!(params.validate().is_ok()); } #[test] fn image_params_validate_zero_quality_rejected() { let params = ImageParams { quality: Some(0), ..Default::default() }; assert!(params.validate().is_err()); } #[test] fn image_params_validate_over_100_quality_rejected() { let params = ImageParams { quality: Some(101), ..Default::default() }; assert!(params.validate().is_err()); } #[test] fn is_path_safe_normal() { assert!(is_path_safe("images/photo.jpg")); assert!(is_path_safe("2024/01/photo.png")); } #[test] fn is_path_safe_rejects_parent_dir() { assert!(!is_path_safe("../etc/passwd")); assert!(!is_path_safe("foo/../../bar")); } #[test] fn is_path_safe_rejects_null_bytes() { assert!(!is_path_safe("foo\0bar")); } #[test] fn is_path_safe_rejects_absolute_path() { assert!(!is_path_safe("/etc/passwd")); } #[test] fn detect_format_jpeg() { assert!(matches!( detect_format("photo.jpg"), image::ImageFormat::Jpeg )); assert!(matches!( detect_format("photo.jpeg"), image::ImageFormat::Jpeg )); assert!(matches!( detect_format("PHOTO.JPG"), image::ImageFormat::Jpeg )); } #[test] fn detect_format_png() { assert!(matches!(detect_format("icon.png"), image::ImageFormat::Png)); } #[test] fn detect_format_webp() { assert!(matches!( detect_format("anim.webp"), image::ImageFormat::WebP )); } #[test] fn detect_format_defaults_to_jpeg() { assert!(matches!( detect_format("file.xyz"), image::ImageFormat::Jpeg )); } #[test] fn cache_key_differs_for_different_params() { let p1 = ImageParams { w: Some(100), ..Default::default() }; let p2 = ImageParams { w: Some(200), ..Default::default() }; assert_ne!(p1.cache_key("img.jpg"), p2.cache_key("img.jpg")); } #[test] fn is_empty_true_when_all_none() { let params = ImageParams::default(); assert!(params.is_empty()); } #[test] fn is_empty_false_when_any_set() { let params = ImageParams { w: Some(100), ..Default::default() }; assert!(!params.is_empty()); } #[test] fn disk_cache_base_is_deterministic() { let key = "path|w=800"; let base1 = disk_cache_base(key); let base2 = disk_cache_base(key); assert_eq!(base1, base2); assert!(base1.starts_with("uploads/.cache/cache_")); } #[test] fn disk_cache_base_differs_for_different_keys() { let base1 = disk_cache_base("path|w=800"); let base2 = disk_cache_base("path|w=1200"); assert_ne!(base1, base2); } #[test] fn process_image_blocking_resizes_png() { let img = image::DynamicImage::new_rgb8(100, 100); let mut buf = std::io::Cursor::new(Vec::new()); img.write_to(&mut buf, image::ImageFormat::Png).unwrap(); let data = buf.into_inner(); let params = ImageParams { w: Some(50), format: Some("webp".to_string()), ..Default::default() }; let (out, ct) = process_image_blocking(data, params, "test.png".to_string()).unwrap(); assert!(!out.is_empty()); assert_eq!(ct, HeaderValue::from_static("image/webp")); } #[test] fn image_response_includes_cache_headers() { let resp = image_response( Bytes::from(vec![1, 2, 3]), HeaderValue::from_static("image/webp"), "public, max-age=86400", &HeaderMap::new(), ); assert_eq!(resp.status(), StatusCode::OK); let headers = resp.headers(); assert_eq!( headers.get(header::CONTENT_TYPE).unwrap(), "image/webp" ); assert_eq!( headers.get(header::CACHE_CONTROL).unwrap(), "public, max-age=86400" ); assert!(headers.get(header::ETAG).unwrap().to_str().unwrap().starts_with('"')); } #[test] fn image_response_returns_304_when_etag_matches() { let data = Bytes::from(vec![1, 2, 3]); let etag = etag_for(&data); let mut req_headers = HeaderMap::new(); req_headers.insert( header::IF_NONE_MATCH, HeaderValue::from_str(&etag).unwrap(), ); let resp = image_response( data, HeaderValue::from_static("image/webp"), "public, max-age=86400", &req_headers, ); assert_eq!(resp.status(), StatusCode::NOT_MODIFIED); let headers = resp.headers(); assert_eq!(headers.get(header::ETAG).unwrap(), etag.as_str()); assert_eq!(headers.get(header::CONTENT_TYPE).unwrap(), "image/webp"); assert_eq!( headers.get(header::CACHE_CONTROL).unwrap(), "public, max-age=86400" ); } #[test] fn etag_matches_single() { assert!(etag_matches("\"abc\"", "\"abc\"")); assert!(!etag_matches("\"abc\"", "\"def\"")); } #[test] fn etag_matches_list() { assert!(etag_matches("\"abc\", \"def\"", "\"def\"")); assert!(!etag_matches("\"abc\", \"def\"", "\"ghi\"")); } #[test] fn etag_matches_weak_prefix() { assert!(etag_matches("W/\"abc\"", "\"abc\"")); } #[test] fn etag_matches_wildcard() { assert!(etag_matches("*", "\"anything\"")); } #[test] fn image_response_raw_file_is_immutable() { let resp = image_response( Bytes::from(vec![1, 2, 3]), HeaderValue::from_static("image/jpeg"), "public, max-age=31536000, immutable", &HeaderMap::new(), ); assert_eq!(resp.status(), StatusCode::OK); let cache_control = resp .headers() .get(header::CACHE_CONTROL) .unwrap() .to_str() .unwrap(); assert!(cache_control.contains("immutable")); } #[test] fn etag_for_same_data_is_stable() { let a = etag_for(b"hello"); let b = etag_for(b"hello"); assert_eq!(a, b); assert_ne!(a, etag_for(b"world")); } }