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/// 启动时从 settings 表(经 [`crate::config::image_limit`])读取,默认 8192。
50/// 下限 512(防误调到危险小值)。值烘焙进 LazyLock,修改面板值后需重启生效。
51pub static MAX_IMAGE_DIMENSION: LazyLock<u32> = LazyLock::new(|| {
52    let val = crate::config::image_limit().max_dimension;
53    tracing::info!("Image dimension limit loaded from DB: {}", val);
54    val
55});
56#[cfg(feature = "server")]
57const DEFAULT_JPEG_QUALITY: u8 = 85;
58#[cfg(feature = "server")]
59/// 允许处理的最大图片像素数(默认约 7k x 7k)。
60///
61/// 启动时从 settings 表(经 [`crate::config::image_limit`])读取,默认 50_000_000。
62/// 下限 1_000_000(防误调)。⚠️ 此值同时决定单图解码内存缓冲
63/// (max_alloc = pixels × 4 + 1MB),默认 50M 像素对应约 200MB/图。
64/// 值烘焙进 LazyLock,修改面板值后需重启生效。
65pub static MAX_IMAGE_PIXELS: LazyLock<u32> = LazyLock::new(|| {
66    // config 中以 u64 存储;此处回退到 u32(原 env 版本亦是 u32)。
67    let val = crate::config::image_limit().max_pixels.min(u32::MAX as u64) as u32;
68    tracing::info!("Image pixel limit loaded from DB: {}", val);
69    val
70});
71
72#[cfg(feature = "server")]
73#[derive(Debug, Clone)]
74/// 缓存条目,保存处理后的图片字节与 Content-Type。
75struct CachedImage {
76    data: Bytes,
77    content_type: HeaderValue,
78}
79
80#[cfg(feature = "server")]
81static IMAGE_CACHE: LazyLock<Cache<String, CachedImage>> = LazyLock::new(|| {
82    Cache::builder()
83        .max_capacity(100)
84        .time_to_idle(std::time::Duration::from_secs(300))
85        .build()
86});
87
88#[cfg(feature = "server")]
89/// 图片处理(解码/缩放/编码)的全局并发上限。
90///
91/// 取 CPU 核数 clamp 到 [2, 8]:单图处理大致单线程吃满一个核,下限保证小
92/// 机型吞吐,上限防止突发缓存 miss 同时分配过多大缓冲(单图最坏约
93/// MAX_IMAGE_PIXELS×4 字节瞬时内存)。只约束并发、不拒绝请求:超出许可
94/// 的 miss 排队等待;配合按 miss 计费的限流器,排队长度有界。
95static IMAGE_PROCESSING_PERMITS: LazyLock<tokio::sync::Semaphore> = LazyLock::new(|| {
96    let cores = std::thread::available_parallelism()
97        .map(std::num::NonZeroUsize::get)
98        .unwrap_or(2);
99    tokio::sync::Semaphore::new(cores.clamp(2, 8))
100});
101
102#[cfg(feature = "server")]
103#[derive(Debug, Deserialize, Clone, Hash, Eq, PartialEq, Default)]
104/// 图片处理查询参数。
105pub struct ImageParams {
106    /// 限制最大宽度。
107    pub w: Option<u32>,
108    /// 限制最大高度。
109    pub h: Option<u32>,
110    /// 缩略图尺寸,格式 `WxH`。
111    pub thumb: Option<String>,
112    /// 旋转角度,仅允许 0/90/180/270。
113    pub rotate: Option<u16>,
114    /// 输出格式:`jpeg`/`jpg`、`png`、`webp`。
115    pub format: Option<String>,
116    /// 输出质量,范围 1-100。
117    pub quality: Option<u8>,
118}
119
120#[cfg(feature = "server")]
121impl ImageParams {
122    fn is_empty(&self) -> bool {
123        self.w.is_none()
124            && self.h.is_none()
125            && self.thumb.is_none()
126            && self.rotate.is_none()
127            && self.format.is_none()
128            && self.quality.is_none()
129    }
130
131    fn cache_key(&self, path: &str) -> String {
132        use std::fmt::Write as _;
133        // 旧实现 vec![path.to_string()] + 最多 6 个 format! + join,最坏 9 次堆分配。
134        // 单次 with_capacity + write! 直写,1 次分配。
135        let mut key = String::with_capacity(path.len() + 64);
136        key.push_str(path);
137        if let Some(w) = self.w {
138            let _ = write!(key, "|w={}", w);
139        }
140        if let Some(h) = self.h {
141            let _ = write!(key, "|h={}", h);
142        }
143        if let Some(ref thumb) = self.thumb {
144            let _ = write!(key, "|thumb={}", thumb);
145        }
146        if let Some(r) = self.rotate {
147            let _ = write!(key, "|rotate={}", r);
148        }
149        if let Some(ref fmt) = self.format {
150            let _ = write!(key, "|format={}", fmt);
151        }
152        if let Some(q) = self.quality {
153            let _ = write!(key, "|quality={}", q);
154        }
155        key
156    }
157
158    /// 校验参数合法性,返回 HTTP 400 状态码表示非法。
159    fn validate(&self) -> Result<(), StatusCode> {
160        if let Some(dim) = self.w {
161            if dim == 0 || dim > *MAX_IMAGE_DIMENSION {
162                return Err(StatusCode::BAD_REQUEST);
163            }
164        }
165        if let Some(dim) = self.h {
166            if dim == 0 || dim > *MAX_IMAGE_DIMENSION {
167                return Err(StatusCode::BAD_REQUEST);
168            }
169        }
170        if let Some(r) = self.rotate {
171            if !matches!(r, 0 | 90 | 180 | 270) {
172                return Err(StatusCode::BAD_REQUEST);
173            }
174        }
175        if let Some(ref fmt) = self.format {
176            if !matches!(fmt.to_lowercase().as_str(), "jpeg" | "jpg" | "png" | "webp") {
177                return Err(StatusCode::BAD_REQUEST);
178            }
179        }
180        if let Some(ref thumb) = self.thumb {
181            let parts: Vec<&str> = thumb.split('x').collect();
182            if parts.len() != 2 {
183                return Err(StatusCode::BAD_REQUEST);
184            }
185            let tw: u32 = parts[0].parse().map_err(|_| StatusCode::BAD_REQUEST)?;
186            let th: u32 = parts[1].parse().map_err(|_| StatusCode::BAD_REQUEST)?;
187            if tw == 0 || th == 0 || tw > *MAX_IMAGE_DIMENSION || th > *MAX_IMAGE_DIMENSION {
188                return Err(StatusCode::BAD_REQUEST);
189            }
190        }
191        if let Some(q) = self.quality {
192            if q == 0 || q > 100 {
193                return Err(StatusCode::BAD_REQUEST);
194            }
195        }
196        Ok(())
197    }
198}
199
200#[cfg(feature = "server")]
201fn detect_format(path: &str) -> ImageFmt {
202    // 仅对路径后缀做大小写不敏感匹配,避免 to_lowercase() 对整条路径的 String 分配。
203    // path 形如 `uploads/2026/06/22/abc.webp`,rsplit('.') 取最后一段后缀即可。
204    let ext = path.rsplit('.').next().unwrap_or("");
205    if ext.eq_ignore_ascii_case("jpg") || ext.eq_ignore_ascii_case("jpeg") {
206        ImageFmt::Jpeg
207    } else if ext.eq_ignore_ascii_case("png") {
208        ImageFmt::Png
209    } else if ext.eq_ignore_ascii_case("webp") {
210        ImageFmt::WebP
211    } else if ext.eq_ignore_ascii_case("gif") {
212        ImageFmt::Gif
213    } else {
214        ImageFmt::Jpeg
215    }
216}
217
218/// detect_format 的轻量返回类型,避免在热路径上构造 image::ImageFormat。
219#[cfg(feature = "server")]
220type ImageFmt = image::ImageFormat;
221
222#[cfg(feature = "server")]
223fn content_type(format: image::ImageFormat) -> HeaderValue {
224    match format {
225        image::ImageFormat::Jpeg => HeaderValue::from_static("image/jpeg"),
226        image::ImageFormat::Png => HeaderValue::from_static("image/png"),
227        image::ImageFormat::WebP => HeaderValue::from_static("image/webp"),
228        image::ImageFormat::Gif => HeaderValue::from_static("image/gif"),
229        _ => HeaderValue::from_static("application/octet-stream"),
230    }
231}
232
233#[cfg(feature = "server")]
234fn image_response(
235    data: Bytes,
236    content_type: HeaderValue,
237    cache_control: &'static str,
238    headers: &HeaderMap,
239) -> Response {
240    let etag = etag_for(&data);
241    // etag 形如 `"deadbeef..."`(引号 + hex),都是合法 token-char,from_str 不可能失败。
242    // 用 expect 说明恒成立的不变量,避免裸 unwrap 触发 lint。
243    let etag_value = HeaderValue::from_str(&etag)
244        .expect("etag 仅含 ASCII hex 与双引号,必然是合法的 HeaderValue");
245
246    if let Some(if_none_match) = headers
247        .get(header::IF_NONE_MATCH)
248        .and_then(|v| v.to_str().ok())
249    {
250        if etag_matches(if_none_match, &etag) {
251            return (
252                StatusCode::NOT_MODIFIED,
253                [
254                    (header::ETAG, etag_value.clone()),
255                    (
256                        header::CACHE_CONTROL,
257                        HeaderValue::from_static(cache_control),
258                    ),
259                    (header::CONTENT_TYPE, content_type),
260                    // nosniff 防止浏览器对 content-type 错配的图片字节做 MIME sniff(M2)。
261                    (
262                        header::X_CONTENT_TYPE_OPTIONS,
263                        HeaderValue::from_static("nosniff"),
264                    ),
265                ],
266            )
267                .into_response();
268        }
269    }
270
271    (
272        StatusCode::OK,
273        [
274            (header::CONTENT_TYPE, content_type),
275            (
276                header::CACHE_CONTROL,
277                HeaderValue::from_static(cache_control),
278            ),
279            (header::ETAG, etag_value),
280            (
281                header::X_CONTENT_TYPE_OPTIONS,
282                HeaderValue::from_static("nosniff"),
283            ),
284        ],
285        data,
286    )
287        .into_response()
288}
289
290#[cfg(feature = "server")]
291fn check_image_dimensions(width: u32, height: u32) -> Result<(), StatusCode> {
292    if width == 0 || height == 0 {
293        return Err(StatusCode::BAD_REQUEST);
294    }
295    let pixels = u64::from(width) * u64::from(height);
296    if pixels > u64::from(*MAX_IMAGE_PIXELS) {
297        tracing::warn!(
298            "Image dimensions too large: {}x{} ({} pixels, max {})",
299            width,
300            height,
301            pixels,
302            *MAX_IMAGE_PIXELS
303        );
304        return Err(StatusCode::PAYLOAD_TOO_LARGE);
305    }
306    Ok(())
307}
308
309#[cfg(feature = "server")]
310/// 仅读取 header 校验上传图片的尺寸/像素是否超限,并返回 (width, height)。
311///
312/// - 输入是原始字节 + MIME,内部按 MIME 分发只解析 header 拿尺寸(不解码像素)
313/// - 返回带友好中文提示的 `&'static str`(供上传接口直接回给用户)
314///
315/// 上传入口三种格式在此统一拦截至尺寸上限;WebP 走 `zenwebp` header,
316/// JPEG/PNG/GIF 走 `image` crate 的 `into_dimensions`(均只读 header)。
317/// 尺寸随结果返回,供 `upload_image` 校验通过后直接写入 assets 表,避免二次解析。
318pub(crate) fn upload_dimensions(data: &[u8], mime_type: &str) -> Result<(u32, u32), &'static str> {
319    let dims = read_dimensions_by_mime(data, mime_type)?;
320    let (width, height) = dims;
321    if width == 0 || height == 0 {
322        return Err("图片文件损坏或格式不正确");
323    }
324    let pixels = u64::from(width) * u64::from(height);
325    let max_dim = *MAX_IMAGE_DIMENSION;
326    let max_pixels = *MAX_IMAGE_PIXELS;
327    if width > max_dim || height > max_dim || pixels > u64::from(max_pixels) {
328        tracing::warn!(
329            "Uploaded image too large: {}x{} ({} pixels, max {}x{} / {} pixels)",
330            width,
331            height,
332            pixels,
333            max_dim,
334            max_dim,
335            max_pixels
336        );
337        return Err("图片尺寸过大,请压缩后再上传");
338    }
339    Ok(dims)
340}
341
342#[cfg(feature = "server")]
343/// 按 MIME 只读 header 拿 (width, height)。失败返回损坏错误。
344fn read_dimensions_by_mime(data: &[u8], mime_type: &str) -> Result<(u32, u32), &'static str> {
345    match mime_type {
346        "image/webp" => read_webp_dimensions(data).ok_or("图片文件损坏或格式不正确"),
347        "image/jpeg" | "image/png" | "image/gif" => {
348            let format = match mime_type {
349                "image/jpeg" => image::ImageFormat::Jpeg,
350                "image/png" => image::ImageFormat::Png,
351                _ => image::ImageFormat::Gif,
352            };
353            read_image_dimensions(data, format).ok_or("图片文件损坏或格式不正确")
354        }
355        // 上游已用 ALLOWED_MIME_TYPES 白名单拦截,理论不到这里
356        _ => Err("图片文件损坏或格式不正确"),
357    }
358}
359
360/// 手动解析 WebP RIFF header 拿尺寸(前 30 字节即够,不调用 zenwebp 全解码器)。
361///
362/// 不用 `zenwebp::WebPDecoder::build` 的原因:VP8X(扩展格式)的全解码器在
363/// 构造时会扫描所有 RIFF chunk(EXIF/ICCP/ALPH…),文件大于传入缓冲区时失败。
364/// [`get_image_dimensions`] 只读 64 KiB header 前缀,大 VP8X WebP(> 64 KiB)
365/// 因此丢失尺寸 → rebuild 索引时该文件被跳过 → DB 行被误删(issue #30)。
366///
367/// 三种子格式的尺寸都在 RIFF 签名之后的前 30 字节内:
368/// - `VP8 `(lossy):byte 26-27 width、28-29 height,`u16_le & 0x3FFF`
369/// - `VP8L`(lossless):byte 21-24 为 `u32_le` header,
370///   `width=(1+h)&0x3FFF`、`height=(1+(h>>14))&0x3FFF`
371/// - `VP8X`(extended):byte 24-26 / 27-29 各为 24-bit LE,`+1` 得画布尺寸
372#[cfg(feature = "server")]
373fn read_webp_dimensions(data: &[u8]) -> Option<(u32, u32)> {
374    // RIFF(4) + size(4) + WEBP(4) + chunk(4) = 16 字节签名;VP8X 尺寸到 byte 29。
375    if data.len() < 30 || &data[0..4] != b"RIFF" || &data[8..12] != b"WEBP" {
376        return None;
377    }
378    let dims = match &data[12..16] {
379        b"VP8 " => {
380            // lossy:byte 26-27 / 28-29 为 u16_le,低 14 位是尺寸(高 2 位是缩放系数)。
381            let w = u16::from_le_bytes([data[26], data[27]]) & 0x3FFF;
382            let h = u16::from_le_bytes([data[28], data[29]]) & 0x3FFF;
383            (w as u32, h as u32)
384        }
385        b"VP8L" => {
386            // lossless:byte 21-24 为 u32_le bitstream header。
387            let h = u32::from_le_bytes([data[21], data[22], data[23], data[24]]);
388            ((1 + h) & 0x3FFF, (1 + (h >> 14)) & 0x3FFF)
389        }
390        b"VP8X" => {
391            // extended:byte 24-26 / 27-29 各为 24-bit LE,+1 得画布尺寸。
392            let w = u32::from_le_bytes([data[24], data[25], data[26], 0]) + 1;
393            let h = u32::from_le_bytes([data[27], data[28], data[29], 0]) + 1;
394            (w, h)
395        }
396        _ => return None,
397    };
398    if dims.0 == 0 || dims.1 == 0 {
399        return None;
400    }
401    Some(dims)
402}
403
404/// 读 image crate 支持格式(jpeg/png/gif)的 header 拿尺寸。
405#[cfg(feature = "server")]
406fn read_image_dimensions(data: &[u8], format: image::ImageFormat) -> Option<(u32, u32)> {
407    let reader = image::ImageReader::with_format(std::io::Cursor::new(data), format);
408    reader.into_dimensions().ok()
409}
410
411/// 构造统一的 image::Limits(宽度/高度/分配上限),供 upload 与 image serving 共享。
412#[cfg(feature = "server")]
413pub(crate) fn image_reader_limits() -> image::Limits {
414    let mut limits = image::Limits::default();
415    limits.max_image_width = Some(*MAX_IMAGE_DIMENSION);
416    limits.max_image_height = Some(*MAX_IMAGE_DIMENSION);
417    limits.max_alloc = Some(*MAX_IMAGE_PIXELS as u64 * 4 + 1024 * 1024);
418    limits
419}
420
421/// 检测图片是否为动画(多帧)格式。
422///
423/// 动图的每一帧都承载信息(运动、表情、进度),而 `image::DynamicImage` 与
424/// `zenwebp` 的 `read_image`(`api.rs` 中非动画路径)只能解码单帧——经过
425/// `process_image` 解码→缩放→重编码后会永久丢失动画,只剩第一帧。
426///
427/// 因此在处理流水线入口拦截:动图原样返回字节(动画保留),由两层缓存
428/// 按参数化 key 缓存。检测只读容器 header,不做像素解码、不分配:
429/// - **WebP**:扫描 RIFF chunk,出现 `ANMF`(动画帧)即动图。
430/// - **GIF**:扫描 `NETSCAPE2.0` 应用扩展(动图 GIF 的标准循环标记)。
431///
432/// JPEG/PNG 不可能是动图,直接返回 false。
433#[cfg(feature = "server")]
434fn is_animated_image(data: &[u8], format: image::ImageFormat) -> bool {
435    match format {
436        image::ImageFormat::WebP => {
437            // RIFF(4)+size(4)+WEBP(4)=12 字节签名;逐 chunk 扫描 FourCC+size。
438            // Animated WebP 必有 VP8X + ANIM + 至少一个 ANMF。
439            data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" && {
440                let mut pos = 12;
441                let mut found = false;
442                while pos + 8 <= data.len() {
443                    let fourcc = &data[pos..pos + 4];
444                    let chunk_size = u32::from_le_bytes([
445                        data[pos + 4],
446                        data[pos + 5],
447                        data[pos + 6],
448                        data[pos + 7],
449                    ]) as usize;
450                    if fourcc == b"ANMF" {
451                        found = true;
452                        break;
453                    }
454                    // chunk 体 2 字节对齐(RIFF 规范)。
455                    pos += 8 + ((chunk_size + 1) & !1);
456                }
457                found
458            }
459        }
460        image::ImageFormat::Gif => {
461            // 动画 GIF 的标志是 NETSCAPE2.0 应用扩展块(循环控制)。
462            // 块结构:0x21(扩展) 0xFF(应用) 0x0B(块大小) "NETSCAPE2.0" ...
463            const NETSCAPE: &[u8] = b"\x21\xff\x0bNETSCAPE2.0";
464            data.windows(NETSCAPE.len()).any(|w| w == NETSCAPE)
465        }
466        _ => false,
467    }
468}
469
470#[cfg(feature = "server")]
471fn process_image(
472    img: image::DynamicImage,
473    params: &ImageParams,
474    original_format: image::ImageFormat,
475) -> Result<(Vec<u8>, HeaderValue), StatusCode> {
476    check_image_dimensions(img.width(), img.height())?;
477    let mut img = img;
478
479    // Rotate first, then resize
480    if let Some(degrees) = params.rotate {
481        img = match degrees {
482            90 => img.rotate90(),
483            180 => img.rotate180(),
484            270 => img.rotate270(),
485            _ => img,
486        };
487    }
488
489    // Resize by max dimensions (keep aspect ratio)
490    if params.w.is_some() || params.h.is_some() {
491        let max_w = params.w.unwrap_or(img.width());
492        let max_h = params.h.unwrap_or(img.height());
493        if img.width() > max_w || img.height() > max_h {
494            img = img.resize(max_w, max_h, image::imageops::FilterType::Lanczos3);
495        }
496    }
497
498    // Thumbnail: fit-in-box (same semantics as resize, but both dimensions required)
499    if let Some(ref thumb_spec) = params.thumb {
500        let parts: Vec<&str> = thumb_spec.split('x').collect();
501        if parts.len() == 2 {
502            let tw: u32 = parts[0].parse().map_err(|_| StatusCode::BAD_REQUEST)?;
503            let th: u32 = parts[1].parse().map_err(|_| StatusCode::BAD_REQUEST)?;
504            if tw > 0 && th > 0 && tw <= *MAX_IMAGE_DIMENSION && th <= *MAX_IMAGE_DIMENSION {
505                img = img.thumbnail(tw, th);
506            }
507        }
508    }
509
510    // Output format (case-insensitive)
511    let output_format = match params.format.as_deref().map(str::to_lowercase).as_deref() {
512        Some("webp") => image::ImageFormat::WebP,
513        Some("png") => image::ImageFormat::Png,
514        Some("jpeg") | Some("jpg") => image::ImageFormat::Jpeg,
515        _ => original_format,
516    };
517
518    let quality = params.quality.unwrap_or(DEFAULT_JPEG_QUALITY);
519
520    let mut buf = std::io::Cursor::new(Vec::new());
521    match output_format {
522        image::ImageFormat::Jpeg => {
523            let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality);
524            img.write_with_encoder(encoder)
525                .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
526        }
527        image::ImageFormat::WebP => {
528            let config = crate::infra::webp::WEBP_CONFIG.clone();
529            let webp_quality = params.quality.map(|q| q as f32).unwrap_or(config.quality);
530            let webp_data = crate::infra::webp::encode(&img, webp_quality, config.method)
531                .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
532            buf = std::io::Cursor::new(webp_data);
533        }
534        _ => {
535            img.write_to(&mut buf, output_format)
536                .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
537        }
538    }
539
540    let ct = content_type(output_format);
541    Ok((buf.into_inner(), ct))
542}
543
544#[cfg(feature = "server")]
545fn process_image_blocking(
546    data: Vec<u8>,
547    params: ImageParams,
548    path: String,
549) -> Result<(Vec<u8>, HeaderValue), StatusCode> {
550    let original_format = detect_format(&path);
551
552    // 动图(animated WebP / GIF)原样返回,绕过解码→缩放→重编码。
553    // `image::DynamicImage` 与 zenwebp 的单帧 `read_image` 无法承载多帧;
554    // 一旦走 process_image,动画会被永久压缩成第一帧(issue #29)。
555    // 原始字节进入两层缓存(key 含 thumb/w 参数),后续命中即静态返回。
556    // 代价:动图缩略图返回全尺寸文件(上传上限 5 MiB,首 miss 后缓存),
557    // 相对丢失动画是不可接受的回归。
558    if is_animated_image(&data, original_format) {
559        return Ok((data, content_type(original_format)));
560    }
561    let img = if original_format == image::ImageFormat::WebP {
562        match crate::infra::webp::decode(&data) {
563            Ok(img) => {
564                check_image_dimensions(img.width(), img.height())?;
565                img
566            }
567            Err(e) => {
568                // decode 失败不再降级返回原始字节(可能是构造的畸形文件,配合 nosniff
569                // 构成内容混淆面),直接报错让上层返回 422(M3)。
570                tracing::warn!("WebP decode failed ({}), rejecting", e);
571                return Err(StatusCode::UNPROCESSABLE_ENTITY);
572            }
573        }
574    } else {
575        let cursor = std::io::Cursor::new(&data);
576        let mut reader = image::ImageReader::with_format(cursor, original_format);
577        reader.limits(image_reader_limits());
578        match reader.decode() {
579            Ok(img) => img,
580            Err(e) => {
581                tracing::warn!("Image decode failed ({}), rejecting", e);
582                return Err(StatusCode::UNPROCESSABLE_ENTITY);
583            }
584        }
585    };
586
587    process_image(img, &params, original_format)
588}
589
590#[cfg(feature = "server")]
591/// 校验请求路径不会逃出 uploads 目录。
592///
593/// 两层校验:① 子串级拒绝 `..`/`\0`/绝对路径前缀;② 对已存在文件用 canonicalize
594/// 确认解析后真实路径仍在 uploads 目录内(纵深防御,抵御符号链接等绕过)。
595/// 文件不存在或 uploads 目录不存在时只做第一层校验(由后续 read 报 404)。
596async fn is_path_safe(path: &str) -> bool {
597    if path.contains("..") || path.contains('\0') || path.starts_with('/') {
598        return false;
599    }
600    let candidate = std::path::Path::new("uploads").join(path);
601    let uploads_root = match tokio::fs::canonicalize("uploads").await {
602        Ok(p) => p,
603        Err(_) => return true, // uploads 目录不存在(测试环境),只靠第一层校验。
604    };
605    match tokio::fs::canonicalize(&candidate).await {
606        Ok(resolved) => resolved.starts_with(&uploads_root),
607        Err(_) => true, // 文件不存在,交由后续读取报 404。
608    }
609}
610
611#[cfg(feature = "server")]
612use axum::http::HeaderMap;
613
614#[cfg(feature = "server")]
615const CACHE_DIR: &str = "uploads/.cache";
616
617#[cfg(feature = "server")]
618/// 素材删除时清理其派生缓存。
619///
620/// - 内存处理缓存 `IMAGE_CACHE`:key 形如 `{path}|w=..|thumb=..`,按前缀批量失效;
621/// - 尺寸缓存 `IMAGE_DIMENSIONS_CACHE`:key 即相对路径,直接失效。
622///
623/// 磁盘派生缓存(`uploads/.cache/cache_<sha256(key)>`)文件名是整 key 的哈希,
624/// 无法按路径前缀枚举;这些死条目由 `image_cache_cleanup` 后台任务
625/// 按容量/年龄回收,不在删除路径上处理。
626pub async fn invalidate_asset_caches(rel_path: &str) {
627    let prefix = format!("{}|", rel_path);
628    // moka 的 invalidate_entries_if 是同步谓词求值,返回 Result(仅谓词 panic 时 Err)。
629    let _ = IMAGE_CACHE.invalidate_entries_if(move |k, _| k.starts_with(&prefix));
630    IMAGE_DIMENSIONS_CACHE.invalidate(rel_path);
631}
632
633/// 全量清理图片内存、尺寸与磁盘派生缓存。
634///
635/// 数据库恢复可能让同一路径对应不同的 assets/文件内容,不能只递增 SSR
636/// generation;必须同时删除磁盘 `.cache`,否则缓存命中会返回恢复前的图片。
637#[cfg(feature = "server")]
638pub async fn invalidate_all_caches() {
639    IMAGE_CACHE.invalidate_all();
640    IMAGE_DIMENSIONS_CACHE.invalidate_all();
641    if let Err(error) = tokio::fs::remove_dir_all(CACHE_DIR).await {
642        if error.kind() != std::io::ErrorKind::NotFound {
643            tracing::warn!("Failed to clear image disk cache: {error}");
644        }
645    }
646}
647
648#[cfg(feature = "server")]
649fn disk_cache_base(cache_key: &str) -> String {
650    // 使用 SHA-256 生成稳定的磁盘缓存文件名,避免进程重启后 DefaultHasher 随机 seed
651    // 导致旧缓存无法命中且文件无限累积。
652    use sha2::Digest;
653    let hash = sha2::Sha256::digest(cache_key.as_bytes());
654    let hash_hex = hex::encode(hash);
655    format!("{}/cache_{}", CACHE_DIR, hash_hex)
656}
657
658#[cfg(feature = "server")]
659async fn read_disk_cache(cache_key: &str) -> Option<CachedImage> {
660    let base = disk_cache_base(cache_key);
661    let data = tokio::fs::read(format!("{}.dat", base)).await.ok()?;
662    let ct_str = tokio::fs::read_to_string(format!("{}.ct", base))
663        .await
664        .ok()
665        .unwrap_or_else(|| "application/octet-stream".to_string());
666    let content_type = HeaderValue::from_str(&ct_str).ok()?;
667    Some(CachedImage {
668        data: Bytes::from(data),
669        content_type,
670    })
671}
672
673#[cfg(feature = "server")]
674async fn write_disk_cache(cache_key: &str, cached: &CachedImage) {
675    let base = disk_cache_base(cache_key);
676    if let Err(e) = tokio::fs::create_dir_all(CACHE_DIR).await {
677        tracing::warn!("Failed to create cache dir: {:?}", e);
678        return;
679    }
680    let ct_str = cached
681        .content_type
682        .to_str()
683        .unwrap_or("application/octet-stream");
684
685    // 原子写:先写 .tmp 再 rename,避免并发请求读到 .dat 与 .ct 错配的半成品(L5)。
686    let dat_path = format!("{}.dat", base);
687    let ct_path = format!("{}.ct", base);
688    let dat_tmp = format!("{}.dat.tmp", base);
689    let ct_tmp = format!("{}.ct.tmp", base);
690
691    // 两个临时文件都写成功后才 rename;任一失败则清理半成品。
692    let writes_ok = tokio::fs::write(&dat_tmp, &cached.data).await.is_ok()
693        && tokio::fs::write(&ct_tmp, ct_str).await.is_ok();
694
695    if !writes_ok {
696        let _ = tokio::fs::remove_file(&dat_tmp).await;
697        let _ = tokio::fs::remove_file(&ct_tmp).await;
698        tracing::warn!("Failed to write disk cache temp files at {}", base);
699        return;
700    }
701
702    let rename_dat = tokio::fs::rename(&dat_tmp, &dat_path).await;
703    let rename_ct = tokio::fs::rename(&ct_tmp, &ct_path).await;
704    if rename_dat.is_err() || rename_ct.is_err() {
705        // rename 失败:清理可能残留的临时文件与目标,避免读到错配内容。
706        let _ = tokio::fs::remove_file(&dat_tmp).await;
707        let _ = tokio::fs::remove_file(&ct_tmp).await;
708        tracing::warn!("Failed to atomically rename disk cache at {}", base);
709    }
710}
711
712#[cfg(feature = "server")]
713/// 图片访问与动态处理的 Axum handler。
714///
715/// 依次执行:路径安全校验 → 参数校验 → 分支:
716/// - 无参数原图直返:限流(每次真实读盘,防带宽滥用)→ 读盘返回;
717/// - 处理路径:内存缓存 → 磁盘缓存(命中即返回,本质是静态文件服务,
718///   不计费)→ 限流(只有穿透到「读盘 + 解码处理」的 miss 才扣令牌)→
719///   处理并发排队 → 读取并解码 → 处理 → 写入两级缓存 → 返回。
720///
721/// 按 miss 计费的原因:素材页/长文首刷单页可产生上百个缩略图请求,
722/// 若缓存命中也扣令牌,burst 配额会被正常浏览耗尽,批量 429 误杀。
723pub async fn serve_image(
724    connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
725    Path(path): Path<String>,
726    Query(params): Query<ImageParams>,
727    headers: HeaderMap,
728) -> Response {
729    let peer = connect_info.map(|Extension(ConnectInfo(addr))| addr);
730    let ip = crate::api::rate_limit::get_client_ip_with_peer(&headers, peer).await;
731
732    if !is_path_safe(&path).await {
733        return StatusCode::FORBIDDEN.into_response();
734    }
735
736    let file_path = format!("uploads/{}", path);
737
738    // Validate params
739    if let Err(status) = params.validate() {
740        return status.into_response();
741    }
742
743    // No processing params: return raw file with long-lived cache headers.
744    if params.is_empty() {
745        // 原图直返不经过任何缓存层(每次真实读盘),保留限流防带宽滥用。
746        if let Err(resp) = crate::api::rate_limit::check_image_limit(&ip) {
747            return *resp;
748        }
749        // 原始分支也限制大小,避免读取超大文件撑爆内存(M3)。上限 20MB
750        // 覆盖正常上传图(上传侧 MAX_FILE_SIZE=5MB),拒绝异常大文件。
751        const MAX_RAW_BYTES: u64 = 20 * 1024 * 1024;
752        return match tokio::fs::metadata(&file_path).await {
753            Ok(meta) if meta.len() > MAX_RAW_BYTES => StatusCode::PAYLOAD_TOO_LARGE.into_response(),
754            Ok(_) => match tokio::fs::read(&file_path).await {
755                Ok(data) => {
756                    let ct = content_type(detect_format(&path));
757                    image_response(
758                        Bytes::from(data),
759                        ct,
760                        "public, max-age=31536000, immutable",
761                        &headers,
762                    )
763                }
764                Err(_) => StatusCode::NOT_FOUND.into_response(),
765            },
766            Err(_) => StatusCode::NOT_FOUND.into_response(),
767        };
768    }
769
770    let cache_key = params.cache_key(&path);
771    if let Some(cached) = IMAGE_CACHE.get(&cache_key).await {
772        return image_response(
773            cached.data.clone(),
774            cached.content_type,
775            "public, max-age=86400",
776            &headers,
777        );
778    }
779
780    if let Some(cached) = read_disk_cache(&cache_key).await {
781        let data = cached.data.clone();
782        let content_type = cached.content_type.clone();
783        let _ = IMAGE_CACHE.insert(cache_key.clone(), cached).await;
784        return image_response(data, content_type, "public, max-age=86400", &headers);
785    }
786
787    // 两层缓存均未命中:此处起的请求才真实消耗「读盘 + 解码处理」工作量,
788    // 计入限流。
789    if let Err(resp) = crate::api::rate_limit::check_image_limit(&ip) {
790        return *resp;
791    }
792
793    // 处理并发上限:限流控速率不控并发,冷缓存突发可在令牌允许内向阻塞池
794    // 瞬间提交几十个重处理任务(单图最坏约 200MB 瞬时内存)。超出许可的
795    // 请求在此排队,把尖峰摊平成有序处理。
796    let _permit = IMAGE_PROCESSING_PERMITS
797        .acquire()
798        .await
799        .expect("图片处理信号量从不 close,acquire 不会失败");
800
801    let data = match tokio::fs::read(&file_path).await {
802        Ok(d) => d,
803        Err(_) => return StatusCode::NOT_FOUND.into_response(),
804    };
805    // Offload decode + resize + encode to the blocking pool so the async
806    // runtime stays responsive to other requests.
807    let (processed, content_type) =
808        match tokio::task::spawn_blocking(move || process_image_blocking(data, params, path)).await
809        {
810            Ok(Ok(r)) => r,
811            Ok(Err(status)) => return status.into_response(),
812            Err(_) => {
813                tracing::error!("Image processing task panicked");
814                return StatusCode::INTERNAL_SERVER_ERROR.into_response();
815            }
816        };
817
818    let processed = Bytes::from(processed);
819    let cached = CachedImage {
820        data: processed,
821        content_type,
822    };
823    let _ = IMAGE_CACHE.insert(cache_key.clone(), cached.clone()).await;
824    write_disk_cache(&cache_key, &cached).await;
825
826    image_response(
827        cached.data,
828        cached.content_type,
829        "public, max-age=86400",
830        &headers,
831    )
832}
833
834/// 图片尺寸缓存(moka sync)。key = 相对路径如 "2026/06/22/x.webp"。
835/// 用 sync cache 而非 future cache:render_markdown_enhanced 是同步函数,不能 .await。
836#[cfg(feature = "server")]
837static IMAGE_DIMENSIONS_CACHE: LazyLock<SyncCache<String, (u32, u32)>> = LazyLock::new(|| {
838    let ttl =
839        std::time::Duration::from_secs(crate::config::image_limit().dimensions_cache_ttl_secs);
840    SyncCache::builder().time_to_live(ttl).build()
841});
842
843/// 读取图片真实尺寸(只读 header,不解码像素)。
844///
845/// - `rel_path`:相对路径如 "2026/06/22/x.webp"(不含 /uploads/ 前缀和 query)
846/// - 优先查缓存;miss 时读文件、解析 header、写入缓存
847/// - 失败返回 None(调用方回退到不设 aspect-ratio)
848#[cfg(feature = "server")]
849pub fn get_image_dimensions(rel_path: &str) -> Option<(u32, u32)> {
850    if let Some(dims) = IMAGE_DIMENSIONS_CACHE.get(rel_path) {
851        return Some(dims);
852    }
853    let full_path = std::path::Path::new("uploads").join(rel_path);
854    // 只读取文件头部:尺寸信息位于各格式 header(PNG IHDR / GIF / WebP RIFF /
855    // JPEG SOF),无需把整张多 MB 图片读进内存。JPEG 的 SOF 可能跟在
856    // EXIF/APPn 标记之后,64 KiB 足以覆盖常见情况。
857    let file = std::fs::File::open(&full_path).ok()?;
858    use std::io::Read;
859    let mut header = Vec::new();
860    file.take(65_536).read_to_end(&mut header).ok()?;
861    let dims = read_dimensions_from_bytes(&header, rel_path)?;
862    IMAGE_DIMENSIONS_CACHE.insert(rel_path.to_string(), dims);
863    Some(dims)
864}
865
866/// 按扩展名分发:webp 走 zenwebp header,gif/png/jpeg 走 image crate。
867#[cfg(feature = "server")]
868fn read_dimensions_from_bytes(data: &[u8], path: &str) -> Option<(u32, u32)> {
869    let ext = std::path::Path::new(path)
870        .extension()?
871        .to_str()?
872        .to_lowercase();
873    match ext.as_str() {
874        "webp" => read_webp_dimensions(data),
875        "jpg" | "jpeg" => read_image_dimensions(data, image::ImageFormat::Jpeg),
876        "png" => read_image_dimensions(data, image::ImageFormat::Png),
877        "gif" => read_image_dimensions(data, image::ImageFormat::Gif),
878        _ => None,
879    }
880}
881
882#[cfg(all(test, feature = "server"))]
883mod tests {
884    use super::*;
885
886    #[test]
887    fn read_webp_dimensions_from_bytes() {
888        // 构造一个 16x9 的 webp
889        let img = image::DynamicImage::new_rgb8(16, 9);
890        let webp_bytes = crate::infra::webp::encode(&img, 85.0, 2).unwrap();
891        let dims = read_dimensions_from_bytes(&webp_bytes, "test.webp");
892        assert_eq!(dims, Some((16, 9)));
893    }
894
895    // —— read_webp_dimensions:手动 RIFF 解析回归测试(issue #30)——
896
897    /// 构造 VP8 (lossy) 最小 RIFF:RIFF+size+WEBP+"VP8 "+chunk_size+
898    /// frame_tag(3)+start_code(3: 9d 01 2a)+width(u16le)+height(u16le)。
899    fn synth_vp8_riff(w: u16, h: u16) -> Vec<u8> {
900        let mut buf = b"RIFF\x00\x00\x00\x00WEBPVP8 \x00\x00\x00\x00".to_vec();
901        // frame tag (3 bytes) + start code (3 bytes)
902        buf.extend_from_slice(&[0x00, 0x00, 0x00, 0x9d, 0x01, 0x2a]);
903        buf.extend_from_slice(&w.to_le_bytes());
904        buf.extend_from_slice(&h.to_le_bytes());
905        buf
906    }
907
908    /// 构造 VP8L (lossless) 最小 RIFF:RIFF+size+WEBP+"VP8L"+chunk_size+
909    /// signature(0x2f)+u32le header。
910    fn synth_vp8l_riff(w: u32, h: u32) -> Vec<u8> {
911        let mut buf = b"RIFF\x00\x00\x00\x00WEBPVP8L\x00\x00\x00\x00".to_vec();
912        buf.push(0x2f); // signature
913        let header: u32 = (w - 1) | ((h - 1) << 14);
914        buf.extend_from_slice(&header.to_le_bytes());
915        // pad to ≥ 30 bytes (VP8L data ends at byte 25, but read_webp_dimensions
916        // requires ≥ 30 for uniformity with VP8/VP8X).
917        while buf.len() < 30 {
918            buf.push(0);
919        }
920        buf
921    }
922
923    /// 构造 VP8X (extended) 最小 RIFF:RIFF+size+WEBP+"VP8X"+chunk_size(10)+
924    /// flags(1)+reserved(3)+canvas_w_minus1(3 LE)+canvas_h_minus1(3 LE)。
925    fn synth_vp8x_riff(w: u32, h: u32) -> Vec<u8> {
926        let mut buf = b"RIFF\x00\x00\x00\x00WEBPVP8X\x0a\x00\x00\x00".to_vec();
927        buf.push(0x00); // flags
928        buf.extend_from_slice(&[0, 0, 0]); // reserved
929        let wm1 = w - 1;
930        buf.extend_from_slice(&[wm1 as u8, (wm1 >> 8) as u8, (wm1 >> 16) as u8]);
931        let hm1 = h - 1;
932        buf.extend_from_slice(&[hm1 as u8, (hm1 >> 8) as u8, (hm1 >> 16) as u8]);
933        buf
934    }
935
936    #[test]
937    fn read_webp_vp8_lossy_dimensions() {
938        let data = synth_vp8_riff(640, 480);
939        assert_eq!(read_webp_dimensions(&data), Some((640, 480)));
940    }
941
942    #[test]
943    fn read_webp_vp8l_lossless_dimensions() {
944        let data = synth_vp8l_riff(100, 50);
945        assert_eq!(read_webp_dimensions(&data), Some((100, 50)));
946    }
947
948    #[test]
949    fn read_webp_vp8x_extended_dimensions() {
950        let data = synth_vp8x_riff(1920, 1080);
951        assert_eq!(read_webp_dimensions(&data), Some((1920, 1080)));
952    }
953
954    #[test]
955    fn read_webp_dimensions_rejects_bad_signature() {
956        let mut data = synth_vp8x_riff(100, 100);
957        data[0] = b'X'; // corrupt RIFF
958        assert_eq!(read_webp_dimensions(&data), None);
959    }
960
961    #[test]
962    fn read_webp_dimensions_rejects_short_data() {
963        assert_eq!(read_webp_dimensions(b"RIFF\x00\x00\x00\x00WEBP"), None);
964    }
965
966    #[test]
967    fn read_webp_dimensions_rejects_zero_size() {
968        // VP8 with zero dimensions → None
969        let data = synth_vp8_riff(0, 100);
970        assert_eq!(read_webp_dimensions(&data), None);
971    }
972
973    /// 核心回归测试:VP8X WebP > 64 KiB 截断后仍能读尺寸(issue #30 根因)。
974    #[test]
975    fn read_webp_vp8x_large_truncated() {
976        // 构造一张大噪声 RGBA 图 → encoder 产出 > 64 KiB 的 VP8X WebP。
977        let (w, h) = (400, 400);
978        let mut rgba = image::RgbaImage::new(w, h);
979        let mut s: u64 = 42;
980        for px in rgba.iter_mut() {
981            s = s.wrapping_mul(6364136223846793005).wrapping_add(1);
982            *px = (s >> 33) as u8;
983        }
984        let webp =
985            crate::infra::webp::encode(&image::DynamicImage::ImageRgba8(rgba), 100.0, 0).unwrap();
986        assert!(
987            webp.len() > 65_536,
988            "test image should produce > 64 KiB webp, got {}",
989            webp.len()
990        );
991        // 截断到 64 KiB(模拟 get_image_dimensions 只读 header 前缀)
992        let truncated = &webp[..65_536];
993        assert_eq!(
994            read_webp_dimensions(truncated),
995            Some((w, h)),
996            "VP8X webp > 64 KiB must parse dimensions from 64 KiB header"
997        );
998    }
999
1000    #[test]
1001    fn read_png_dimensions_from_bytes() {
1002        let img = image::DynamicImage::new_rgb8(32, 24);
1003        let mut buf = std::io::Cursor::new(Vec::new());
1004        img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
1005        let dims = read_dimensions_from_bytes(&buf.into_inner(), "test.png");
1006        assert_eq!(dims, Some((32, 24)));
1007    }
1008
1009    #[test]
1010    fn read_dimensions_unknown_extension_returns_none() {
1011        let dims = read_dimensions_from_bytes(b"not an image", "test.xyz");
1012        assert_eq!(dims, None);
1013    }
1014
1015    // —— upload_dimensions:统一上传尺寸/像素上限校验 ——
1016
1017    /// 构造指定尺寸的 PNG 字节(内存占用 = 尺寸,仅用于 header 校验测试)。
1018    fn make_png_bytes(w: u32, h: u32) -> Vec<u8> {
1019        let img = image::DynamicImage::new_rgb8(w, h);
1020        let mut buf = std::io::Cursor::new(Vec::new());
1021        img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
1022        buf.into_inner()
1023    }
1024
1025    #[test]
1026    fn upload_dimensions_accepts_small_png() {
1027        let data = make_png_bytes(100, 100);
1028        assert!(upload_dimensions(&data, "image/png").is_ok());
1029    }
1030
1031    #[test]
1032    fn upload_dimensions_accepts_boundary_png() {
1033        // 用 7000×7000(≈49M 像素):单边 7000 < 默认 8192 上限,且总像素 < 默认 50M 上限,应放行。
1034        // 注意不能直接用 *MAX_IMAGE_DIMENSION 做正方形边——8192²≈67M 会触发像素上限拒绝。
1035        let data = make_png_bytes(7000, 7000);
1036        assert!(upload_dimensions(&data, "image/png").is_ok());
1037    }
1038
1039    #[test]
1040    fn upload_dimensions_rejects_oversized_width() {
1041        // 单边超限:(上限+1)×1,像素远低于上限,但单边越界
1042        let data = make_png_bytes(*MAX_IMAGE_DIMENSION + 1, 1);
1043        let err = upload_dimensions(&data, "image/png").unwrap_err();
1044        assert!(err.contains("尺寸过大"));
1045    }
1046
1047    #[test]
1048    fn upload_dimensions_rejects_oversized_height() {
1049        let data = make_png_bytes(1, *MAX_IMAGE_DIMENSION + 1);
1050        let err = upload_dimensions(&data, "image/png").unwrap_err();
1051        assert!(err.contains("尺寸过大"));
1052    }
1053
1054    #[test]
1055    fn upload_dimensions_accepts_small_webp() {
1056        let img = image::DynamicImage::new_rgb8(64, 48);
1057        let webp_bytes = crate::infra::webp::encode(&img, 85.0, 2).unwrap();
1058        assert!(upload_dimensions(&webp_bytes, "image/webp").is_ok());
1059    }
1060
1061    #[test]
1062    fn upload_dimensions_accepts_gif() {
1063        // image crate 默认启用 gif feature,into_dimensions 可读 GIF header
1064        let img = image::DynamicImage::new_rgb8(32, 32);
1065        let mut buf = std::io::Cursor::new(Vec::new());
1066        img.write_to(&mut buf, image::ImageFormat::Gif).unwrap();
1067        assert!(upload_dimensions(&buf.into_inner(), "image/gif").is_ok());
1068    }
1069
1070    #[test]
1071    fn upload_dimensions_rejects_corrupt_bytes() {
1072        // 非 magic bytes,与现有损坏文件校验文案对齐
1073        let err = upload_dimensions(b"not an image at all", "image/png").unwrap_err();
1074        assert_eq!(err, "图片文件损坏或格式不正确");
1075    }
1076
1077    #[test]
1078    fn read_dimensions_by_mime_dispatches_webp() {
1079        let img = image::DynamicImage::new_rgb8(16, 9);
1080        let webp_bytes = crate::infra::webp::encode(&img, 85.0, 2).unwrap();
1081        assert_eq!(
1082            read_dimensions_by_mime(&webp_bytes, "image/webp").unwrap(),
1083            (16, 9)
1084        );
1085    }
1086
1087    #[test]
1088    fn image_params_validate_valid_defaults() {
1089        let params = ImageParams::default();
1090        assert!(params.validate().is_ok());
1091    }
1092
1093    #[test]
1094    fn image_params_validate_valid_width() {
1095        let params = ImageParams {
1096            w: Some(100),
1097            ..Default::default()
1098        };
1099        assert!(params.validate().is_ok());
1100    }
1101
1102    #[test]
1103    fn image_params_validate_zero_width_rejected() {
1104        let params = ImageParams {
1105            w: Some(0),
1106            ..Default::default()
1107        };
1108        assert!(params.validate().is_err());
1109    }
1110
1111    #[test]
1112    fn image_params_validate_oversized_width_rejected() {
1113        let params = ImageParams {
1114            w: Some(*MAX_IMAGE_DIMENSION + 1),
1115            ..Default::default()
1116        };
1117        assert!(params.validate().is_err());
1118    }
1119
1120    #[test]
1121    fn image_params_validate_valid_rotation() {
1122        for angle in [0, 90, 180, 270] {
1123            let params = ImageParams {
1124                rotate: Some(angle),
1125                ..Default::default()
1126            };
1127            assert!(params.validate().is_ok(), "angle {} should be valid", angle);
1128        }
1129    }
1130
1131    #[test]
1132    fn image_params_validate_invalid_rotation_rejected() {
1133        let params = ImageParams {
1134            rotate: Some(45),
1135            ..Default::default()
1136        };
1137        assert!(params.validate().is_err());
1138    }
1139
1140    #[test]
1141    fn image_params_validate_valid_format() {
1142        for fmt in &["jpeg", "jpg", "png", "webp", "JPEG", "PNG"] {
1143            let params = ImageParams {
1144                format: Some(fmt.to_string()),
1145                ..Default::default()
1146            };
1147            assert!(params.validate().is_ok(), "format {} should be valid", fmt);
1148        }
1149    }
1150
1151    #[test]
1152    fn image_params_validate_invalid_format_rejected() {
1153        let params = ImageParams {
1154            format: Some("gif".to_string()),
1155            ..Default::default()
1156        };
1157        assert!(params.validate().is_err());
1158    }
1159
1160    #[test]
1161    fn image_params_validate_valid_thumbnail() {
1162        let params = ImageParams {
1163            thumb: Some("200x150".to_string()),
1164            ..Default::default()
1165        };
1166        assert!(params.validate().is_ok());
1167    }
1168
1169    #[test]
1170    fn image_params_validate_invalid_thumbnail_rejected() {
1171        let params = ImageParams {
1172            thumb: Some("200".to_string()),
1173            ..Default::default()
1174        };
1175        assert!(params.validate().is_err());
1176    }
1177
1178    #[test]
1179    fn image_params_validate_valid_quality() {
1180        let params = ImageParams {
1181            quality: Some(85),
1182            ..Default::default()
1183        };
1184        assert!(params.validate().is_ok());
1185    }
1186
1187    #[test]
1188    fn image_params_validate_zero_quality_rejected() {
1189        let params = ImageParams {
1190            quality: Some(0),
1191            ..Default::default()
1192        };
1193        assert!(params.validate().is_err());
1194    }
1195
1196    #[test]
1197    fn image_params_validate_over_100_quality_rejected() {
1198        let params = ImageParams {
1199            quality: Some(101),
1200            ..Default::default()
1201        };
1202        assert!(params.validate().is_err());
1203    }
1204
1205    #[tokio::test]
1206    async fn is_path_safe_normal() {
1207        assert!(is_path_safe("images/photo.jpg").await);
1208        assert!(is_path_safe("2024/01/photo.png").await);
1209    }
1210
1211    #[tokio::test]
1212    async fn is_path_safe_rejects_parent_dir() {
1213        assert!(!is_path_safe("../etc/passwd").await);
1214        assert!(!is_path_safe("foo/../../bar").await);
1215    }
1216
1217    #[tokio::test]
1218    async fn is_path_safe_rejects_null_bytes() {
1219        assert!(!is_path_safe("foo\0bar").await);
1220    }
1221
1222    #[tokio::test]
1223    async fn is_path_safe_rejects_absolute_path() {
1224        assert!(!is_path_safe("/etc/passwd").await);
1225    }
1226
1227    #[test]
1228    fn detect_format_jpeg() {
1229        assert!(matches!(
1230            detect_format("photo.jpg"),
1231            image::ImageFormat::Jpeg
1232        ));
1233        assert!(matches!(
1234            detect_format("photo.jpeg"),
1235            image::ImageFormat::Jpeg
1236        ));
1237        assert!(matches!(
1238            detect_format("PHOTO.JPG"),
1239            image::ImageFormat::Jpeg
1240        ));
1241    }
1242
1243    #[test]
1244    fn detect_format_png() {
1245        assert!(matches!(detect_format("icon.png"), image::ImageFormat::Png));
1246    }
1247
1248    #[test]
1249    fn detect_format_webp() {
1250        assert!(matches!(
1251            detect_format("anim.webp"),
1252            image::ImageFormat::WebP
1253        ));
1254    }
1255
1256    #[test]
1257    fn detect_format_defaults_to_jpeg() {
1258        assert!(matches!(
1259            detect_format("file.xyz"),
1260            image::ImageFormat::Jpeg
1261        ));
1262    }
1263
1264    #[test]
1265    fn cache_key_differs_for_different_params() {
1266        let p1 = ImageParams {
1267            w: Some(100),
1268            ..Default::default()
1269        };
1270        let p2 = ImageParams {
1271            w: Some(200),
1272            ..Default::default()
1273        };
1274        assert_ne!(p1.cache_key("img.jpg"), p2.cache_key("img.jpg"));
1275    }
1276
1277    #[test]
1278    fn is_empty_true_when_all_none() {
1279        let params = ImageParams::default();
1280        assert!(params.is_empty());
1281    }
1282
1283    #[test]
1284    fn is_empty_false_when_any_set() {
1285        let params = ImageParams {
1286            w: Some(100),
1287            ..Default::default()
1288        };
1289        assert!(!params.is_empty());
1290    }
1291
1292    #[test]
1293    fn disk_cache_base_is_deterministic() {
1294        let key = "path|w=800";
1295        let base1 = disk_cache_base(key);
1296        let base2 = disk_cache_base(key);
1297        assert_eq!(base1, base2);
1298        assert!(base1.starts_with("uploads/.cache/cache_"));
1299    }
1300
1301    #[test]
1302    fn disk_cache_base_differs_for_different_keys() {
1303        let base1 = disk_cache_base("path|w=800");
1304        let base2 = disk_cache_base("path|w=1200");
1305        assert_ne!(base1, base2);
1306    }
1307
1308    #[test]
1309    fn process_image_blocking_resizes_png() {
1310        let img = image::DynamicImage::new_rgb8(100, 100);
1311        let mut buf = std::io::Cursor::new(Vec::new());
1312        img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
1313        let data = buf.into_inner();
1314
1315        let params = ImageParams {
1316            w: Some(50),
1317            format: Some("webp".to_string()),
1318            ..Default::default()
1319        };
1320
1321        let (out, ct) = process_image_blocking(data, params, "test.png".to_string()).unwrap();
1322        assert!(!out.is_empty());
1323        assert_eq!(ct, HeaderValue::from_static("image/webp"));
1324    }
1325
1326    #[test]
1327    fn image_response_includes_cache_headers() {
1328        let resp = image_response(
1329            Bytes::from(vec![1, 2, 3]),
1330            HeaderValue::from_static("image/webp"),
1331            "public, max-age=86400",
1332            &HeaderMap::new(),
1333        );
1334        assert_eq!(resp.status(), StatusCode::OK);
1335        let headers = resp.headers();
1336        assert_eq!(headers.get(header::CONTENT_TYPE).unwrap(), "image/webp");
1337        assert_eq!(
1338            headers.get(header::CACHE_CONTROL).unwrap(),
1339            "public, max-age=86400"
1340        );
1341        assert!(headers
1342            .get(header::ETAG)
1343            .unwrap()
1344            .to_str()
1345            .unwrap()
1346            .starts_with('"'));
1347    }
1348
1349    #[test]
1350    fn image_response_returns_304_when_etag_matches() {
1351        let data = Bytes::from(vec![1, 2, 3]);
1352        let etag = etag_for(&data);
1353        let mut req_headers = HeaderMap::new();
1354        req_headers.insert(header::IF_NONE_MATCH, HeaderValue::from_str(&etag).unwrap());
1355        let resp = image_response(
1356            data,
1357            HeaderValue::from_static("image/webp"),
1358            "public, max-age=86400",
1359            &req_headers,
1360        );
1361        assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
1362        let headers = resp.headers();
1363        assert_eq!(headers.get(header::ETAG).unwrap(), etag.as_str());
1364        assert_eq!(headers.get(header::CONTENT_TYPE).unwrap(), "image/webp");
1365        assert_eq!(
1366            headers.get(header::CACHE_CONTROL).unwrap(),
1367            "public, max-age=86400"
1368        );
1369    }
1370
1371    #[test]
1372    fn etag_matches_single() {
1373        assert!(etag_matches("\"abc\"", "\"abc\""));
1374        assert!(!etag_matches("\"abc\"", "\"def\""));
1375    }
1376
1377    #[test]
1378    fn etag_matches_list() {
1379        assert!(etag_matches("\"abc\", \"def\"", "\"def\""));
1380        assert!(!etag_matches("\"abc\", \"def\"", "\"ghi\""));
1381    }
1382
1383    #[test]
1384    fn etag_matches_weak_prefix() {
1385        assert!(etag_matches("W/\"abc\"", "\"abc\""));
1386    }
1387
1388    #[test]
1389    fn etag_matches_wildcard() {
1390        assert!(etag_matches("*", "\"anything\""));
1391    }
1392
1393    #[test]
1394    fn image_response_raw_file_is_immutable() {
1395        let resp = image_response(
1396            Bytes::from(vec![1, 2, 3]),
1397            HeaderValue::from_static("image/jpeg"),
1398            "public, max-age=31536000, immutable",
1399            &HeaderMap::new(),
1400        );
1401        assert_eq!(resp.status(), StatusCode::OK);
1402        let cache_control = resp
1403            .headers()
1404            .get(header::CACHE_CONTROL)
1405            .unwrap()
1406            .to_str()
1407            .unwrap();
1408        assert!(cache_control.contains("immutable"));
1409    }
1410
1411    #[test]
1412    fn etag_for_same_data_is_stable() {
1413        let a = etag_for(b"hello");
1414        let b = etag_for(b"hello");
1415        assert_eq!(a, b);
1416        assert_ne!(a, etag_for(b"world"));
1417    }
1418
1419    // —— serve_image 限流计费路径:缓存命中免费 / miss 计费且 429 带 Retry-After ——
1420
1421    /// 每个测试用独立的 TEST-NET-3 客户端地址:governor 按 IP 分桶,
1422    /// 桶互不影响,无需 serial。
1423    fn unique_peer(third: u8) -> std::net::SocketAddr {
1424        let ip = std::net::Ipv4Addr::new(203, 0, 113, third);
1425        std::net::SocketAddr::new(std::net::IpAddr::V4(ip), 8080)
1426    }
1427
1428    #[tokio::test]
1429    async fn serve_image_cache_hit_does_not_consume_rate_limit_tokens() {
1430        // 直接往内存缓存塞条目,模拟温缓存。
1431        let path = "test/rl_hit.webp";
1432        let params = ImageParams {
1433            thumb: Some("300x300".to_string()),
1434            ..Default::default()
1435        };
1436        let key = params.cache_key(path);
1437        IMAGE_CACHE
1438            .insert(
1439                key.clone(),
1440                CachedImage {
1441                    data: Bytes::from_static(b"cached"),
1442                    content_type: HeaderValue::from_static("image/webp"),
1443                },
1444            )
1445            .await;
1446
1447        // 连续 60 次(超过默认 burst 50)全部命中缓存 → 无一 429。
1448        let peer = unique_peer(1);
1449        for _ in 0..60 {
1450            let resp = serve_image(
1451                Some(Extension(ConnectInfo(peer))),
1452                Path(path.to_string()),
1453                Query(params.clone()),
1454                HeaderMap::new(),
1455            )
1456            .await;
1457            assert_eq!(resp.status(), StatusCode::OK, "缓存命中不应消耗限流令牌");
1458        }
1459        IMAGE_CACHE.invalidate(&key).await;
1460    }
1461
1462    #[tokio::test]
1463    async fn serve_image_cache_miss_is_rate_limited_with_retry_after() {
1464        // 不存在的文件 + 每次唯一的 w(保证 miss 且不产生缓存写入):
1465        // burst 内的 miss 正常走完全流程(404),令牌耗尽后 429 且带 Retry-After。
1466        let peer = unique_peer(2);
1467        let mut not_found = 0;
1468        let mut too_many = 0;
1469        for w in 1..=60_u32 {
1470            let resp = serve_image(
1471                Some(Extension(ConnectInfo(peer))),
1472                Path("test/rl_miss_nonexistent.webp".to_string()),
1473                Query(ImageParams {
1474                    w: Some(w),
1475                    ..Default::default()
1476                }),
1477                HeaderMap::new(),
1478            )
1479            .await;
1480            match resp.status() {
1481                StatusCode::NOT_FOUND => not_found += 1,
1482                StatusCode::TOO_MANY_REQUESTS => {
1483                    too_many += 1;
1484                    let retry_after = resp
1485                        .headers()
1486                        .get(header::RETRY_AFTER)
1487                        .expect("429 必须带 Retry-After")
1488                        .to_str()
1489                        .expect("Retry-After 仅含 ASCII 数字");
1490                    assert!(retry_after.parse::<u64>().expect("Retry-After 为秒数") >= 1);
1491                }
1492                other => panic!("unexpected status {other}"),
1493            }
1494        }
1495        assert!(not_found > 0, "burst 内的 miss 应正常处理(404)");
1496        assert!(too_many > 0, "超出 burst 的 miss 应被 429 限流");
1497    }
1498
1499    // —— is_animated_image / 动图保留回归测试(issue #29)——
1500    //
1501    // 动图(animated WebP/GIF)经 process_image 解码→缩放→重编码后会丢失全部帧
1502    // 只剩第一帧。is_animated_image 在处理流水线入口拦截,让动图原样返回。
1503    // 以下测试覆盖:真动图检出、静态图不误报、process_image_blocking 字节保留。
1504
1505    /// 用 zenwebp 的 AnimationEncoder 构造一个真实的多帧 animated WebP(8x8,2 帧)。
1506    /// 这是用户实际会上传的格式,比手动拼 RIFF 更贴近真实场景。
1507    fn make_animated_webp() -> Vec<u8> {
1508        use zenwebp::mux::{AnimationConfig, AnimationEncoder};
1509        use zenwebp::{EncoderConfig, PixelLayout};
1510
1511        let mut enc =
1512            AnimationEncoder::new(8, 8, AnimationConfig::default()).expect("8x8 在合法画布范围内");
1513        let cfg = EncoderConfig::new_lossy();
1514        // 两帧不同内容,确保 finalize 不会降级成单帧静态图。
1515        let frame_a = vec![255u8; 8 * 8 * 3]; // 白
1516        let frame_b = vec![0u8; 8 * 8 * 3]; // 黑
1517        enc.add_frame(&frame_a, PixelLayout::Rgb8, 0, &cfg)
1518            .expect("首帧编码");
1519        enc.add_frame(&frame_b, PixelLayout::Rgb8, 100, &cfg)
1520            .expect("次帧编码");
1521        enc.finalize(100).expect("动画装配")
1522    }
1523
1524    #[test]
1525    fn is_animated_image_detects_real_animated_webp() {
1526        let animated = make_animated_webp();
1527        assert!(
1528            is_animated_image(&animated, image::ImageFormat::WebP),
1529            "真实多帧 animated WebP 必须被检出"
1530        );
1531        // 交叉验证:zenwebp 自己的 header-only probe 也认为是动画
1532        let probe = zenwebp::detect::probe(&animated).expect("合法 WebP");
1533        assert!(probe.has_animation, "probe 应报告 has_animation");
1534    }
1535
1536    #[test]
1537    fn is_animated_image_false_for_static_webp() {
1538        // 普通静态 WebP(三种子格式)都不应被误判为动图。
1539        let img = image::DynamicImage::new_rgb8(32, 32);
1540        let static_webp = crate::infra::webp::encode(&img, 80.0, 2).unwrap();
1541        assert!(
1542            !is_animated_image(&static_webp, image::ImageFormat::WebP),
1543            "静态 WebP 不应被误报为动图"
1544        );
1545        // VP8X 扩展格式(无动画标志、无 ANMF chunk)也不应误报
1546        let extended = synth_vp8x_riff(100, 100);
1547        assert!(
1548            !is_animated_image(&extended, image::ImageFormat::WebP),
1549            "VP8X 非动画 WebP 不应被误报"
1550        );
1551    }
1552
1553    #[test]
1554    fn process_image_blocking_preserves_animated_webp_bytes() {
1555        // 核心回归:带处理参数(thumb)的动图请求必须原样返回输入字节,
1556        // 不能走解码→重编码(会丢帧)。issue #29 的精确症状。
1557        let animated = make_animated_webp();
1558        let params = ImageParams {
1559            thumb: Some("300x300".to_string()),
1560            ..Default::default()
1561        };
1562        let (out_bytes, out_ct) =
1563            process_image_blocking(animated.clone(), params, "2026/08/13/anim.webp".to_string())
1564                .expect("动图绕过处理不应失败");
1565        assert_eq!(
1566            out_bytes, animated,
1567            "动图字节必须原样返回(绕过解码/缩放/重编码)"
1568        );
1569        assert_eq!(out_ct, "image/webp");
1570    }
1571
1572    #[test]
1573    fn process_image_blocking_still_processes_static_webp() {
1574        // 静态 WebP 仍走完整处理流水线:输出字节应与输入不同(被缩放/重编码)。
1575        let img = image::DynamicImage::new_rgb8(200, 200);
1576        let static_webp = crate::infra::webp::encode(&img, 80.0, 2).unwrap();
1577        let params = ImageParams {
1578            thumb: Some("50x50".to_string()),
1579            ..Default::default()
1580        };
1581        let (out_bytes, out_ct) =
1582            process_image_blocking(static_webp.clone(), params, "static.webp".to_string())
1583                .expect("静态图处理不应失败");
1584        assert_ne!(
1585            out_bytes, static_webp,
1586            "静态 WebP 应被实际处理(字节变化),不能被错误绕过"
1587        );
1588        assert_eq!(out_ct, "image/webp");
1589    }
1590
1591    #[test]
1592    fn is_animated_image_detects_gif() {
1593        // 动画 GIF 的标志是 NETSCAPE2.0 应用扩展块。
1594        // 构造包含该标记的最小字节序列(不要求完整 GIF 解码)。
1595        let anim_gif: Vec<u8> = {
1596            let mut b = b"GIF89a".to_vec();
1597            b.extend_from_slice(b"\x21\xff\x0bNETSCAPE2.0\x03\x01\x00\x00\x00");
1598            b.extend_from_slice(&[0x3b]); // trailer
1599            b
1600        };
1601        assert!(
1602            is_animated_image(&anim_gif, image::ImageFormat::Gif),
1603            "含 NETSCAPE2.0 标记的 GIF 应被检出为动图"
1604        );
1605        // 无标记的 GIF 头不应误报
1606        let static_gif = b"GIF89a\x01\x00\x01\x00";
1607        assert!(
1608            !is_animated_image(static_gif, image::ImageFormat::Gif),
1609            "无 NETSCAPE 标记的 GIF 不应被误报"
1610        );
1611    }
1612
1613    #[test]
1614    fn is_animated_image_false_for_jpeg_png() {
1615        // JPEG/PNG 不可能是动图,直接返回 false(不扫描字节)。
1616        assert!(!is_animated_image(
1617            &[0xFF, 0xD8, 0xFF],
1618            image::ImageFormat::Jpeg
1619        ));
1620        assert!(!is_animated_image(
1621            &[0x89, 0x50, 0x4E, 0x47],
1622            image::ImageFormat::Png
1623        ));
1624    }
1625}