first commit

This commit is contained in:
2026-07-18 20:32:51 +08:00
commit 77bf076ad0
34 changed files with 4068 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
import asyncio
import io
import logging
import uuid
from pathlib import Path
from fastapi import HTTPException, UploadFile, status
from PIL import Image, UnidentifiedImageError
from app.config import settings
logger = logging.getLogger(__name__)
ALLOWED_FORMATS = {"JPEG": "jpg", "PNG": "png", "WEBP": "webp"}
def _process_image(raw: bytes) -> tuple[bytes, str, bytes]:
Image.MAX_IMAGE_PIXELS = settings.max_image_pixels
try:
with Image.open(io.BytesIO(raw)) as source:
if source.width * source.height > settings.max_image_pixels:
raise ValueError("图片像素尺寸过大")
source.load()
image_format = source.format
if image_format not in ALLOWED_FORMATS:
raise ValueError("仅支持 JPEG、PNG 和 WebP 图片")
extension = ALLOWED_FORMATS[image_format]
normalized = io.BytesIO()
if image_format == "PNG":
source.save(normalized, format="PNG", optimize=True)
else:
rgb = source.convert("RGB")
output_format = "JPEG" if image_format == "JPEG" else "WEBP"
rgb.save(normalized, format=output_format, quality=92, optimize=True)
thumbnail = source.copy()
thumbnail.thumbnail(
(settings.thumbnail_max_edge, settings.thumbnail_max_edge),
Image.Resampling.LANCZOS,
)
thumbnail_bytes = io.BytesIO()
thumbnail.convert("RGB").save(
thumbnail_bytes,
format="JPEG",
quality=78,
optimize=True,
)
return normalized.getvalue(), extension, thumbnail_bytes.getvalue()
except (UnidentifiedImageError, OSError, Image.DecompressionBombError, ValueError) as exc:
raise ValueError(str(exc) or "图片内容无效") from exc
def _write_files(
user_id: uuid.UUID,
original: bytes,
extension: str,
thumbnail: bytes,
) -> tuple[str, str]:
file_id = uuid.uuid4()
relative_dir = Path(str(user_id))
image_path = relative_dir / f"{file_id}.{extension}"
thumbnail_path = relative_dir / f"{file_id}-thumb.jpg"
target_dir = settings.upload_dir / relative_dir
target_dir.mkdir(parents=True, exist_ok=True)
(settings.upload_dir / image_path).write_bytes(original)
(settings.upload_dir / thumbnail_path).write_bytes(thumbnail)
return image_path.as_posix(), thumbnail_path.as_posix()
async def save_cloud_image(file: UploadFile, user_id: uuid.UUID) -> tuple[str, str]:
raw = await file.read(settings.max_upload_bytes + 1)
if not raw:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="图片不能为空")
if len(raw) > settings.max_upload_bytes:
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="图片文件过大")
try:
original, extension, thumbnail = await asyncio.to_thread(_process_image, raw)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"图片无法处理:{exc}",
) from exc
return await asyncio.to_thread(_write_files, user_id, original, extension, thumbnail)
def _safe_file_path(relative_path: str) -> Path | None:
root = settings.upload_dir.resolve()
candidate = (root / relative_path).resolve()
if candidate == root or root not in candidate.parents:
return None
return candidate
async def delete_files(paths: list[str]) -> None:
def remove() -> None:
for relative_path in paths:
candidate = _safe_file_path(relative_path)
if not candidate:
logger.error("拒绝删除不安全的文件路径:%s", relative_path)
continue
try:
candidate.unlink(missing_ok=True)
except OSError:
logger.exception("删除图片失败:%s", relative_path)
await asyncio.to_thread(remove)