From 661f1ce5525d7c7d83b4bbddff3e5c1a316cb819 Mon Sep 17 00:00:00 2001 From: CCS Admin Date: Thu, 26 Feb 2026 09:39:21 +0000 Subject: [PATCH] feat: add avatar upload/delete endpoints with static file serving Co-Authored-By: Claude Opus 4.6 --- .gitignore | 4 ++ backend/app/api/auth.py | 74 +++++++++++++++++++++++++++++++- backend/app/main.py | 5 +++ backend/uploads/avatars/.gitkeep | 0 4 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 backend/uploads/avatars/.gitkeep diff --git a/.gitignore b/.gitignore index d45ed33..2edb58f 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,7 @@ Thumbs.db # Logs *.log + +# Uploaded avatars (keep directory structure only) +backend/uploads/avatars/* +!backend/uploads/avatars/.gitkeep diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 88bd4dc..20e4d9a 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,6 +1,9 @@ """Auth API router: login, register, refresh, logout, MFA, password change.""" -from fastapi import APIRouter, Depends, Request +import os +import time + +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile from sqlalchemy.orm import Session from app.core.dependencies import get_current_user @@ -163,3 +166,72 @@ def update_profile_endpoint( update_data = data.model_dump(exclude_unset=True) user = update_profile(db, current_user, **update_data) return UserResponse.model_validate(user) + + +# --------------------------------------------------------------------------- +# Avatar upload / delete +# --------------------------------------------------------------------------- + +ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png"} +MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB + + +@router.post("/avatar", response_model=UserResponse) +async def upload_avatar( + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Upload or replace the authenticated user's avatar image.""" + if file.content_type not in ALLOWED_IMAGE_TYPES: + raise HTTPException(status_code=400, detail="Only JPG and PNG images are allowed") + + contents = await file.read() + if len(contents) > MAX_AVATAR_SIZE: + raise HTTPException(status_code=400, detail="Image must be smaller than 2MB") + + ext = "jpg" if file.content_type == "image/jpeg" else "png" + filename = f"{current_user.id}_{int(time.time())}.{ext}" + + uploads_dir = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + "uploads", "avatars", + ) + os.makedirs(uploads_dir, exist_ok=True) + + # Delete old avatar file if exists + if current_user.avatar_url: + old_file = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + current_user.avatar_url.lstrip("/"), + ) + if os.path.isfile(old_file): + os.remove(old_file) + + filepath = os.path.join(uploads_dir, filename) + with open(filepath, "wb") as f: + f.write(contents) + + current_user.avatar_url = f"/uploads/avatars/{filename}" + db.commit() + db.refresh(current_user) + return UserResponse.model_validate(current_user) + + +@router.delete("/avatar", response_model=UserResponse) +def delete_avatar( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Remove the authenticated user's avatar image.""" + if current_user.avatar_url: + old_file = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + current_user.avatar_url.lstrip("/"), + ) + if os.path.isfile(old_file): + os.remove(old_file) + current_user.avatar_url = None + db.commit() + db.refresh(current_user) + return UserResponse.model_validate(current_user) diff --git a/backend/app/main.py b/backend/app/main.py index 2bdcf00..471c8b2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -42,6 +42,11 @@ def health_check(): return {"status": "ok"} +# --- Serve uploaded files --- +_uploads_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "uploads") +if os.path.isdir(_uploads_dir): + app.mount("/uploads", StaticFiles(directory=_uploads_dir), name="uploads") + # --- Serve frontend SPA --- _frontend_dist = os.path.join( os.path.dirname(os.path.dirname(os.path.dirname(__file__))), diff --git a/backend/uploads/avatars/.gitkeep b/backend/uploads/avatars/.gitkeep new file mode 100644 index 0000000..e69de29