Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions backend/app/routes/albums.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,10 @@
GetAlbumImagesRequest,
GetAlbumImagesResponse,
UpdateAlbumRequest,
SuccessResponse,
ErrorResponse,
ImageIdsRequest,
Album,
)
from app.schemas.common import ErrorResponse, SuccessResponse
from app.database.albums import (
db_get_all_albums,
db_get_album_by_name,
Expand Down
117 changes: 117 additions & 0 deletions backend/app/routes/avatar.py
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:
Comment on lines +51 to +56
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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:
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)
except Exception as e:
🤖 Prompt for AI Agents
In @backend/app/routes/avatar.py around lines 51 - 56, The current avatar saving
code opens and resizes images but may attempt to save modes with transparency or
non-RGB color spaces as JPEG, causing failures; before calling
image.save(filepath, ...), ensure the PIL Image is in RGB by checking image.mode
and, if not 'RGB', call image.convert("RGB") (handle modes like 'RGBA', 'LA',
'P', 'CMYK', etc.), then proceed to save using the existing image.save(...) call
with filepath and the same optimize/quality options.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Path traversal vulnerability: validate filename parameter.

The filename parameter is used directly in os.path.join() without validation. An attacker could potentially access arbitrary files outside UPLOAD_DIR by using path traversal sequences like ../../../etc/passwd.

🔒 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
In @backend/app/routes/avatar.py around lines 111 - 117, The get_avatar endpoint
builds filepath with os.path.join(UPLOAD_DIR, filename) without validating
filename, allowing path traversal; fix by rejecting any filename that is
absolute or contains path separators/parent references and by resolving the
joined path and ensuring it is inside UPLOAD_DIR before returning. Specifically,
in get_avatar validate filename (no os.sep or ".." or leading "/"), compute
resolved_base = Path(UPLOAD_DIR).resolve() and resolved_target = (resolved_base
/ filename).resolve(), then check that resolved_target is inside resolved_base
(e.g., resolved_target.parts startswith or
resolved_target.as_posix().startswith(resolved_base.as_posix())), and raise
HTTPException(404) or 400 if the check fails; only call FileResponse on the
validated resolved_target.

