Skip to main content

yggdrasil/api/
image.rs

1//! 图片服务的 Axum 处理器与处理流水线。
2//!
3//! 支持按宽度/高度、缩略图、旋转角度、输出格式/质量动态处理图片,
4//! 使用内存(moka)+ 磁盘两级缓存加速响应。
5//! WebP 编解码走 `zenwebp`(`image` crate 未启用 WebP feature)。
6//! 本模块属于手动注册的 Axum 路由,仅在 `feature = "server"` 时可用。
7
8#[cfg(feature = "server")]
9use axum::{
10    extract::{ConnectInfo, Extension, Path, Query},
11    http::{header, HeaderValue, StatusCode},
12    response::{IntoResponse, Response},
13};
14#[cfg(feature = "server")]
15use bytes::Bytes;
16#[cfg(feature = "server")]
17use moka::future::Cache;
18#[cfg(feature = "server")]
19use moka::sync::Cache as SyncCache;
20#[cfg(feature = "server")]
21use serde::Deserialize;
22#[cfg(feature = "server")]
23use std::net::SocketAddr;
24#[cfg(feature = "server")]
25use std::sync::LazyLock;
26
27#[cfg(feature = "server")]
28fn etag_for(data: &[u8]) -> String {
29    use sha2::{Digest, Sha256};
30    let hash = Sha256::digest(data);
31    format!("\"{}\"", hex::encode(&hash[..16]))
32}
33
34#[cfg(feature = "server")]
35fn etag_matches(if_none_match: &str, etag: &str) -> bool {
36    let trimmed = if_none_match.trim();
37    if trimmed == "*" {
38        return true;
39    }
40    trimmed
41        .split(',')
42        .map(|s| s.trim().trim_start_matches("W/"))
43        .any(|candidate| candidate == etag)
44}
45
46#[cfg(feature = "server")]
47/// 图片单边(宽或高)尺寸上限,单位像素。
48///
49/// 启动时从 `MAX_IMAGE_DIMENSION` 环境变量读取,默认 8192。
50/// 只设下限 512(防误调到危险小值导致正常图都传不上),无上限(完全信任运维)。
51/// 低于下限时 clamp 回 512 并打 WARN。
52pub static MAX_IMAGE_DIMENSION: LazyLock<u32> = LazyLock::new(|| {
53    const DEFAULT: u32 = 8192;
54    const MIN: u32 = 512;
55    let (val, clamped) = std::env::var("MAX_IMAGE_DIMENSION")
56        .ok()
57        .and_then(|s| s.parse::<u32>().ok())
58        .map(|v| if v < MIN { (MIN, true) } else { (v, false) })
59        .unwrap_or((DEFAULT, false));
60    if clamped {
61        tracing::warn!(
62            "MAX_IMAGE_DIMENSION was clamped from {} to {} (minimum {})",
63            std::env::var("MAX_IMAGE_DIMENSION").unwrap_or_default(),
64            val,
65            MIN
66        );
67    }
68    tracing::info!("Image dimension limit loaded: {}", val);
69    val
70});
71#[cfg(feature = "server")]
72const DEFAULT_JPEG_QUALITY: u8 = 85;
73#[cfg(feature = "server")]
74/// 允许处理的最大图片像素数(默认约 7k x 7k)。
75///
76/// 启动时从 `MAX_IMAGE_PIXELS` 环境变量读取,默认 50_000_000。
77/// 只设下限 1_000_000(防误调),无上限。
78/// ⚠️ 此值同时决定单图解码内存缓冲(max_alloc = pixels × 4 + 1MB),
79///    默认 50M 像素对应约 200MB/图,上调前确认部署环境内存。
80pub static MAX_IMAGE_PIXELS: LazyLock<u32> = LazyLock::new(|| {
81    const DEFAULT: u32 = 50_000_000;
82    const MIN: u32 = 1_000_000;
83    let (val, clamped) = std::env::var("MAX_IMAGE_PIXELS")
84        .ok()
85        .and_then(|s| s.parse::<u32>().ok())
86        .map(|v| if v < MIN { (MIN, true) } else { (v, false) })
87        .unwrap_or((DEFAULT, false));
88    if clamped {
89        tracing::warn!(
90            "MAX_IMAGE_PIXELS was clamped from {} to {} (minimum {})",
91            std::env::var("MAX_IMAGE_PIXELS").unwrap_or_default(),
92            val,
93            MIN
94        );
95    }
96    tracing::info!("Image pixel limit loaded: {}", val);
97    val
98});
99
100#[cfg(feature = "server")]
101#[derive(Debug, Clone)]
102/// 缓存条目,保存处理后的图片字节与 Content-Type。
103struct CachedImage {
104    data: Bytes,
105    content_type: HeaderValue,
106}
107
108#[cfg(feature = "server")]
109static IMAGE_CACHE: LazyLock<Cache<String, CachedImage>> = LazyLock::new(|| {
110    Cache::builder()
111        .max_capacity(100)
112        .time_to_idle(std::time::Duration::from_secs(300))
113        .build()
114});
115
116#[cfg(feature = "server")]
117#[derive(Debug, Deserialize, Clone, Hash, Eq, PartialEq, Default)]
118/// 图片处理查询参数。
119pub struct ImageParams {
120    /// 限制最大宽度。
121    pub w: Option<u32>,
122    /// 限制最大高度。
123    pub h: Option<u32>,
124    /// 缩略图尺寸,格式 `WxH`。
125    pub thumb: Option<String>,
126    /// 旋转角度,仅允许 0/90/180/270。
127    pub rotate: Option<u16>,
128    /// 输出格式:`jpeg`/`jpg`、`png`、`webp`。
129    pub format: Option<String>,
130    /// 输出质量,范围 1-100。
131    pub quality: Option<u8>,
132}
133
134#[cfg(feature = "server")]
135impl ImageParams {
136    fn is_empty(&self) -> bool {
137        self.w.is_none()
138            && self.h.is_none()
139            && self.thumb.is_none()
140            && self.rotate.is_none()
141            && self.format.is_none()
142            && self.quality.is_none()
143    }
144
145    fn cache_key(&self, path: &str) -> String {
146        use std::fmt::Write as _;
147        // 旧实现 vec![path.to_string()] + 最多 6 个 format! + join,最坏 9 次堆分配。
148        // 单次 with_capacity + write! 直写,1 次分配。
149        let mut key = String::with_capacity(path.len() + 64);
150        key.push_str(path);
151        if let Some(w) = self.w {
152            let _ = write!(key, "|w={}", w);
153        }
154        if let Some(h) = self.h {
155            let _ = write!(key, "|h={}", h);
156        }
157        if let Some(ref thumb) = self.thumb {
158            let _ = write!(key, "|thumb={}", thumb);
159        }
160        if let Some(r) = self.rotate {
161            let _ = write!(key, "|rotate={}", r);
162        }
163        if let Some(ref fmt) = self.format {
164            let _ = write!(key, "|format={}", fmt);
165        }
166        if let Some(q) = self.quality {
167            let _ = write!(key, "|quality={}", q);
168        }
169        key
170    }
171
172    /// 校验参数合法性,返回 HTTP 400 状态码表示非法。
173    fn validate(&self) -> Result<(), StatusCode> {
174        if let Some(dim) = self.w {
175            if dim == 0 || dim > *MAX_IMAGE_DIMENSION {
176                return Err(StatusCode::BAD_REQUEST);
177            }
178        }
179        if let Some(dim) = self.h {
180            if dim == 0 || dim > *MAX_IMAGE_DIMENSION {
181                return Err(StatusCode::BAD_REQUEST);
182            }
183        }
184        if let Some(r) = self.rotate {
185            if !matches!(r, 0 | 90 | 180 | 270) {
186                return Err(StatusCode::BAD_REQUEST);
187            }
188        }
189        if let Some(ref fmt) = self.format {
190            if !matches!(fmt.to_lowercase().as_str(), "jpeg" | "jpg" | "png" | "webp") {
191                return Err(StatusCode::BAD_REQUEST);
192            }
193        }
194        if let Some(ref thumb) = self.thumb {
195            let parts: Vec<&str> = thumb.split('x').collect();
196            if parts.len() != 2 {
197                return Err(StatusCode::BAD_REQUEST);
198            }
199            let tw: u32 = parts[0].parse().map_err(|_| StatusCode::BAD_REQUEST)?;
200            let th: u32 = parts[1].parse().map_err(|_| StatusCode::BAD_REQUEST)?;
201            if tw == 0 || th == 0 || tw > *MAX_IMAGE_DIMENSION || th > *MAX_IMAGE_DIMENSION {
202                return Err(StatusCode::BAD_REQUEST);
203            }
204        }
205        if let Some(q) = self.quality {
206            if q == 0 || q > 100 {
207                return Err(StatusCode::BAD_REQUEST);
208            }
209        }
210        Ok(())
211    }
212}
213
214#[cfg(feature = "server")]
215fn detect_format(path: &str) -> ImageFmt {
216    // 仅对路径后缀做大小写不敏感匹配,避免 to_lowercase() 对整条路径的 String 分配。
217    // path 形如 `uploads/2026/06/22/abc.webp`,rsplit('.') 取最后一段后缀即可。
218    let ext = path.rsplit('.').next().unwrap_or("");
219    if ext.eq_ignore_ascii_case("jpg") || ext.eq_ignore_ascii_case("jpeg") {
220        ImageFmt::Jpeg
221    } else if ext.eq_ignore_ascii_case("png") {
222        ImageFmt::Png
223    } else if ext.eq_ignore_ascii_case("webp") {
224        ImageFmt::WebP
225    } else if ext.eq_ignore_ascii_case("gif") {
226        ImageFmt::Gif
227    } else {
228        ImageFmt::Jpeg
229    }
230}
231
232/// detect_format 的轻量返回类型,避免在热路径上构造 image::ImageFormat。
233#[cfg(feature = "server")]
234type ImageFmt = image::ImageFormat;
235
236#[cfg(feature = "server")]
237fn content_type(format: image::ImageFormat) -> HeaderValue {
238    match format {
239        image::ImageFormat::Jpeg => HeaderValue::from_static("image/jpeg"),
240        image::ImageFormat::Png => HeaderValue::from_static("image/png"),
241        image::ImageFormat::WebP => HeaderValue::from_static("image/webp"),
242        image::ImageFormat::Gif => HeaderValue::from_static("image/gif"),
243        _ => HeaderValue::from_static("application/octet-stream"),
244    }
245}
246
247#[cfg(feature = "server")]
248fn image_response(
249    data: Bytes,
250    content_type: HeaderValue,
251    cache_control: &'static str,
252    headers: &HeaderMap,
253) -> Response {
254    let etag = etag_for(&data);
255    // etag 形如 `"deadbeef..."`(引号 + hex),都是合法 token-char,from_str 不可能失败。
256    // 用 expect 说明恒成立的不变量,避免裸 unwrap 触发 lint。
257    let etag_value = HeaderValue::from_str(&etag)
258        .expect("etag 仅含 ASCII hex 与双引号,必然是合法的 HeaderValue");
259
260    if let Some(if_none_match) = headers
261        .get(header::IF_NONE_MATCH)
262        .and_then(|v| v.to_str().ok())
263    {
264        if etag_matches(if_none_match, &etag) {
265            return (
266                StatusCode::NOT_MODIFIED,
267                [
268                    (header::ETAG, etag_value.clone()),
269                    (
270                        header::CACHE_CONTROL,
271                        HeaderValue::from_static(cache_control),
272                    ),
273                    (header::CONTENT_TYPE, content_type),
274                    // nosniff 防止浏览器对 content-type 错配的图片字节做 MIME sniff(M2)。
275                    (
276                        header::X_CONTENT_TYPE_OPTIONS,
277                        HeaderValue::from_static("nosniff"),
278                    ),
279                ],
280            )
281                .into_response();
282        }
283    }
284
285    (
286        StatusCode::OK,
287        [
288            (header::CONTENT_TYPE, content_type),
289            (
290                header::CACHE_CONTROL,
291                HeaderValue::from_static(cache_control),
292            ),
293            (header::ETAG, etag_value),
294            (
295                header::X_CONTENT_TYPE_OPTIONS,
296                HeaderValue::from_static("nosniff"),
297            ),
298        ],
299        data,
300    )
301        .into_response()
302}
303
304#[cfg(feature = "server")]
305fn check_image_dimensions(width: u32, height: u32) -> Result<(), StatusCode> {
306    if width == 0 || height == 0 {
307        return Err(StatusCode::BAD_REQUEST);
308    }
309    let pixels = u64::from(width) * u64::from(height);
310    if pixels > u64::from(*MAX_IMAGE_PIXELS) {
311        tracing::warn!(
312            "Image dimensions too large: {}x{} ({} pixels, max {})",
313            width,
314            height,
315            pixels,
316            *MAX_IMAGE_PIXELS
317        );
318        return Err(StatusCode::PAYLOAD_TOO_LARGE);
319    }
320    Ok(())
321}
322
323#[cfg(feature = "server")]
324/// 仅读取 header 校验上传图片的尺寸/像素是否超限,并返回 (width, height)。
325///
326/// - 输入是原始字节 + MIME,内部按 MIME 分发只解析 header 拿尺寸(不解码像素)
327/// - 返回带友好中文提示的 `&'static str`(供上传接口直接回给用户)
328///
329/// 上传入口三种格式在此统一拦截至尺寸上限;WebP 走 `zenwebp` header,
330/// JPEG/PNG/GIF 走 `image` crate 的 `into_dimensions`(均只读 header)。
331/// 尺寸随结果返回,供 `upload_image` 校验通过后直接写入 assets 表,避免二次解析。
332pub(crate) fn upload_dimensions(data: &[u8], mime_type: &str) -> Result<(u32, u32), &'static str> {
333    let dims = read_dimensions_by_mime(data, mime_type)?;
334    let (width, height) = dims;
335    if width == 0 || height == 0 {
336        return Err("图片文件损坏或格式不正确");
337    }
338    let pixels = u64::from(width) * u64::from(height);
339    let max_dim = *MAX_IMAGE_DIMENSION;
340    let max_pixels = *MAX_IMAGE_PIXELS;
341    if width > max_dim || height > max_dim || pixels > u64::from(max_pixels) {
342        tracing::warn!(
343            "Uploaded image too large: {}x{} ({} pixels, max {}x{} / {} pixels)",
344            width,
345            height,
346            pixels,
347            max_dim,
348            max_dim,
349            max_pixels
350        );
351        return Err("图片尺寸过大,请压缩后再上传");
352    }
353    Ok(dims)
354}
355
356#[cfg(feature = "server")]
357/// 按 MIME 只读 header 拿 (width, height)。失败返回损坏错误。
358fn read_dimensions_by_mime(data: &[u8], mime_type: &str) -> Result<(u32, u32), &'static str> {
359    match mime_type {
360        "image/webp" => read_webp_dimensions(data).ok_or("图片文件损坏或格式不正确"),
361        "image/jpeg" | "image/png" | "image/gif" => {
362            let format = match mime_type {
363                "image/jpeg" => image::ImageFormat::Jpeg,
364                "image/png" => image::ImageFormat::Png,
365                _ => image::ImageFormat::Gif,
366            };
367            read_image_dimensions(data, format).ok_or("图片文件损坏或格式不正确")
368        }
369        // 上游已用 ALLOWED_MIME_TYPES 白名单拦截,理论不到这里
370        _ => Err("图片文件损坏或格式不正确"),
371    }
372}
373
374/// 读 WebP header 拿尺寸(zenwebp 只解析 RIFF header,不解码像素)。
375#[cfg(feature = "server")]
376fn read_webp_dimensions(data: &[u8]) -> Option<(u32, u32)> {
377    let decoder = zenwebp::WebPDecoder::build(data).ok()?;
378    let info = decoder.info();
379    Some((info.width, info.height))
380}
381
382/// 读 image crate 支持格式(jpeg/png/gif)的 header 拿尺寸。
383#[cfg(feature = "server")]
384fn read_image_dimensions(data: &[u8], format: image::ImageFormat) -> Option<(u32, u32)> {
385    let reader = image::ImageReader::with_format(std::io::Cursor::new(data), format);
386    reader.into_dimensions().ok()
387}
388
389/// 构造统一的 image::Limits(宽度/高度/分配上限),供 upload 与 image serving 共享。
390#[cfg(feature = "server")]
391pub(crate) fn image_reader_limits() -> image::Limits {
392    let mut limits = image::Limits::default();
393    limits.max_image_width = Some(*MAX_IMAGE_DIMENSION);
394    limits.max_image_height = Some(*MAX_IMAGE_DIMENSION);
395    limits.max_alloc = Some(*MAX_IMAGE_PIXELS as u64 * 4 + 1024 * 1024);
396    limits
397}
398
399#[cfg(feature = "server")]
400fn process_image(
401    img: image::DynamicImage,
402    params: &ImageParams,
403    original_format: image::ImageFormat,
404) -> Result<(Vec<u8>, HeaderValue), StatusCode> {
405    check_image_dimensions(img.width(), img.height())?;
406    let mut img = img;
407
408    // Rotate first, then resize
409    if let Some(degrees) = params.rotate {
410        img = match degrees {
411            90 => img.rotate90(),
412            180 => img.rotate180(),
413            270 => img.rotate270(),
414            _ => img,
415        };
416    }
417
418    // Resize by max dimensions (keep aspect ratio)
419    if params.w.is_some() || params.h.is_some() {
420        let max_w = params.w.unwrap_or(img.width());
421        let max_h = params.h.unwrap_or(img.height());
422        if img.width() > max_w || img.height() > max_h {
423            img = img.resize(max_w, max_h, image::imageops::FilterType::Lanczos3);
424        }
425    }
426
427    // Thumbnail: fit-in-box (same semantics as resize, but both dimensions required)
428    if let Some(ref thumb_spec) = params.thumb {
429        let parts: Vec<&str> = thumb_spec.split('x').collect();
430        if parts.len() == 2 {
431            let tw: u32 = parts[0].parse().map_err(|_| StatusCode::BAD_REQUEST)?;
432            let th: u32 = parts[1].parse().map_err(|_| StatusCode::BAD_REQUEST)?;
433            if tw > 0 && th > 0 && tw <= *MAX_IMAGE_DIMENSION && th <= *MAX_IMAGE_DIMENSION {
434                img = img.thumbnail(tw, th);
435            }
436        }
437    }
438
439    // Output format (case-insensitive)
440    let output_format = match params.format.as_deref().map(str::to_lowercase).as_deref() {
441        Some("webp") => image::ImageFormat::WebP,
442        Some("png") => image::ImageFormat::Png,
443        Some("jpeg") | Some("jpg") => image::ImageFormat::Jpeg,
444        _ => original_format,
445    };
446
447    let quality = params.quality.unwrap_or(DEFAULT_JPEG_QUALITY);
448
449    let mut buf = std::io::Cursor::new(Vec::new());
450    match output_format {
451        image::ImageFormat::Jpeg => {
452            let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality);
453            img.write_with_encoder(encoder)
454                .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
455        }
456        image::ImageFormat::WebP => {
457            let config = crate::webp::WEBP_CONFIG.clone();
458            let webp_quality = params.quality.map(|q| q as f32).unwrap_or(config.quality);
459            let webp_data = crate::webp::encode(&img, webp_quality, config.method)
460                .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
461            buf = std::io::Cursor::new(webp_data);
462        }
463        _ => {
464            img.write_to(&mut buf, output_format)
465                .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
466        }
467    }
468
469    let ct = content_type(output_format);
470    Ok((buf.into_inner(), ct))
471}
472
473#[cfg(feature = "server")]
474fn process_image_blocking(
475    data: Vec<u8>,
476    params: ImageParams,
477    path: String,
478) -> Result<(Vec<u8>, HeaderValue), StatusCode> {
479    let original_format = detect_format(&path);
480
481    let img = if original_format == image::ImageFormat::WebP {
482        match crate::webp::decode(&data) {
483            Ok(img) => {
484                check_image_dimensions(img.width(), img.height())?;
485                img
486            }
487            Err(e) => {
488                // decode 失败不再降级返回原始字节(可能是构造的畸形文件,配合 nosniff
489                // 构成内容混淆面),直接报错让上层返回 422(M3)。
490                tracing::warn!("WebP decode failed ({}), rejecting", e);
491                return Err(StatusCode::UNPROCESSABLE_ENTITY);
492            }
493        }
494    } else {
495        let cursor = std::io::Cursor::new(&data);
496        let mut reader = image::ImageReader::with_format(cursor, original_format);
497        reader.limits(image_reader_limits());
498        match reader.decode() {
499            Ok(img) => img,
500            Err(e) => {
501                tracing::warn!("Image decode failed ({}), rejecting", e);
502                return Err(StatusCode::UNPROCESSABLE_ENTITY);
503            }
504        }
505    };
506
507    process_image(img, &params, original_format)
508}
509
510#[cfg(feature = "server")]
511/// 校验请求路径不会逃出 uploads 目录。
512///
513/// 两层校验:① 子串级拒绝 `..`/`\0`/绝对路径前缀;② 对已存在文件用 canonicalize
514/// 确认解析后真实路径仍在 uploads 目录内(纵深防御,抵御符号链接等绕过)。
515/// 文件不存在或 uploads 目录不存在时只做第一层校验(由后续 read 报 404)。
516async fn is_path_safe(path: &str) -> bool {
517    if path.contains("..") || path.contains('\0') || path.starts_with('/') {
518        return false;
519    }
520    let candidate = std::path::Path::new("uploads").join(path);
521    let uploads_root = match tokio::fs::canonicalize("uploads").await {
522        Ok(p) => p,
523        Err(_) => return true, // uploads 目录不存在(测试环境),只靠第一层校验。
524    };
525    match tokio::fs::canonicalize(&candidate).await {
526        Ok(resolved) => resolved.starts_with(&uploads_root),
527        Err(_) => true, // 文件不存在,交由后续读取报 404。
528    }
529}
530
531#[cfg(feature = "server")]
532use axum::http::HeaderMap;
533
534#[cfg(feature = "server")]
535const CACHE_DIR: &str = "uploads/.cache";
536
537#[cfg(feature = "server")]
538/// 素材删除时清理其派生缓存。
539///
540/// - 内存处理缓存 `IMAGE_CACHE`:key 形如 `{path}|w=..|thumb=..`,按前缀批量失效;
541/// - 尺寸缓存 `IMAGE_DIMENSIONS_CACHE`:key 即相对路径,直接失效。
542///
543/// 磁盘派生缓存(`uploads/.cache/cache_<sha256(key)>`)文件名是整 key 的哈希,
544/// 无法按路径前缀枚举;这些死条目由 `image_cache_cleanup` 后台任务
545/// 按容量/年龄回收,不在删除路径上处理。
546pub async fn invalidate_asset_caches(rel_path: &str) {
547    let prefix = format!("{}|", rel_path);
548    // moka 的 invalidate_entries_if 是同步谓词求值,返回 Result(仅谓词 panic 时 Err)。
549    let _ = IMAGE_CACHE.invalidate_entries_if(move |k, _| k.starts_with(&prefix));
550    IMAGE_DIMENSIONS_CACHE.invalidate(rel_path);
551}
552
553#[cfg(feature = "server")]
554fn disk_cache_base(cache_key: &str) -> String {
555    // 使用 SHA-256 生成稳定的磁盘缓存文件名,避免进程重启后 DefaultHasher 随机 seed
556    // 导致旧缓存无法命中且文件无限累积。
557    use sha2::Digest;
558    let hash = sha2::Sha256::digest(cache_key.as_bytes());
559    let hash_hex = hex::encode(hash);
560    format!("{}/cache_{}", CACHE_DIR, hash_hex)
561}
562
563#[cfg(feature = "server")]
564async fn read_disk_cache(cache_key: &str) -> Option<CachedImage> {
565    let base = disk_cache_base(cache_key);
566    let data = tokio::fs::read(format!("{}.dat", base)).await.ok()?;
567    let ct_str = tokio::fs::read_to_string(format!("{}.ct", base))
568        .await
569        .ok()
570        .unwrap_or_else(|| "application/octet-stream".to_string());
571    let content_type = HeaderValue::from_str(&ct_str).ok()?;
572    Some(CachedImage {
573        data: Bytes::from(data),
574        content_type,
575    })
576}
577
578#[cfg(feature = "server")]
579async fn write_disk_cache(cache_key: &str, cached: &CachedImage) {
580    let base = disk_cache_base(cache_key);
581    if let Err(e) = tokio::fs::create_dir_all(CACHE_DIR).await {
582        tracing::warn!("Failed to create cache dir: {:?}", e);
583        return;
584    }
585    let ct_str = cached
586        .content_type
587        .to_str()
588        .unwrap_or("application/octet-stream");
589
590    // 原子写:先写 .tmp 再 rename,避免并发请求读到 .dat 与 .ct 错配的半成品(L5)。
591    let dat_path = format!("{}.dat", base);
592    let ct_path = format!("{}.ct", base);
593    let dat_tmp = format!("{}.dat.tmp", base);
594    let ct_tmp = format!("{}.ct.tmp", base);
595
596    // 两个临时文件都写成功后才 rename;任一失败则清理半成品。
597    let writes_ok = tokio::fs::write(&dat_tmp, &cached.data).await.is_ok()
598        && tokio::fs::write(&ct_tmp, ct_str).await.is_ok();
599
600    if !writes_ok {
601        let _ = tokio::fs::remove_file(&dat_tmp).await;
602        let _ = tokio::fs::remove_file(&ct_tmp).await;
603        tracing::warn!("Failed to write disk cache temp files at {}", base);
604        return;
605    }
606
607    let rename_dat = tokio::fs::rename(&dat_tmp, &dat_path).await;
608    let rename_ct = tokio::fs::rename(&ct_tmp, &ct_path).await;
609    if rename_dat.is_err() || rename_ct.is_err() {
610        // rename 失败:清理可能残留的临时文件与目标,避免读到错配内容。
611        let _ = tokio::fs::remove_file(&dat_tmp).await;
612        let _ = tokio::fs::remove_file(&ct_tmp).await;
613        tracing::warn!("Failed to atomically rename disk cache at {}", base);
614    }
615}
616
617#[cfg(feature = "server")]
618/// 图片访问与动态处理的 Axum handler。
619///
620/// 依次执行:限流 → 路径安全校验 → 参数校验 → 无参数时直接返回原文件 →
621/// 查询内存缓存 → 查询磁盘缓存 → 读取并解码 → 处理 → 写入两级缓存 → 返回。
622pub async fn serve_image(
623    connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
624    Path(path): Path<String>,
625    Query(params): Query<ImageParams>,
626    headers: HeaderMap,
627) -> Response {
628    let peer = connect_info.map(|Extension(ConnectInfo(addr))| addr);
629    let ip = crate::api::rate_limit::get_client_ip_with_peer(&headers, peer);
630    if let Err(status) = crate::api::rate_limit::check_image_limit(&ip) {
631        return status.into_response();
632    }
633
634    if !is_path_safe(&path).await {
635        return StatusCode::FORBIDDEN.into_response();
636    }
637
638    let file_path = format!("uploads/{}", path);
639
640    // Validate params
641    if let Err(status) = params.validate() {
642        return status.into_response();
643    }
644
645    // No processing params: return raw file with long-lived cache headers.
646    if params.is_empty() {
647        // 原始分支也限制大小,避免读取超大文件撑爆内存(M3)。上限 20MB
648        // 覆盖正常上传图(上传侧 MAX_FILE_SIZE=5MB),拒绝异常大文件。
649        const MAX_RAW_BYTES: u64 = 20 * 1024 * 1024;
650        return match tokio::fs::metadata(&file_path).await {
651            Ok(meta) if meta.len() > MAX_RAW_BYTES => StatusCode::PAYLOAD_TOO_LARGE.into_response(),
652            Ok(_) => match tokio::fs::read(&file_path).await {
653                Ok(data) => {
654                    let ct = content_type(detect_format(&path));
655                    image_response(
656                        Bytes::from(data),
657                        ct,
658                        "public, max-age=31536000, immutable",
659                        &headers,
660                    )
661                }
662                Err(_) => StatusCode::NOT_FOUND.into_response(),
663            },
664            Err(_) => StatusCode::NOT_FOUND.into_response(),
665        };
666    }
667
668    let cache_key = params.cache_key(&path);
669    if let Some(cached) = IMAGE_CACHE.get(&cache_key).await {
670        return image_response(
671            cached.data.clone(),
672            cached.content_type,
673            "public, max-age=86400",
674            &headers,
675        );
676    }
677
678    if let Some(cached) = read_disk_cache(&cache_key).await {
679        let data = cached.data.clone();
680        let content_type = cached.content_type.clone();
681        let _ = IMAGE_CACHE.insert(cache_key.clone(), cached).await;
682        return image_response(data, content_type, "public, max-age=86400", &headers);
683    }
684
685    let data = match tokio::fs::read(&file_path).await {
686        Ok(d) => d,
687        Err(_) => return StatusCode::NOT_FOUND.into_response(),
688    };
689
690    // Offload decode + resize + encode to the blocking pool so the async
691    // runtime stays responsive to other requests.
692    let path_for_blocking = path.clone();
693    let params_for_blocking = params.clone();
694    let (processed, content_type) = match tokio::task::spawn_blocking(move || {
695        process_image_blocking(data, params_for_blocking, path_for_blocking)
696    })
697    .await
698    {
699        Ok(Ok(r)) => r,
700        Ok(Err(status)) => return status.into_response(),
701        Err(_) => {
702            tracing::error!("Image processing task panicked");
703            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
704        }
705    };
706
707    let processed = Bytes::from(processed);
708    let cached = CachedImage {
709        data: processed.clone(),
710        content_type: content_type.clone(),
711    };
712    let _ = IMAGE_CACHE.insert(cache_key.clone(), cached.clone()).await;
713    write_disk_cache(&cache_key, &cached).await;
714
715    image_response(processed, content_type, "public, max-age=86400", &headers)
716}
717
718/// 图片尺寸缓存(moka sync)。key = 相对路径如 "2026/06/22/x.webp"。
719/// 用 sync cache 而非 future cache:render_markdown_enhanced 是同步函数,不能 .await。
720#[cfg(feature = "server")]
721static IMAGE_DIMENSIONS_CACHE: LazyLock<SyncCache<String, (u32, u32)>> = LazyLock::new(|| {
722    let ttl = std::env::var("IMAGE_DIMENSIONS_CACHE_TTL_SECS")
723        .ok()
724        .and_then(|s| s.parse::<u64>().ok())
725        .map(std::time::Duration::from_secs)
726        .unwrap_or(std::time::Duration::from_secs(86400)); // 默认 24h
727    SyncCache::builder().time_to_live(ttl).build()
728});
729
730/// 读取图片真实尺寸(只读 header,不解码像素)。
731///
732/// - `rel_path`:相对路径如 "2026/06/22/x.webp"(不含 /uploads/ 前缀和 query)
733/// - 优先查缓存;miss 时读文件、解析 header、写入缓存
734/// - 失败返回 None(调用方回退到不设 aspect-ratio)
735#[cfg(feature = "server")]
736pub fn get_image_dimensions(rel_path: &str) -> Option<(u32, u32)> {
737    if let Some(dims) = IMAGE_DIMENSIONS_CACHE.get(rel_path) {
738        return Some(dims);
739    }
740    let full_path = std::path::Path::new("uploads").join(rel_path);
741    // 只读取文件头部:尺寸信息位于各格式 header(PNG IHDR / GIF / WebP RIFF /
742    // JPEG SOF),无需把整张多 MB 图片读进内存。JPEG 的 SOF 可能跟在
743    // EXIF/APPn 标记之后,64 KiB 足以覆盖常见情况。
744    let file = std::fs::File::open(&full_path).ok()?;
745    use std::io::Read;
746    let mut header = Vec::new();
747    file.take(65_536).read_to_end(&mut header).ok()?;
748    let dims = read_dimensions_from_bytes(&header, rel_path)?;
749    IMAGE_DIMENSIONS_CACHE.insert(rel_path.to_string(), dims);
750    Some(dims)
751}
752
753/// 按扩展名分发:webp 走 zenwebp header,gif/png/jpeg 走 image crate。
754#[cfg(feature = "server")]
755fn read_dimensions_from_bytes(data: &[u8], path: &str) -> Option<(u32, u32)> {
756    let ext = std::path::Path::new(path)
757        .extension()?
758        .to_str()?
759        .to_lowercase();
760    match ext.as_str() {
761        "webp" => read_webp_dimensions(data),
762        "jpg" | "jpeg" => read_image_dimensions(data, image::ImageFormat::Jpeg),
763        "png" => read_image_dimensions(data, image::ImageFormat::Png),
764        "gif" => read_image_dimensions(data, image::ImageFormat::Gif),
765        _ => None,
766    }
767}
768
769#[cfg(all(test, feature = "server"))]
770mod tests {
771    use super::*;
772
773    #[test]
774    fn read_webp_dimensions_from_bytes() {
775        // 构造一个 16x9 的 webp
776        let img = image::DynamicImage::new_rgb8(16, 9);
777        let webp_bytes = crate::webp::encode(&img, 85.0, 2).unwrap();
778        let dims = read_dimensions_from_bytes(&webp_bytes, "test.webp");
779        assert_eq!(dims, Some((16, 9)));
780    }
781
782    #[test]
783    fn read_png_dimensions_from_bytes() {
784        let img = image::DynamicImage::new_rgb8(32, 24);
785        let mut buf = std::io::Cursor::new(Vec::new());
786        img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
787        let dims = read_dimensions_from_bytes(&buf.into_inner(), "test.png");
788        assert_eq!(dims, Some((32, 24)));
789    }
790
791    #[test]
792    fn read_dimensions_unknown_extension_returns_none() {
793        let dims = read_dimensions_from_bytes(b"not an image", "test.xyz");
794        assert_eq!(dims, None);
795    }
796
797    // —— upload_dimensions:统一上传尺寸/像素上限校验 ——
798
799    /// 构造指定尺寸的 PNG 字节(内存占用 = 尺寸,仅用于 header 校验测试)。
800    fn make_png_bytes(w: u32, h: u32) -> Vec<u8> {
801        let img = image::DynamicImage::new_rgb8(w, h);
802        let mut buf = std::io::Cursor::new(Vec::new());
803        img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
804        buf.into_inner()
805    }
806
807    #[test]
808    fn upload_dimensions_accepts_small_png() {
809        let data = make_png_bytes(100, 100);
810        assert!(upload_dimensions(&data, "image/png").is_ok());
811    }
812
813    #[test]
814    fn upload_dimensions_accepts_boundary_png() {
815        // 用 7000×7000(≈49M 像素):单边 7000 < 默认 8192 上限,且总像素 < 默认 50M 上限,应放行。
816        // 注意不能直接用 *MAX_IMAGE_DIMENSION 做正方形边——8192²≈67M 会触发像素上限拒绝。
817        let data = make_png_bytes(7000, 7000);
818        assert!(upload_dimensions(&data, "image/png").is_ok());
819    }
820
821    #[test]
822    fn upload_dimensions_rejects_oversized_width() {
823        // 单边超限:(上限+1)×1,像素远低于上限,但单边越界
824        let data = make_png_bytes(*MAX_IMAGE_DIMENSION + 1, 1);
825        let err = upload_dimensions(&data, "image/png").unwrap_err();
826        assert!(err.contains("尺寸过大"));
827    }
828
829    #[test]
830    fn upload_dimensions_rejects_oversized_height() {
831        let data = make_png_bytes(1, *MAX_IMAGE_DIMENSION + 1);
832        let err = upload_dimensions(&data, "image/png").unwrap_err();
833        assert!(err.contains("尺寸过大"));
834    }
835
836    #[test]
837    fn upload_dimensions_accepts_small_webp() {
838        let img = image::DynamicImage::new_rgb8(64, 48);
839        let webp_bytes = crate::webp::encode(&img, 85.0, 2).unwrap();
840        assert!(upload_dimensions(&webp_bytes, "image/webp").is_ok());
841    }
842
843    #[test]
844    fn upload_dimensions_accepts_gif() {
845        // image crate 默认启用 gif feature,into_dimensions 可读 GIF header
846        let img = image::DynamicImage::new_rgb8(32, 32);
847        let mut buf = std::io::Cursor::new(Vec::new());
848        img.write_to(&mut buf, image::ImageFormat::Gif).unwrap();
849        assert!(upload_dimensions(&buf.into_inner(), "image/gif").is_ok());
850    }
851
852    #[test]
853    fn upload_dimensions_rejects_corrupt_bytes() {
854        // 非 magic bytes,与现有损坏文件校验文案对齐
855        let err = upload_dimensions(b"not an image at all", "image/png").unwrap_err();
856        assert_eq!(err, "图片文件损坏或格式不正确");
857    }
858
859    #[test]
860    fn read_dimensions_by_mime_dispatches_webp() {
861        let img = image::DynamicImage::new_rgb8(16, 9);
862        let webp_bytes = crate::webp::encode(&img, 85.0, 2).unwrap();
863        assert_eq!(
864            read_dimensions_by_mime(&webp_bytes, "image/webp").unwrap(),
865            (16, 9)
866        );
867    }
868
869    #[test]
870    fn image_params_validate_valid_defaults() {
871        let params = ImageParams::default();
872        assert!(params.validate().is_ok());
873    }
874
875    #[test]
876    fn image_params_validate_valid_width() {
877        let params = ImageParams {
878            w: Some(100),
879            ..Default::default()
880        };
881        assert!(params.validate().is_ok());
882    }
883
884    #[test]
885    fn image_params_validate_zero_width_rejected() {
886        let params = ImageParams {
887            w: Some(0),
888            ..Default::default()
889        };
890        assert!(params.validate().is_err());
891    }
892
893    #[test]
894    fn image_params_validate_oversized_width_rejected() {
895        let params = ImageParams {
896            w: Some(*MAX_IMAGE_DIMENSION + 1),
897            ..Default::default()
898        };
899        assert!(params.validate().is_err());
900    }
901
902    #[test]
903    fn image_params_validate_valid_rotation() {
904        for angle in [0, 90, 180, 270] {
905            let params = ImageParams {
906                rotate: Some(angle),
907                ..Default::default()
908            };
909            assert!(params.validate().is_ok(), "angle {} should be valid", angle);
910        }
911    }
912
913    #[test]
914    fn image_params_validate_invalid_rotation_rejected() {
915        let params = ImageParams {
916            rotate: Some(45),
917            ..Default::default()
918        };
919        assert!(params.validate().is_err());
920    }
921
922    #[test]
923    fn image_params_validate_valid_format() {
924        for fmt in &["jpeg", "jpg", "png", "webp", "JPEG", "PNG"] {
925            let params = ImageParams {
926                format: Some(fmt.to_string()),
927                ..Default::default()
928            };
929            assert!(params.validate().is_ok(), "format {} should be valid", fmt);
930        }
931    }
932
933    #[test]
934    fn image_params_validate_invalid_format_rejected() {
935        let params = ImageParams {
936            format: Some("gif".to_string()),
937            ..Default::default()
938        };
939        assert!(params.validate().is_err());
940    }
941
942    #[test]
943    fn image_params_validate_valid_thumbnail() {
944        let params = ImageParams {
945            thumb: Some("200x150".to_string()),
946            ..Default::default()
947        };
948        assert!(params.validate().is_ok());
949    }
950
951    #[test]
952    fn image_params_validate_invalid_thumbnail_rejected() {
953        let params = ImageParams {
954            thumb: Some("200".to_string()),
955            ..Default::default()
956        };
957        assert!(params.validate().is_err());
958    }
959
960    #[test]
961    fn image_params_validate_valid_quality() {
962        let params = ImageParams {
963            quality: Some(85),
964            ..Default::default()
965        };
966        assert!(params.validate().is_ok());
967    }
968
969    #[test]
970    fn image_params_validate_zero_quality_rejected() {
971        let params = ImageParams {
972            quality: Some(0),
973            ..Default::default()
974        };
975        assert!(params.validate().is_err());
976    }
977
978    #[test]
979    fn image_params_validate_over_100_quality_rejected() {
980        let params = ImageParams {
981            quality: Some(101),
982            ..Default::default()
983        };
984        assert!(params.validate().is_err());
985    }
986
987    #[tokio::test]
988    async fn is_path_safe_normal() {
989        assert!(is_path_safe("images/photo.jpg").await);
990        assert!(is_path_safe("2024/01/photo.png").await);
991    }
992
993    #[tokio::test]
994    async fn is_path_safe_rejects_parent_dir() {
995        assert!(!is_path_safe("../etc/passwd").await);
996        assert!(!is_path_safe("foo/../../bar").await);
997    }
998
999    #[tokio::test]
1000    async fn is_path_safe_rejects_null_bytes() {
1001        assert!(!is_path_safe("foo\0bar").await);
1002    }
1003
1004    #[tokio::test]
1005    async fn is_path_safe_rejects_absolute_path() {
1006        assert!(!is_path_safe("/etc/passwd").await);
1007    }
1008
1009    #[test]
1010    fn detect_format_jpeg() {
1011        assert!(matches!(
1012            detect_format("photo.jpg"),
1013            image::ImageFormat::Jpeg
1014        ));
1015        assert!(matches!(
1016            detect_format("photo.jpeg"),
1017            image::ImageFormat::Jpeg
1018        ));
1019        assert!(matches!(
1020            detect_format("PHOTO.JPG"),
1021            image::ImageFormat::Jpeg
1022        ));
1023    }
1024
1025    #[test]
1026    fn detect_format_png() {
1027        assert!(matches!(detect_format("icon.png"), image::ImageFormat::Png));
1028    }
1029
1030    #[test]
1031    fn detect_format_webp() {
1032        assert!(matches!(
1033            detect_format("anim.webp"),
1034            image::ImageFormat::WebP
1035        ));
1036    }
1037
1038    #[test]
1039    fn detect_format_defaults_to_jpeg() {
1040        assert!(matches!(
1041            detect_format("file.xyz"),
1042            image::ImageFormat::Jpeg
1043        ));
1044    }
1045
1046    #[test]
1047    fn cache_key_differs_for_different_params() {
1048        let p1 = ImageParams {
1049            w: Some(100),
1050            ..Default::default()
1051        };
1052        let p2 = ImageParams {
1053            w: Some(200),
1054            ..Default::default()
1055        };
1056        assert_ne!(p1.cache_key("img.jpg"), p2.cache_key("img.jpg"));
1057    }
1058
1059    #[test]
1060    fn is_empty_true_when_all_none() {
1061        let params = ImageParams::default();
1062        assert!(params.is_empty());
1063    }
1064
1065    #[test]
1066    fn is_empty_false_when_any_set() {
1067        let params = ImageParams {
1068            w: Some(100),
1069            ..Default::default()
1070        };
1071        assert!(!params.is_empty());
1072    }
1073
1074    #[test]
1075    fn disk_cache_base_is_deterministic() {
1076        let key = "path|w=800";
1077        let base1 = disk_cache_base(key);
1078        let base2 = disk_cache_base(key);
1079        assert_eq!(base1, base2);
1080        assert!(base1.starts_with("uploads/.cache/cache_"));
1081    }
1082
1083    #[test]
1084    fn disk_cache_base_differs_for_different_keys() {
1085        let base1 = disk_cache_base("path|w=800");
1086        let base2 = disk_cache_base("path|w=1200");
1087        assert_ne!(base1, base2);
1088    }
1089
1090    #[test]
1091    fn process_image_blocking_resizes_png() {
1092        let img = image::DynamicImage::new_rgb8(100, 100);
1093        let mut buf = std::io::Cursor::new(Vec::new());
1094        img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
1095        let data = buf.into_inner();
1096
1097        let params = ImageParams {
1098            w: Some(50),
1099            format: Some("webp".to_string()),
1100            ..Default::default()
1101        };
1102
1103        let (out, ct) = process_image_blocking(data, params, "test.png".to_string()).unwrap();
1104        assert!(!out.is_empty());
1105        assert_eq!(ct, HeaderValue::from_static("image/webp"));
1106    }
1107
1108    #[test]
1109    fn image_response_includes_cache_headers() {
1110        let resp = image_response(
1111            Bytes::from(vec![1, 2, 3]),
1112            HeaderValue::from_static("image/webp"),
1113            "public, max-age=86400",
1114            &HeaderMap::new(),
1115        );
1116        assert_eq!(resp.status(), StatusCode::OK);
1117        let headers = resp.headers();
1118        assert_eq!(headers.get(header::CONTENT_TYPE).unwrap(), "image/webp");
1119        assert_eq!(
1120            headers.get(header::CACHE_CONTROL).unwrap(),
1121            "public, max-age=86400"
1122        );
1123        assert!(headers
1124            .get(header::ETAG)
1125            .unwrap()
1126            .to_str()
1127            .unwrap()
1128            .starts_with('"'));
1129    }
1130
1131    #[test]
1132    fn image_response_returns_304_when_etag_matches() {
1133        let data = Bytes::from(vec![1, 2, 3]);
1134        let etag = etag_for(&data);
1135        let mut req_headers = HeaderMap::new();
1136        req_headers.insert(header::IF_NONE_MATCH, HeaderValue::from_str(&etag).unwrap());
1137        let resp = image_response(
1138            data,
1139            HeaderValue::from_static("image/webp"),
1140            "public, max-age=86400",
1141            &req_headers,
1142        );
1143        assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
1144        let headers = resp.headers();
1145        assert_eq!(headers.get(header::ETAG).unwrap(), etag.as_str());
1146        assert_eq!(headers.get(header::CONTENT_TYPE).unwrap(), "image/webp");
1147        assert_eq!(
1148            headers.get(header::CACHE_CONTROL).unwrap(),
1149            "public, max-age=86400"
1150        );
1151    }
1152
1153    #[test]
1154    fn etag_matches_single() {
1155        assert!(etag_matches("\"abc\"", "\"abc\""));
1156        assert!(!etag_matches("\"abc\"", "\"def\""));
1157    }
1158
1159    #[test]
1160    fn etag_matches_list() {
1161        assert!(etag_matches("\"abc\", \"def\"", "\"def\""));
1162        assert!(!etag_matches("\"abc\", \"def\"", "\"ghi\""));
1163    }
1164
1165    #[test]
1166    fn etag_matches_weak_prefix() {
1167        assert!(etag_matches("W/\"abc\"", "\"abc\""));
1168    }
1169
1170    #[test]
1171    fn etag_matches_wildcard() {
1172        assert!(etag_matches("*", "\"anything\""));
1173    }
1174
1175    #[test]
1176    fn image_response_raw_file_is_immutable() {
1177        let resp = image_response(
1178            Bytes::from(vec![1, 2, 3]),
1179            HeaderValue::from_static("image/jpeg"),
1180            "public, max-age=31536000, immutable",
1181            &HeaderMap::new(),
1182        );
1183        assert_eq!(resp.status(), StatusCode::OK);
1184        let cache_control = resp
1185            .headers()
1186            .get(header::CACHE_CONTROL)
1187            .unwrap()
1188            .to_str()
1189            .unwrap();
1190        assert!(cache_control.contains("immutable"));
1191    }
1192
1193    #[test]
1194    fn etag_for_same_data_is_stable() {
1195        let a = etag_for(b"hello");
1196        let b = etag_for(b"hello");
1197        assert_eq!(a, b);
1198        assert_ne!(a, etag_for(b"world"));
1199    }
1200}