From 4e1a4be650a6565fa86ac6110b05e9281fea34c2 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Fri, 28 Mar 2025 19:59:39 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20introduce=20a=20creation?= =?UTF-8?q?=20callback=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Necessary in cross browser context situation, where we need to pass data of a room newly created between two different windows. This happens in Calendar integration. --- src/backend/core/api/serializers.py | 14 +++ src/backend/core/api/viewsets.py | 35 +++++++ src/backend/core/services/room_creation.py | 37 +++++++ .../core/tests/rooms/test_api_rooms_create.py | 41 +++++++- .../rooms/test_api_rooms_creation_callback.py | 98 +++++++++++++++++++ src/backend/meet/settings.py | 12 +++ 6 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 src/backend/core/services/room_creation.py create mode 100644 src/backend/core/tests/rooms/test_api_rooms_creation_callback.py diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index d340c3ad..d0af829f 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -209,3 +209,17 @@ class ParticipantEntrySerializer(serializers.Serializer): def update(self, instance, validated_data): """Not implemented as this is a validation-only serializer.""" raise NotImplementedError("StartRecordingSerializer is validation-only") + + +class CreationCallbackSerializer(serializers.Serializer): + """Validate room creation callback data.""" + + callback_id = serializers.CharField(required=True) + + def create(self, validated_data): + """Not implemented as this is a validation-only serializer.""" + raise NotImplementedError("StartRecordingSerializer is validation-only") + + def update(self, instance, validated_data): + """Not implemented as this is a validation-only serializer.""" + raise NotImplementedError("StartRecordingSerializer is validation-only") diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index d38994b1..d212bad6 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -47,6 +47,7 @@ from core.services.lobby import ( LobbyParticipantNotFound, LobbyService, ) +from core.services.room_creation import RoomCreation from . import permissions, serializers @@ -186,6 +187,12 @@ class RequestEntryAnonRateThrottle(throttling.AnonRateThrottle): scope = "request_entry" +class CreationCallbackAnonRateThrottle(throttling.AnonRateThrottle): + """Throttle Anonymous user requesting room generation callback""" + + scope = "creation_callback" + + class RoomViewSet( mixins.CreateModelMixin, mixins.DestroyModelMixin, @@ -268,6 +275,9 @@ class RoomViewSet( role=models.RoleChoices.OWNER, ) + if callback_id := self.request.data.get("callback_id"): + RoomCreation().persist_callback_state(callback_id, room) + @decorators.action( detail=True, methods=["post"], @@ -460,6 +470,31 @@ class RoomViewSet( {"status": "error", "message": str(e)}, status=status_code ) + @decorators.action( + detail=False, + methods=["POST"], + url_path="creation-callback", + permission_classes=[], + throttle_classes=[CreationCallbackAnonRateThrottle], + ) + def creation_callback(self, request): + """Retrieve cached room data via an unauthenticated request with a unique ID. + + Designed for interoperability across iframes, popups, and other contexts, + even on the same domain, bypassing browser security restrictions on direct communication. + """ + + serializer = serializers.CreationCallbackSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + room = RoomCreation().get_callback_state( + callback_id=serializer.validated_data.get("callback_id") + ) + + return drf_response.Response( + {"status": "success", "room": room}, status=drf_status.HTTP_200_OK + ) + class ResourceAccessListModelMixin: """List mixin for resource access API.""" diff --git a/src/backend/core/services/room_creation.py b/src/backend/core/services/room_creation.py new file mode 100644 index 00000000..a1ac62e4 --- /dev/null +++ b/src/backend/core/services/room_creation.py @@ -0,0 +1,37 @@ +"""Room creation service.""" + +from django.conf import settings +from django.core.cache import cache + + +class RoomCreation: + """Room creation related methods""" + + @staticmethod + def _get_cache_key(callback_id): + """Generate a standardized cache key for room creation callbacks.""" + return f"room-creation-callback_{callback_id}" + + def persist_callback_state(self, callback_id: str, room) -> None: + """Store room data in cache using the callback ID as an identifier.""" + data = { + "slug": room.slug, + } + cache.set( + self._get_cache_key(callback_id), + data, + timeout=settings.ROOM_CREATION_CALLBACK_CACHE_TIMEOUT, + ) + + def get_callback_state(self, callback_id: str) -> dict: + """Retrieve and clear cached room data for the given callback ID.""" + + cache_key = self._get_cache_key(callback_id) + data = cache.get(cache_key) + + if not data: + return {} + + cache.delete(cache_key) + + return data diff --git a/src/backend/core/tests/rooms/test_api_rooms_create.py b/src/backend/core/tests/rooms/test_api_rooms_create.py index 89cc8e43..f5c2412b 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_create.py +++ b/src/backend/core/tests/rooms/test_api_rooms_create.py @@ -2,6 +2,9 @@ Test rooms API endpoints in the Meet core app: create. """ +# pylint: disable=W0621,W0613 +from django.core.cache import cache + import pytest from rest_framework.test import APIClient @@ -11,6 +14,15 @@ from ...models import Room pytestmark = pytest.mark.django_db +@pytest.fixture +def reset_cache(): + """Provide cache cleanup after each test to maintain test isolation.""" + yield + keys = cache.keys("room-creation-callback_*") + if keys: + cache.delete(*keys) + + def test_api_rooms_create_anonymous(): """Anonymous users should not be allowed to create rooms.""" client = APIClient() @@ -26,7 +38,7 @@ def test_api_rooms_create_anonymous(): assert Room.objects.exists() is False -def test_api_rooms_create_authenticated(): +def test_api_rooms_create_authenticated(reset_cache): """ Authenticated users should be able to create rooms and should automatically be declared as owner of the newly created room. @@ -49,6 +61,33 @@ def test_api_rooms_create_authenticated(): assert room.slug == "my-room" assert room.accesses.filter(role="owner", user=user).exists() is True + rooms_data = cache.keys("room-creation-callback_*") + assert not rooms_data + + +def test_api_rooms_create_generation_cache(reset_cache): + """ + Authenticated users creating a room with a callback ID should have room data stored in cache. + """ + user = UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.post( + "/api/v1.0/rooms/", + {"name": "my room", "callback_id": "1234"}, + ) + + assert response.status_code == 201 + room = Room.objects.get() + assert room.name == "my room" + assert room.slug == "my-room" + assert room.accesses.filter(role="owner", user=user).exists() is True + + room_data = cache.get("room-creation-callback_1234") + assert room_data.get("slug") == "my-room" + def test_api_rooms_create_authenticated_existing_slug(): """ diff --git a/src/backend/core/tests/rooms/test_api_rooms_creation_callback.py b/src/backend/core/tests/rooms/test_api_rooms_creation_callback.py new file mode 100644 index 00000000..e57730be --- /dev/null +++ b/src/backend/core/tests/rooms/test_api_rooms_creation_callback.py @@ -0,0 +1,98 @@ +""" +Test rooms API endpoints in the Meet core app: creation callback functionality. +""" + +# pylint: disable=W0621,W0613 +from django.core.cache import cache + +import pytest +from rest_framework.test import APIClient + +from ...factories import UserFactory + +pytestmark = pytest.mark.django_db + + +# Tests for creation_callback endpoint + + +@pytest.fixture +def reset_cache(): + """Provide cache cleanup after each test to maintain test isolation.""" + yield + keys = cache.keys("room-creation-callback_*") + if keys: + cache.delete(*keys) + + +def test_api_rooms_create_anonymous(reset_cache): + """Anonymous user can retrieve room data once using a valid callback ID.""" + client = APIClient() + cache.set("room-creation-callback_123", {"slug": "my room"}) + response = client.post( + "/api/v1.0/rooms/creation-callback/", + { + "callback_id": "123", + }, + ) + + assert response.status_code == 200 + assert response.json() == {"status": "success", "room": {"slug": "my room"}} + + # Data should be cleared after retrieval + response = client.post( + "/api/v1.0/rooms/creation-callback/", + { + "callback_id": "123", + }, + ) + + assert response.status_code == 200 + assert response.json() == {"status": "success", "room": {}} + + +def test_api_rooms_create_empty_cache(): + """Valid callback ID return empty room data when nothing is stored in the cache.""" + client = APIClient() + + response = client.post( + "/api/v1.0/rooms/creation-callback/", + { + "callback_id": "123", + }, + ) + + assert response.status_code == 200 + assert response.json() == {"status": "success", "room": {}} + + +def test_api_rooms_create_missing_callback_id(): + """Requests without a callback ID properly fail with a 400 status code.""" + client = APIClient() + + response = client.post( + "/api/v1.0/rooms/creation-callback/", + {}, + ) + + assert response.status_code == 400 + + +def test_api_rooms_create_authenticated(reset_cache): + """Authenticated users can also successfully retrieve room data using a valid callback ID""" + user = UserFactory() + + client = APIClient() + client.force_login(user) + + cache.set("room-creation-callback_123", {"slug": "my room"}) + + response = client.post( + "/api/v1.0/rooms/creation-callback/", + { + "callback_id": "123", + }, + ) + + assert response.status_code == 200 + assert response.json() == {"status": "success", "room": {"slug": "my room"}} diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index 680d2724..57d076c8 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -272,6 +272,11 @@ class Base(Configuration): environ_name="REQUEST_ENTRY_THROTTLE_RATES", environ_prefix=None, ), + "creation_callback": values.Value( + default="600/minute", + environ_name="CREATION_CALLBACK_THROTTLE_RATES", + environ_prefix=None, + ), }, } @@ -528,6 +533,13 @@ class Base(Configuration): environ_prefix=None, ) + # Calendar integrations + ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue( + 600, # 10 minutes + environ_name="ROOM_CREATION_CALLBACK_CACHE_TIMEOUT", + environ_prefix=None, + ) + # pylint: disable=invalid-name @property def ENVIRONMENT(self):