diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c77e00834..2b750b31f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,9 @@ and this project adheres to - 📝(project) add troubleshoot doc #1066 - 📝(project) add system-requirement doc #1066 - 🔧(front) configure x-frame-options to DENY in nginx conf #1084 -- (doc) add documentation to install with compose #855 +- ✨(doc) add documentation to install with compose #855 +- ✨ Give priority to users connected to collaboration server + (aka no websocket feature) #1093 ### Changed diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index e86288bb34..d0b0edbc8d 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -239,6 +239,7 @@ class DocumentSerializer(ListDocumentSerializer): """Serialize documents with all fields for display in detail views.""" content = serializers.CharField(required=False) + websocket = serializers.BooleanField(required=False, write_only=True) class Meta: model = models.Document @@ -260,6 +261,7 @@ class Meta: "title", "updated_at", "user_roles", + "websocket", ] read_only_fields = [ "id", diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 2250c91aa4..07c76a1a3c 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -30,6 +30,7 @@ from rest_framework import response as drf_response from rest_framework.permissions import AllowAny from rest_framework.throttling import UserRateThrottle +from sentry_sdk import capture_exception from core import authentication, enums, models from core.services.ai_services import AIService @@ -631,6 +632,82 @@ def perform_destroy(self, instance): """Override to implement a soft delete instead of dumping the record in database.""" instance.soft_delete() + def _compute_no_websocket_cache_key(self, document_id): + """Compute the cache key for the no websocket cache.""" + return f"docs:no-websocket:{document_id}" + + def _can_user_edit_document(self, document_id, set_cache=False): + """Check if the user can edit the document.""" + try: + connection_info = CollaborationService().get_document_connection_info( + document_id, + self.request.session.session_key, + ) + except requests.HTTPError as e: + capture_exception(e) + connection_info = { + "count": 0, + "exists": False, + } + + if connection_info["count"] == 0: + # Nobody is connected to the websocket server + logger.debug("update without connection found in the websocket server") + cache_key = self._compute_no_websocket_cache_key(document_id) + current_editor = cache.get(cache_key) + + if not current_editor: + if set_cache: + cache.set( + cache_key, + self.request.session.session_key, + settings.NO_WEBSOCKET_CACHE_TIMEOUT, + ) + return True + + if current_editor != self.request.session.session_key: + return False + + if set_cache: + cache.touch(cache_key, settings.NO_WEBSOCKET_CACHE_TIMEOUT) + return True + + if connection_info["exists"]: + # Current user is connected to the websocket server + logger.debug("session key found in the websocket server") + return True + + logger.debug( + "Users connected to the websocket but current editor not connected to it. Can not edit." + ) + + return False + + def perform_update(self, serializer): + """Check rules about collaboration.""" + if serializer.validated_data.get("websocket"): + return super().perform_update(serializer) + + if self._can_user_edit_document(serializer.instance.id, set_cache=True): + return super().perform_update(serializer) + + raise drf.exceptions.PermissionDenied( + "You are not allowed to edit this document." + ) + + @drf.decorators.action( + detail=True, + methods=["get"], + url_path="can-edit", + ) + def can_edit(self, request, *args, **kwargs): + """Check if the current user can edit the document.""" + document = self.get_object() + + return drf.response.Response( + {"can_edit": self._can_user_edit_document(document.id)} + ) + @drf.decorators.action( detail=False, methods=["get"], diff --git a/src/backend/core/middleware.py b/src/backend/core/middleware.py new file mode 100644 index 0000000000..a46cb3eb84 --- /dev/null +++ b/src/backend/core/middleware.py @@ -0,0 +1,21 @@ +"""Force session creation for all requests.""" + + +class ForceSessionMiddleware: + """ + Force session creation for unauthenticated users. + Must be used after Authentication middleware. + """ + + def __init__(self, get_response): + """Initialize the middleware.""" + self.get_response = get_response + + def __call__(self, request): + """Force session creation for unauthenticated users.""" + + if not request.user.is_authenticated and request.session.session_key is None: + request.session.save() + + response = self.get_response(request) + return response diff --git a/src/backend/core/models.py b/src/backend/core/models.py index a2599edfb2..5dcb09ca74 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -836,6 +836,7 @@ def get_abilities(self, user, ancestors_links=None): "ai_translate": ai_access, "attachment_upload": can_update, "media_check": can_get, + "can_edit": can_update, "children_list": can_get, "children_create": can_update and user.is_authenticated, "collaboration_auth": can_get, diff --git a/src/backend/core/services/collaboration_services.py b/src/backend/core/services/collaboration_services.py index dac16fa6db..fe6229c573 100644 --- a/src/backend/core/services/collaboration_services.py +++ b/src/backend/core/services/collaboration_services.py @@ -41,3 +41,31 @@ def reset_connections(self, room, user_id=None): f"Failed to notify WebSocket server. Status code: {response.status_code}, " f"Response: {response.text}" ) + + def get_document_connection_info(self, room, session_key): + """ + Get the connection info for a document. + """ + endpoint = "get-connections" + querystring = { + "room": room, + "sessionKey": session_key, + } + endpoint_url = f"{settings.COLLABORATION_API_URL}{endpoint}/" + + headers = {"Authorization": settings.COLLABORATION_SERVER_SECRET} + + try: + response = requests.get( + endpoint_url, headers=headers, params=querystring, timeout=10 + ) + except requests.RequestException as e: + raise requests.HTTPError("Failed to get document connection info.") from e + + if response.status_code != 200: + raise requests.HTTPError( + f"Failed to get document connection info. Status code: {response.status_code}, " + f"Response: {response.text}" + ) + + return response.json() diff --git a/src/backend/core/tests/documents/test_api_documents_can_edit.py b/src/backend/core/tests/documents/test_api_documents_can_edit.py new file mode 100644 index 0000000000..ac3b835e91 --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_can_edit.py @@ -0,0 +1,219 @@ +"""Test the can_edit endpoint in the viewset DocumentViewSet.""" + +from django.core.cache import cache + +import pytest +import responses +from rest_framework.test import APIClient + +from core import factories + +pytestmark = pytest.mark.django_db + + +def test_api_documents_can_edit_anonymous(): + """Anonymous users can not edit documents.""" + document = factories.DocumentFactory() + client = APIClient() + response = client.get(f"/api/v1.0/documents/{document.id!s}/can-edit/") + assert response.status_code == 401 + +@responses.activate +def test_api_documents_can_edit_authenticated_no_websocket(settings): + """ + A user not connected to the websocket and no other user have already updated the document, + the document can be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + + ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False}) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/can-edit/", + ) + assert response.status_code == 200 + + assert response.json() == {"can_edit": True} + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_can_edit_authenticated_no_websocket_user_already_editing( + settings, +): + """ + A user not connected to the websocket and another user have already updated the document, + the document can not be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False}) + + cache.set(f"docs:no-websocket:{document.id}", "other_session_key") + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/can-edit/", + ) + assert response.status_code == 200 + assert response.json() == {"can_edit": False} + + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_can_edit_no_websocket_other_user_connected_to_websocket( + settings, +): + """ + A user not connected to the websocket and another user is connected to the websocket, + the document can not be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False}) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/can-edit/", + ) + assert response.status_code == 200 + assert response.json() == {"can_edit": False} + assert cache.get(f"docs:no-websocket:{document.id}") is None + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_can_edit_user_connected_to_websocket(settings): + """ + A user connected to the websocket, the document can be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True}) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/can-edit/", + ) + assert response.status_code == 200 + assert response.json() == {"can_edit": True} + assert cache.get(f"docs:no-websocket:{document.id}") is None + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket( + settings, +): + """ + When the websocket server is unreachable, the document can be updated like if the user was + not connected to the websocket. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, status=500) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/can-edit/", + ) + assert response.status_code == 200 + assert response.json() == {"can_edit": True} + + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket_other_users( + settings, +): + """ + When the websocket server is unreachable, the behavior fallback to the no websocket one. + If an other user is already editing, the document can not be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, status=500) + + cache.set(f"docs:no-websocket:{document.id}", "other_session_key") + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/can-edit/", + ) + assert response.status_code == 200 + assert response.json() == {"can_edit": False} + + assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key" + assert ws_resp.call_count == 1 diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py index 91e6ca0e52..f30a2ee557 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -31,6 +31,7 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "ai_transform": False, "ai_translate": False, "attachment_upload": document.link_role == "editor", + "can_edit": document.link_role == "editor", "children_create": False, "children_list": True, "collaboration_auth": True, @@ -99,6 +100,7 @@ def test_api_documents_retrieve_anonymous_public_parent(): "ai_transform": False, "ai_translate": False, "attachment_upload": grand_parent.link_role == "editor", + "can_edit": grand_parent.link_role == "editor", "children_create": False, "children_list": True, "collaboration_auth": True, @@ -196,6 +198,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "ai_transform": document.link_role == "editor", "ai_translate": document.link_role == "editor", "attachment_upload": document.link_role == "editor", + "can_edit": document.link_role == "editor", "children_create": document.link_role == "editor", "children_list": True, "collaboration_auth": True, @@ -271,6 +274,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea "ai_transform": grand_parent.link_role == "editor", "ai_translate": grand_parent.link_role == "editor", "attachment_upload": grand_parent.link_role == "editor", + "can_edit": grand_parent.link_role == "editor", "children_create": grand_parent.link_role == "editor", "children_list": True, "collaboration_auth": True, @@ -452,6 +456,7 @@ def test_api_documents_retrieve_authenticated_related_parent(): "ai_transform": access.role != "reader", "ai_translate": access.role != "reader", "attachment_upload": access.role != "reader", + "can_edit": access.role != "reader", "children_create": access.role != "reader", "children_list": True, "collaboration_auth": True, diff --git a/src/backend/core/tests/documents/test_api_documents_trashbin.py b/src/backend/core/tests/documents/test_api_documents_trashbin.py index 4e4eb27698..61ccc02145 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -75,6 +75,7 @@ def test_api_documents_trashbin_format(): "ai_transform": True, "ai_translate": True, "attachment_upload": True, + "can_edit": True, "children_create": True, "children_list": True, "collaboration_auth": True, diff --git a/src/backend/core/tests/documents/test_api_documents_update.py b/src/backend/core/tests/documents/test_api_documents_update.py index 1c583bc950..b24b8e7346 100644 --- a/src/backend/core/tests/documents/test_api_documents_update.py +++ b/src/backend/core/tests/documents/test_api_documents_update.py @@ -5,8 +5,10 @@ import random from django.contrib.auth.models import AnonymousUser +from django.core.cache import cache import pytest +import responses from rest_framework.test import APIClient from core import factories, models @@ -44,6 +46,7 @@ def test_api_documents_update_anonymous_forbidden(reach, role, via_parent): new_document_values = serializers.DocumentSerializer( instance=factories.DocumentFactory() ).data + new_document_values["websocket"] = True response = APIClient().put( f"/api/v1.0/documents/{document.id!s}/", new_document_values, @@ -90,8 +93,9 @@ def test_api_documents_update_authenticated_unrelated_forbidden( old_document_values = serializers.DocumentSerializer(instance=document).data new_document_values = serializers.DocumentSerializer( - instance=factories.DocumentFactory() + instance=factories.DocumentFactory(), ).data + new_document_values["websocket"] = True response = client.put( f"/api/v1.0/documents/{document.id!s}/", new_document_values, @@ -141,8 +145,9 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated( old_document_values = serializers.DocumentSerializer(instance=document).data new_document_values = serializers.DocumentSerializer( - instance=factories.DocumentFactory() + instance=factories.DocumentFactory(), ).data + new_document_values["websocket"] = True response = client.put( f"/api/v1.0/documents/{document.id!s}/", new_document_values, @@ -206,6 +211,7 @@ def test_api_documents_update_authenticated_reader(via, via_parent, mock_user_te new_document_values = serializers.DocumentSerializer( instance=factories.DocumentFactory() ).data + new_document_values["websocket"] = True response = client.put( f"/api/v1.0/documents/{document.id!s}/", new_document_values, @@ -258,6 +264,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner( new_document_values = serializers.DocumentSerializer( instance=factories.DocumentFactory() ).data + new_document_values["websocket"] = True response = client.put( f"/api/v1.0/documents/{document.id!s}/", new_document_values, @@ -287,6 +294,274 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner( assert value == new_document_values[key] +@responses.activate +def test_api_documents_update_authenticated_no_websocket(settings): + """ + When a user updates the document, not connected to the websocket and is the first to update, + the document should be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + new_document_values["websocket"] = False + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + + ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False}) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 200 + + assert cache.get(f"docs:no-websocket:{document.id}") == session_key + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_update_authenticated_no_websocket_user_already_editing(settings): + """ + When a user updates the document, not connected to the websocket and is not the first to update, + the document should not be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + new_document_values["websocket"] = False + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False}) + + cache.set(f"docs:no-websocket:{document.id}", "other_session_key") + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 403 + assert response.json() == {"detail": "You are not allowed to edit this document."} + + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_update_no_websocket_other_user_connected_to_websocket(settings): + """ + When a user updates the document, not connected to the websocket and another user is connected + to the websocket, the document should not be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + new_document_values["websocket"] = False + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False}) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 403 + assert response.json() == {"detail": "You are not allowed to edit this document."} + assert cache.get(f"docs:no-websocket:{document.id}") is None + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_update_user_connected_to_websocket(settings): + """ + When a user updates the document, connected to the websocket, the document should be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + new_document_values["websocket"] = False + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True}) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 200 + assert cache.get(f"docs:no-websocket:{document.id}") is None + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websocket( + settings, +): + """ + When the websocket server is unreachable, the document should be updated like if the user was + not connected to the websocket. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + new_document_values["websocket"] = False + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, status=500) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 200 + + assert cache.get(f"docs:no-websocket:{document.id}") == session_key + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websocket_other_users( + settings, +): + """ + When the websocket server is unreachable, the behavior fallback to the no websocket one. + If an other user is already editing, the document should not be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + new_document_values["websocket"] = False + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, status=500) + + cache.set(f"docs:no-websocket:{document.id}", "other_session_key") + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 403 + + assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key" + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_update_force_websocket_param_to_true(settings): + """ + When the websocket parameter is set to true, the document should be updated without any check. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + new_document_values["websocket"] = True + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, status=500) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 200 + + assert cache.get(f"docs:no-websocket:{document.id}") is None + assert ws_resp.call_count == 0 + + @pytest.mark.parametrize("via", VIA) def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_teams): """ @@ -317,6 +592,7 @@ def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_t new_document_values = serializers.DocumentSerializer( instance=factories.DocumentFactory() ).data + new_document_values["websocket"] = True response = client.put( f"/api/v1.0/documents/{other_document.id!s}/", new_document_values, diff --git a/src/backend/core/tests/documents/test_api_documents_update_extract_attachments.py b/src/backend/core/tests/documents/test_api_documents_update_extract_attachments.py index cf30b5e66c..5623749fdd 100644 --- a/src/backend/core/tests/documents/test_api_documents_update_extract_attachments.py +++ b/src/backend/core/tests/documents/test_api_documents_update_extract_attachments.py @@ -50,7 +50,7 @@ def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_qu with django_assert_num_queries(11): response = APIClient().put( f"/api/v1.0/documents/{document.id!s}/", - {"content": get_ydoc_with_mages(image_keys)}, + {"content": get_ydoc_with_mages(image_keys), "websocket": True}, format="json", ) assert response.status_code == 200 @@ -63,7 +63,7 @@ def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_qu with django_assert_num_queries(7): response = APIClient().put( f"/api/v1.0/documents/{document.id!s}/", - {"content": get_ydoc_with_mages(image_keys[:2])}, + {"content": get_ydoc_with_mages(image_keys[:2]), "websocket": True}, format="json", ) assert response.status_code == 200 diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index ae10fb55ba..ba0d31ebf2 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -155,6 +155,7 @@ def test_models_documents_get_abilities_forbidden( "ai_transform": False, "ai_translate": False, "attachment_upload": False, + "can_edit": False, "children_create": False, "children_list": False, "collaboration_auth": False, @@ -216,6 +217,7 @@ def test_models_documents_get_abilities_reader( "ai_transform": False, "ai_translate": False, "attachment_upload": False, + "can_edit": False, "children_create": False, "children_list": True, "collaboration_auth": True, @@ -279,6 +281,7 @@ def test_models_documents_get_abilities_editor( "ai_transform": is_authenticated, "ai_translate": is_authenticated, "attachment_upload": True, + "can_edit": True, "children_create": is_authenticated, "children_list": True, "collaboration_auth": True, @@ -331,6 +334,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): "ai_transform": True, "ai_translate": True, "attachment_upload": True, + "can_edit": True, "children_create": True, "children_list": True, "collaboration_auth": True, @@ -380,6 +384,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries) "ai_transform": True, "ai_translate": True, "attachment_upload": True, + "can_edit": True, "children_create": True, "children_list": True, "collaboration_auth": True, @@ -432,6 +437,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): "ai_transform": True, "ai_translate": True, "attachment_upload": True, + "can_edit": True, "children_create": True, "children_list": True, "collaboration_auth": True, @@ -491,6 +497,7 @@ def test_models_documents_get_abilities_reader_user( "ai_transform": access_from_link and ai_access_setting != "restricted", "ai_translate": access_from_link and ai_access_setting != "restricted", "attachment_upload": access_from_link, + "can_edit": access_from_link, "children_create": access_from_link, "children_list": True, "collaboration_auth": True, @@ -548,6 +555,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): "ai_transform": False, "ai_translate": False, "attachment_upload": False, + "can_edit": False, "children_create": False, "children_list": True, "collaboration_auth": True, diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 5d16e165ba..a60048aabb 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -283,6 +283,7 @@ class Base(Configuration): "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "core.middleware.ForceSessionMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "dockerflow.django.middleware.DockerflowMiddleware", ] @@ -470,6 +471,7 @@ class Base(Configuration): SESSION_COOKIE_AGE = values.PositiveIntegerValue( default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None ) + SESSION_COOKIE_NAME = "docs_sessionid" # OIDC - Authorization Code Flow OIDC_CREATE_USER = values.BooleanValue( @@ -649,6 +651,12 @@ class Base(Configuration): environ_prefix=None, ) + NO_WEBSOCKET_CACHE_TIMEOUT = values.Value( + default=120, + environ_name="NO_WEBSOCKET_CACHE_TIMEOUT", + environ_prefix=None, + ) + # Logging # We want to make it easy to log to console but by default we log production # to Sentry and don't want to log to console. @@ -811,15 +819,9 @@ class Development(Base): CSRF_TRUSTED_ORIGINS = ["http://localhost:8072", "http://localhost:3000"] DEBUG = True - SESSION_COOKIE_NAME = "impress_sessionid" - USE_SWAGGER = True - SESSION_CACHE_ALIAS = "session" CACHES = { "default": { - "BACKEND": "django.core.cache.backends.dummy.DummyCache", - }, - "session": { "BACKEND": "django_redis.cache.RedisCache", "LOCATION": values.Value( "redis://redis:6379/2", diff --git a/src/frontend/servers/y-provider/__tests__/getDocumentConnectionInfoHandler.test.ts b/src/frontend/servers/y-provider/__tests__/getDocumentConnectionInfoHandler.test.ts new file mode 100644 index 0000000000..754dd87322 --- /dev/null +++ b/src/frontend/servers/y-provider/__tests__/getDocumentConnectionInfoHandler.test.ts @@ -0,0 +1,174 @@ +import request from 'supertest'; +import { v4 as uuid } from 'uuid'; + +const port = 5555; +const origin = 'http://localhost:3000'; + +jest.mock('../src/env', () => { + return { + PORT: port, + COLLABORATION_SERVER_ORIGIN: origin, + COLLABORATION_SERVER_SECRET: 'test-secret-api-key', + }; +}); + +console.error = jest.fn(); + +import { hocusPocusServer } from '@/servers/hocusPocusServer'; + +import { initServer } from '../src/servers/appServer'; + +const { app, server } = initServer(); +const apiEndpoint = '/collaboration/api/get-connections/'; + +describe('Server Tests', () => { + afterAll(() => { + server.close(); + }); + + test('POST /collaboration/api/get-connections?room=[ROOM_ID] with incorrect API key should return 403', async () => { + const response = await request(app as any) + .get(`${apiEndpoint}?room=test-room`) + .set('Origin', origin) + .set('Authorization', 'wrong-api-key'); + + expect(response.status).toBe(403); + expect(response.body.error).toBe('Forbidden: Invalid API Key'); + }); + + test('POST /collaboration/api/get-connections?room=[ROOM_ID] failed if room not indicated', async () => { + const response = await request(app as any) + .get(`${apiEndpoint}`) + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key') + .send({ document_id: 'test-document' }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Room name not provided'); + }); + + test('POST /collaboration/api/get-connections?room=[ROOM_ID] failed if session key not indicated', async () => { + const response = await request(app as any) + .get(`${apiEndpoint}?room=test-room`) + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key') + .send({ document_id: 'test-document' }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Session key not provided'); + }); + + test('POST /collaboration/api/get-connections?room=[ROOM_ID] return a 404 if room not found', async () => { + const response = await request(app as any) + .get(`${apiEndpoint}?room=test-room&sessionKey=test-session-key`) + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key'); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Room not found'); + }); + + test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key existing', async () => { + const document = await hocusPocusServer.createDocument( + 'test-room', + {}, + uuid(), + { isAuthenticated: true, readOnly: false, requiresAuthentication: true }, + {}, + ); + + document.addConnection({ + webSocket: 1, + context: { sessionKey: 'test-session-key' }, + document: document, + pongReceived: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 2, + context: { sessionKey: 'other-session-key' }, + document: document, + pongReceived: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 3, + context: { sessionKey: 'last-session-key' }, + document: document, + pongReceived: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + + const response = await request(app as any) + .get(`${apiEndpoint}?room=test-room&sessionKey=test-session-key`) + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + count: 3, + exists: true, + }); + }); + + test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key not existing', async () => { + const document = await hocusPocusServer.createDocument( + 'test-room', + {}, + uuid(), + { isAuthenticated: true, readOnly: false, requiresAuthentication: true }, + {}, + ); + + document.addConnection({ + webSocket: 1, + context: { sessionKey: 'test-session-key' }, + document: document, + pongReceived: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 2, + context: { sessionKey: 'other-session-key' }, + document: document, + pongReceived: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 3, + context: { sessionKey: 'last-session-key' }, + document: document, + pongReceived: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + + const response = await request(app as any) + .get(`${apiEndpoint}?room=test-room&sessionKey=non-existing-session-key`) + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + count: 3, + exists: false, + }); + }); +}); diff --git a/src/frontend/servers/y-provider/src/handlers/getDocumentConnectionInfoHandler.ts b/src/frontend/servers/y-provider/src/handlers/getDocumentConnectionInfoHandler.ts new file mode 100644 index 0000000000..32f05e3a18 --- /dev/null +++ b/src/frontend/servers/y-provider/src/handlers/getDocumentConnectionInfoHandler.ts @@ -0,0 +1,46 @@ +import { Request, Response } from 'express'; + +import { hocusPocusServer } from '@/servers/hocusPocusServer'; +import { logger } from '@/utils'; + +type getDocumentConnectionInfoRequestQuery = { + room?: string; + sessionKey?: string; +}; + +export const getDocumentConnectionInfoHandler = ( + req: Request, + res: Response, +) => { + const room = req.query.room; + const sessionKey = req.query.sessionKey; + + if (!room) { + res.status(400).json({ error: 'Room name not provided' }); + return; + } + + if (!req.query.sessionKey) { + res.status(400).json({ error: 'Session key not provided' }); + return; + } + + logger('Getting document connection info for room:', room); + + const roomInfo = hocusPocusServer.documents.get(room); + + if (!roomInfo) { + logger('Room not found:', room); + res.status(404).json({ error: 'Room not found' }); + return; + } + const connections = roomInfo.getConnections(); + + res.status(200).json({ + count: connections.length, + exists: connections.some( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (connection) => connection.context.sessionKey === sessionKey, + ), + }); +}; diff --git a/src/frontend/servers/y-provider/src/handlers/index.ts b/src/frontend/servers/y-provider/src/handlers/index.ts index 75bd7f7bbf..6b18a86fea 100644 --- a/src/frontend/servers/y-provider/src/handlers/index.ts +++ b/src/frontend/servers/y-provider/src/handlers/index.ts @@ -1,3 +1,4 @@ export * from './collaborationResetConnectionsHandler'; export * from './collaborationWSHandler'; export * from './convertMarkdownHandler'; +export * from './getDocumentConnectionInfoHandler'; diff --git a/src/frontend/servers/y-provider/src/middlewares.ts b/src/frontend/servers/y-provider/src/middlewares.ts index 2769e3a868..99570af3d3 100644 --- a/src/frontend/servers/y-provider/src/middlewares.ts +++ b/src/frontend/servers/y-provider/src/middlewares.ts @@ -28,6 +28,7 @@ export const httpSecurity = ( // Note: Changing this header to Bearer token format will break backend compatibility with this microservice. const apiKey = req.headers['authorization']; if (!apiKey || !VALID_API_KEYS.includes(apiKey)) { + logger('Forbidden: Invalid API Key', apiKey); res.status(403).json({ error: 'Forbidden: Invalid API Key' }); return; } diff --git a/src/frontend/servers/y-provider/src/routes.ts b/src/frontend/servers/y-provider/src/routes.ts index 98803b87f6..e5c9dda010 100644 --- a/src/frontend/servers/y-provider/src/routes.ts +++ b/src/frontend/servers/y-provider/src/routes.ts @@ -1,5 +1,6 @@ export const routes = { COLLABORATION_WS: '/collaboration/ws/', COLLABORATION_RESET_CONNECTIONS: '/collaboration/api/reset-connections/', + COLLABORATION_GET_CONNECTIONS: '/collaboration/api/get-connections/', CONVERT_MARKDOWN: '/api/convert-markdown/', }; diff --git a/src/frontend/servers/y-provider/src/servers/appServer.ts b/src/frontend/servers/y-provider/src/servers/appServer.ts index 5c035db799..8baf37a6f9 100644 --- a/src/frontend/servers/y-provider/src/servers/appServer.ts +++ b/src/frontend/servers/y-provider/src/servers/appServer.ts @@ -9,6 +9,7 @@ import { collaborationResetConnectionsHandler, collaborationWSHandler, convertMarkdownHandler, + getDocumentConnectionInfoHandler, } from '../handlers'; import { corsMiddleware, httpSecurity, wsSecurity } from '../middlewares'; import { routes } from '../routes'; @@ -46,6 +47,12 @@ export const initServer = () => { collaborationResetConnectionsHandler, ); + app.get( + routes.COLLABORATION_GET_CONNECTIONS, + httpSecurity, + getDocumentConnectionInfoHandler, + ); + /** * Route to convert markdown */ diff --git a/src/frontend/servers/y-provider/src/servers/hocusPocusServer.ts b/src/frontend/servers/y-provider/src/servers/hocusPocusServer.ts index 0fdefa7eac..2854d8b865 100644 --- a/src/frontend/servers/y-provider/src/servers/hocusPocusServer.ts +++ b/src/frontend/servers/y-provider/src/servers/hocusPocusServer.ts @@ -61,6 +61,14 @@ export const hocusPocusServer = Server.configure({ connection.readOnly = !can_edit; + const session = requestHeaders['cookie'] + ?.split('; ') + .find((cookie) => cookie.startsWith('docs_sessionid=')); + if (session) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + context.sessionKey = session.split('=')[1]; + } + /* * Unauthenticated users can be allowed to connect * so we flag only authenticated users