from __future__ import annotations from io import BytesIO from uuid import uuid4 from PIL import Image from app.core.config import settings from app.core.storage import upload_object ALLOWED_MIME = { "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", } FORMAT_TO_MIME = { "JPEG": "image/jpeg", "PNG": "image/png", "WEBP": "image/webp", } class AvatarValidationError(ValueError): pass def validate_and_process_avatar(content: bytes) -> tuple[str, bytes]: if len(content) > settings.avatar_max_bytes: raise AvatarValidationError("FILE_TOO_LARGE") if not content: raise AvatarValidationError("INVALID_IMAGE") try: with Image.open(BytesIO(content)) as image: image.verify() with Image.open(BytesIO(content)) as image: mime = FORMAT_TO_MIME.get(image.format or "") if mime not in ALLOWED_MIME: raise AvatarValidationError("INVALID_MIME") buffer = BytesIO() if mime == "image/jpeg": rgb = image.convert("RGB") rgb.save(buffer, format="JPEG", quality=85, optimize=True) elif mime == "image/png": image.save(buffer, format="PNG", optimize=True) else: image.save(buffer, format="WEBP", quality=85, method=6) return mime, buffer.getvalue() except AvatarValidationError: raise except Exception as exc: raise AvatarValidationError("INVALID_IMAGE") from exc def upload_user_avatar(user_id: str, content: bytes) -> str: mime, processed = validate_and_process_avatar(content) extension = ALLOWED_MIME[mime] key = f"avatars/{user_id}/{uuid4()}{extension}" upload_object(key, processed, mime) return f"/api/v1/media/files/{key}"