1#[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")]
47pub 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")]
59pub static MAX_IMAGE_PIXELS: LazyLock<u32> = LazyLock::new(|| {
66 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)]
74struct 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")]
89static 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)]
104pub struct ImageParams {
106 pub w: Option<u32>,
108 pub h: Option<u32>,
110 pub thumb: Option<String>,
112 pub rotate: Option<u16>,
114 pub format: Option<String>,
116 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 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 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 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#[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 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 (
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")]
310pub(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")]
343fn 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 _ => Err("图片文件损坏或格式不正确"),
357 }
358}
359
360#[cfg(feature = "server")]
373fn read_webp_dimensions(data: &[u8]) -> Option<(u32, u32)> {
374 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 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 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 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#[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#[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#[cfg(feature = "server")]
434fn is_animated_image(data: &[u8], format: image::ImageFormat) -> bool {
435 match format {
436 image::ImageFormat::WebP => {
437 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 pos += 8 + ((chunk_size + 1) & !1);
456 }
457 found
458 }
459 }
460 image::ImageFormat::Gif => {
461 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 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 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 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 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 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 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, ¶ms, original_format)
588}
589
590#[cfg(feature = "server")]
591async 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, };
605 match tokio::fs::canonicalize(&candidate).await {
606 Ok(resolved) => resolved.starts_with(&uploads_root),
607 Err(_) => true, }
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")]
618pub async fn invalidate_asset_caches(rel_path: &str) {
627 let prefix = format!("{}|", rel_path);
628 let _ = IMAGE_CACHE.invalidate_entries_if(move |k, _| k.starts_with(&prefix));
630 IMAGE_DIMENSIONS_CACHE.invalidate(rel_path);
631}
632
633#[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 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 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 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 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")]
713pub 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 if let Err(status) = params.validate() {
740 return status.into_response();
741 }
742
743 if params.is_empty() {
745 if let Err(resp) = crate::api::rate_limit::check_image_limit(&ip) {
747 return *resp;
748 }
749 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 if let Err(resp) = crate::api::rate_limit::check_image_limit(&ip) {
790 return *resp;
791 }
792
793 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 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#[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#[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 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#[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 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 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 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 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); let header: u32 = (w - 1) | ((h - 1) << 14);
914 buf.extend_from_slice(&header.to_le_bytes());
915 while buf.len() < 30 {
918 buf.push(0);
919 }
920 buf
921 }
922
923 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); buf.extend_from_slice(&[0, 0, 0]); 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'; 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 let data = synth_vp8_riff(0, 100);
970 assert_eq!(read_webp_dimensions(&data), None);
971 }
972
973 #[test]
975 fn read_webp_vp8x_large_truncated() {
976 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 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 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 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 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 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 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 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 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 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 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 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 let frame_a = vec![255u8; 8 * 8 * 3]; let frame_b = vec![0u8; 8 * 8 * 3]; 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 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 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 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 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 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 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]); b
1600 };
1601 assert!(
1602 is_animated_image(&anim_gif, image::ImageFormat::Gif),
1603 "含 NETSCAPE2.0 标记的 GIF 应被检出为动图"
1604 );
1605 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 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}