feat: 修改 400问题

This commit is contained in:
2026-09-28 22:39:54 +08:00
parent 7f1503294f
commit 0fa7b6bb8f
4 changed files with 57 additions and 3 deletions
+21 -2
View File
@@ -82,6 +82,23 @@ def _is_image(url: str, ctype: str) -> bool:
return bool(re.search(r"\.(jpe?g|png|webp|gif|bmp)(\?|$)", url, re.IGNORECASE))
def _image_content_type(data: bytes, url: str, ctype: str) -> str | None:
"""存储对象无扩展名且 MIME 为 octet-stream 时,按图片文件头识别格式。"""
if ctype.split(";")[0].strip().lower().startswith("image/"):
return ctype
if data.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
if data.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if data.startswith((b"GIF87a", b"GIF89a")):
return "image/gif"
if data.startswith(b"RIFF") and data[8:12] == b"WEBP":
return "image/webp"
if data.startswith(b"BM"):
return "image/bmp"
return ctype if _is_image(url, ctype) else None
def _download_disposition(filename: str) -> str:
"""生成兼容中文文件名的下载响应头。
@@ -138,7 +155,8 @@ async def export_images(req: ExportImagesRequest):
raise HTTPException(status_code=504, detail=str(exc)) from exc
except Exception: # noqa: BLE001
continue # 单张下载失败不中断整包
if not _is_image(img.url, ctype):
ctype = _image_content_type(data, img.url, ctype)
if ctype is None:
continue
total_source_bytes += len(data)
if total_source_bytes > settings.export_zip_max_bytes:
@@ -192,7 +210,8 @@ async def export_image(img: ExportImageItem):
raise HTTPException(status_code=504, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=422, detail="图片下载失败") from exc
if not _is_image(img.url, ctype):
ctype = _image_content_type(data, img.url, ctype)
if ctype is None:
raise HTTPException(status_code=400, detail="该地址不是图片")
ext = _ext(img.url, ctype)
+19
View File
@@ -8,6 +8,8 @@ import zipfile
from pathlib import Path
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
@@ -16,6 +18,23 @@ from api.export import ExportImageItem, ExportImagesRequest, export_image, expor
class ExportImageTests(unittest.IsolatedAsyncioTestCase):
async def test_extensionless_image_with_generic_mime_can_be_downloaded(self) -> None:
image = ExportImageItem(url="https://example.com/assets/opaque-key", key="upload")
png = b"\x89PNG\r\n\x1a\n" + b"image bytes"
with patch("api.export._download", new=AsyncMock(return_value=(png, "application/octet-stream"))):
response = await export_image(image)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.media_type, "image/png")
self.assertIn('filename="upload.png"', response.headers["content-disposition"])
async def test_extensionless_non_image_is_rejected(self) -> None:
image = ExportImageItem(url="https://example.com/assets/opaque-key")
with patch("api.export._download", new=AsyncMock(return_value=(b"not an image", "application/octet-stream"))):
with self.assertRaises(HTTPException) as raised:
await export_image(image)
self.assertEqual(raised.exception.status_code, 400)
async def test_chinese_filename_uses_rfc5987_header(self) -> None:
image = ExportImageItem(
url="https://example.com/image.png",