Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.core.media_signing import verify_signed_media
|
||||
from app.core.storage import download_object
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/files/{file_path:path}")
|
||||
async def get_media_file(
|
||||
file_path: str,
|
||||
expires: int = Query(...),
|
||||
sig: str = Query(...),
|
||||
):
|
||||
if not file_path.startswith("avatars/"):
|
||||
raise HTTPException(status_code=404, detail="FILE_NOT_FOUND")
|
||||
if not verify_signed_media(file_path, expires, sig):
|
||||
raise HTTPException(status_code=403, detail="INVALID_SIGNATURE")
|
||||
stored = download_object(file_path)
|
||||
if not stored:
|
||||
raise HTTPException(status_code=404, detail="FILE_NOT_FOUND")
|
||||
body, content_type = stored
|
||||
return Response(content=body, media_type=content_type)
|
||||
@@ -0,0 +1,5 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AvatarUploadOut(BaseModel):
|
||||
message: str
|
||||
@@ -0,0 +1,62 @@
|
||||
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}"
|
||||
Reference in New Issue
Block a user