fix(upload): unify image size limit across all formats
上传入口此前对超大图片行为不一致:JPEG/PNG 因 decode limits 失败后 静默存原图,WebP 报"损坏",GIF 完全不查。统一为 header 阶段硬拒绝。 - image.rs: 新增 check_upload_dimensions,按 MIME 只读 header 拿尺寸 (WebP 走 zenwebp header,JPEG/PNG/GIF 走 image into_dimensions), 超 MAX_IMAGE_DIMENSION/MAX_IMAGE_PIXELS 返回友好提示 - upload.rs: magic bytes 校验后插入统一尺寸校验,三种格式同路径 - upload.rs: JPEG/PNG 转码 fallback 的 Err(_) 改为 Err(e) 带原因日志 (超限已在 header 阶段拦截,此处仅剩真损坏兜底) 读取侧 serve_image 不动;webp::decode 内的像素校验保留(读取侧仍需)。
This commit is contained in:
parent
eed0b24ec3
commit
0777fd469a
137
src/api/image.rs
137
src/api/image.rs
@ -254,6 +254,72 @@ fn check_image_dimensions(width: u32, height: u32) -> Result<(), StatusCode> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
/// 仅读取 header 校验上传图片的尺寸/像素是否超限。
|
||||||
|
///
|
||||||
|
/// 与 `check_image_dimensions` 的区别:
|
||||||
|
/// - 输入是原始字节 + MIME,内部按 MIME 分发只解析 header 拿尺寸(不解码像素)
|
||||||
|
/// - 返回带友好中文提示的 `&'static str`(供上传接口直接回给用户)
|
||||||
|
///
|
||||||
|
/// 上传入口三种格式在此统一拦截至尺寸上限;WebP 走 `zenwebp` header,
|
||||||
|
/// JPEG/PNG/GIF 走 `image` crate 的 `into_dimensions`(均只读 header)。
|
||||||
|
pub fn check_upload_dimensions(data: &[u8], mime_type: &str) -> Result<(), &'static str> {
|
||||||
|
let dims = read_dimensions_by_mime(data, mime_type)?;
|
||||||
|
let (width, height) = dims;
|
||||||
|
if width == 0 || height == 0 {
|
||||||
|
return Err("图片文件损坏或格式不正确");
|
||||||
|
}
|
||||||
|
let pixels = u64::from(width) * u64::from(height);
|
||||||
|
if width > MAX_IMAGE_DIMENSION
|
||||||
|
|| height > MAX_IMAGE_DIMENSION
|
||||||
|
|| pixels > u64::from(MAX_IMAGE_PIXELS)
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
"Uploaded image too large: {}x{} ({} pixels, max {}x{} / {} pixels)",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
pixels,
|
||||||
|
MAX_IMAGE_DIMENSION,
|
||||||
|
MAX_IMAGE_DIMENSION,
|
||||||
|
MAX_IMAGE_PIXELS
|
||||||
|
);
|
||||||
|
return Err(
|
||||||
|
"图片尺寸过大,请压缩到 4096×4096 以内或单图不超过约 5000 万像素后再上传",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
/// 按 MIME 只读 header 拿 (width, height)。失败返回损坏错误。
|
||||||
|
fn read_dimensions_by_mime(
|
||||||
|
data: &[u8],
|
||||||
|
mime_type: &str,
|
||||||
|
) -> Result<(u32, u32), &'static str> {
|
||||||
|
match mime_type {
|
||||||
|
"image/webp" => {
|
||||||
|
// zenwebp 的 WebPDecoder::build 只解析 RIFF header,不解码像素(与 webp::decode 同源)。
|
||||||
|
let decoder = zenwebp::WebPDecoder::build(data)
|
||||||
|
.map_err(|_| "图片文件损坏或格式不正确")?;
|
||||||
|
let info = decoder.info();
|
||||||
|
Ok((info.width, info.height))
|
||||||
|
}
|
||||||
|
"image/jpeg" | "image/png" | "image/gif" => {
|
||||||
|
let format = match mime_type {
|
||||||
|
"image/jpeg" => image::ImageFormat::Jpeg,
|
||||||
|
"image/png" => image::ImageFormat::Png,
|
||||||
|
_ => image::ImageFormat::Gif,
|
||||||
|
};
|
||||||
|
let reader = image::ImageReader::with_format(std::io::Cursor::new(data), format);
|
||||||
|
reader
|
||||||
|
.into_dimensions()
|
||||||
|
.map_err(|_| "图片文件损坏或格式不正确")
|
||||||
|
}
|
||||||
|
// 上游已用 ALLOWED_MIME_TYPES 白名单拦截,理论不到这里
|
||||||
|
_ => Err("图片文件损坏或格式不正确"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
fn image_reader_limits() -> image::Limits {
|
fn image_reader_limits() -> image::Limits {
|
||||||
let mut limits = image::Limits::default();
|
let mut limits = image::Limits::default();
|
||||||
@ -644,6 +710,77 @@ mod tests {
|
|||||||
assert_eq!(dims, None);
|
assert_eq!(dims, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// —— check_upload_dimensions:统一上传尺寸/像素上限校验 ——
|
||||||
|
|
||||||
|
/// 构造指定尺寸的 PNG 字节(内存占用 = 尺寸,仅用于 header 校验测试)。
|
||||||
|
fn make_png_bytes(w: u32, h: u32) -> Vec<u8> {
|
||||||
|
let img = image::DynamicImage::new_rgb8(w, h);
|
||||||
|
let mut buf = std::io::Cursor::new(Vec::new());
|
||||||
|
img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
|
||||||
|
buf.into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_upload_dimensions_accepts_small_png() {
|
||||||
|
let data = make_png_bytes(100, 100);
|
||||||
|
assert!(check_upload_dimensions(&data, "image/png").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_upload_dimensions_accepts_boundary_png() {
|
||||||
|
// 恰好 4096×4096(约 16.7M 像素,低于 25M 上限)应放行
|
||||||
|
let data = make_png_bytes(MAX_IMAGE_DIMENSION, MAX_IMAGE_DIMENSION);
|
||||||
|
assert!(check_upload_dimensions(&data, "image/png").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_upload_dimensions_rejects_oversized_width() {
|
||||||
|
// 单边超限:5000×1(像素仅 5000,远低于 25M,但单边 >4096)
|
||||||
|
let data = make_png_bytes(MAX_IMAGE_DIMENSION + 1, 1);
|
||||||
|
let err = check_upload_dimensions(&data, "image/png").unwrap_err();
|
||||||
|
assert!(err.contains("尺寸过大"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_upload_dimensions_rejects_oversized_height() {
|
||||||
|
let data = make_png_bytes(1, MAX_IMAGE_DIMENSION + 1);
|
||||||
|
let err = check_upload_dimensions(&data, "image/png").unwrap_err();
|
||||||
|
assert!(err.contains("尺寸过大"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_upload_dimensions_accepts_small_webp() {
|
||||||
|
let img = image::DynamicImage::new_rgb8(64, 48);
|
||||||
|
let webp_bytes = crate::webp::encode(&img, 85.0, 2).unwrap();
|
||||||
|
assert!(check_upload_dimensions(&webp_bytes, "image/webp").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_upload_dimensions_accepts_gif() {
|
||||||
|
// image crate 默认启用 gif feature,into_dimensions 可读 GIF header
|
||||||
|
let img = image::DynamicImage::new_rgb8(32, 32);
|
||||||
|
let mut buf = std::io::Cursor::new(Vec::new());
|
||||||
|
img.write_to(&mut buf, image::ImageFormat::Gif).unwrap();
|
||||||
|
assert!(check_upload_dimensions(&buf.into_inner(), "image/gif").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_upload_dimensions_rejects_corrupt_bytes() {
|
||||||
|
// 非 magic bytes,与现有损坏文件校验文案对齐
|
||||||
|
let err = check_upload_dimensions(b"not an image at all", "image/png").unwrap_err();
|
||||||
|
assert_eq!(err, "图片文件损坏或格式不正确");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_dimensions_by_mime_dispatches_webp() {
|
||||||
|
let img = image::DynamicImage::new_rgb8(16, 9);
|
||||||
|
let webp_bytes = crate::webp::encode(&img, 85.0, 2).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
read_dimensions_by_mime(&webp_bytes, "image/webp").unwrap(),
|
||||||
|
(16, 9)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn image_params_validate_valid_defaults() {
|
fn image_params_validate_valid_defaults() {
|
||||||
let params = ImageParams::default();
|
let params = ImageParams::default();
|
||||||
|
|||||||
@ -208,6 +208,19 @@ pub async fn upload_image(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 仅读 header 统一校验尺寸/像素上限。三种格式走同一路径:
|
||||||
|
// JPEG/PNG/GIF 用 image crate 的 into_dimensions,WebP 用 zenwebp header。
|
||||||
|
// 超限直接拒绝,避免大图走 decode 后被静默降级(原 fallback 存原图)。
|
||||||
|
if let Err(msg) = crate::api::image::check_upload_dimensions(&data, mime_type.as_str()) {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({
|
||||||
|
"success": false,
|
||||||
|
"error": msg
|
||||||
|
})),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let is_gif = mime_type.as_str() == "image/gif";
|
let is_gif = mime_type.as_str() == "image/gif";
|
||||||
let is_webp = mime_type.as_str() == "image/webp";
|
let is_webp = mime_type.as_str() == "image/webp";
|
||||||
|
|
||||||
@ -299,8 +312,9 @@ pub async fn upload_image(
|
|||||||
};
|
};
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(e) => {
|
||||||
tracing::warn!("Failed to decode image, keeping original format");
|
// 到这里尺寸校验已通过(超限在 header 阶段被拒),decode 失败只能是真损坏。
|
||||||
|
tracing::warn!("Failed to decode image ({}), keeping original format", e);
|
||||||
(original_data, mime_to_ext(&mime).to_string(), false)
|
(original_data, mime_to_ext(&mime).to_string(), false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user