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(|| {
53 const DEFAULT: u32 = 8192;
54 const MIN: u32 = 512;
55 let (val, clamped) = std::env::var("MAX_IMAGE_DIMENSION")
56 .ok()
57 .and_then(|s| s.parse::<u32>().ok())
58 .map(|v| if v < MIN { (MIN, true) } else { (v, false) })
59 .unwrap_or((DEFAULT, false));
60 if clamped {
61 tracing::warn!(
62 "MAX_IMAGE_DIMENSION was clamped from {} to {} (minimum {})",
63 std::env::var("MAX_IMAGE_DIMENSION").unwrap_or_default(),
64 val,
65 MIN
66 );
67 }
68 tracing::info!("Image dimension limit loaded: {}", val);
69 val
70});
71#[cfg(feature = "server")]
72const DEFAULT_JPEG_QUALITY: u8 = 85;
73#[cfg(feature = "server")]
74pub static MAX_IMAGE_PIXELS: LazyLock<u32> = LazyLock::new(|| {
81 const DEFAULT: u32 = 50_000_000;
82 const MIN: u32 = 1_000_000;
83 let (val, clamped) = std::env::var("MAX_IMAGE_PIXELS")
84 .ok()
85 .and_then(|s| s.parse::<u32>().ok())
86 .map(|v| if v < MIN { (MIN, true) } else { (v, false) })
87 .unwrap_or((DEFAULT, false));
88 if clamped {
89 tracing::warn!(
90 "MAX_IMAGE_PIXELS was clamped from {} to {} (minimum {})",
91 std::env::var("MAX_IMAGE_PIXELS").unwrap_or_default(),
92 val,
93 MIN
94 );
95 }
96 tracing::info!("Image pixel limit loaded: {}", val);
97 val
98});
99
100#[cfg(feature = "server")]
101#[derive(Debug, Clone)]
102struct CachedImage {
104 data: Bytes,
105 content_type: HeaderValue,
106}
107
108#[cfg(feature = "server")]
109static IMAGE_CACHE: LazyLock<Cache<String, CachedImage>> = LazyLock::new(|| {
110 Cache::builder()
111 .max_capacity(100)
112 .time_to_idle(std::time::Duration::from_secs(300))
113 .build()
114});
115
116#[cfg(feature = "server")]
117#[derive(Debug, Deserialize, Clone, Hash, Eq, PartialEq, Default)]
118pub struct ImageParams {
120 pub w: Option<u32>,
122 pub h: Option<u32>,
124 pub thumb: Option<String>,
126 pub rotate: Option<u16>,
128 pub format: Option<String>,
130 pub quality: Option<u8>,
132}
133
134#[cfg(feature = "server")]
135impl ImageParams {
136 fn is_empty(&self) -> bool {
137 self.w.is_none()
138 && self.h.is_none()
139 && self.thumb.is_none()
140 && self.rotate.is_none()
141 && self.format.is_none()
142 && self.quality.is_none()
143 }
144
145 fn cache_key(&self, path: &str) -> String {
146 use std::fmt::Write as _;
147 let mut key = String::with_capacity(path.len() + 64);
150 key.push_str(path);
151 if let Some(w) = self.w {
152 let _ = write!(key, "|w={}", w);
153 }
154 if let Some(h) = self.h {
155 let _ = write!(key, "|h={}", h);
156 }
157 if let Some(ref thumb) = self.thumb {
158 let _ = write!(key, "|thumb={}", thumb);
159 }
160 if let Some(r) = self.rotate {
161 let _ = write!(key, "|rotate={}", r);
162 }
163 if let Some(ref fmt) = self.format {
164 let _ = write!(key, "|format={}", fmt);
165 }
166 if let Some(q) = self.quality {
167 let _ = write!(key, "|quality={}", q);
168 }
169 key
170 }
171
172 fn validate(&self) -> Result<(), StatusCode> {
174 if let Some(dim) = self.w {
175 if dim == 0 || dim > *MAX_IMAGE_DIMENSION {
176 return Err(StatusCode::BAD_REQUEST);
177 }
178 }
179 if let Some(dim) = self.h {
180 if dim == 0 || dim > *MAX_IMAGE_DIMENSION {
181 return Err(StatusCode::BAD_REQUEST);
182 }
183 }
184 if let Some(r) = self.rotate {
185 if !matches!(r, 0 | 90 | 180 | 270) {
186 return Err(StatusCode::BAD_REQUEST);
187 }
188 }
189 if let Some(ref fmt) = self.format {
190 if !matches!(fmt.to_lowercase().as_str(), "jpeg" | "jpg" | "png" | "webp") {
191 return Err(StatusCode::BAD_REQUEST);
192 }
193 }
194 if let Some(ref thumb) = self.thumb {
195 let parts: Vec<&str> = thumb.split('x').collect();
196 if parts.len() != 2 {
197 return Err(StatusCode::BAD_REQUEST);
198 }
199 let tw: u32 = parts[0].parse().map_err(|_| StatusCode::BAD_REQUEST)?;
200 let th: u32 = parts[1].parse().map_err(|_| StatusCode::BAD_REQUEST)?;
201 if tw == 0 || th == 0 || tw > *MAX_IMAGE_DIMENSION || th > *MAX_IMAGE_DIMENSION {
202 return Err(StatusCode::BAD_REQUEST);
203 }
204 }
205 if let Some(q) = self.quality {
206 if q == 0 || q > 100 {
207 return Err(StatusCode::BAD_REQUEST);
208 }
209 }
210 Ok(())
211 }
212}
213
214#[cfg(feature = "server")]
215fn detect_format(path: &str) -> ImageFmt {
216 let ext = path.rsplit('.').next().unwrap_or("");
219 if ext.eq_ignore_ascii_case("jpg") || ext.eq_ignore_ascii_case("jpeg") {
220 ImageFmt::Jpeg
221 } else if ext.eq_ignore_ascii_case("png") {
222 ImageFmt::Png
223 } else if ext.eq_ignore_ascii_case("webp") {
224 ImageFmt::WebP
225 } else if ext.eq_ignore_ascii_case("gif") {
226 ImageFmt::Gif
227 } else {
228 ImageFmt::Jpeg
229 }
230}
231
232#[cfg(feature = "server")]
234type ImageFmt = image::ImageFormat;
235
236#[cfg(feature = "server")]
237fn content_type(format: image::ImageFormat) -> HeaderValue {
238 match format {
239 image::ImageFormat::Jpeg => HeaderValue::from_static("image/jpeg"),
240 image::ImageFormat::Png => HeaderValue::from_static("image/png"),
241 image::ImageFormat::WebP => HeaderValue::from_static("image/webp"),
242 image::ImageFormat::Gif => HeaderValue::from_static("image/gif"),
243 _ => HeaderValue::from_static("application/octet-stream"),
244 }
245}
246
247#[cfg(feature = "server")]
248fn image_response(
249 data: Bytes,
250 content_type: HeaderValue,
251 cache_control: &'static str,
252 headers: &HeaderMap,
253) -> Response {
254 let etag = etag_for(&data);
255 let etag_value = HeaderValue::from_str(&etag)
258 .expect("etag 仅含 ASCII hex 与双引号,必然是合法的 HeaderValue");
259
260 if let Some(if_none_match) = headers
261 .get(header::IF_NONE_MATCH)
262 .and_then(|v| v.to_str().ok())
263 {
264 if etag_matches(if_none_match, &etag) {
265 return (
266 StatusCode::NOT_MODIFIED,
267 [
268 (header::ETAG, etag_value.clone()),
269 (
270 header::CACHE_CONTROL,
271 HeaderValue::from_static(cache_control),
272 ),
273 (header::CONTENT_TYPE, content_type),
274 (
276 header::X_CONTENT_TYPE_OPTIONS,
277 HeaderValue::from_static("nosniff"),
278 ),
279 ],
280 )
281 .into_response();
282 }
283 }
284
285 (
286 StatusCode::OK,
287 [
288 (header::CONTENT_TYPE, content_type),
289 (
290 header::CACHE_CONTROL,
291 HeaderValue::from_static(cache_control),
292 ),
293 (header::ETAG, etag_value),
294 (
295 header::X_CONTENT_TYPE_OPTIONS,
296 HeaderValue::from_static("nosniff"),
297 ),
298 ],
299 data,
300 )
301 .into_response()
302}
303
304#[cfg(feature = "server")]
305fn check_image_dimensions(width: u32, height: u32) -> Result<(), StatusCode> {
306 if width == 0 || height == 0 {
307 return Err(StatusCode::BAD_REQUEST);
308 }
309 let pixels = u64::from(width) * u64::from(height);
310 if pixels > u64::from(*MAX_IMAGE_PIXELS) {
311 tracing::warn!(
312 "Image dimensions too large: {}x{} ({} pixels, max {})",
313 width,
314 height,
315 pixels,
316 *MAX_IMAGE_PIXELS
317 );
318 return Err(StatusCode::PAYLOAD_TOO_LARGE);
319 }
320 Ok(())
321}
322
323#[cfg(feature = "server")]
324pub(crate) fn upload_dimensions(data: &[u8], mime_type: &str) -> Result<(u32, u32), &'static str> {
333 let dims = read_dimensions_by_mime(data, mime_type)?;
334 let (width, height) = dims;
335 if width == 0 || height == 0 {
336 return Err("图片文件损坏或格式不正确");
337 }
338 let pixels = u64::from(width) * u64::from(height);
339 let max_dim = *MAX_IMAGE_DIMENSION;
340 let max_pixels = *MAX_IMAGE_PIXELS;
341 if width > max_dim || height > max_dim || pixels > u64::from(max_pixels) {
342 tracing::warn!(
343 "Uploaded image too large: {}x{} ({} pixels, max {}x{} / {} pixels)",
344 width,
345 height,
346 pixels,
347 max_dim,
348 max_dim,
349 max_pixels
350 );
351 return Err("图片尺寸过大,请压缩后再上传");
352 }
353 Ok(dims)
354}
355
356#[cfg(feature = "server")]
357fn read_dimensions_by_mime(data: &[u8], mime_type: &str) -> Result<(u32, u32), &'static str> {
359 match mime_type {
360 "image/webp" => read_webp_dimensions(data).ok_or("图片文件损坏或格式不正确"),
361 "image/jpeg" | "image/png" | "image/gif" => {
362 let format = match mime_type {
363 "image/jpeg" => image::ImageFormat::Jpeg,
364 "image/png" => image::ImageFormat::Png,
365 _ => image::ImageFormat::Gif,
366 };
367 read_image_dimensions(data, format).ok_or("图片文件损坏或格式不正确")
368 }
369 _ => Err("图片文件损坏或格式不正确"),
371 }
372}
373
374#[cfg(feature = "server")]
376fn read_webp_dimensions(data: &[u8]) -> Option<(u32, u32)> {
377 let decoder = zenwebp::WebPDecoder::build(data).ok()?;
378 let info = decoder.info();
379 Some((info.width, info.height))
380}
381
382#[cfg(feature = "server")]
384fn read_image_dimensions(data: &[u8], format: image::ImageFormat) -> Option<(u32, u32)> {
385 let reader = image::ImageReader::with_format(std::io::Cursor::new(data), format);
386 reader.into_dimensions().ok()
387}
388
389#[cfg(feature = "server")]
391pub(crate) fn image_reader_limits() -> image::Limits {
392 let mut limits = image::Limits::default();
393 limits.max_image_width = Some(*MAX_IMAGE_DIMENSION);
394 limits.max_image_height = Some(*MAX_IMAGE_DIMENSION);
395 limits.max_alloc = Some(*MAX_IMAGE_PIXELS as u64 * 4 + 1024 * 1024);
396 limits
397}
398
399#[cfg(feature = "server")]
400fn process_image(
401 img: image::DynamicImage,
402 params: &ImageParams,
403 original_format: image::ImageFormat,
404) -> Result<(Vec<u8>, HeaderValue), StatusCode> {
405 check_image_dimensions(img.width(), img.height())?;
406 let mut img = img;
407
408 if let Some(degrees) = params.rotate {
410 img = match degrees {
411 90 => img.rotate90(),
412 180 => img.rotate180(),
413 270 => img.rotate270(),
414 _ => img,
415 };
416 }
417
418 if params.w.is_some() || params.h.is_some() {
420 let max_w = params.w.unwrap_or(img.width());
421 let max_h = params.h.unwrap_or(img.height());
422 if img.width() > max_w || img.height() > max_h {
423 img = img.resize(max_w, max_h, image::imageops::FilterType::Lanczos3);
424 }
425 }
426
427 if let Some(ref thumb_spec) = params.thumb {
429 let parts: Vec<&str> = thumb_spec.split('x').collect();
430 if parts.len() == 2 {
431 let tw: u32 = parts[0].parse().map_err(|_| StatusCode::BAD_REQUEST)?;
432 let th: u32 = parts[1].parse().map_err(|_| StatusCode::BAD_REQUEST)?;
433 if tw > 0 && th > 0 && tw <= *MAX_IMAGE_DIMENSION && th <= *MAX_IMAGE_DIMENSION {
434 img = img.thumbnail(tw, th);
435 }
436 }
437 }
438
439 let output_format = match params.format.as_deref().map(str::to_lowercase).as_deref() {
441 Some("webp") => image::ImageFormat::WebP,
442 Some("png") => image::ImageFormat::Png,
443 Some("jpeg") | Some("jpg") => image::ImageFormat::Jpeg,
444 _ => original_format,
445 };
446
447 let quality = params.quality.unwrap_or(DEFAULT_JPEG_QUALITY);
448
449 let mut buf = std::io::Cursor::new(Vec::new());
450 match output_format {
451 image::ImageFormat::Jpeg => {
452 let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality);
453 img.write_with_encoder(encoder)
454 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
455 }
456 image::ImageFormat::WebP => {
457 let config = crate::webp::WEBP_CONFIG.clone();
458 let webp_quality = params.quality.map(|q| q as f32).unwrap_or(config.quality);
459 let webp_data = crate::webp::encode(&img, webp_quality, config.method)
460 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
461 buf = std::io::Cursor::new(webp_data);
462 }
463 _ => {
464 img.write_to(&mut buf, output_format)
465 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
466 }
467 }
468
469 let ct = content_type(output_format);
470 Ok((buf.into_inner(), ct))
471}
472
473#[cfg(feature = "server")]
474fn process_image_blocking(
475 data: Vec<u8>,
476 params: ImageParams,
477 path: String,
478) -> Result<(Vec<u8>, HeaderValue), StatusCode> {
479 let original_format = detect_format(&path);
480
481 let img = if original_format == image::ImageFormat::WebP {
482 match crate::webp::decode(&data) {
483 Ok(img) => {
484 check_image_dimensions(img.width(), img.height())?;
485 img
486 }
487 Err(e) => {
488 tracing::warn!("WebP decode failed ({}), rejecting", e);
491 return Err(StatusCode::UNPROCESSABLE_ENTITY);
492 }
493 }
494 } else {
495 let cursor = std::io::Cursor::new(&data);
496 let mut reader = image::ImageReader::with_format(cursor, original_format);
497 reader.limits(image_reader_limits());
498 match reader.decode() {
499 Ok(img) => img,
500 Err(e) => {
501 tracing::warn!("Image decode failed ({}), rejecting", e);
502 return Err(StatusCode::UNPROCESSABLE_ENTITY);
503 }
504 }
505 };
506
507 process_image(img, ¶ms, original_format)
508}
509
510#[cfg(feature = "server")]
511async fn is_path_safe(path: &str) -> bool {
517 if path.contains("..") || path.contains('\0') || path.starts_with('/') {
518 return false;
519 }
520 let candidate = std::path::Path::new("uploads").join(path);
521 let uploads_root = match tokio::fs::canonicalize("uploads").await {
522 Ok(p) => p,
523 Err(_) => return true, };
525 match tokio::fs::canonicalize(&candidate).await {
526 Ok(resolved) => resolved.starts_with(&uploads_root),
527 Err(_) => true, }
529}
530
531#[cfg(feature = "server")]
532use axum::http::HeaderMap;
533
534#[cfg(feature = "server")]
535const CACHE_DIR: &str = "uploads/.cache";
536
537#[cfg(feature = "server")]
538pub async fn invalidate_asset_caches(rel_path: &str) {
547 let prefix = format!("{}|", rel_path);
548 let _ = IMAGE_CACHE.invalidate_entries_if(move |k, _| k.starts_with(&prefix));
550 IMAGE_DIMENSIONS_CACHE.invalidate(rel_path);
551}
552
553#[cfg(feature = "server")]
554fn disk_cache_base(cache_key: &str) -> String {
555 use sha2::Digest;
558 let hash = sha2::Sha256::digest(cache_key.as_bytes());
559 let hash_hex = hex::encode(hash);
560 format!("{}/cache_{}", CACHE_DIR, hash_hex)
561}
562
563#[cfg(feature = "server")]
564async fn read_disk_cache(cache_key: &str) -> Option<CachedImage> {
565 let base = disk_cache_base(cache_key);
566 let data = tokio::fs::read(format!("{}.dat", base)).await.ok()?;
567 let ct_str = tokio::fs::read_to_string(format!("{}.ct", base))
568 .await
569 .ok()
570 .unwrap_or_else(|| "application/octet-stream".to_string());
571 let content_type = HeaderValue::from_str(&ct_str).ok()?;
572 Some(CachedImage {
573 data: Bytes::from(data),
574 content_type,
575 })
576}
577
578#[cfg(feature = "server")]
579async fn write_disk_cache(cache_key: &str, cached: &CachedImage) {
580 let base = disk_cache_base(cache_key);
581 if let Err(e) = tokio::fs::create_dir_all(CACHE_DIR).await {
582 tracing::warn!("Failed to create cache dir: {:?}", e);
583 return;
584 }
585 let ct_str = cached
586 .content_type
587 .to_str()
588 .unwrap_or("application/octet-stream");
589
590 let dat_path = format!("{}.dat", base);
592 let ct_path = format!("{}.ct", base);
593 let dat_tmp = format!("{}.dat.tmp", base);
594 let ct_tmp = format!("{}.ct.tmp", base);
595
596 let writes_ok = tokio::fs::write(&dat_tmp, &cached.data).await.is_ok()
598 && tokio::fs::write(&ct_tmp, ct_str).await.is_ok();
599
600 if !writes_ok {
601 let _ = tokio::fs::remove_file(&dat_tmp).await;
602 let _ = tokio::fs::remove_file(&ct_tmp).await;
603 tracing::warn!("Failed to write disk cache temp files at {}", base);
604 return;
605 }
606
607 let rename_dat = tokio::fs::rename(&dat_tmp, &dat_path).await;
608 let rename_ct = tokio::fs::rename(&ct_tmp, &ct_path).await;
609 if rename_dat.is_err() || rename_ct.is_err() {
610 let _ = tokio::fs::remove_file(&dat_tmp).await;
612 let _ = tokio::fs::remove_file(&ct_tmp).await;
613 tracing::warn!("Failed to atomically rename disk cache at {}", base);
614 }
615}
616
617#[cfg(feature = "server")]
618pub async fn serve_image(
623 connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
624 Path(path): Path<String>,
625 Query(params): Query<ImageParams>,
626 headers: HeaderMap,
627) -> Response {
628 let peer = connect_info.map(|Extension(ConnectInfo(addr))| addr);
629 let ip = crate::api::rate_limit::get_client_ip_with_peer(&headers, peer);
630 if let Err(status) = crate::api::rate_limit::check_image_limit(&ip) {
631 return status.into_response();
632 }
633
634 if !is_path_safe(&path).await {
635 return StatusCode::FORBIDDEN.into_response();
636 }
637
638 let file_path = format!("uploads/{}", path);
639
640 if let Err(status) = params.validate() {
642 return status.into_response();
643 }
644
645 if params.is_empty() {
647 const MAX_RAW_BYTES: u64 = 20 * 1024 * 1024;
650 return match tokio::fs::metadata(&file_path).await {
651 Ok(meta) if meta.len() > MAX_RAW_BYTES => StatusCode::PAYLOAD_TOO_LARGE.into_response(),
652 Ok(_) => match tokio::fs::read(&file_path).await {
653 Ok(data) => {
654 let ct = content_type(detect_format(&path));
655 image_response(
656 Bytes::from(data),
657 ct,
658 "public, max-age=31536000, immutable",
659 &headers,
660 )
661 }
662 Err(_) => StatusCode::NOT_FOUND.into_response(),
663 },
664 Err(_) => StatusCode::NOT_FOUND.into_response(),
665 };
666 }
667
668 let cache_key = params.cache_key(&path);
669 if let Some(cached) = IMAGE_CACHE.get(&cache_key).await {
670 return image_response(
671 cached.data.clone(),
672 cached.content_type,
673 "public, max-age=86400",
674 &headers,
675 );
676 }
677
678 if let Some(cached) = read_disk_cache(&cache_key).await {
679 let data = cached.data.clone();
680 let content_type = cached.content_type.clone();
681 let _ = IMAGE_CACHE.insert(cache_key.clone(), cached).await;
682 return image_response(data, content_type, "public, max-age=86400", &headers);
683 }
684
685 let data = match tokio::fs::read(&file_path).await {
686 Ok(d) => d,
687 Err(_) => return StatusCode::NOT_FOUND.into_response(),
688 };
689
690 let path_for_blocking = path.clone();
693 let params_for_blocking = params.clone();
694 let (processed, content_type) = match tokio::task::spawn_blocking(move || {
695 process_image_blocking(data, params_for_blocking, path_for_blocking)
696 })
697 .await
698 {
699 Ok(Ok(r)) => r,
700 Ok(Err(status)) => return status.into_response(),
701 Err(_) => {
702 tracing::error!("Image processing task panicked");
703 return StatusCode::INTERNAL_SERVER_ERROR.into_response();
704 }
705 };
706
707 let processed = Bytes::from(processed);
708 let cached = CachedImage {
709 data: processed.clone(),
710 content_type: content_type.clone(),
711 };
712 let _ = IMAGE_CACHE.insert(cache_key.clone(), cached.clone()).await;
713 write_disk_cache(&cache_key, &cached).await;
714
715 image_response(processed, content_type, "public, max-age=86400", &headers)
716}
717
718#[cfg(feature = "server")]
721static IMAGE_DIMENSIONS_CACHE: LazyLock<SyncCache<String, (u32, u32)>> = LazyLock::new(|| {
722 let ttl = std::env::var("IMAGE_DIMENSIONS_CACHE_TTL_SECS")
723 .ok()
724 .and_then(|s| s.parse::<u64>().ok())
725 .map(std::time::Duration::from_secs)
726 .unwrap_or(std::time::Duration::from_secs(86400)); SyncCache::builder().time_to_live(ttl).build()
728});
729
730#[cfg(feature = "server")]
736pub fn get_image_dimensions(rel_path: &str) -> Option<(u32, u32)> {
737 if let Some(dims) = IMAGE_DIMENSIONS_CACHE.get(rel_path) {
738 return Some(dims);
739 }
740 let full_path = std::path::Path::new("uploads").join(rel_path);
741 let file = std::fs::File::open(&full_path).ok()?;
745 use std::io::Read;
746 let mut header = Vec::new();
747 file.take(65_536).read_to_end(&mut header).ok()?;
748 let dims = read_dimensions_from_bytes(&header, rel_path)?;
749 IMAGE_DIMENSIONS_CACHE.insert(rel_path.to_string(), dims);
750 Some(dims)
751}
752
753#[cfg(feature = "server")]
755fn read_dimensions_from_bytes(data: &[u8], path: &str) -> Option<(u32, u32)> {
756 let ext = std::path::Path::new(path)
757 .extension()?
758 .to_str()?
759 .to_lowercase();
760 match ext.as_str() {
761 "webp" => read_webp_dimensions(data),
762 "jpg" | "jpeg" => read_image_dimensions(data, image::ImageFormat::Jpeg),
763 "png" => read_image_dimensions(data, image::ImageFormat::Png),
764 "gif" => read_image_dimensions(data, image::ImageFormat::Gif),
765 _ => None,
766 }
767}
768
769#[cfg(all(test, feature = "server"))]
770mod tests {
771 use super::*;
772
773 #[test]
774 fn read_webp_dimensions_from_bytes() {
775 let img = image::DynamicImage::new_rgb8(16, 9);
777 let webp_bytes = crate::webp::encode(&img, 85.0, 2).unwrap();
778 let dims = read_dimensions_from_bytes(&webp_bytes, "test.webp");
779 assert_eq!(dims, Some((16, 9)));
780 }
781
782 #[test]
783 fn read_png_dimensions_from_bytes() {
784 let img = image::DynamicImage::new_rgb8(32, 24);
785 let mut buf = std::io::Cursor::new(Vec::new());
786 img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
787 let dims = read_dimensions_from_bytes(&buf.into_inner(), "test.png");
788 assert_eq!(dims, Some((32, 24)));
789 }
790
791 #[test]
792 fn read_dimensions_unknown_extension_returns_none() {
793 let dims = read_dimensions_from_bytes(b"not an image", "test.xyz");
794 assert_eq!(dims, None);
795 }
796
797 fn make_png_bytes(w: u32, h: u32) -> Vec<u8> {
801 let img = image::DynamicImage::new_rgb8(w, h);
802 let mut buf = std::io::Cursor::new(Vec::new());
803 img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
804 buf.into_inner()
805 }
806
807 #[test]
808 fn upload_dimensions_accepts_small_png() {
809 let data = make_png_bytes(100, 100);
810 assert!(upload_dimensions(&data, "image/png").is_ok());
811 }
812
813 #[test]
814 fn upload_dimensions_accepts_boundary_png() {
815 let data = make_png_bytes(7000, 7000);
818 assert!(upload_dimensions(&data, "image/png").is_ok());
819 }
820
821 #[test]
822 fn upload_dimensions_rejects_oversized_width() {
823 let data = make_png_bytes(*MAX_IMAGE_DIMENSION + 1, 1);
825 let err = upload_dimensions(&data, "image/png").unwrap_err();
826 assert!(err.contains("尺寸过大"));
827 }
828
829 #[test]
830 fn upload_dimensions_rejects_oversized_height() {
831 let data = make_png_bytes(1, *MAX_IMAGE_DIMENSION + 1);
832 let err = upload_dimensions(&data, "image/png").unwrap_err();
833 assert!(err.contains("尺寸过大"));
834 }
835
836 #[test]
837 fn upload_dimensions_accepts_small_webp() {
838 let img = image::DynamicImage::new_rgb8(64, 48);
839 let webp_bytes = crate::webp::encode(&img, 85.0, 2).unwrap();
840 assert!(upload_dimensions(&webp_bytes, "image/webp").is_ok());
841 }
842
843 #[test]
844 fn upload_dimensions_accepts_gif() {
845 let img = image::DynamicImage::new_rgb8(32, 32);
847 let mut buf = std::io::Cursor::new(Vec::new());
848 img.write_to(&mut buf, image::ImageFormat::Gif).unwrap();
849 assert!(upload_dimensions(&buf.into_inner(), "image/gif").is_ok());
850 }
851
852 #[test]
853 fn upload_dimensions_rejects_corrupt_bytes() {
854 let err = upload_dimensions(b"not an image at all", "image/png").unwrap_err();
856 assert_eq!(err, "图片文件损坏或格式不正确");
857 }
858
859 #[test]
860 fn read_dimensions_by_mime_dispatches_webp() {
861 let img = image::DynamicImage::new_rgb8(16, 9);
862 let webp_bytes = crate::webp::encode(&img, 85.0, 2).unwrap();
863 assert_eq!(
864 read_dimensions_by_mime(&webp_bytes, "image/webp").unwrap(),
865 (16, 9)
866 );
867 }
868
869 #[test]
870 fn image_params_validate_valid_defaults() {
871 let params = ImageParams::default();
872 assert!(params.validate().is_ok());
873 }
874
875 #[test]
876 fn image_params_validate_valid_width() {
877 let params = ImageParams {
878 w: Some(100),
879 ..Default::default()
880 };
881 assert!(params.validate().is_ok());
882 }
883
884 #[test]
885 fn image_params_validate_zero_width_rejected() {
886 let params = ImageParams {
887 w: Some(0),
888 ..Default::default()
889 };
890 assert!(params.validate().is_err());
891 }
892
893 #[test]
894 fn image_params_validate_oversized_width_rejected() {
895 let params = ImageParams {
896 w: Some(*MAX_IMAGE_DIMENSION + 1),
897 ..Default::default()
898 };
899 assert!(params.validate().is_err());
900 }
901
902 #[test]
903 fn image_params_validate_valid_rotation() {
904 for angle in [0, 90, 180, 270] {
905 let params = ImageParams {
906 rotate: Some(angle),
907 ..Default::default()
908 };
909 assert!(params.validate().is_ok(), "angle {} should be valid", angle);
910 }
911 }
912
913 #[test]
914 fn image_params_validate_invalid_rotation_rejected() {
915 let params = ImageParams {
916 rotate: Some(45),
917 ..Default::default()
918 };
919 assert!(params.validate().is_err());
920 }
921
922 #[test]
923 fn image_params_validate_valid_format() {
924 for fmt in &["jpeg", "jpg", "png", "webp", "JPEG", "PNG"] {
925 let params = ImageParams {
926 format: Some(fmt.to_string()),
927 ..Default::default()
928 };
929 assert!(params.validate().is_ok(), "format {} should be valid", fmt);
930 }
931 }
932
933 #[test]
934 fn image_params_validate_invalid_format_rejected() {
935 let params = ImageParams {
936 format: Some("gif".to_string()),
937 ..Default::default()
938 };
939 assert!(params.validate().is_err());
940 }
941
942 #[test]
943 fn image_params_validate_valid_thumbnail() {
944 let params = ImageParams {
945 thumb: Some("200x150".to_string()),
946 ..Default::default()
947 };
948 assert!(params.validate().is_ok());
949 }
950
951 #[test]
952 fn image_params_validate_invalid_thumbnail_rejected() {
953 let params = ImageParams {
954 thumb: Some("200".to_string()),
955 ..Default::default()
956 };
957 assert!(params.validate().is_err());
958 }
959
960 #[test]
961 fn image_params_validate_valid_quality() {
962 let params = ImageParams {
963 quality: Some(85),
964 ..Default::default()
965 };
966 assert!(params.validate().is_ok());
967 }
968
969 #[test]
970 fn image_params_validate_zero_quality_rejected() {
971 let params = ImageParams {
972 quality: Some(0),
973 ..Default::default()
974 };
975 assert!(params.validate().is_err());
976 }
977
978 #[test]
979 fn image_params_validate_over_100_quality_rejected() {
980 let params = ImageParams {
981 quality: Some(101),
982 ..Default::default()
983 };
984 assert!(params.validate().is_err());
985 }
986
987 #[tokio::test]
988 async fn is_path_safe_normal() {
989 assert!(is_path_safe("images/photo.jpg").await);
990 assert!(is_path_safe("2024/01/photo.png").await);
991 }
992
993 #[tokio::test]
994 async fn is_path_safe_rejects_parent_dir() {
995 assert!(!is_path_safe("../etc/passwd").await);
996 assert!(!is_path_safe("foo/../../bar").await);
997 }
998
999 #[tokio::test]
1000 async fn is_path_safe_rejects_null_bytes() {
1001 assert!(!is_path_safe("foo\0bar").await);
1002 }
1003
1004 #[tokio::test]
1005 async fn is_path_safe_rejects_absolute_path() {
1006 assert!(!is_path_safe("/etc/passwd").await);
1007 }
1008
1009 #[test]
1010 fn detect_format_jpeg() {
1011 assert!(matches!(
1012 detect_format("photo.jpg"),
1013 image::ImageFormat::Jpeg
1014 ));
1015 assert!(matches!(
1016 detect_format("photo.jpeg"),
1017 image::ImageFormat::Jpeg
1018 ));
1019 assert!(matches!(
1020 detect_format("PHOTO.JPG"),
1021 image::ImageFormat::Jpeg
1022 ));
1023 }
1024
1025 #[test]
1026 fn detect_format_png() {
1027 assert!(matches!(detect_format("icon.png"), image::ImageFormat::Png));
1028 }
1029
1030 #[test]
1031 fn detect_format_webp() {
1032 assert!(matches!(
1033 detect_format("anim.webp"),
1034 image::ImageFormat::WebP
1035 ));
1036 }
1037
1038 #[test]
1039 fn detect_format_defaults_to_jpeg() {
1040 assert!(matches!(
1041 detect_format("file.xyz"),
1042 image::ImageFormat::Jpeg
1043 ));
1044 }
1045
1046 #[test]
1047 fn cache_key_differs_for_different_params() {
1048 let p1 = ImageParams {
1049 w: Some(100),
1050 ..Default::default()
1051 };
1052 let p2 = ImageParams {
1053 w: Some(200),
1054 ..Default::default()
1055 };
1056 assert_ne!(p1.cache_key("img.jpg"), p2.cache_key("img.jpg"));
1057 }
1058
1059 #[test]
1060 fn is_empty_true_when_all_none() {
1061 let params = ImageParams::default();
1062 assert!(params.is_empty());
1063 }
1064
1065 #[test]
1066 fn is_empty_false_when_any_set() {
1067 let params = ImageParams {
1068 w: Some(100),
1069 ..Default::default()
1070 };
1071 assert!(!params.is_empty());
1072 }
1073
1074 #[test]
1075 fn disk_cache_base_is_deterministic() {
1076 let key = "path|w=800";
1077 let base1 = disk_cache_base(key);
1078 let base2 = disk_cache_base(key);
1079 assert_eq!(base1, base2);
1080 assert!(base1.starts_with("uploads/.cache/cache_"));
1081 }
1082
1083 #[test]
1084 fn disk_cache_base_differs_for_different_keys() {
1085 let base1 = disk_cache_base("path|w=800");
1086 let base2 = disk_cache_base("path|w=1200");
1087 assert_ne!(base1, base2);
1088 }
1089
1090 #[test]
1091 fn process_image_blocking_resizes_png() {
1092 let img = image::DynamicImage::new_rgb8(100, 100);
1093 let mut buf = std::io::Cursor::new(Vec::new());
1094 img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
1095 let data = buf.into_inner();
1096
1097 let params = ImageParams {
1098 w: Some(50),
1099 format: Some("webp".to_string()),
1100 ..Default::default()
1101 };
1102
1103 let (out, ct) = process_image_blocking(data, params, "test.png".to_string()).unwrap();
1104 assert!(!out.is_empty());
1105 assert_eq!(ct, HeaderValue::from_static("image/webp"));
1106 }
1107
1108 #[test]
1109 fn image_response_includes_cache_headers() {
1110 let resp = image_response(
1111 Bytes::from(vec![1, 2, 3]),
1112 HeaderValue::from_static("image/webp"),
1113 "public, max-age=86400",
1114 &HeaderMap::new(),
1115 );
1116 assert_eq!(resp.status(), StatusCode::OK);
1117 let headers = resp.headers();
1118 assert_eq!(headers.get(header::CONTENT_TYPE).unwrap(), "image/webp");
1119 assert_eq!(
1120 headers.get(header::CACHE_CONTROL).unwrap(),
1121 "public, max-age=86400"
1122 );
1123 assert!(headers
1124 .get(header::ETAG)
1125 .unwrap()
1126 .to_str()
1127 .unwrap()
1128 .starts_with('"'));
1129 }
1130
1131 #[test]
1132 fn image_response_returns_304_when_etag_matches() {
1133 let data = Bytes::from(vec![1, 2, 3]);
1134 let etag = etag_for(&data);
1135 let mut req_headers = HeaderMap::new();
1136 req_headers.insert(header::IF_NONE_MATCH, HeaderValue::from_str(&etag).unwrap());
1137 let resp = image_response(
1138 data,
1139 HeaderValue::from_static("image/webp"),
1140 "public, max-age=86400",
1141 &req_headers,
1142 );
1143 assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
1144 let headers = resp.headers();
1145 assert_eq!(headers.get(header::ETAG).unwrap(), etag.as_str());
1146 assert_eq!(headers.get(header::CONTENT_TYPE).unwrap(), "image/webp");
1147 assert_eq!(
1148 headers.get(header::CACHE_CONTROL).unwrap(),
1149 "public, max-age=86400"
1150 );
1151 }
1152
1153 #[test]
1154 fn etag_matches_single() {
1155 assert!(etag_matches("\"abc\"", "\"abc\""));
1156 assert!(!etag_matches("\"abc\"", "\"def\""));
1157 }
1158
1159 #[test]
1160 fn etag_matches_list() {
1161 assert!(etag_matches("\"abc\", \"def\"", "\"def\""));
1162 assert!(!etag_matches("\"abc\", \"def\"", "\"ghi\""));
1163 }
1164
1165 #[test]
1166 fn etag_matches_weak_prefix() {
1167 assert!(etag_matches("W/\"abc\"", "\"abc\""));
1168 }
1169
1170 #[test]
1171 fn etag_matches_wildcard() {
1172 assert!(etag_matches("*", "\"anything\""));
1173 }
1174
1175 #[test]
1176 fn image_response_raw_file_is_immutable() {
1177 let resp = image_response(
1178 Bytes::from(vec![1, 2, 3]),
1179 HeaderValue::from_static("image/jpeg"),
1180 "public, max-age=31536000, immutable",
1181 &HeaderMap::new(),
1182 );
1183 assert_eq!(resp.status(), StatusCode::OK);
1184 let cache_control = resp
1185 .headers()
1186 .get(header::CACHE_CONTROL)
1187 .unwrap()
1188 .to_str()
1189 .unwrap();
1190 assert!(cache_control.contains("immutable"));
1191 }
1192
1193 #[test]
1194 fn etag_for_same_data_is_stable() {
1195 let a = etag_for(b"hello");
1196 let b = etag_for(b"hello");
1197 assert_eq!(a, b);
1198 assert_ne!(a, etag_for(b"world"));
1199 }
1200}