2 changes: 1 addition & 1 deletion backend/app/routes/face_clusters.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
db_get_all_clusters_with_face_counts,
db_get_images_by_cluster_id, # Add this import
)
from app.schemas.face_clusters import (
from app.schemas.faces import (
RenameClusterRequest,
RenameClusterResponse,
RenameClusterData,
Expand Down
2 changes: 1 addition & 1 deletion backend/app/routes/folders.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
AddFolderRequest,
AddFolderResponse,
AddFolderData,
ErrorResponse,
UpdateAITaggingRequest,
UpdateAITaggingResponse,
UpdateAITaggingData,
Expand All @@ -30,6 +29,7 @@
GetAllFoldersData,
FolderDetails,
)
from app.schemas.common import ErrorResponse
import os
from app.utils.folders import (
folder_util_add_folder_tree,
Expand Down
2 changes: 1 addition & 1 deletion backend/app/routes/images.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from fastapi import APIRouter, HTTPException, Query, status
from typing import List, Optional
from app.database.images import db_get_all_images
from app.schemas.images import ErrorResponse
from app.schemas.common import ErrorResponse
from app.utils.images import image_util_parse_metadata
from pydantic import BaseModel
from app.database.images import db_toggle_image_favourite_status
Expand Down
11 changes: 9 additions & 2 deletions backend/app/routes/user_preferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
UpdateUserPreferencesRequest,
UpdateUserPreferencesResponse,
UserPreferencesData,
ErrorResponse,
)
from app.schemas.common import ErrorResponse


router = APIRouter()
Expand All @@ -31,6 +31,7 @@ def get_user_preferences():
user_preferences = UserPreferencesData(
YOLO_model_size=user_prefs_data.get("YOLO_model_size", "small"),
GPU_Acceleration=user_prefs_data.get("GPU_Acceleration", True),
avatar=user_prefs_data.get("avatar"),
)

return GetUserPreferencesResponse(
Expand Down Expand Up @@ -59,7 +60,9 @@ def update_user_preferences(request: UpdateUserPreferencesRequest):
"""Update user preferences in metadata."""
try:
# Step 1: Validate that at least one field is provided
if request.YOLO_model_size is None and request.GPU_Acceleration is None:
if (request.YOLO_model_size is None and
request.GPU_Acceleration is None and
request.avatar is None):
raise ValueError("At least one preference field must be provided")

# Step 2: Get current metadata
Expand All @@ -74,6 +77,9 @@ def update_user_preferences(request: UpdateUserPreferencesRequest):

if request.GPU_Acceleration is not None:
current_user_prefs["GPU_Acceleration"] = request.GPU_Acceleration

if request.avatar is not None:
current_user_prefs["avatar"] = request.avatar

# Step 5: Update metadata with new user preferences
metadata["user_preferences"] = current_user_prefs
Expand All @@ -95,6 +101,7 @@ def update_user_preferences(request: UpdateUserPreferencesRequest):
user_preferences = UserPreferencesData(
YOLO_model_size=current_user_prefs.get("YOLO_model_size", "small"),
GPU_Acceleration=current_user_prefs.get("GPU_Acceleration", True),
avatar=current_user_prefs.get("avatar"),
)

return UpdateUserPreferencesResponse(
Expand Down
9 changes: 1 addition & 8 deletions backend/app/schemas/album.py
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 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.py

Repository: AOSSIE-Org/PictoPy

Length of output: 2793


Fix SuccessResponse field name in albums routes.

The SuccessResponse schema in common.py defines the field as message, but the route handlers in albums.py are using msg when instantiating responses (lines 158, 184, 280, 308, 348). Change all occurrences from msg= to message= to match the schema definition.

🤖 Prompt for AI Agents
In @backend/app/schemas/album.py at line 4, The route handlers are instantiating
SuccessResponse with the wrong field name `msg`; update all uses of
SuccessResponse(...) that pass `msg=` to instead pass `message=` so they match
the schema defined in common.py; search for `SuccessResponse(` in albums.py (the
response constructions around lines mentioned: ~158, 184, 280, 308, 348) and
replace each `msg=` with `message=`.



class Album(BaseModel):
Expand Down Expand Up @@ -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
15 changes: 15 additions & 0 deletions backend/app/schemas/common.py
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
Original file line number Diff line number Diff line change
@@ -1,13 +1,38 @@
from pydantic import BaseModel
from typing import List, Optional, Dict, Union, Any
from .common import ErrorResponse


# Request Models
# Face Tagging Models
class SimilarPair(BaseModel):
image1: str
image2: str
similarity: float


class FaceMatchingResponse(BaseModel):
success: bool
message: str
similar_pairs: List[SimilarPair]


class FaceClustersResponse(BaseModel):
success: bool
message: str
clusters: Dict[int, List[str]]


class GetRelatedImagesResponse(BaseModel):
success: bool
message: str
data: Dict[str, List[str]]


# Face Clusters Models
class RenameClusterRequest(BaseModel):
cluster_name: str


# Response Models
class RenameClusterData(BaseModel):
cluster_id: str
cluster_name: str
Expand All @@ -20,12 +45,6 @@ class RenameClusterResponse(BaseModel):
data: Optional[RenameClusterData] = None


class ErrorResponse(BaseModel):
success: bool = False
message: Optional[str] = None
error: Optional[str] = None


class ClusterMetadata(BaseModel):
cluster_id: str
cluster_name: Optional[str]
Expand All @@ -46,7 +65,6 @@ class GetClustersResponse(BaseModel):

class ImageInCluster(BaseModel):
"""Represents an image that contains faces from a specific cluster."""

id: str
path: str
thumbnailPath: Optional[str] = None
Expand All @@ -58,7 +76,6 @@ class ImageInCluster(BaseModel):

class GetClusterImagesData(BaseModel):
"""Data model for cluster images response."""

cluster_id: str
cluster_name: Optional[str] = None
images: List[ImageInCluster]
Expand All @@ -67,7 +84,6 @@ class GetClusterImagesData(BaseModel):

class GetClusterImagesResponse(BaseModel):
"""Response model for getting images in a cluster."""

success: bool
message: Optional[str] = None
error: Optional[str] = None
Expand All @@ -82,4 +98,4 @@ class GlobalReclusterResponse(BaseModel):
success: bool
message: Optional[str] = None
error: Optional[str] = None
data: Optional[GlobalReclusterData] = None
data: Optional[GlobalReclusterData] = None
33 changes: 0 additions & 33 deletions backend/app/schemas/facetagging.py

This file was deleted.

6 changes: 2 additions & 4 deletions backend/app/schemas/folders.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from pydantic import BaseModel
from typing import Optional, List
from .common import ErrorResponse


# Request Models
Expand Down Expand Up @@ -97,7 +98,4 @@ class SyncFolderResponse(BaseModel):
data: Optional[SyncFolderData] = None


class ErrorResponse(BaseModel):
success: bool = False
message: Optional[str] = None
error: Optional[str] = None

Loading