Skip to content
Merged
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
138 changes: 92 additions & 46 deletions app/api/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@
import uuid
import io
import os
import logging

from app.database import get_db
from app.models import UserProfile
from app.schemas import ProfileUpdateRequest, ProfileResponse
from app.crud import get_profile_by_user_id, create_or_update_profile
from app.auth import get_current_user, get_optional_current_user
from app.services.s3_service import s3_service

logger = logging.getLogger(__name__)

router = APIRouter()

Expand All @@ -32,16 +36,45 @@ async def update_profile(
# Convert URLs to database fields
profile_dict = profile_data.model_dump()

# Handle the URL field conversions
# Helper function to normalize URLs
def normalize_url(url: Optional[str], base_url: str = None) -> Optional[str]:
if not url:
return None
url = url.strip()
if not url:
return None
# If it's already a full URL, return it
if url.startswith(('http://', 'https://')):
return url
# If base_url provided and url is just username/path, prepend base
if base_url and not url.startswith('/'):
# Handle username-only inputs
if '/' not in url and '.' not in url:
return f"{base_url}/{url}"
# Handle partial paths
elif not url.startswith('http'):
return f"{base_url}/{url}"
# Default to https:// if it looks like a domain
if '.' in url:
return f"https://{url}"
return url

# Handle the URL field conversions with smart normalization
if 'github' in profile_dict:
profile_dict['github_url'] = str(profile_dict.pop('github')) if profile_dict['github'] else None
github_url = normalize_url(profile_dict.pop('github'), 'https://github.com')
profile_dict['github_url'] = github_url
if 'twitter' in profile_dict:
profile_dict['twitter_url'] = str(profile_dict.pop('twitter')) if profile_dict['twitter'] else None
twitter_url = normalize_url(profile_dict.pop('twitter'), 'https://twitter.com')
profile_dict['twitter_url'] = twitter_url
if 'linkedin' in profile_dict:
profile_dict['linkedin_url'] = str(profile_dict.pop('linkedin')) if profile_dict['linkedin'] else None
linkedin_url = normalize_url(profile_dict.pop('linkedin'), 'https://linkedin.com/in')
profile_dict['linkedin_url'] = linkedin_url

# Handle website URL (require more complete URL)
if 'website' in profile_dict:
profile_dict['website'] = normalize_url(profile_dict['website'])

# Ensure these fields are strings or None
profile_dict['website'] = str(profile_dict['website']) if profile_dict.get('website') else None
# Email doesn't need URL processing
profile_dict['email'] = str(profile_dict['email']) if profile_dict.get('email') else None

# Create or update profile
Expand Down Expand Up @@ -108,51 +141,64 @@ async def upload_avatar(
background.paste(image, mask=image.split()[-1])
image = background

# Resize image
# Resize image to max 500x500 while maintaining aspect ratio
image.thumbnail((500, 500), Image.Resampling.LANCZOS)

# Generate unique filename
# Prepare image for upload
output = io.BytesIO()
file_extension = avatar.filename.split('.')[-1].lower()
if file_extension not in ['jpg', 'jpeg', 'png', 'gif']:
if file_extension not in ['jpg', 'jpeg', 'png', 'gif', 'webp']:
file_extension = 'jpg'
unique_filename = f"{user_id}_{uuid.uuid4()}.{file_extension}"

# Create directory if it doesn't exist
upload_dir = "static/avatars"
os.makedirs(upload_dir, exist_ok=True)
upload_path = os.path.join(upload_dir, unique_filename)

# Save image
output = io.BytesIO()
image_format = 'JPEG' if file_extension in ['jpg', 'jpeg'] else file_extension.upper()
image.save(output, format=image_format)
output.seek(0)

with open(upload_path, "wb") as f:
f.write(output.read())

avatar_url = f"/static/avatars/{unique_filename}"
# Save optimized image to bytes
if image_format == 'JPEG':
image.save(output, format=image_format, quality=85, optimize=True)
else:
image.save(output, format=image_format, optimize=True)
output.seek(0)

# Update database with new avatar_url
profile = get_profile_by_user_id(db, user_id)
if profile:
# Delete old avatar file if exists
if profile.avatar_url and profile.avatar_url.startswith("/static/avatars/"):
old_file_path = profile.avatar_url[1:] # Remove leading slash
if os.path.exists(old_file_path):
os.remove(old_file_path)
try:
# Upload to S3
logger.info(f"Uploading avatar for user {user_id}, filename: {avatar.filename}")
avatar_url = s3_service.upload_avatar(
file_content=output.read(),
filename=avatar.filename,
user_id=user_id
)
logger.info(f"Avatar uploaded to S3: {avatar_url}")

# Update avatar URL
profile.avatar_url = avatar_url
db.commit()
db.refresh(profile)
else:
# Create new profile with avatar
profile_data = {
"user_id": user_id,
"name": user_id, # Default name
"avatar_url": avatar_url
}
profile = create_or_update_profile(db, profile_data)

return {"avatar_url": avatar_url, "message": "Avatar uploaded successfully"}
# Update database with new avatar_url
profile = get_profile_by_user_id(db, user_id)
if profile:
# Delete old avatar from S3 if it exists and is an S3 URL
if profile.avatar_url and "s3" in profile.avatar_url and "amazonaws.com" in profile.avatar_url:
try:
s3_service.delete_avatar(profile.avatar_url)
except Exception as e:
# Log error but continue - old avatar deletion shouldn't block new upload
logger.warning(f"Error deleting old avatar: {e}")

# Update avatar URL
profile.avatar_url = avatar_url
db.commit()
db.refresh(profile)
logger.info(f"Profile updated with new avatar URL: {avatar_url}")
else:
# Create new profile with avatar
profile_data = {
"user_id": user_id,
"name": user_id, # Default name
"avatar_url": avatar_url
}
profile = create_or_update_profile(db, profile_data)
logger.info(f"New profile created with avatar URL: {avatar_url}")

return {"avatar_url": avatar_url, "message": "Avatar uploaded successfully"}

except Exception as e:
logger.error(f"Failed to upload avatar for user {user_id}: {str(e)}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to upload avatar: {str(e)}"
)
8 changes: 4 additions & 4 deletions app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,10 @@ class ProfileUpdateRequest(BaseModel):
location: Optional[str] = None
bio: Optional[str] = None
email: Optional[EmailStr] = None
website: Optional[HttpUrl] = None
github: Optional[HttpUrl] = None
twitter: Optional[HttpUrl] = None
linkedin: Optional[HttpUrl] = None
website: Optional[str] = None
github: Optional[str] = None
twitter: Optional[str] = None
linkedin: Optional[str] = None
avatar_url: Optional[str] = None


Expand Down
59 changes: 58 additions & 1 deletion app/services/s3_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ def _get_content_type(self, file_extension: str) -> str:
content_types = {
'pdf': 'application/pdf',
'tex': 'application/x-tex',
'latex': 'application/x-tex'
'latex': 'application/x-tex',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'gif': 'image/gif',
'webp': 'image/webp'
}
return content_types.get(file_extension, 'application/octet-stream')

Expand Down Expand Up @@ -99,6 +104,58 @@ def file_exists(self, file_key: str) -> bool:
if e.response['Error']['Code'] == '404':
return False
raise Exception(f"Error checking file existence: {str(e)}")

def upload_avatar(self, file_content: bytes, filename: str, user_id: str) -> str:
"""
Upload avatar image directly to S3
Returns the S3 URL of the uploaded avatar
"""
file_extension = self._get_file_extension(filename)

# Validate file extension for avatars
if file_extension not in ['jpg', 'jpeg', 'png', 'gif', 'webp']:
raise ValueError("Only image files (jpg, jpeg, png, gif, webp) are supported for avatars")

try:
# Generate unique file key with user ID prefix for organization
file_uuid = str(uuid.uuid4())
file_key = f"avatars/{user_id}/{file_uuid}.{file_extension}"

# Get appropriate content type
content_type = self._get_content_type(file_extension)

# Upload directly to S3
self.s3_client.put_object(
Bucket=self.bucket_name,
Key=file_key,
Body=file_content,
ContentType=content_type,
# Set cache control for avatars
CacheControl='max-age=31536000'
)

# Construct the final S3 URL
s3_url = f"https://{self.bucket_name}.s3.{settings.aws_region}.amazonaws.com/{file_key}"

return s3_url

except ClientError as e:
raise Exception(f"S3 error uploading avatar: {str(e)}")
except Exception as e:
raise Exception(f"Error uploading avatar: {str(e)}")

def delete_avatar(self, avatar_url: str) -> bool:
"""
Delete an avatar from S3 based on its URL
"""
try:
# Extract file key from S3 URL
if f"{self.bucket_name}.s3" in avatar_url:
file_key = avatar_url.split(f"{self.bucket_name}.s3.{settings.aws_region}.amazonaws.com/")[-1]
return self.delete_file(file_key)
return False
except Exception as e:
raise Exception(f"Error deleting avatar: {str(e)}")

# Create a singleton instance
s3_service = S3Service()