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"} AVATAR_MAX_EDGE = 512 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) async def save_avatar(file: UploadFile, user_id: uuid.UUID) -> 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="图片文件过大") def process() -> str: Image.MAX_IMAGE_PIXELS = settings.max_image_pixels try: with Image.open(io.BytesIO(raw)) as source: if source.format not in ALLOWED_FORMATS: raise ValueError("仅支持 JPEG、PNG 和 WebP 图片") source = source.convert("RGB") source.thumbnail( (AVATAR_MAX_EDGE, AVATAR_MAX_EDGE), Image.Resampling.LANCZOS, ) relative_path = Path("avatars") / f"{user_id}.jpg" target = settings.upload_dir / relative_path target.parent.mkdir(parents=True, exist_ok=True) source.save(target, format="JPEG", quality=85, optimize=True) return relative_path.as_posix() except (UnidentifiedImageError, OSError, Image.DecompressionBombError, ValueError) as exc: raise ValueError(str(exc) or "图片内容无效") from exc try: return await asyncio.to_thread(process) except ValueError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"图片无法处理:{exc}", ) from exc