-
Notifications
You must be signed in to change notification settings - Fork 534
feat: add profile avatar update functionality #991
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| from fastapi import APIRouter, HTTPException, UploadFile, File, status | ||
| from fastapi.responses import FileResponse | ||
| import os | ||
| import uuid | ||
| import io | ||
| from PIL import Image | ||
| from app.database.metadata import db_get_metadata, db_update_metadata | ||
| from app.schemas.common import ErrorResponse | ||
|
|
||
| router = APIRouter() | ||
|
|
||
| UPLOAD_DIR = "uploads/avatars" | ||
| ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg"} | ||
| MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB | ||
|
|
||
| os.makedirs(UPLOAD_DIR, exist_ok=True) | ||
|
|
||
| @router.post("/upload") | ||
| async def upload_avatar(file: UploadFile = File(...)): | ||
| """Upload and set user avatar""" | ||
| try: | ||
| # Validate file extension | ||
| file_ext = os.path.splitext(file.filename)[1].lower() | ||
| if file_ext not in ALLOWED_EXTENSIONS: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_400_BAD_REQUEST, | ||
| detail=ErrorResponse( | ||
| success=False, | ||
| error="Invalid file type", | ||
| message="Only PNG, JPG, and JPEG files are allowed" | ||
| ).model_dump() | ||
| ) | ||
|
|
||
| # Read and validate file size | ||
| content = await file.read() | ||
| if len(content) > MAX_FILE_SIZE: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_400_BAD_REQUEST, | ||
| detail=ErrorResponse( | ||
| success=False, | ||
| error="File too large", | ||
| message="File size must be less than 5MB" | ||
| ).model_dump() | ||
| ) | ||
|
|
||
| # Generate unique filename | ||
| filename = f"{uuid.uuid4()}{file_ext}" | ||
| filepath = os.path.join(UPLOAD_DIR, filename) | ||
|
|
||
| # Process and save image | ||
| try: | ||
| image = Image.open(io.BytesIO(content)) | ||
| # Resize to 200x200 for consistency | ||
| image = image.resize((200, 200), Image.Resampling.LANCZOS) | ||
| image.save(filepath, optimize=True, quality=85) | ||
| except Exception as e: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_400_BAD_REQUEST, | ||
| detail=ErrorResponse( | ||
| success=False, | ||
| error="Invalid image", | ||
| message="Unable to process image file" | ||
| ).model_dump() | ||
| ) | ||
|
|
||
| # Update user preferences with new avatar path | ||
| metadata = db_get_metadata() or {} | ||
| user_prefs = metadata.get("user_preferences", {}) | ||
|
|
||
| # Remove old avatar file if exists | ||
| old_avatar = user_prefs.get("avatar") | ||
| if old_avatar and old_avatar.startswith("/avatars/uploads/"): | ||
| old_path = old_avatar.replace("/avatars/uploads/", f"{UPLOAD_DIR}/") | ||
| if os.path.exists(old_path): | ||
| os.remove(old_path) | ||
|
|
||
| user_prefs["avatar"] = f"/avatars/uploads/{filename}" | ||
| metadata["user_preferences"] = user_prefs | ||
|
|
||
| if not db_update_metadata(metadata): | ||
| # Clean up uploaded file on database error | ||
| if os.path.exists(filepath): | ||
| os.remove(filepath) | ||
| raise HTTPException( | ||
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | ||
| detail=ErrorResponse( | ||
| success=False, | ||
| error="Database error", | ||
| message="Failed to save avatar preference" | ||
| ).model_dump() | ||
| ) | ||
|
|
||
| return { | ||
| "success": True, | ||
| "message": "Avatar uploaded successfully", | ||
| "avatar_url": f"/avatars/uploads/{filename}" | ||
| } | ||
|
|
||
| except HTTPException: | ||
| raise | ||
| except Exception as e: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | ||
| detail=ErrorResponse( | ||
| success=False, | ||
| error="Internal server error", | ||
| message=f"Failed to upload avatar: {str(e)}" | ||
| ).model_dump() | ||
| ) | ||
|
|
||
| @router.get("/uploads/{filename}") | ||
| async def get_avatar(filename: str): | ||
| """Serve uploaded avatar files""" | ||
| filepath = os.path.join(UPLOAD_DIR, filename) | ||
| if not os.path.exists(filepath): | ||
| raise HTTPException(status_code=404, detail="Avatar not found") | ||
| return FileResponse(filepath) | ||
|
Comment on lines
+111
to
+117
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Path traversal vulnerability: validate filename parameter. The 🔒 Proposed fix to prevent path traversal @router.get("/uploads/{filename}")
async def get_avatar(filename: str):
"""Serve uploaded avatar files"""
+ # Prevent path traversal attacks
+ if ".." in filename or "/" in filename or "\\" in filename:
+ raise HTTPException(status_code=400, detail="Invalid filename")
+
filepath = os.path.join(UPLOAD_DIR, filename)
+
+ # Verify the resolved path is still within UPLOAD_DIR
+ if not os.path.abspath(filepath).startswith(os.path.abspath(UPLOAD_DIR)):
+ raise HTTPException(status_code=400, detail="Invalid filename")
+
if not os.path.exists(filepath):
raise HTTPException(status_code=404, detail="Avatar not found")
return FileResponse(filepath)🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| from pydantic import BaseModel, Field, field_validator | ||
| from typing import Optional, List | ||
| from pydantic_core.core_schema import ValidationInfo | ||
| from .common import ErrorResponse, SuccessResponse | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
echo "=== Checking SuccessResponse schema definition in common.py ==="
cat -n backend/app/schemas/common.py | grep -A 10 "class SuccessResponse"
echo -e "\n=== Checking SuccessResponse usage in albums.py around mentioned lines ==="
rg -n "SuccessResponse" backend/app/routes/albums.py -B 1 -A 1
echo -e "\n=== Detailed check of lines 158 and 184 in albums.py ==="
sed -n '155,160p' backend/app/routes/albums.py
echo "---"
sed -n '181,186p' backend/app/routes/albums.pyRepository: AOSSIE-Org/PictoPy Length of output: 2793 Fix SuccessResponse field name in albums routes. The 🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| class Album(BaseModel): | ||
|
|
@@ -75,12 +76,4 @@ class GetAlbumImagesResponse(BaseModel): | |
| image_ids: List[str] | ||
|
|
||
|
|
||
| class SuccessResponse(BaseModel): | ||
| success: bool | ||
| msg: str | ||
|
|
||
|
|
||
| class ErrorResponse(BaseModel): | ||
| success: bool = False | ||
| message: str | ||
| error: str | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| from pydantic import BaseModel | ||
| from typing import Optional | ||
|
|
||
|
|
||
| class ErrorResponse(BaseModel): | ||
| """Common error response model used across all endpoints""" | ||
| success: bool = False | ||
| message: str | ||
| error: str | ||
|
|
||
|
|
||
| class SuccessResponse(BaseModel): | ||
| """Common success response model""" | ||
| success: bool | ||
| message: str |
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Handle image modes before saving.
Images with transparency (RGBA) or other color modes (CMYK, LA, etc.) will fail when saved as JPEG since JPEG doesn't support transparency. Convert to RGB before saving to prevent runtime errors.
🐛 Proposed fix to handle different image modes
try: image = Image.open(io.BytesIO(content)) + # Convert to RGB if necessary (JPEG doesn't support transparency) + if image.mode in ('RGBA', 'LA', 'P', 'CMYK'): + # Create a white background for transparent images + if image.mode in ('RGBA', 'LA', 'P'): + background = Image.new('RGB', image.size, (255, 255, 255)) + if image.mode == 'P': + image = image.convert('RGBA') + background.paste(image, mask=image.split()[-1] if image.mode in ('RGBA', 'LA') else None) + image = background + else: + image = image.convert('RGB') + # Resize to 200x200 for consistency image = image.resize((200, 200), Image.Resampling.LANCZOS) image.save(filepath, optimize=True, quality=85)📝 Committable suggestion
🤖 Prompt for AI Agents