Skip to content

Commit 2240ac0

Browse files
[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.
1 parent 11e7bd1 commit 2240ac0

File tree

2 files changed

+129
-6
lines changed

2 files changed

+129
-6
lines changed

apps/api/plane/authentication/adapter/base.py

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
11
# Python imports
22
import os
33
import uuid
4+
import requests
5+
from io import BytesIO
46

57
# Django imports
68
from django.utils import timezone
79
from django.core.validators import validate_email
810
from django.core.exceptions import ValidationError
11+
from django.conf import settings
912

1013
# Third party imports
1114
from zxcvbn import zxcvbn
1215

1316
# Module imports
14-
from plane.db.models import Profile, User, WorkspaceMemberInvite
17+
from plane.db.models import Profile, User, WorkspaceMemberInvite, FileAsset
1518
from plane.license.utils.instance_value import get_configuration_value
1619
from .error import AuthenticationException, AUTHENTICATION_ERROR_CODES
1720
from plane.bgtasks.user_activation_email_task import user_activation_email
1821
from plane.utils.host import base_host
1922
from plane.utils.ip_address import get_client_ip
23+
from plane.utils.exception_logger import log_exception
2024

2125

2226
class Adapter:
@@ -86,9 +90,9 @@ def __check_signup(self, email):
8690
"""Check if sign up is enabled or not and raise exception if not enabled"""
8791

8892
# Get configuration value
89-
(ENABLE_SIGNUP,) = get_configuration_value(
90-
[{"key": "ENABLE_SIGNUP", "default": os.environ.get("ENABLE_SIGNUP", "1")}]
91-
)
93+
(ENABLE_SIGNUP,) = get_configuration_value([
94+
{"key": "ENABLE_SIGNUP", "default": os.environ.get("ENABLE_SIGNUP", "1")}
95+
])
9296

9397
# Check if sign up is disabled and invite is present or not
9498
if ENABLE_SIGNUP == "0" and not WorkspaceMemberInvite.objects.filter(email=email).exists():
@@ -101,6 +105,93 @@ def __check_signup(self, email):
101105

102106
return True
103107

108+
def get_avatar_download_headers(self):
109+
return {}
110+
111+
def download_and_upload_avatar(self, avatar_url, user):
112+
"""
113+
Downloads avatar from OAuth provider and uploads to our storage.
114+
Returns the uploaded file path or None if failed.
115+
"""
116+
if not avatar_url:
117+
return None
118+
119+
try:
120+
headers = self.get_avatar_download_headers()
121+
# Download the avatar image
122+
response = requests.get(avatar_url, timeout=10, headers=headers)
123+
response.raise_for_status()
124+
125+
# Check content length before downloading
126+
content_length = response.headers.get("Content-Length")
127+
max_size = settings.DATA_UPLOAD_MAX_MEMORY_SIZE
128+
if content_length and int(content_length) > max_size:
129+
return None
130+
131+
# Get content type and determine file extension
132+
content_type = response.headers.get("Content-Type", "image/jpeg")
133+
extension_map = {
134+
"image/jpeg": "jpg",
135+
"image/jpg": "jpg",
136+
"image/png": "png",
137+
"image/gif": "gif",
138+
"image/webp": "webp",
139+
}
140+
extension = extension_map.get(content_type)
141+
142+
if not extension:
143+
return None
144+
145+
# Download with size limit
146+
chunks = []
147+
total_size = 0
148+
for chunk in response.iter_content(chunk_size=8192):
149+
total_size += len(chunk)
150+
if total_size > max_size:
151+
return None
152+
chunks.append(chunk)
153+
content = b"".join(chunks)
154+
file_size = len(content)
155+
156+
# Generate unique filename
157+
filename = f"{uuid.uuid4().hex}-user-avatar.{extension}"
158+
159+
# Upload to S3/MinIO storage
160+
from plane.settings.storage import S3Storage
161+
162+
storage = S3Storage(request=self.request)
163+
164+
# Create file-like object
165+
file_obj = BytesIO(response.content)
166+
file_obj.seek(0)
167+
168+
# Upload using boto3 directly
169+
upload_success = storage.upload_file(file_obj=file_obj, object_name=filename, content_type=content_type)
170+
if not upload_success:
171+
return None
172+
173+
# Get storage metadata
174+
storage_metadata = storage.get_object_metadata(object_name=filename)
175+
176+
# Create FileAsset record
177+
file_asset = FileAsset.objects.create(
178+
attributes={"name": f"{self.provider}-avatar.{extension}", "type": content_type, "size": file_size},
179+
asset=filename,
180+
size=file_size,
181+
user=user,
182+
created_by=user,
183+
entity_type=FileAsset.EntityTypeContext.USER_AVATAR,
184+
is_uploaded=True,
185+
storage_metadata=storage_metadata,
186+
)
187+
188+
return file_asset
189+
190+
except Exception as e:
191+
log_exception(e)
192+
# Return None if upload fails, so original URL can be used as fallback
193+
return None
194+
104195
def save_user_data(self, user):
105196
# Update user details
106197
user.last_login_medium = self.provider
@@ -151,14 +242,23 @@ def complete_login_or_signup(self):
151242
user.is_password_autoset = False
152243

153244
# Set user details
154-
avatar = self.user_data.get("user", {}).get("avatar", "")
155245
first_name = self.user_data.get("user", {}).get("first_name", "")
156246
last_name = self.user_data.get("user", {}).get("last_name", "")
157-
user.avatar = avatar if avatar else ""
158247
user.first_name = first_name if first_name else ""
159248
user.last_name = last_name if last_name else ""
249+
160250
user.save()
161251

252+
# Download and upload avatar
253+
avatar = self.user_data.get("user", {}).get("avatar", "")
254+
if avatar:
255+
avatar_asset = self.download_and_upload_avatar(avatar_url=avatar, user=user)
256+
if avatar_asset:
257+
user.avatar_asset = avatar_asset
258+
# If avatar upload fails, set the avatar to the original URL
259+
else:
260+
user.avatar = avatar
261+
162262
# Create profile
163263
Profile.objects.create(user=user)
164264

apps/api/plane/settings/storage.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,26 @@ def copy_object(self, object_name, new_object_name):
164164
return None
165165

166166
return response
167+
168+
def upload_file(
169+
self,
170+
file_obj,
171+
object_name: str,
172+
content_type: str = None,
173+
extra_args: dict = {},
174+
) -> bool:
175+
"""Upload a file directly to S3"""
176+
try:
177+
if content_type:
178+
extra_args["ContentType"] = content_type
179+
180+
self.s3_client.upload_fileobj(
181+
file_obj,
182+
self.aws_storage_bucket_name,
183+
object_name,
184+
ExtraArgs=extra_args,
185+
)
186+
return True
187+
except ClientError as e:
188+
log_exception(e)
189+
return False

0 commit comments

Comments
 (0)