diff --git a/plane-src/apps/api/plane/app/views/asset/v2.py b/plane-src/apps/api/plane/app/views/asset/v2.py index 3cff428..e2273a3 100644 --- a/plane-src/apps/api/plane/app/views/asset/v2.py +++ b/plane-src/apps/api/plane/app/views/asset/v2.py @@ -23,6 +23,8 @@ from plane.settings.storage import S3Storage from plane.app.permissions import allow_permission, ROLE from plane.utils.cache import invalidate_cache_directly from plane.throttles.asset import AssetRateThrottle +from plane.app.realtime.nodedc_events import publish_nodedc_user_profile_event_on_commit +from plane.authentication.nodedc_profile_sync import push_nodedc_user_profile_update_on_commit from plane.utils.upload_limits import get_project_storage_quota_response, resolve_workspace_upload_size_limit from plane.utils.file_dedup import ( UploadedObjectMissing, @@ -57,6 +59,8 @@ class UserAssetsV2Endpoint(BaseAPIView): # Save the new avatar user.avatar_asset_id = asset_id user.save() + publish_nodedc_user_profile_event_on_commit(user, changed_fields=["avatar"]) + push_nodedc_user_profile_update_on_commit(user, changed_fields=["avatar"]) invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request) invalidate_cache_directly( path="/api/users/me/settings/", @@ -89,8 +93,11 @@ class UserAssetsV2Endpoint(BaseAPIView): # User Avatar if entity_type == FileAsset.EntityTypeContext.USER_AVATAR: user = User.objects.get(id=asset.user_id) + user.avatar = "" user.avatar_asset_id = None user.save() + publish_nodedc_user_profile_event_on_commit(user, changed_fields=["avatar"]) + push_nodedc_user_profile_update_on_commit(user, changed_fields=["avatar"]) invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request) invalidate_cache_directly( path="/api/users/me/settings/", diff --git a/plane-src/apps/api/plane/app/views/user/base.py b/plane-src/apps/api/plane/app/views/user/base.py index cd58d74..a874e2b 100644 --- a/plane-src/apps/api/plane/app/views/user/base.py +++ b/plane-src/apps/api/plane/app/views/user/base.py @@ -52,9 +52,12 @@ from plane.utils.host import base_host from plane.bgtasks.user_email_update_task import send_email_update_magic_code, send_email_update_confirmation from plane.authentication.rate_limit import EmailVerificationThrottle from plane.license.utils.instance_value import get_configuration_value +from plane.app.realtime.nodedc_events import publish_nodedc_user_profile_event_on_commit +from plane.authentication.nodedc_profile_sync import push_nodedc_user_profile_update_on_commit logger = logging.getLogger("plane") +NODEDC_PROFILE_SYNC_FIELDS = ("display_name", "first_name", "last_name", "avatar") class UserEndpoint(BaseViewSet): @@ -91,7 +94,21 @@ class UserEndpoint(BaseViewSet): return Response({"is_instance_admin": is_admin}, status=status.HTTP_200_OK) def partial_update(self, request, *args, **kwargs): - return super().partial_update(request, *args, **kwargs) + user = self.get_object() + previous_profile = {field: getattr(user, field, None) for field in NODEDC_PROFILE_SYNC_FIELDS} + response = super().partial_update(request, *args, **kwargs) + + if response.status_code < 400: + user.refresh_from_db() + changed_fields = [ + field for field in NODEDC_PROFILE_SYNC_FIELDS if previous_profile.get(field) != getattr(user, field, None) + ] + + if changed_fields: + publish_nodedc_user_profile_event_on_commit(user, changed_fields=changed_fields) + push_nodedc_user_profile_update_on_commit(user, changed_fields=changed_fields) + + return response def _validate_new_email(self, user, new_email): """ diff --git a/plane-src/apps/api/plane/authentication/nodedc_profile_sync.py b/plane-src/apps/api/plane/authentication/nodedc_profile_sync.py new file mode 100644 index 0000000..4792cf4 --- /dev/null +++ b/plane-src/apps/api/plane/authentication/nodedc_profile_sync.py @@ -0,0 +1,130 @@ +import logging +import os +from urllib.parse import urlparse + +import requests +from django.db import transaction + +from plane.authentication.views.nodedc_logout import get_nodedc_internal_token +from plane.db.models import ExternalIdentityLink, User + + +logger = logging.getLogger("plane") +OIDC_PROVIDER = "authentik" + + +def get_nodedc_profile_sync_url(): + launcher_base_url = ( + os.environ.get("PLANE_NODEDC_LAUNCHER_URL", "").strip() + or os.environ.get("PLANE_NODEDC_LAUNCHER_PUBLIC_URL", "").strip() + or "http://launcher.local.nodedc" + ).rstrip("/") + return ( + os.environ.get("PLANE_NODEDC_PROFILE_SYNC_URL", "").strip() + or f"{launcher_base_url}/api/internal/tasker/profile-sync" + ) + + +def get_tasker_public_origin(): + explicit_origin = os.environ.get("PLANE_NODEDC_TASK_PUBLIC_URL", "").strip() + if explicit_origin: + return explicit_origin.rstrip("/") + + configured_url = os.environ.get("WEB_URL", "").strip() + if configured_url: + parsed_url = urlparse(configured_url) + if parsed_url.scheme and parsed_url.netloc: + return f"{parsed_url.scheme}://{parsed_url.netloc}" + + task_domain = os.environ.get("TASK_DOMAIN", "").strip() + if task_domain: + return f"http://{task_domain}" + + return "" + + +def normalize_tasker_avatar_url(value): + if not isinstance(value, str): + return None + + avatar_url = value.strip() + if not avatar_url: + return None + + if avatar_url.startswith(("http://", "https://", "data:")): + return avatar_url + + if avatar_url.startswith("/"): + tasker_origin = get_tasker_public_origin() + return f"{tasker_origin}{avatar_url}" if tasker_origin else avatar_url + + return avatar_url + + +def get_user_display_name(user): + display_name = getattr(user, "display_name", "") + if display_name: + return display_name + + name = " ".join( + value for value in [getattr(user, "first_name", ""), getattr(user, "last_name", "")] if value + ).strip() + return name or user.email + + +def get_nodedc_subject(user): + link = ExternalIdentityLink.objects.filter(provider=OIDC_PROVIDER, user=user, status="active").first() + return link.subject if link else None + + +def build_nodedc_profile_payload(user, changed_fields=None): + return { + "source": "tasker", + "planeUserId": str(user.id), + "subject": get_nodedc_subject(user), + "email": user.email, + "name": get_user_display_name(user), + "displayName": user.display_name or get_user_display_name(user), + "firstName": user.first_name, + "lastName": user.last_name, + "avatarUrl": normalize_tasker_avatar_url(user.avatar_url), + "changedFields": sorted(set(changed_fields or [])), + } + + +def push_nodedc_user_profile_update(user, changed_fields=None): + request_url = get_nodedc_profile_sync_url() + token = get_nodedc_internal_token() + + if not request_url or not token: + logger.warning("NODE.DC profile sync is not configured") + return None + + response = requests.post( + request_url, + json=build_nodedc_profile_payload(user, changed_fields=changed_fields), + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + }, + timeout=float(os.environ.get("PLANE_NODEDC_PROFILE_SYNC_TIMEOUT_SECONDS", "3") or "3"), + ) + response.raise_for_status() + return response.json() + + +def push_nodedc_user_profile_update_on_commit(user, changed_fields=None): + user_id = user.id + changed_fields = sorted(set(changed_fields or [])) + + def _push(): + fresh_user = User.objects.filter(id=user_id, is_bot=False).first() + if fresh_user is None: + return + + try: + push_nodedc_user_profile_update(fresh_user, changed_fields=changed_fields) + except Exception: + logger.exception("Failed to push NODE.DC profile update to Launcher") + + transaction.on_commit(_push) diff --git a/plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py b/plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py index 04e6d31..e87d7e8 100644 --- a/plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py +++ b/plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py @@ -183,15 +183,17 @@ def resolve_nodedc_launcher_origin(): def sync_user_profile_from_payload(user, payload): updated_fields = [] display_name = first_payload_string(payload, "displayName", "display_name", "name") + has_avatar = any(key in payload for key in ["avatarUrl", "avatar_url", "avatar"]) avatar_url = normalize_nodedc_avatar_url(first_payload_string(payload, "avatarUrl", "avatar_url", "avatar")) if display_name and user.display_name != display_name: user.display_name = display_name updated_fields.append("display_name") - if avatar_url and user.avatar != avatar_url: + if has_avatar and (user.avatar != avatar_url or user.avatar_asset_id is not None): user.avatar = avatar_url - updated_fields.append("avatar") + user.avatar_asset_id = None + updated_fields.extend(["avatar", "avatar_asset"]) if updated_fields: updated_fields.append("updated_at")