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")]
47use crate::config::{MAX_IMAGE_DIMENSION, MAX_IMAGE_PIXELS};
48#[cfg(feature = "server")]
49const DEFAULT_JPEG_QUALITY: u8 = 85;
50
51#[cfg(feature = "server")]
52#[derive(Debug, Clone)]
53struct CachedImage {
55 data: Bytes,
56 content_type: HeaderValue,
57}
58
59#[cfg(feature = "server")]
60static IMAGE_CACHE: LazyLock<Cache<String, CachedImage>> = LazyLock::new(|| {
61 Cache::builder()
62 .max_capacity(100)
63 .time_to_idle(std::time::Duration::from_secs(300))
64 .build()
65});
66
67#[cfg(feature = "server")]
68static IMAGE_PROCESSING_PERMITS: LazyLock<tokio::sync::Semaphore> = LazyLock::new(|| {
75 let cores = std::thread::available_parallelism()
76 .map(std::num::NonZeroUsize::get)
77 .unwrap_or(2);
78 tokio::sync::Semaphore::new(cores.clamp(2, 8))
79});
80
81#[cfg(feature = "server")]
82#[derive(Debug, Deserialize, Clone, Hash, Eq, PartialEq, Default)]
83pub struct ImageParams {
85 pub w: Option<u32>,
87 pub h: Option<u32>,
89 pub thumb: Option<String>,
91 pub rotate: Option<u16>,
93 pub format: Option<String>,
95 pub quality: Option<u8>,
97}
98
99#[cfg(feature = "server")]
100impl ImageParams {
101 fn is_empty(&self) -> bool {
102 self.w.is_none()
103 && self.h.is_none()
104 && self.thumb.is_none()
105 && self.rotate.is_none()
106 && self.format.is_none()
107 && self.quality.is_none()
108 }
109
110 fn cache_key(&self, path: &str) -> String {
111 use std::fmt::Write as _;
112 let mut key = String::with_capacity(path.len() + 64);
115 key.push_str(path);
116 if let Some(w) = self.w {
117 let _ = write!(key, "|w={}", w);
118 }
119 if let Some(h) = self.h {
120 let _ = write!(key, "|h={}", h);
121 }
122 if let Some(ref thumb) = self.thumb {
123 let _ = write!(key, "|thumb={}", thumb);
124 }
125 if let Some(r) = self.rotate {
126 let _ = write!(key, "|rotate={}", r);
127 }
128 if let Some(ref fmt) = self.format {
129 let _ = write!(key, "|format={}", fmt);
130 }
131 if let Some(q) = self.quality {
132 let _ = write!(key, "|quality={}", q);
133 }
134 key
135 }
136
137 fn validate(&self) -> Result<(), StatusCode> {
139 if let Some(dim) = self.w {
140 if dim == 0 || dim > *MAX_IMAGE_DIMENSION {
141 return Err(StatusCode::BAD_REQUEST);
142 }
143 }
144 if let Some(dim) = self.h {
145 if dim == 0 || dim > *MAX_IMAGE_DIMENSION {
146 return Err(StatusCode::BAD_REQUEST);
147 }
148 }
149 if let Some(r) = self.rotate {
150 if !matches!(r, 0 | 90 | 180 | 270) {
151 return Err(StatusCode::BAD_REQUEST);
152 }
153 }
154 if let Some(ref fmt) = self.format {
155 if !matches!(fmt.to_lowercase().as_str(), "jpeg" | "jpg" | "png" | "webp") {
156 return Err(StatusCode::BAD_REQUEST);
157 }
158 }
159 if let Some(ref thumb) = self.thumb {
160 let parts: Vec<&str> = thumb.split('x').collect();
161 if parts.len() != 2 {
162 return Err(StatusCode::BAD_REQUEST);
163 }
164 let tw: u32 = parts[0].parse().map_err(|_| StatusCode::BAD_REQUEST)?;
165 let th: u32 = parts[1].parse().map_err(|_| StatusCode::BAD_REQUEST)?;
166 if tw == 0 || th == 0 || tw > *MAX_IMAGE_DIMENSION || th > *MAX_IMAGE_DIMENSION {
167 return Err(StatusCode::BAD_REQUEST);
168 }
169 }
170 if let Some(q) = self.quality {
171 if q == 0 || q > 100 {
172 return Err(StatusCode::BAD_REQUEST);
173 }
174 }
175 Ok(())
176 }
177}
178
179#[cfg(feature = "server")]
180fn detect_format(path: &str) -> ImageFmt {
181 let ext = path.rsplit('.').next().unwrap_or("");
184 if ext.eq_ignore_ascii_case("jpg") || ext.eq_ignore_ascii_case("jpeg") {
185 ImageFmt::Jpeg
186 } else if ext.eq_ignore_ascii_case("png") {
187 ImageFmt::Png
188 } else if ext.eq_ignore_ascii_case("webp") {
189 ImageFmt::WebP
190 } else if ext.eq_ignore_ascii_case("gif") {
191 ImageFmt::Gif
192 } else {
193 ImageFmt::Jpeg
194 }
195}
196
197#[cfg(feature = "server")]
199type ImageFmt = image::ImageFormat;
200
201#[cfg(feature = "server")]
202fn content_type(format: image::ImageFormat) -> HeaderValue {
203 match format {
204 image::ImageFormat::Jpeg => HeaderValue::from_static("image/jpeg"),
205 image::ImageFormat::Png => HeaderValue::from_static("image/png"),
206 image::ImageFormat::WebP => HeaderValue::from_static("image/webp"),
207 image::ImageFormat::Gif => HeaderValue::from_static("image/gif"),
208 _ => HeaderValue::from_static("application/octet-stream"),
209 }
210}
211
212#[cfg(feature = "server")]
213fn image_response(
214 data: Bytes,
215 content_type: HeaderValue,
216 cache_control: &'static str,
217 headers: &HeaderMap,
218) -> Response {
219 let etag = etag_for(&data);
220 let etag_value = HeaderValue::from_str(&etag)
223 .expect("etag 仅含 ASCII hex 与双引号,必然是合法的 HeaderValue");
224
225 if let Some(if_none_match) = headers
226 .get(header::IF_NONE_MATCH)
227 .and_then(|v| v.to_str().ok())
228 {
229 if etag_matches(if_none_match, &etag) {
230 return (
231 StatusCode::NOT_MODIFIED,
232 [
233 (header::ETAG, etag_value.clone()),
234 (
235 header::CACHE_CONTROL,
236 HeaderValue::from_static(cache_control),
237 ),
238 (header::CONTENT_TYPE, content_type),
239 (
241 header::X_CONTENT_TYPE_OPTIONS,
242 HeaderValue::from_static("nosniff"),
243 ),
244 ],
245 )
246 .into_response();
247 }
248 }
249
250 (
251 StatusCode::OK,
252 [
253 (header::CONTENT_TYPE, content_type),
254 (
255 header::CACHE_CONTROL,
256 HeaderValue::from_static(cache_control),
257 ),
258 (header::ETAG, etag_value),
259 (
260 header::X_CONTENT_TYPE_OPTIONS,
261 HeaderValue::from_static("nosniff"),
262 ),
263 ],
264 data,
265 )
266 .into_response()
267}
268
269#[cfg(feature = "server")]
270fn check_image_dimensions(width: u32, height: u32) -> Result<(), StatusCode> {
271 if width == 0 || height == 0 {
272 return Err(StatusCode::BAD_REQUEST);
273 }
274 let pixels = u64::from(width) * u64::from(height);
275 if pixels > u64::from(*MAX_IMAGE_PIXELS) {
276 tracing::warn!(
277 "Image dimensions too large: {}x{} ({} pixels, max {})",
278 width,
279 height,
280 pixels,
281 *MAX_IMAGE_PIXELS
282 );
283 return Err(StatusCode::PAYLOAD_TOO_LARGE);
284 }
285 Ok(())
286}
287
288#[cfg(feature = "server")]
289pub(crate) fn upload_dimensions(data: &[u8], mime_type: &str) -> Result<(u32, u32), &'static str> {
298 let dims = read_dimensions_by_mime(data, mime_type)?;
299 let (width, height) = dims;
300 if width == 0 || height == 0 {
301 return Err("图片文件损坏或格式不正确");
302 }
303 let pixels = u64::from(width) * u64::from(height);
304 let max_dim = *MAX_IMAGE_DIMENSION;
305 let max_pixels = *MAX_IMAGE_PIXELS;
306 if width > max_dim || height > max_dim || pixels > u64::from(max_pixels) {
307 tracing::warn!(
308 "Uploaded image too large: {}x{} ({} pixels, max {}x{} / {} pixels)",
309 width,
310 height,
311 pixels,
312 max_dim,
313 max_dim,
314 max_pixels
315 );
316 return Err("图片尺寸过大,请压缩后再上传");
317 }
318 Ok(dims)
319}
320
321#[cfg(feature = "server")]
322fn read_dimensions_by_mime(data: &[u8], mime_type: &str) -> Result<(u32, u32), &'static str> {
324 match mime_type {
325 "image/webp" => read_webp_dimensions(data).ok_or("图片文件损坏或格式不正确"),
326 "image/jpeg" | "image/png" | "image/gif" => {
327 let format = match mime_type {
328 "image/jpeg" => image::ImageFormat::Jpeg,
329 "image/png" => image::ImageFormat::Png,
330 _ => image::ImageFormat::Gif,
331 };
332 read_image_dimensions(data, format).ok_or("图片文件损坏或格式不正确")
333 }
334 _ => Err("图片文件损坏或格式不正确"),
336 }
337}
338
339#[cfg(feature = "server")]
352fn read_webp_dimensions(data: &[u8]) -> Option<(u32, u32)> {
353 if data.len() < 30 || &data[0..4] != b"RIFF" || &data[8..12] != b"WEBP" {
355 return None;
356 }
357 let dims = match &data[12..16] {
358 b"VP8 " => {
359 let w = u16::from_le_bytes([data[26], data[27]]) & 0x3FFF;
361 let h = u16::from_le_bytes([data[28], data[29]]) & 0x3FFF;
362 (w as u32, h as u32)
363 }
364 b"VP8L" => {
365 let h = u32::from_le_bytes([data[21], data[22], data[23], data[24]]);
367 ((1 + h) & 0x3FFF, (1 + (h >> 14)) & 0x3FFF)
368 }
369 b"VP8X" => {
370 let w = u32::from_le_bytes([data[24], data[25], data[26], 0]) + 1;
372 let h = u32::from_le_bytes([data[27], data[28], data[29], 0]) + 1;
373 (w, h)
374 }
375 _ => return None,
376 };
377 if dims.0 == 0 || dims.1 == 0 {
378 return None;
379 }
380 Some(dims)
381}
382
383#[cfg(feature = "server")]
385fn read_image_dimensions(data: &[u8], format: image::ImageFormat) -> Option<(u32, u32)> {
386 let reader = image::ImageReader::with_format(std::io::Cursor::new(data), format);
387 reader.into_dimensions().ok()
388}
389
390#[cfg(feature = "server")]
392pub(crate) fn image_reader_limits() -> image::Limits {
393 let mut limits = image::Limits::default();
394 limits.max_image_width = Some(*MAX_IMAGE_DIMENSION);
395 limits.max_image_height = Some(*MAX_IMAGE_DIMENSION);
396 limits.max_alloc = Some(*MAX_IMAGE_PIXELS as u64 * 4 + 1024 * 1024);
397 limits
398}
399
400#[cfg(feature = "server")]
413fn is_animated_image(data: &[u8], format: image::ImageFormat) -> bool {
414 match format {
415 image::ImageFormat::WebP => {
416 data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" && {
419 let mut pos = 12;
420 let mut found = false;
421 while pos + 8 <= data.len() {
422 let fourcc = &data[pos..pos + 4];
423 let chunk_size = u32::from_le_bytes([
424 data[pos + 4],
425 data[pos + 5],
426 data[pos + 6],
427 data[pos + 7],
428 ]) as usize;
429 if fourcc == b"ANMF" {
430 found = true;
431 break;
432 }
433 pos += 8 + ((chunk_size + 1) & !1);
435 }
436 found
437 }
438 }
439 image::ImageFormat::Gif => {
440 const NETSCAPE: &[u8] = b"\x21\xff\x0bNETSCAPE2.0";
443 data.windows(NETSCAPE.len()).any(|w| w == NETSCAPE)
444 }
445 _ => false,
446 }
447}
448
449#[cfg(feature = "server")]
450fn process_image(
451 img: image::DynamicImage,
452 params: &ImageParams,
453 original_format: image::ImageFormat,
454) -> Result<(Vec<u8>, HeaderValue), StatusCode> {
455 check_image_dimensions(img.width(), img.height())?;
456 let mut img = img;
457
458 if let Some(degrees) = params.rotate {
460 img = match degrees {
461 90 => img.rotate90(),
462 180 => img.rotate180(),
463 270 => img.rotate270(),
464 _ => img,
465 };
466 }
467
468 if params.w.is_some() || params.h.is_some() {
470 let max_w = params.w.unwrap_or(img.width());
471 let max_h = params.h.unwrap_or(img.height());
472 if img.width() > max_w || img.height() > max_h {
473 img = img.resize(max_w, max_h, image::imageops::FilterType::Lanczos3);
474 }
475 }
476
477 if let Some(ref thumb_spec) = params.thumb {
479 let parts: Vec<&str> = thumb_spec.split('x').collect();
480 if parts.len() == 2 {
481 let tw: u32 = parts[0].parse().map_err(|_| StatusCode::BAD_REQUEST)?;
482 let th: u32 = parts[1].parse().map_err(|_| StatusCode::BAD_REQUEST)?;
483 if tw > 0 && th > 0 && tw <= *MAX_IMAGE_DIMENSION && th <= *MAX_IMAGE_DIMENSION {
484 img = img.thumbnail(tw, th);
485 }
486 }
487 }
488
489 let output_format = match params.format.as_deref().map(str::to_lowercase).as_deref() {
491 Some("webp") => image::ImageFormat::WebP,
492 Some("png") => image::ImageFormat::Png,
493 Some("jpeg") | Some("jpg") => image::ImageFormat::Jpeg,
494 _ => original_format,
495 };
496
497 let quality = params.quality.unwrap_or(DEFAULT_JPEG_QUALITY);
498
499 let mut buf = std::io::Cursor::new(Vec::new());
500 match output_format {
501 image::ImageFormat::Jpeg => {
502 let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality);
503 img.write_with_encoder(encoder)
504 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
505 }
506 image::ImageFormat::WebP => {
507 let config = crate::infra::webp::WEBP_CONFIG.clone();
508 let webp_quality = params.quality.map(|q| q as f32).unwrap_or(config.quality);
509 let webp_data = crate::infra::webp::encode(&img, webp_quality, config.method)
510 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
511 buf = std::io::Cursor::new(webp_data);
512 }
513 _ => {
514 img.write_to(&mut buf, output_format)
515 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
516 }
517 }
518
519 let ct = content_type(output_format);
520 Ok((buf.into_inner(), ct))
521}
522
523#[cfg(feature = "server")]
524fn process_image_blocking(
525 data: Vec<u8>,
526 params: ImageParams,
527 path: String,
528) -> Result<(Vec<u8>, HeaderValue), StatusCode> {
529 let original_format = detect_format(&path);
530
531 if is_animated_image(&data, original_format) {
538 return Ok((data, content_type(original_format)));
539 }
540 let img = if original_format == image::ImageFormat::WebP {
541 match crate::infra::webp::decode(&data) {
542 Ok(img) => {
543 check_image_dimensions(img.width(), img.height())?;
544 img
545 }
546 Err(e) => {
547 tracing::warn!("WebP decode failed ({}), rejecting", e);
550 return Err(StatusCode::UNPROCESSABLE_ENTITY);
551 }
552 }
553 } else {
554 let cursor = std::io::Cursor::new(&data);
555 let mut reader = image::ImageReader::with_format(cursor, original_format);
556 reader.limits(image_reader_limits());
557 match reader.decode() {
558 Ok(img) => img,
559 Err(e) => {
560 tracing::warn!("Image decode failed ({}), rejecting", e);
561 return Err(StatusCode::UNPROCESSABLE_ENTITY);
562 }
563 }
564 };
565
566 process_image(img, ¶ms, original_format)
567}
568
569#[cfg(feature = "server")]
570async fn is_path_safe(path: &str) -> bool {
576 if path.contains("..") || path.contains('\0') || path.starts_with('/') {
577 return false;
578 }
579 let candidate = std::path::Path::new("uploads").join(path);
580 let uploads_root = match tokio::fs::canonicalize("uploads").await {
581 Ok(p) => p,
582 Err(_) => return true, };
584 match tokio::fs::canonicalize(&candidate).await {
585 Ok(resolved) => resolved.starts_with(&uploads_root),
586 Err(_) => true, }
588}
589
590#[cfg(feature = "server")]
591use axum::http::HeaderMap;
592
593#[cfg(feature = "server")]
594const CACHE_DIR: &str = "uploads/.cache";
595
596#[cfg(feature = "server")]
597pub async fn invalidate_asset_caches(rel_path: &str) {
606 let prefix = format!("{}|", rel_path);
607 let _ = IMAGE_CACHE.invalidate_entries_if(move |k, _| k.starts_with(&prefix));
609 IMAGE_DIMENSIONS_CACHE.invalidate(rel_path);
610}
611
612#[cfg(feature = "server")]
617pub async fn invalidate_all_caches() {
618 IMAGE_CACHE.invalidate_all();
619 IMAGE_DIMENSIONS_CACHE.invalidate_all();
620 if let Err(error) = tokio::fs::remove_dir_all(CACHE_DIR).await {
621 if error.kind() != std::io::ErrorKind::NotFound {
622 tracing::warn!("Failed to clear image disk cache: {error}");
623 }
624 }
625}
626
627#[cfg(feature = "server")]
628fn disk_cache_base(cache_key: &str) -> String {
629 use sha2::Digest;
632 let hash = sha2::Sha256::digest(cache_key.as_bytes());
633 let hash_hex = hex::encode(hash);
634 format!("{}/cache_{}", CACHE_DIR, hash_hex)
635}
636
637#[cfg(feature = "server")]
638async fn read_disk_cache(cache_key: &str) -> Option<CachedImage> {
639 let base = disk_cache_base(cache_key);
640 let data = tokio::fs::read(format!("{}.dat", base)).await.ok()?;
641 let ct_str = tokio::fs::read_to_string(format!("{}.ct", base))
642 .await
643 .ok()
644 .unwrap_or_else(|| "application/octet-stream".to_string());
645 let content_type = HeaderValue::from_str(&ct_str).ok()?;
646 Some(CachedImage {
647 data: Bytes::from(data),
648 content_type,
649 })
650}
651
652#[cfg(feature = "server")]
653async fn write_disk_cache(cache_key: &str, cached: &CachedImage) {
654 let base = disk_cache_base(cache_key);
655 if let Err(e) = tokio::fs::create_dir_all(CACHE_DIR).await {
656 tracing::warn!("Failed to create cache dir: {:?}", e);
657 return;
658 }
659 let ct_str = cached
660 .content_type
661 .to_str()
662 .unwrap_or("application/octet-stream");
663
664 let dat_path = format!("{}.dat", base);
666 let ct_path = format!("{}.ct", base);
667 let dat_tmp = format!("{}.dat.tmp", base);
668 let ct_tmp = format!("{}.ct.tmp", base);
669
670 let writes_ok = tokio::fs::write(&dat_tmp, &cached.data).await.is_ok()
672 && tokio::fs::write(&ct_tmp, ct_str).await.is_ok();
673
674 if !writes_ok {
675 let _ = tokio::fs::remove_file(&dat_tmp).await;
676 let _ = tokio::fs::remove_file(&ct_tmp).await;
677 tracing::warn!("Failed to write disk cache temp files at {}", base);
678 return;
679 }
680
681 let rename_dat = tokio::fs::rename(&dat_tmp, &dat_path).await;
682 let rename_ct = tokio::fs::rename(&ct_tmp, &ct_path).await;
683 if rename_dat.is_err() || rename_ct.is_err() {
684 let _ = tokio::fs::remove_file(&dat_tmp).await;
686 let _ = tokio::fs::remove_file(&ct_tmp).await;
687 tracing::warn!("Failed to atomically rename disk cache at {}", base);
688 }
689}
690
691#[cfg(feature = "server")]
692pub async fn serve_image(
703 connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
704 Path(path): Path<String>,
705 Query(params): Query<ImageParams>,
706 headers: HeaderMap,
707) -> Response {
708 let peer = connect_info.map(|Extension(ConnectInfo(addr))| addr);
709 let ip = crate::api::rate_limit::get_client_ip_with_peer(&headers, peer).await;
710
711 if !is_path_safe(&path).await {
712 return StatusCode::FORBIDDEN.into_response();
713 }
714
715 let file_path = format!("uploads/{}", path);
716
717 if let Err(status) = params.validate() {
719 return status.into_response();
720 }
721
722 if params.is_empty() {
724 if let Err(resp) = crate::api::rate_limit::check_image_limit(&ip) {
726 return *resp;
727 }
728 const MAX_RAW_BYTES: u64 = 20 * 1024 * 1024;
731 return match tokio::fs::metadata(&file_path).await {
732 Ok(meta) if meta.len() > MAX_RAW_BYTES => StatusCode::PAYLOAD_TOO_LARGE.into_response(),
733 Ok(_) => match tokio::fs::read(&file_path).await {
734 Ok(data) => {
735 let ct = content_type(detect_format(&path));
736 image_response(
737 Bytes::from(data),
738 ct,
739 "public, max-age=31536000, immutable",
740 &headers,
741 )
742 }
743 Err(_) => StatusCode::NOT_FOUND.into_response(),
744 },
745 Err(_) => StatusCode::NOT_FOUND.into_response(),
746 };
747 }
748
749 let cache_key = params.cache_key(&path);
750 if let Some(cached) = IMAGE_CACHE.get(&cache_key).await {
751 return image_response(
752 cached.data.clone(),
753 cached.content_type,
754 "public, max-age=86400",
755 &headers,
756 );
757 }
758
759 if let Some(cached) = read_disk_cache(&cache_key).await {
760 let data = cached.data.clone();
761 let content_type = cached.content_type.clone();
762 let _ = IMAGE_CACHE.insert(cache_key.clone(), cached).await;
763 return image_response(data, content_type, "public, max-age=86400", &headers);
764 }
765
766 if let Err(resp) = crate::api::rate_limit::check_image_limit(&ip) {
769 return *resp;
770 }
771
772 let _permit = IMAGE_PROCESSING_PERMITS
776 .acquire()
777 .await
778 .expect("图片处理信号量从不 close,acquire 不会失败");
779
780 let data = match tokio::fs::read(&file_path).await {
781 Ok(d) => d,
782 Err(_) => return StatusCode::NOT_FOUND.into_response(),
783 };
784 let (processed, content_type) =
787 match tokio::task::spawn_blocking(move || process_image_blocking(data, params, path)).await
788 {
789 Ok(Ok(r)) => r,
790 Ok(Err(status)) => return status.into_response(),
791 Err(_) => {
792 tracing::error!("Image processing task panicked");
793 return StatusCode::INTERNAL_SERVER_ERROR.into_response();
794 }
795 };
796
797 let processed = Bytes::from(processed);
798 let cached = CachedImage {
799 data: processed,
800 content_type,
801 };
802 let _ = IMAGE_CACHE.insert(cache_key.clone(), cached.clone()).await;
803 write_disk_cache(&cache_key, &cached).await;
804
805 image_response(
806 cached.data,
807 cached.content_type,
808 "public, max-age=86400",
809 &headers,
810 )
811}
812
813#[cfg(feature = "server")]
816static IMAGE_DIMENSIONS_CACHE: LazyLock<SyncCache<String, (u32, u32)>> = LazyLock::new(|| {
817 let ttl =
818 std::time::Duration::from_secs(crate::config::image_limit().dimensions_cache_ttl_secs);
819 SyncCache::builder().time_to_live(ttl).build()
820});
821
822#[cfg(feature = "server")]
828pub fn get_image_dimensions(rel_path: &str) -> Option<(u32, u32)> {
829 if let Some(dims) = IMAGE_DIMENSIONS_CACHE.get(rel_path) {
830 return Some(dims);
831 }
832 let full_path = std::path::Path::new("uploads").join(rel_path);
833 let file = std::fs::File::open(&full_path).ok()?;
837 use std::io::Read;
838 let mut header = Vec::new();
839 file.take(65_536).read_to_end(&mut header).ok()?;
840 let dims = read_dimensions_from_bytes(&header, rel_path)?;
841 IMAGE_DIMENSIONS_CACHE.insert(rel_path.to_string(), dims);
842 Some(dims)
843}
844
845#[cfg(feature = "server")]
847fn read_dimensions_from_bytes(data: &[u8], path: &str) -> Option<(u32, u32)> {
848 let ext = std::path::Path::new(path)
849 .extension()?
850 .to_str()?
851 .to_lowercase();
852 match ext.as_str() {
853 "webp" => read_webp_dimensions(data),
854 "jpg" | "jpeg" => read_image_dimensions(data, image::ImageFormat::Jpeg),
855 "png" => read_image_dimensions(data, image::ImageFormat::Png),
856 "gif" => read_image_dimensions(data, image::ImageFormat::Gif),
857 _ => None,
858 }
859}
860
861#[cfg(all(test, feature = "server"))]
862mod tests {
863 use super::*;
864
865 #[test]
866 fn read_webp_dimensions_from_bytes() {
867 let img = image::DynamicImage::new_rgb8(16, 9);
869 let webp_bytes = crate::infra::webp::encode(&img, 85.0, 2).unwrap();
870 let dims = read_dimensions_from_bytes(&webp_bytes, "test.webp");
871 assert_eq!(dims, Some((16, 9)));
872 }
873
874 fn synth_vp8_riff(w: u16, h: u16) -> Vec<u8> {
879 let mut buf = b"RIFF\x00\x00\x00\x00WEBPVP8 \x00\x00\x00\x00".to_vec();
880 buf.extend_from_slice(&[0x00, 0x00, 0x00, 0x9d, 0x01, 0x2a]);
882 buf.extend_from_slice(&w.to_le_bytes());
883 buf.extend_from_slice(&h.to_le_bytes());
884 buf
885 }
886
887 fn synth_vp8l_riff(w: u32, h: u32) -> Vec<u8> {
890 let mut buf = b"RIFF\x00\x00\x00\x00WEBPVP8L\x00\x00\x00\x00".to_vec();
891 buf.push(0x2f); let header: u32 = (w - 1) | ((h - 1) << 14);
893 buf.extend_from_slice(&header.to_le_bytes());
894 while buf.len() < 30 {
897 buf.push(0);
898 }
899 buf
900 }
901
902 fn synth_vp8x_riff(w: u32, h: u32) -> Vec<u8> {
905 let mut buf = b"RIFF\x00\x00\x00\x00WEBPVP8X\x0a\x00\x00\x00".to_vec();
906 buf.push(0x00); buf.extend_from_slice(&[0, 0, 0]); let wm1 = w - 1;
909 buf.extend_from_slice(&[wm1 as u8, (wm1 >> 8) as u8, (wm1 >> 16) as u8]);
910 let hm1 = h - 1;
911 buf.extend_from_slice(&[hm1 as u8, (hm1 >> 8) as u8, (hm1 >> 16) as u8]);
912 buf
913 }
914
915 #[test]
916 fn read_webp_vp8_lossy_dimensions() {
917 let data = synth_vp8_riff(640, 480);
918 assert_eq!(read_webp_dimensions(&data), Some((640, 480)));
919 }
920
921 #[test]
922 fn read_webp_vp8l_lossless_dimensions() {
923 let data = synth_vp8l_riff(100, 50);
924 assert_eq!(read_webp_dimensions(&data), Some((100, 50)));
925 }
926
927 #[test]
928 fn read_webp_vp8x_extended_dimensions() {
929 let data = synth_vp8x_riff(1920, 1080);
930 assert_eq!(read_webp_dimensions(&data), Some((1920, 1080)));
931 }
932
933 #[test]
934 fn read_webp_dimensions_rejects_bad_signature() {
935 let mut data = synth_vp8x_riff(100, 100);
936 data[0] = b'X'; assert_eq!(read_webp_dimensions(&data), None);
938 }
939
940 #[test]
941 fn read_webp_dimensions_rejects_short_data() {
942 assert_eq!(read_webp_dimensions(b"RIFF\x00\x00\x00\x00WEBP"), None);
943 }
944
945 #[test]
946 fn read_webp_dimensions_rejects_zero_size() {
947 let data = synth_vp8_riff(0, 100);
949 assert_eq!(read_webp_dimensions(&data), None);
950 }
951
952 #[test]
954 fn read_webp_vp8x_large_truncated() {
955 let (w, h) = (400, 400);
957 let mut rgba = image::RgbaImage::new(w, h);
958 let mut s: u64 = 42;
959 for px in rgba.iter_mut() {
960 s = s.wrapping_mul(6364136223846793005).wrapping_add(1);
961 *px = (s >> 33) as u8;
962 }
963 let webp =
964 crate::infra::webp::encode(&image::DynamicImage::ImageRgba8(rgba), 100.0, 0).unwrap();
965 assert!(
966 webp.len() > 65_536,
967 "test image should produce > 64 KiB webp, got {}",
968 webp.len()
969 );
970 let truncated = &webp[..65_536];
972 assert_eq!(
973 read_webp_dimensions(truncated),
974 Some((w, h)),
975 "VP8X webp > 64 KiB must parse dimensions from 64 KiB header"
976 );
977 }
978
979 #[test]
980 fn read_png_dimensions_from_bytes() {
981 let img = image::DynamicImage::new_rgb8(32, 24);
982 let mut buf = std::io::Cursor::new(Vec::new());
983 img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
984 let dims = read_dimensions_from_bytes(&buf.into_inner(), "test.png");
985 assert_eq!(dims, Some((32, 24)));
986 }
987
988 #[test]
989 fn read_dimensions_unknown_extension_returns_none() {
990 let dims = read_dimensions_from_bytes(b"not an image", "test.xyz");
991 assert_eq!(dims, None);
992 }
993
994 fn make_png_bytes(w: u32, h: u32) -> Vec<u8> {
998 let img = image::DynamicImage::new_rgb8(w, h);
999 let mut buf = std::io::Cursor::new(Vec::new());
1000 img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
1001 buf.into_inner()
1002 }
1003
1004 #[test]
1005 fn upload_dimensions_accepts_small_png() {
1006 let data = make_png_bytes(100, 100);
1007 assert!(upload_dimensions(&data, "image/png").is_ok());
1008 }
1009
1010 #[test]
1011 fn upload_dimensions_accepts_boundary_png() {
1012 let data = make_png_bytes(7000, 7000);
1015 assert!(upload_dimensions(&data, "image/png").is_ok());
1016 }
1017
1018 #[test]
1019 fn upload_dimensions_rejects_oversized_width() {
1020 let data = make_png_bytes(*MAX_IMAGE_DIMENSION + 1, 1);
1022 let err = upload_dimensions(&data, "image/png").unwrap_err();
1023 assert!(err.contains("尺寸过大"));
1024 }
1025
1026 #[test]
1027 fn upload_dimensions_rejects_oversized_height() {
1028 let data = make_png_bytes(1, *MAX_IMAGE_DIMENSION + 1);
1029 let err = upload_dimensions(&data, "image/png").unwrap_err();
1030 assert!(err.contains("尺寸过大"));
1031 }
1032
1033 #[test]
1034 fn upload_dimensions_accepts_small_webp() {
1035 let img = image::DynamicImage::new_rgb8(64, 48);
1036 let webp_bytes = crate::infra::webp::encode(&img, 85.0, 2).unwrap();
1037 assert!(upload_dimensions(&webp_bytes, "image/webp").is_ok());
1038 }
1039
1040 #[test]
1041 fn upload_dimensions_accepts_gif() {
1042 let img = image::DynamicImage::new_rgb8(32, 32);
1044 let mut buf = std::io::Cursor::new(Vec::new());
1045 img.write_to(&mut buf, image::ImageFormat::Gif).unwrap();
1046 assert!(upload_dimensions(&buf.into_inner(), "image/gif").is_ok());
1047 }
1048
1049 #[test]
1050 fn upload_dimensions_rejects_corrupt_bytes() {
1051 let err = upload_dimensions(b"not an image at all", "image/png").unwrap_err();
1053 assert_eq!(err, "图片文件损坏或格式不正确");
1054 }
1055
1056 #[test]
1057 fn read_dimensions_by_mime_dispatches_webp() {
1058 let img = image::DynamicImage::new_rgb8(16, 9);
1059 let webp_bytes = crate::infra::webp::encode(&img, 85.0, 2).unwrap();
1060 assert_eq!(
1061 read_dimensions_by_mime(&webp_bytes, "image/webp").unwrap(),
1062 (16, 9)
1063 );
1064 }
1065
1066 #[test]
1067 fn image_params_validate_valid_defaults() {
1068 let params = ImageParams::default();
1069 assert!(params.validate().is_ok());
1070 }
1071
1072 #[test]
1073 fn image_params_validate_valid_width() {
1074 let params = ImageParams {
1075 w: Some(100),
1076 ..Default::default()
1077 };
1078 assert!(params.validate().is_ok());
1079 }
1080
1081 #[test]
1082 fn image_params_validate_zero_width_rejected() {
1083 let params = ImageParams {
1084 w: Some(0),
1085 ..Default::default()
1086 };
1087 assert!(params.validate().is_err());
1088 }
1089
1090 #[test]
1091 fn image_params_validate_oversized_width_rejected() {
1092 let params = ImageParams {
1093 w: Some(*MAX_IMAGE_DIMENSION + 1),
1094 ..Default::default()
1095 };
1096 assert!(params.validate().is_err());
1097 }
1098
1099 #[test]
1100 fn image_params_validate_valid_rotation() {
1101 for angle in [0, 90, 180, 270] {
1102 let params = ImageParams {
1103 rotate: Some(angle),
1104 ..Default::default()
1105 };
1106 assert!(params.validate().is_ok(), "angle {} should be valid", angle);
1107 }
1108 }
1109
1110 #[test]
1111 fn image_params_validate_invalid_rotation_rejected() {
1112 let params = ImageParams {
1113 rotate: Some(45),
1114 ..Default::default()
1115 };
1116 assert!(params.validate().is_err());
1117 }
1118
1119 #[test]
1120 fn image_params_validate_valid_format() {
1121 for fmt in &["jpeg", "jpg", "png", "webp", "JPEG", "PNG"] {
1122 let params = ImageParams {
1123 format: Some(fmt.to_string()),
1124 ..Default::default()
1125 };
1126 assert!(params.validate().is_ok(), "format {} should be valid", fmt);
1127 }
1128 }
1129
1130 #[test]
1131 fn image_params_validate_invalid_format_rejected() {
1132 let params = ImageParams {
1133 format: Some("gif".to_string()),
1134 ..Default::default()
1135 };
1136 assert!(params.validate().is_err());
1137 }
1138
1139 #[test]
1140 fn image_params_validate_valid_thumbnail() {
1141 let params = ImageParams {
1142 thumb: Some("200x150".to_string()),
1143 ..Default::default()
1144 };
1145 assert!(params.validate().is_ok());
1146 }
1147
1148 #[test]
1149 fn image_params_validate_invalid_thumbnail_rejected() {
1150 let params = ImageParams {
1151 thumb: Some("200".to_string()),
1152 ..Default::default()
1153 };
1154 assert!(params.validate().is_err());
1155 }
1156
1157 #[test]
1158 fn image_params_validate_valid_quality() {
1159 let params = ImageParams {
1160 quality: Some(85),
1161 ..Default::default()
1162 };
1163 assert!(params.validate().is_ok());
1164 }
1165
1166 #[test]
1167 fn image_params_validate_zero_quality_rejected() {
1168 let params = ImageParams {
1169 quality: Some(0),
1170 ..Default::default()
1171 };
1172 assert!(params.validate().is_err());
1173 }
1174
1175 #[test]
1176 fn image_params_validate_over_100_quality_rejected() {
1177 let params = ImageParams {
1178 quality: Some(101),
1179 ..Default::default()
1180 };
1181 assert!(params.validate().is_err());
1182 }
1183
1184 #[tokio::test]
1185 async fn is_path_safe_normal() {
1186 assert!(is_path_safe("images/photo.jpg").await);
1187 assert!(is_path_safe("2024/01/photo.png").await);
1188 }
1189
1190 #[tokio::test]
1191 async fn is_path_safe_rejects_parent_dir() {
1192 assert!(!is_path_safe("../etc/passwd").await);
1193 assert!(!is_path_safe("foo/../../bar").await);
1194 }
1195
1196 #[tokio::test]
1197 async fn is_path_safe_rejects_null_bytes() {
1198 assert!(!is_path_safe("foo\0bar").await);
1199 }
1200
1201 #[tokio::test]
1202 async fn is_path_safe_rejects_absolute_path() {
1203 assert!(!is_path_safe("/etc/passwd").await);
1204 }
1205
1206 #[test]
1207 fn detect_format_jpeg() {
1208 assert!(matches!(
1209 detect_format("photo.jpg"),
1210 image::ImageFormat::Jpeg
1211 ));
1212 assert!(matches!(
1213 detect_format("photo.jpeg"),
1214 image::ImageFormat::Jpeg
1215 ));
1216 assert!(matches!(
1217 detect_format("PHOTO.JPG"),
1218 image::ImageFormat::Jpeg
1219 ));
1220 }
1221
1222 #[test]
1223 fn detect_format_png() {
1224 assert!(matches!(detect_format("icon.png"), image::ImageFormat::Png));
1225 }
1226
1227 #[test]
1228 fn detect_format_webp() {
1229 assert!(matches!(
1230 detect_format("anim.webp"),
1231 image::ImageFormat::WebP
1232 ));
1233 }
1234
1235 #[test]
1236 fn detect_format_defaults_to_jpeg() {
1237 assert!(matches!(
1238 detect_format("file.xyz"),
1239 image::ImageFormat::Jpeg
1240 ));
1241 }
1242
1243 #[test]
1244 fn cache_key_differs_for_different_params() {
1245 let p1 = ImageParams {
1246 w: Some(100),
1247 ..Default::default()
1248 };
1249 let p2 = ImageParams {
1250 w: Some(200),
1251 ..Default::default()
1252 };
1253 assert_ne!(p1.cache_key("img.jpg"), p2.cache_key("img.jpg"));
1254 }
1255
1256 #[test]
1257 fn is_empty_true_when_all_none() {
1258 let params = ImageParams::default();
1259 assert!(params.is_empty());
1260 }
1261
1262 #[test]
1263 fn is_empty_false_when_any_set() {
1264 let params = ImageParams {
1265 w: Some(100),
1266 ..Default::default()
1267 };
1268 assert!(!params.is_empty());
1269 }
1270
1271 #[test]
1272 fn disk_cache_base_is_deterministic() {
1273 let key = "path|w=800";
1274 let base1 = disk_cache_base(key);
1275 let base2 = disk_cache_base(key);
1276 assert_eq!(base1, base2);
1277 assert!(base1.starts_with("uploads/.cache/cache_"));
1278 }
1279
1280 #[test]
1281 fn disk_cache_base_differs_for_different_keys() {
1282 let base1 = disk_cache_base("path|w=800");
1283 let base2 = disk_cache_base("path|w=1200");
1284 assert_ne!(base1, base2);
1285 }
1286
1287 #[test]
1288 fn process_image_blocking_resizes_png() {
1289 let img = image::DynamicImage::new_rgb8(100, 100);
1290 let mut buf = std::io::Cursor::new(Vec::new());
1291 img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
1292 let data = buf.into_inner();
1293
1294 let params = ImageParams {
1295 w: Some(50),
1296 format: Some("webp".to_string()),
1297 ..Default::default()
1298 };
1299
1300 let (out, ct) = process_image_blocking(data, params, "test.png".to_string()).unwrap();
1301 assert!(!out.is_empty());
1302 assert_eq!(ct, HeaderValue::from_static("image/webp"));
1303 }
1304
1305 #[test]
1306 fn image_response_includes_cache_headers() {
1307 let resp = image_response(
1308 Bytes::from(vec![1, 2, 3]),
1309 HeaderValue::from_static("image/webp"),
1310 "public, max-age=86400",
1311 &HeaderMap::new(),
1312 );
1313 assert_eq!(resp.status(), StatusCode::OK);
1314 let headers = resp.headers();
1315 assert_eq!(headers.get(header::CONTENT_TYPE).unwrap(), "image/webp");
1316 assert_eq!(
1317 headers.get(header::CACHE_CONTROL).unwrap(),
1318 "public, max-age=86400"
1319 );
1320 assert!(headers
1321 .get(header::ETAG)
1322 .unwrap()
1323 .to_str()
1324 .unwrap()
1325 .starts_with('"'));
1326 }
1327
1328 #[test]
1329 fn image_response_returns_304_when_etag_matches() {
1330 let data = Bytes::from(vec![1, 2, 3]);
1331 let etag = etag_for(&data);
1332 let mut req_headers = HeaderMap::new();
1333 req_headers.insert(header::IF_NONE_MATCH, HeaderValue::from_str(&etag).unwrap());
1334 let resp = image_response(
1335 data,
1336 HeaderValue::from_static("image/webp"),
1337 "public, max-age=86400",
1338 &req_headers,
1339 );
1340 assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
1341 let headers = resp.headers();
1342 assert_eq!(headers.get(header::ETAG).unwrap(), etag.as_str());
1343 assert_eq!(headers.get(header::CONTENT_TYPE).unwrap(), "image/webp");
1344 assert_eq!(
1345 headers.get(header::CACHE_CONTROL).unwrap(),
1346 "public, max-age=86400"
1347 );
1348 }
1349
1350 #[test]
1351 fn etag_matches_single() {
1352 assert!(etag_matches("\"abc\"", "\"abc\""));
1353 assert!(!etag_matches("\"abc\"", "\"def\""));
1354 }
1355
1356 #[test]
1357 fn etag_matches_list() {
1358 assert!(etag_matches("\"abc\", \"def\"", "\"def\""));
1359 assert!(!etag_matches("\"abc\", \"def\"", "\"ghi\""));
1360 }
1361
1362 #[test]
1363 fn etag_matches_weak_prefix() {
1364 assert!(etag_matches("W/\"abc\"", "\"abc\""));
1365 }
1366
1367 #[test]
1368 fn etag_matches_wildcard() {
1369 assert!(etag_matches("*", "\"anything\""));
1370 }
1371
1372 #[test]
1373 fn image_response_raw_file_is_immutable() {
1374 let resp = image_response(
1375 Bytes::from(vec![1, 2, 3]),
1376 HeaderValue::from_static("image/jpeg"),
1377 "public, max-age=31536000, immutable",
1378 &HeaderMap::new(),
1379 );
1380 assert_eq!(resp.status(), StatusCode::OK);
1381 let cache_control = resp
1382 .headers()
1383 .get(header::CACHE_CONTROL)
1384 .unwrap()
1385 .to_str()
1386 .unwrap();
1387 assert!(cache_control.contains("immutable"));
1388 }
1389
1390 #[test]
1391 fn etag_for_same_data_is_stable() {
1392 let a = etag_for(b"hello");
1393 let b = etag_for(b"hello");
1394 assert_eq!(a, b);
1395 assert_ne!(a, etag_for(b"world"));
1396 }
1397
1398 fn unique_peer(third: u8) -> std::net::SocketAddr {
1403 let ip = std::net::Ipv4Addr::new(203, 0, 113, third);
1404 std::net::SocketAddr::new(std::net::IpAddr::V4(ip), 8080)
1405 }
1406
1407 #[tokio::test]
1408 async fn serve_image_cache_hit_does_not_consume_rate_limit_tokens() {
1409 let path = "test/rl_hit.webp";
1411 let params = ImageParams {
1412 thumb: Some("300x300".to_string()),
1413 ..Default::default()
1414 };
1415 let key = params.cache_key(path);
1416 IMAGE_CACHE
1417 .insert(
1418 key.clone(),
1419 CachedImage {
1420 data: Bytes::from_static(b"cached"),
1421 content_type: HeaderValue::from_static("image/webp"),
1422 },
1423 )
1424 .await;
1425
1426 let peer = unique_peer(1);
1428 for _ in 0..60 {
1429 let resp = serve_image(
1430 Some(Extension(ConnectInfo(peer))),
1431 Path(path.to_string()),
1432 Query(params.clone()),
1433 HeaderMap::new(),
1434 )
1435 .await;
1436 assert_eq!(resp.status(), StatusCode::OK, "缓存命中不应消耗限流令牌");
1437 }
1438 IMAGE_CACHE.invalidate(&key).await;
1439 }
1440
1441 #[tokio::test]
1442 async fn serve_image_cache_miss_is_rate_limited_with_retry_after() {
1443 let peer = unique_peer(2);
1446 let mut not_found = 0;
1447 let mut too_many = 0;
1448 for w in 1..=60_u32 {
1449 let resp = serve_image(
1450 Some(Extension(ConnectInfo(peer))),
1451 Path("test/rl_miss_nonexistent.webp".to_string()),
1452 Query(ImageParams {
1453 w: Some(w),
1454 ..Default::default()
1455 }),
1456 HeaderMap::new(),
1457 )
1458 .await;
1459 match resp.status() {
1460 StatusCode::NOT_FOUND => not_found += 1,
1461 StatusCode::TOO_MANY_REQUESTS => {
1462 too_many += 1;
1463 let retry_after = resp
1464 .headers()
1465 .get(header::RETRY_AFTER)
1466 .expect("429 必须带 Retry-After")
1467 .to_str()
1468 .expect("Retry-After 仅含 ASCII 数字");
1469 assert!(retry_after.parse::<u64>().expect("Retry-After 为秒数") >= 1);
1470 }
1471 other => panic!("unexpected status {other}"),
1472 }
1473 }
1474 assert!(not_found > 0, "burst 内的 miss 应正常处理(404)");
1475 assert!(too_many > 0, "超出 burst 的 miss 应被 429 限流");
1476 }
1477
1478 fn make_animated_webp() -> Vec<u8> {
1487 use zenwebp::mux::{AnimationConfig, AnimationEncoder};
1488 use zenwebp::{EncoderConfig, PixelLayout};
1489
1490 let mut enc =
1491 AnimationEncoder::new(8, 8, AnimationConfig::default()).expect("8x8 在合法画布范围内");
1492 let cfg = EncoderConfig::new_lossy();
1493 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)
1497 .expect("首帧编码");
1498 enc.add_frame(&frame_b, PixelLayout::Rgb8, 100, &cfg)
1499 .expect("次帧编码");
1500 enc.finalize(100).expect("动画装配")
1501 }
1502
1503 #[test]
1504 fn is_animated_image_detects_real_animated_webp() {
1505 let animated = make_animated_webp();
1506 assert!(
1507 is_animated_image(&animated, image::ImageFormat::WebP),
1508 "真实多帧 animated WebP 必须被检出"
1509 );
1510 let probe = zenwebp::detect::probe(&animated).expect("合法 WebP");
1512 assert!(probe.has_animation, "probe 应报告 has_animation");
1513 }
1514
1515 #[test]
1516 fn is_animated_image_false_for_static_webp() {
1517 let img = image::DynamicImage::new_rgb8(32, 32);
1519 let static_webp = crate::infra::webp::encode(&img, 80.0, 2).unwrap();
1520 assert!(
1521 !is_animated_image(&static_webp, image::ImageFormat::WebP),
1522 "静态 WebP 不应被误报为动图"
1523 );
1524 let extended = synth_vp8x_riff(100, 100);
1526 assert!(
1527 !is_animated_image(&extended, image::ImageFormat::WebP),
1528 "VP8X 非动画 WebP 不应被误报"
1529 );
1530 }
1531
1532 #[test]
1533 fn process_image_blocking_preserves_animated_webp_bytes() {
1534 let animated = make_animated_webp();
1537 let params = ImageParams {
1538 thumb: Some("300x300".to_string()),
1539 ..Default::default()
1540 };
1541 let (out_bytes, out_ct) =
1542 process_image_blocking(animated.clone(), params, "2026/08/13/anim.webp".to_string())
1543 .expect("动图绕过处理不应失败");
1544 assert_eq!(
1545 out_bytes, animated,
1546 "动图字节必须原样返回(绕过解码/缩放/重编码)"
1547 );
1548 assert_eq!(out_ct, "image/webp");
1549 }
1550
1551 #[test]
1552 fn process_image_blocking_still_processes_static_webp() {
1553 let img = image::DynamicImage::new_rgb8(200, 200);
1555 let static_webp = crate::infra::webp::encode(&img, 80.0, 2).unwrap();
1556 let params = ImageParams {
1557 thumb: Some("50x50".to_string()),
1558 ..Default::default()
1559 };
1560 let (out_bytes, out_ct) =
1561 process_image_blocking(static_webp.clone(), params, "static.webp".to_string())
1562 .expect("静态图处理不应失败");
1563 assert_ne!(
1564 out_bytes, static_webp,
1565 "静态 WebP 应被实际处理(字节变化),不能被错误绕过"
1566 );
1567 assert_eq!(out_ct, "image/webp");
1568 }
1569
1570 #[test]
1571 fn is_animated_image_detects_gif() {
1572 let anim_gif: Vec<u8> = {
1575 let mut b = b"GIF89a".to_vec();
1576 b.extend_from_slice(b"\x21\xff\x0bNETSCAPE2.0\x03\x01\x00\x00\x00");
1577 b.extend_from_slice(&[0x3b]); b
1579 };
1580 assert!(
1581 is_animated_image(&anim_gif, image::ImageFormat::Gif),
1582 "含 NETSCAPE2.0 标记的 GIF 应被检出为动图"
1583 );
1584 let static_gif = b"GIF89a\x01\x00\x01\x00";
1586 assert!(
1587 !is_animated_image(static_gif, image::ImageFormat::Gif),
1588 "无 NETSCAPE 标记的 GIF 不应被误报"
1589 );
1590 }
1591
1592 #[test]
1593 fn is_animated_image_false_for_jpeg_png() {
1594 assert!(!is_animated_image(
1596 &[0xFF, 0xD8, 0xFF],
1597 image::ImageFormat::Jpeg
1598 ));
1599 assert!(!is_animated_image(
1600 &[0x89, 0x50, 0x4E, 0x47],
1601 image::ImageFormat::Png
1602 ));
1603 }
1604}