From 2240ac0e7406c2f3bdfa12c65f080502edfdd08c Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:48:27 +0530 Subject: [PATCH] [WEB-5583]feat: add avatar download and upload functionality in authentication adapter (#8247) * feat: add avatar download and upload functionality in authentication adapter - Implemented `download_and_upload_avatar` method to fetch and store user avatars from OAuth providers. - Enhanced user data saving process to include avatar handling. - Updated `S3Storage` class with a new `upload_file` method for direct file uploads to S3. * feat: enhance avatar download functionality with size limit checks - Added checks for content length before downloading avatar images to ensure they do not exceed the maximum allowed size. - Implemented chunked downloading of avatar images to handle large files efficiently. - Updated the upload process to return None if the upload fails, improving error handling. * feat: improve avatar filename generation with content type handling - Refactored avatar download logic to determine file extension based on the content type from the response headers. - Removed redundant code for extension mapping, ensuring a cleaner implementation. - Enhanced error handling by returning None for unsupported content types. * fix: remove authorization header for avatar download - Updated the avatar download logic to remove the Authorization header when token data is not present, ensuring compatibility with scenarios where authentication is not required. * feat: add method for avatar download headers - Introduced `get_avatar_download_headers` method to centralize header management for avatar downloads. - Updated `download_and_upload_avatar` method to utilize the new header method, improving code clarity and maintainability. --- apps/api/plane/authentication/adapter/base.py | 112 +++++++++++++++++- apps/api/plane/settings/storage.py | 23 ++++ 2 files changed, 129 insertions(+), 6 deletions(-) diff --git a/apps/api/plane/authentication/adapter/base.py b/apps/api/plane/authentication/adapter/base.py index bbb50eb76f..b80555fe16 100644 --- a/apps/api/plane/authentication/adapter/base.py +++ b/apps/api/plane/authentication/adapter/base.py @@ -1,22 +1,26 @@ # Python imports import os import uuid +import requests +from io import BytesIO # Django imports from django.utils import timezone from django.core.validators import validate_email from django.core.exceptions import ValidationError +from django.conf import settings # Third party imports from zxcvbn import zxcvbn # Module imports -from plane.db.models import Profile, User, WorkspaceMemberInvite +from plane.db.models import Profile, User, WorkspaceMemberInvite, FileAsset from plane.license.utils.instance_value import get_configuration_value from .error import AuthenticationException, AUTHENTICATION_ERROR_CODES from plane.bgtasks.user_activation_email_task import user_activation_email from plane.utils.host import base_host from plane.utils.ip_address import get_client_ip +from plane.utils.exception_logger import log_exception class Adapter: @@ -86,9 +90,9 @@ class Adapter: """Check if sign up is enabled or not and raise exception if not enabled""" # Get configuration value - (ENABLE_SIGNUP,) = get_configuration_value( - [{"key": "ENABLE_SIGNUP", "default": os.environ.get("ENABLE_SIGNUP", "1")}] - ) + (ENABLE_SIGNUP,) = get_configuration_value([ + {"key": "ENABLE_SIGNUP", "default": os.environ.get("ENABLE_SIGNUP", "1")} + ]) # Check if sign up is disabled and invite is present or not if ENABLE_SIGNUP == "0" and not WorkspaceMemberInvite.objects.filter(email=email).exists(): @@ -101,6 +105,93 @@ class Adapter: return True + def get_avatar_download_headers(self): + return {} + + def download_and_upload_avatar(self, avatar_url, user): + """ + Downloads avatar from OAuth provider and uploads to our storage. + Returns the uploaded file path or None if failed. + """ + if not avatar_url: + return None + + try: + headers = self.get_avatar_download_headers() + # Download the avatar image + response = requests.get(avatar_url, timeout=10, headers=headers) + response.raise_for_status() + + # Check content length before downloading + content_length = response.headers.get("Content-Length") + max_size = settings.DATA_UPLOAD_MAX_MEMORY_SIZE + if content_length and int(content_length) > max_size: + return None + + # Get content type and determine file extension + content_type = response.headers.get("Content-Type", "image/jpeg") + extension_map = { + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/png": "png", + "image/gif": "gif", + "image/webp": "webp", + } + extension = extension_map.get(content_type) + + if not extension: + return None + + # Download with size limit + chunks = [] + total_size = 0 + for chunk in response.iter_content(chunk_size=8192): + total_size += len(chunk) + if total_size > max_size: + return None + chunks.append(chunk) + content = b"".join(chunks) + file_size = len(content) + + # Generate unique filename + filename = f"{uuid.uuid4().hex}-user-avatar.{extension}" + + # Upload to S3/MinIO storage + from plane.settings.storage import S3Storage + + storage = S3Storage(request=self.request) + + # Create file-like object + file_obj = BytesIO(response.content) + file_obj.seek(0) + + # Upload using boto3 directly + upload_success = storage.upload_file(file_obj=file_obj, object_name=filename, content_type=content_type) + if not upload_success: + return None + + # Get storage metadata + storage_metadata = storage.get_object_metadata(object_name=filename) + + # Create FileAsset record + file_asset = FileAsset.objects.create( + attributes={"name": f"{self.provider}-avatar.{extension}", "type": content_type, "size": file_size}, + asset=filename, + size=file_size, + user=user, + created_by=user, + entity_type=FileAsset.EntityTypeContext.USER_AVATAR, + is_uploaded=True, + storage_metadata=storage_metadata, + ) + + return file_asset + + except Exception as e: + log_exception(e) + # Return None if upload fails, so original URL can be used as fallback + return None + def save_user_data(self, user): # Update user details user.last_login_medium = self.provider @@ -151,14 +242,23 @@ class Adapter: user.is_password_autoset = False # Set user details - avatar = self.user_data.get("user", {}).get("avatar", "") first_name = self.user_data.get("user", {}).get("first_name", "") last_name = self.user_data.get("user", {}).get("last_name", "") - user.avatar = avatar if avatar else "" user.first_name = first_name if first_name else "" user.last_name = last_name if last_name else "" + user.save() + # Download and upload avatar + avatar = self.user_data.get("user", {}).get("avatar", "") + if avatar: + avatar_asset = self.download_and_upload_avatar(avatar_url=avatar, user=user) + if avatar_asset: + user.avatar_asset = avatar_asset + # If avatar upload fails, set the avatar to the original URL + else: + user.avatar = avatar + # Create profile Profile.objects.create(user=user) diff --git a/apps/api/plane/settings/storage.py b/apps/api/plane/settings/storage.py index 7c048f679a..01afa62374 100644 --- a/apps/api/plane/settings/storage.py +++ b/apps/api/plane/settings/storage.py @@ -164,3 +164,26 @@ class S3Storage(S3Boto3Storage): return None return response + + def upload_file( + self, + file_obj, + object_name: str, + content_type: str = None, + extra_args: dict = {}, + ) -> bool: + """Upload a file directly to S3""" + try: + if content_type: + extra_args["ContentType"] = content_type + + self.s3_client.upload_fileobj( + file_obj, + self.aws_storage_bucket_name, + object_name, + ExtraArgs=extra_args, + ) + return True + except ClientError as e: + log_exception(e) + return False