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