From 71c50c48dcbc3b8fc3942658dba5244e6fb3687a Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Mon, 18 Nov 2024 07:59:55 +0100 Subject: [PATCH 1/9] =?UTF-8?q?=E2=99=BB=EF=B8=8F(backend)=20rename,=20fac?= =?UTF-8?q?torize=20and=20improve=20the=20subrequest=20media=20auth=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We want to use the same pattern for the websocket collaboration service authorization as what we use for media files. This addition comes in the next commit but doing it efficiently required factorizing some code with the media auth view. --- docker/files/etc/nginx/conf.d/default.conf | 6 +- src/backend/core/api/viewsets.py | 304 ++++++++++-------- src/backend/core/models.py | 19 +- ...th.py => test_api_documents_media_auth.py} | 20 +- .../documents/test_api_documents_retrieve.py | 2 + .../core/tests/test_models_documents.py | 8 + src/helm/env.d/dev/values.impress.yaml.gotmpl | 6 +- src/helm/impress/values.yaml | 2 +- 8 files changed, 207 insertions(+), 160 deletions(-) rename src/backend/core/tests/documents/{test_api_documents_retrieve_auth.py => test_api_documents_media_auth.py} (89%) diff --git a/docker/files/etc/nginx/conf.d/default.conf b/docker/files/etc/nginx/conf.d/default.conf index 4255965553..9b5cf8db49 100644 --- a/docker/files/etc/nginx/conf.d/default.conf +++ b/docker/files/etc/nginx/conf.d/default.conf @@ -6,7 +6,7 @@ server { location /media/ { # Auth request configuration - auth_request /auth; + auth_request /media-auth; auth_request_set $authHeader $upstream_http_authorization; auth_request_set $authDate $upstream_http_x_amz_date; auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256; @@ -21,8 +21,8 @@ server { proxy_set_header Host minio:9000; } - location /auth { - proxy_pass http://app-dev:8000/api/v1.0/documents/retrieve-auth/; + location /media-auth { + proxy_pass http://app-dev:8000/api/v1.0/documents/media-auth/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 7b63a7f3a6..fe163af3ef 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1,6 +1,7 @@ """API endpoints""" # pylint: disable=too-many-lines +import logging import re import uuid from urllib.parse import urlparse @@ -10,10 +11,10 @@ from django.contrib.postgres.search import TrigramSimilarity from django.core.exceptions import ValidationError from django.core.files.storage import default_storage +from django.db import models as db from django.db.models import ( Count, Exists, - Min, OuterRef, Q, Subquery, @@ -21,24 +22,10 @@ ) from django.http import Http404 +import rest_framework as drf from botocore.exceptions import ClientError -from django_filters import rest_framework as filters -from rest_framework import ( - decorators, - exceptions, - metadata, - mixins, - pagination, - status, - views, - viewsets, -) -from rest_framework import ( - filters as drf_filters, -) -from rest_framework import ( - response as drf_response, -) +from django_filters import rest_framework as drf_filters +from rest_framework import filters from rest_framework.permissions import AllowAny from core import enums, models @@ -47,22 +34,22 @@ from . import permissions, serializers, utils from .filters import DocumentFilter +logger = logging.getLogger(__name__) + ATTACHMENTS_FOLDER = "attachments" UUID_REGEX = ( r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}" ) FILE_EXT_REGEX = r"\.[a-zA-Z]{3,4}" -MEDIA_URL_PATTERN = re.compile( - f"{settings.MEDIA_URL:s}({UUID_REGEX:s})/" - f"({ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$" +MEDIA_STORAGE_URL_PATTERN = re.compile( + f"{settings.MEDIA_URL:s}(?P{UUID_REGEX:s})/" + f"(?P{ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$" ) # pylint: disable=too-many-ancestors -ATTACHMENTS_FOLDER = "attachments" - -class NestedGenericViewSet(viewsets.GenericViewSet): +class NestedGenericViewSet(drf.viewsets.GenericViewSet): """ A generic Viewset aims to be used in a nested route context. e.g: `/api/v1.0/resource_1//resource_2//` @@ -134,7 +121,7 @@ def get_serializer_class(self): return self.serializer_classes.get(self.action, self.default_serializer_class) -class Pagination(pagination.PageNumberPagination): +class Pagination(drf.pagination.PageNumberPagination): """Pagination to display no more than 100 objects per page sorted by creation date.""" ordering = "-created_on" @@ -143,7 +130,7 @@ class Pagination(pagination.PageNumberPagination): class UserViewSet( - mixins.UpdateModelMixin, viewsets.GenericViewSet, mixins.ListModelMixin + drf.mixins.UpdateModelMixin, drf.viewsets.GenericViewSet, drf.mixins.ListModelMixin ): """User ViewSet""" @@ -184,7 +171,7 @@ def get_queryset(self): return queryset - @decorators.action( + @drf.decorators.action( detail=False, methods=["get"], url_name="me", @@ -196,7 +183,7 @@ def get_me(self, request): Return information on currently logged user """ context = {"request": request} - return drf_response.Response( + return drf.response.Response( self.serializer_class(request.user, context=context).data ) @@ -231,7 +218,7 @@ def get_queryset(self): teams = user.teams user_roles_query = ( queryset.filter( - Q(user=user) | Q(team__in=teams), + db.Q(user=user) | db.Q(team__in=teams), **{self.resource_field_name: self.kwargs["resource_id"]}, ) .values(self.resource_field_name) @@ -245,11 +232,13 @@ def get_queryset(self): # access instances pointing to the logged-in user) queryset = ( queryset.filter( - Q(**{f"{self.resource_field_name}__accesses__user": user}) - | Q(**{f"{self.resource_field_name}__accesses__team__in": teams}), + db.Q(**{f"{self.resource_field_name}__accesses__user": user}) + | db.Q( + **{f"{self.resource_field_name}__accesses__team__in": teams} + ), **{self.resource_field_name: self.kwargs["resource_id"]}, ) - .annotate(user_roles=Subquery(user_roles_query)) + .annotate(user_roles=db.Subquery(user_roles_query)) .distinct() ) return queryset @@ -264,9 +253,9 @@ def destroy(self, request, *args, **kwargs): instance.role == "owner" and resource.accesses.filter(role="owner").count() == 1 ): - return drf_response.Response( + return drf.response.Response( {"detail": "Cannot delete the last owner access for the resource."}, - status=status.HTTP_403_FORBIDDEN, + status=drf.status.HTTP_403_FORBIDDEN, ) return super().destroy(request, *args, **kwargs) @@ -287,12 +276,12 @@ def perform_update(self, serializer): and resource.accesses.filter(role=models.RoleChoices.OWNER).count() == 1 ): message = "Cannot change the role to a non-owner role for the last owner access." - raise exceptions.PermissionDenied({"detail": message}) + raise drf.exceptions.PermissionDenied({"detail": message}) serializer.save() -class DocumentMetadata(metadata.SimpleMetadata): +class DocumentMetadata(drf.metadata.SimpleMetadata): """Custom metadata class to add information""" def determine_metadata(self, request, view): @@ -310,10 +299,10 @@ def determine_metadata(self, request, view): class DocumentViewSet( - mixins.CreateModelMixin, - mixins.DestroyModelMixin, - mixins.UpdateModelMixin, - viewsets.GenericViewSet, + drf.mixins.CreateModelMixin, + drf.mixins.DestroyModelMixin, + drf.mixins.UpdateModelMixin, + drf.viewsets.GenericViewSet, ): """ Document ViewSet for managing documents. @@ -333,7 +322,7 @@ class DocumentViewSet( - GET /api/v1.0/documents/?is_creator_me=false&title=hello """ - filter_backends = [filters.DjangoFilterBackend, drf_filters.OrderingFilter] + filter_backends = [drf_filters.DjangoFilterBackend, filters.OrderingFilter] filterset_class = DocumentFilter metadata_class = DocumentMetadata ordering = ["-updated_at"] @@ -389,11 +378,11 @@ def list(self, request, *args, **kwargs): if user.is_authenticated: queryset = queryset.filter( - Q(accesses__user=user) - | Q(accesses__team__in=user.teams) + db.Q(accesses__user=user) + | db.Q(accesses__team__in=user.teams) | ( - Q(link_traces__user=user) - & ~Q(link_reach=models.LinkReachChoices.RESTRICTED) + db.Q(link_traces__user=user) + & ~db.Q(link_reach=models.LinkReachChoices.RESTRICTED) ) ) else: @@ -405,7 +394,7 @@ def list(self, request, *args, **kwargs): return self.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True) - return drf_response.Response(serializer.data) + return drf.response.Response(serializer.data) def retrieve(self, request, *args, **kwargs): """ @@ -428,7 +417,7 @@ def retrieve(self, request, *args, **kwargs): # The trace already exists, so we just pass without doing anything pass - return drf_response.Response(serializer.data) + return drf.response.Response(serializer.data) def perform_create(self, serializer): """Set the current user as creator and owner of the newly created object.""" @@ -439,7 +428,7 @@ def perform_create(self, serializer): role=models.RoleChoices.OWNER, ) - @decorators.action(detail=True, methods=["get"], url_path="versions") + @drf.decorators.action(detail=True, methods=["get"], url_path="versions") def versions_list(self, request, *args, **kwargs): """ Return the document's versions but only those created after the user got access @@ -447,7 +436,7 @@ def versions_list(self, request, *args, **kwargs): """ user = request.user if not user.is_authenticated: - raise exceptions.PermissionDenied("Authentication required.") + raise drf.exceptions.PermissionDenied("Authentication required.") # Validate query parameters using dedicated serializer serializer = serializers.VersionFilterSerializer(data=request.query_params) @@ -458,13 +447,13 @@ def versions_list(self, request, *args, **kwargs): # Users should not see version history dating from before they gained access to the # document. Filter to get the minimum access date for the logged-in user access_queryset = document.accesses.filter( - Q(user=user) | Q(team__in=user.teams) - ).aggregate(min_date=Min("created_at")) + db.Q(user=user) | db.Q(team__in=user.teams) + ).aggregate(min_date=db.Min("created_at")) # Handle the case where the user has no accesses min_datetime = access_queryset["min_date"] if not min_datetime: - return exceptions.PermissionDenied( + return drf.exceptions.PermissionDenied( "Only users with specific access can see version history" ) @@ -474,9 +463,9 @@ def versions_list(self, request, *args, **kwargs): page_size=serializer.validated_data.get("page_size"), ) - return drf_response.Response(versions_data) + return drf.response.Response(versions_data) - @decorators.action( + @drf.decorators.action( detail=True, methods=["get", "delete"], url_path="versions/(?P[0-9a-f-]{36})", @@ -497,7 +486,7 @@ def versions_detail(self, request, pk, version_id, *args, **kwargs): min_datetime = min( access.created_at for access in document.accesses.filter( - Q(user=user) | Q(team__in=user.teams), + db.Q(user=user) | db.Q(team__in=user.teams), ) ) if response["LastModified"] < min_datetime: @@ -505,11 +494,11 @@ def versions_detail(self, request, pk, version_id, *args, **kwargs): if request.method == "DELETE": response = document.delete_version(version_id) - return drf_response.Response( + return drf.response.Response( status=response["ResponseMetadata"]["HTTPStatusCode"] ) - return drf_response.Response( + return drf.response.Response( { "content": response["Body"].read().decode("utf-8"), "last_modified": response["LastModified"], @@ -517,7 +506,7 @@ def versions_detail(self, request, pk, version_id, *args, **kwargs): } ) - @decorators.action(detail=True, methods=["put"], url_path="link-configuration") + @drf.decorators.action(detail=True, methods=["put"], url_path="link-configuration") def link_configuration(self, request, *args, **kwargs): """Update link configuration with specific rights (cf get_abilities).""" # Check permissions first @@ -530,9 +519,9 @@ def link_configuration(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) serializer.save() - return drf_response.Response(serializer.data, status=status.HTTP_200_OK) + return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK) - @decorators.action(detail=True, methods=["post", "delete"], url_path="favorite") + @drf.decorators.action(detail=True, methods=["post", "delete"], url_path="favorite") def favorite(self, request, *args, **kwargs): """ Mark or unmark the document as a favorite for the logged-in user based on the HTTP method. @@ -546,13 +535,13 @@ def favorite(self, request, *args, **kwargs): try: models.DocumentFavorite.objects.create(document=document, user=user) except ValidationError: - return drf_response.Response( + return drf.response.Response( {"detail": "Document already marked as favorite"}, - status=status.HTTP_200_OK, + status=drf.status.HTTP_200_OK, ) - return drf_response.Response( + return drf.response.Response( {"detail": "Document marked as favorite"}, - status=status.HTTP_201_CREATED, + status=drf.status.HTTP_201_CREATED, ) # Handle DELETE method to unmark as favorite @@ -560,16 +549,16 @@ def favorite(self, request, *args, **kwargs): document=document, user=user ).delete() if deleted: - return drf_response.Response( + return drf.response.Response( {"detail": "Document unmarked as favorite"}, - status=status.HTTP_204_NO_CONTENT, + status=drf.status.HTTP_204_NO_CONTENT, ) - return drf_response.Response( + return drf.response.Response( {"detail": "Document was already not marked as favorite"}, - status=status.HTTP_200_OK, + status=drf.status.HTTP_200_OK, ) - @decorators.action(detail=True, methods=["post"], url_path="attachment-upload") + @drf.decorators.action(detail=True, methods=["post"], url_path="attachment-upload") def attachment_upload(self, request, *args, **kwargs): """Upload a file related to a given document""" # Check permissions first @@ -594,15 +583,15 @@ def attachment_upload(self, request, *args, **kwargs): file, default_storage.bucket_name, key, ExtraArgs=extra_args ) - return drf_response.Response( - {"file": f"{settings.MEDIA_URL:s}{key:s}"}, status=status.HTTP_201_CREATED + return drf.response.Response( + {"file": f"{settings.MEDIA_URL:s}{key:s}"}, + status=drf.status.HTTP_201_CREATED, ) - @decorators.action(detail=False, methods=["get"], url_path="retrieve-auth") - def retrieve_auth(self, request, *args, **kwargs): + def _authorize_subrequest(self, request, pattern): """ - This view is used by an Nginx subrequest to control access to a document's - attachment file. + Shared method to authorize access based on the original URL of an Nginx subrequest + and user permissions. Returns a dictionary of URL parameters if authorized. The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header. See corresponding ingress configuration in Helm chart and read about the @@ -614,33 +603,80 @@ def retrieve_auth(self, request, *args, **kwargs): a 403 error). Note that we return 403 errors without any further details for security reasons. - When we let the request go through, we compute authorization headers that will be added to - the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers - annotation. The request will then be proxied to the object storage backend who will - respond with the file after checking the signature included in headers. + Parameters: + - pattern: The regex pattern to extract identifiers from the URL. + + Returns: + - A dictionary of URL parameters if the request is authorized. + Raises: + - PermissionDenied if authorization fails. """ - original_url = urlparse(request.META.get("HTTP_X_ORIGINAL_URL")) - match = MEDIA_URL_PATTERN.search(original_url.path) + # Extract the original URL from the request header + original_url = request.META.get("HTTP_X_ORIGINAL_URL") + if not original_url: + logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest") + raise drf.exceptions.PermissionDenied() + + parsed_url = urlparse(original_url) + match = pattern.search(parsed_url.path) + + if not match: + logger.debug( + "Subrequest URL '%s' did not match pattern '%s'", + parsed_url.path, + pattern, + ) + raise drf.exceptions.PermissionDenied() try: - pk, attachment_key = match.groups() - except AttributeError as excpt: - raise exceptions.PermissionDenied() from excpt + url_params = match.groupdict() + except (ValueError, AttributeError) as exc: + logger.debug("Failed to extract parameters from subrequest URL: %s", exc) + raise drf.exceptions.PermissionDenied() from exc + + pk = url_params.get("pk") + if not pk: + logger.debug("Document ID (pk) not found in URL parameters: %s", url_params) + raise drf.exceptions.PermissionDenied() - # Check permission + # Fetch the document and check if the user has access try: - document = models.Document.objects.get(pk=pk) - except models.Document.DoesNotExist as excpt: - raise exceptions.PermissionDenied() from excpt + document, _created = models.Document.objects.get_or_create(pk=pk) + except models.Document.DoesNotExist as exc: + logger.debug("Document with ID '%s' does not exist", pk) + raise drf.exceptions.PermissionDenied() from exc + print(document) + if not document.get_abilities(request.user).get(self.action, False): + logger.debug( + "User '%s' lacks permission for document '%s'", request.user, pk + ) + # raise drf.exceptions.PermissionDenied() + + logger.debug( + "Subrequest authorization successful. Extracted parameters: %s", url_params + ) + return url_params + + @drf.decorators.action(detail=False, methods=["get"], url_path="media-auth") + def media_auth(self, request, *args, **kwargs): + """ + This view is used by an Nginx subrequest to control access to a document's + attachment file. + + When we let the request go through, we compute authorization headers that will be added to + the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers + annotation. The request will then be proxied to the object storage backend who will + respond with the file after checking the signature included in headers. + """ + url_params = self._authorize_subrequest(request, MEDIA_STORAGE_URL_PATTERN) + pk, key = url_params.values() - if not document.get_abilities(request.user).get("retrieve", False): - raise exceptions.PermissionDenied() + # Generate S3 authorization headers using the extracted URL parameters + request = utils.generate_s3_authorization_headers(f"{pk:s}/{key:s}") - # Generate authorization headers and return an authorization to proceed with the request - request = utils.generate_s3_authorization_headers(f"{pk:s}/{attachment_key:s}") - return drf_response.Response("authorized", headers=request.headers, status=200) + return drf.response.Response("authorized", headers=request.headers, status=200) - @decorators.action( + @drf.decorators.action( detail=True, methods=["post"], name="Apply a transformation action on a piece of text with AI", @@ -666,9 +702,9 @@ def ai_transform(self, request, *args, **kwargs): response = AIService().transform(text, action) - return drf_response.Response(response, status=status.HTTP_200_OK) + return drf.response.Response(response, status=drf.status.HTTP_200_OK) - @decorators.action( + @drf.decorators.action( detail=True, methods=["post"], name="Translate a piece of text with AI", @@ -695,17 +731,17 @@ def ai_translate(self, request, *args, **kwargs): response = AIService().translate(text, language) - return drf_response.Response(response, status=status.HTTP_200_OK) + return drf.response.Response(response, status=drf.status.HTTP_200_OK) class DocumentAccessViewSet( ResourceAccessViewsetMixin, - mixins.CreateModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - viewsets.GenericViewSet, + drf.mixins.CreateModelMixin, + drf.mixins.DestroyModelMixin, + drf.mixins.ListModelMixin, + drf.mixins.RetrieveModelMixin, + drf.mixins.UpdateModelMixin, + drf.viewsets.GenericViewSet, ): """ API ViewSet for all interactions with document accesses. @@ -752,15 +788,15 @@ def perform_create(self, serializer): class TemplateViewSet( - mixins.CreateModelMixin, - mixins.DestroyModelMixin, - mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - viewsets.GenericViewSet, + drf.mixins.CreateModelMixin, + drf.mixins.DestroyModelMixin, + drf.mixins.RetrieveModelMixin, + drf.mixins.UpdateModelMixin, + drf.viewsets.GenericViewSet, ): """Template ViewSet""" - filter_backends = [drf_filters.OrderingFilter] + filter_backends = [drf.filters.OrderingFilter] permission_classes = [ permissions.IsAuthenticatedOrSafe, permissions.AccessPermission, @@ -795,9 +831,9 @@ def list(self, request, *args, **kwargs): user = self.request.user if user.is_authenticated: queryset = queryset.filter( - Q(accesses__user=user) - | Q(accesses__team__in=user.teams) - | Q(is_public=True) + db.Q(accesses__user=user) + | db.Q(accesses__team__in=user.teams) + | db.Q(is_public=True) ) else: queryset = queryset.filter(is_public=True) @@ -808,7 +844,7 @@ def list(self, request, *args, **kwargs): return self.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True) - return drf_response.Response(serializer.data) + return drf.response.Response(serializer.data) def perform_create(self, serializer): """Set the current user as owner of the newly created object.""" @@ -819,7 +855,7 @@ def perform_create(self, serializer): role=models.RoleChoices.OWNER, ) - @decorators.action( + @drf.decorators.action( detail=True, methods=["post"], url_path="generate-document", @@ -842,8 +878,8 @@ def generate_document(self, request, pk=None): serializer = serializers.DocumentGenerationSerializer(data=request.data) if not serializer.is_valid(): - return drf_response.Response( - serializer.errors, status=status.HTTP_400_BAD_REQUEST + return drf.response.Response( + serializer.errors, status=drf.status.HTTP_400_BAD_REQUEST ) body = serializer.validated_data["body"] @@ -856,12 +892,12 @@ def generate_document(self, request, pk=None): class TemplateAccessViewSet( ResourceAccessViewsetMixin, - mixins.CreateModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - viewsets.GenericViewSet, + drf.mixins.CreateModelMixin, + drf.mixins.DestroyModelMixin, + drf.mixins.ListModelMixin, + drf.mixins.RetrieveModelMixin, + drf.mixins.UpdateModelMixin, + drf.viewsets.GenericViewSet, ): """ API ViewSet for all interactions with template accesses. @@ -896,12 +932,12 @@ class TemplateAccessViewSet( class InvitationViewset( - mixins.CreateModelMixin, - mixins.ListModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - mixins.UpdateModelMixin, - viewsets.GenericViewSet, + drf.mixins.CreateModelMixin, + drf.mixins.ListModelMixin, + drf.mixins.RetrieveModelMixin, + drf.mixins.DestroyModelMixin, + drf.mixins.UpdateModelMixin, + drf.viewsets.GenericViewSet, ): """API ViewSet for user invitations to document. @@ -953,7 +989,7 @@ def get_queryset(self): # Determine which role the logged-in user has in the document user_roles_query = ( models.DocumentAccess.objects.filter( - Q(user=user) | Q(team__in=teams), + db.Q(user=user) | db.Q(team__in=teams), document=self.kwargs["resource_id"], ) .values("document") @@ -964,18 +1000,18 @@ def get_queryset(self): queryset = ( # The logged-in user should be administrator or owner to see its accesses queryset.filter( - Q( + db.Q( document__accesses__user=user, document__accesses__role__in=models.PRIVILEGED_ROLES, ) - | Q( + | db.Q( document__accesses__team__in=teams, document__accesses__role__in=models.PRIVILEGED_ROLES, ), ) # Abilities are computed based on logged-in user's role and # the user role on each document access - .annotate(user_roles=Subquery(user_roles_query)) + .annotate(user_roles=db.Subquery(user_roles_query)) .distinct() ) return queryset @@ -991,7 +1027,7 @@ def perform_create(self, serializer): ) -class ConfigView(views.APIView): +class ConfigView(drf.views.APIView): """API ViewSet for sharing some public settings.""" permission_classes = [AllowAny] @@ -1016,4 +1052,4 @@ def get(self, request): if hasattr(settings, setting): dict_settings[setting] = getattr(settings, setting) - return drf_response.Response(dict_settings) + return drf.response.Response(dict_settings) diff --git a/src/backend/core/models.py b/src/backend/core/models.py index ec2f7588e0..02f560735d 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -511,22 +511,23 @@ def get_abilities(self, user): is_owner_or_admin = bool( roles.intersection({RoleChoices.OWNER, RoleChoices.ADMIN}) ) - is_editor = bool(RoleChoices.EDITOR in roles) can_get = bool(roles) + can_update = is_owner_or_admin or RoleChoices.EDITOR in roles return { "accesses_manage": is_owner_or_admin, "accesses_view": has_role, - "ai_transform": is_owner_or_admin or is_editor, - "ai_translate": is_owner_or_admin or is_editor, - "attachment_upload": is_owner_or_admin or is_editor, + "ai_transform": can_update, + "ai_translate": can_update, + "attachment_upload": can_update, "destroy": RoleChoices.OWNER in roles, "favorite": can_get and user.is_authenticated, "link_configuration": is_owner_or_admin, "invite_owner": RoleChoices.OWNER in roles, - "partial_update": is_owner_or_admin or is_editor, + "partial_update": can_update, "retrieve": can_get, - "update": is_owner_or_admin or is_editor, + "media_auth": can_get, + "update": can_update, "versions_destroy": is_owner_or_admin, "versions_list": has_role, "versions_retrieve": has_role, @@ -710,15 +711,15 @@ def get_abilities(self, user): is_owner_or_admin = bool( set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}) ) - is_editor = bool(RoleChoices.EDITOR in roles) can_get = self.is_public or bool(roles) + can_update = is_owner_or_admin or RoleChoices.EDITOR in roles return { "destroy": RoleChoices.OWNER in roles, "generate_document": can_get, "accesses_manage": is_owner_or_admin, - "update": is_owner_or_admin or is_editor, - "partial_update": is_owner_or_admin or is_editor, + "update": can_update, + "partial_update": can_update, "retrieve": can_get, } diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve_auth.py b/src/backend/core/tests/documents/test_api_documents_media_auth.py similarity index 89% rename from src/backend/core/tests/documents/test_api_documents_retrieve_auth.py rename to src/backend/core/tests/documents/test_api_documents_media_auth.py index 6de7cb39f8..28fd370c0a 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve_auth.py +++ b/src/backend/core/tests/documents/test_api_documents_media_auth.py @@ -20,7 +20,7 @@ pytestmark = pytest.mark.django_db -def test_api_documents_retrieve_auth_anonymous_public(): +def test_api_documents_media_auth_anonymous_public(): """Anonymous users should be able to retrieve attachments linked to a public document""" document = factories.DocumentFactory(link_reach="public") @@ -36,7 +36,7 @@ def test_api_documents_retrieve_auth_anonymous_public(): original_url = f"http://localhost/media/{key:s}" response = APIClient().get( - "/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url ) assert response.status_code == 200 @@ -65,7 +65,7 @@ def test_api_documents_retrieve_auth_anonymous_public(): @pytest.mark.parametrize("reach", ["authenticated", "restricted"]) -def test_api_documents_retrieve_auth_anonymous_authenticated_or_restricted(reach): +def test_api_documents_media_auth_anonymous_authenticated_or_restricted(reach): """ Anonymous users should not be allowed to retrieve attachments linked to a document with link reach set to authenticated or restricted. @@ -76,7 +76,7 @@ def test_api_documents_retrieve_auth_anonymous_authenticated_or_restricted(reach media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}" response = APIClient().get( - "/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url ) assert response.status_code == 403 @@ -84,7 +84,7 @@ def test_api_documents_retrieve_auth_anonymous_authenticated_or_restricted(reach @pytest.mark.parametrize("reach", ["public", "authenticated"]) -def test_api_documents_retrieve_auth_authenticated_public_or_authenticated(reach): +def test_api_documents_media_auth_authenticated_public_or_authenticated(reach): """ Authenticated users who are not related to a document should be able to retrieve attachments related to a document with public or authenticated link reach. @@ -107,7 +107,7 @@ def test_api_documents_retrieve_auth_authenticated_public_or_authenticated(reach original_url = f"http://localhost/media/{key:s}" response = client.get( - "/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url ) assert response.status_code == 200 @@ -135,7 +135,7 @@ def test_api_documents_retrieve_auth_authenticated_public_or_authenticated(reach assert response.content.decode("utf-8") == "my prose" -def test_api_documents_retrieve_auth_authenticated_restricted(): +def test_api_documents_media_auth_authenticated_restricted(): """ Authenticated users who are not related to a document should not be allowed to retrieve attachments linked to a document that is restricted. @@ -150,7 +150,7 @@ def test_api_documents_retrieve_auth_authenticated_restricted(): media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}" response = client.get( - "/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url ) assert response.status_code == 403 @@ -158,7 +158,7 @@ def test_api_documents_retrieve_auth_authenticated_restricted(): @pytest.mark.parametrize("via", VIA) -def test_api_documents_retrieve_auth_related(via, mock_user_teams): +def test_api_documents_media_auth_related(via, mock_user_teams): """ Users who have a specific access to a document, whatever the role, should be able to retrieve related attachments. @@ -186,7 +186,7 @@ def test_api_documents_retrieve_auth_related(via, mock_user_teams): original_url = f"http://localhost/media/{key:s}" response = client.get( - "/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url ) assert response.status_code == 200 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 3368395d90..deb06038a0 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(): "favorite": False, "invite_owner": False, "link_configuration": False, + "media_auth": True, "partial_update": document.link_role == "editor", "retrieve": True, "update": document.link_role == "editor", @@ -91,6 +92,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "destroy": False, "favorite": True, "invite_owner": False, + "media_auth": True, "link_configuration": False, "partial_update": document.link_role == "editor", "retrieve": True, diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index cc195911e4..91c1726580 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -101,6 +101,7 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role) "destroy": False, "favorite": False, "invite_owner": False, + "media_auth": False, "link_configuration": False, "partial_update": False, "retrieve": False, @@ -137,6 +138,7 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach): "favorite": is_authenticated, "invite_owner": False, "link_configuration": False, + "media_auth": True, "partial_update": False, "retrieve": True, "update": False, @@ -172,6 +174,7 @@ def test_models_documents_get_abilities_editor(is_authenticated, reach): "favorite": is_authenticated, "invite_owner": False, "link_configuration": False, + "media_auth": True, "partial_update": True, "retrieve": True, "update": True, @@ -196,6 +199,7 @@ def test_models_documents_get_abilities_owner(): "favorite": True, "invite_owner": True, "link_configuration": True, + "media_auth": True, "partial_update": True, "retrieve": True, "update": True, @@ -219,6 +223,7 @@ def test_models_documents_get_abilities_administrator(): "favorite": True, "invite_owner": False, "link_configuration": True, + "media_auth": True, "partial_update": True, "retrieve": True, "update": True, @@ -245,6 +250,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): "favorite": True, "invite_owner": False, "link_configuration": False, + "media_auth": True, "partial_update": True, "retrieve": True, "update": True, @@ -273,6 +279,7 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries): "favorite": True, "invite_owner": False, "link_configuration": False, + "media_auth": True, "partial_update": False, "retrieve": True, "update": False, @@ -302,6 +309,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): "favorite": True, "invite_owner": False, "link_configuration": False, + "media_auth": True, "partial_update": False, "retrieve": True, "update": False, diff --git a/src/helm/env.d/dev/values.impress.yaml.gotmpl b/src/helm/env.d/dev/values.impress.yaml.gotmpl index 96d29c936c..1b21ebc6d7 100644 --- a/src/helm/env.d/dev/values.impress.yaml.gotmpl +++ b/src/helm/env.d/dev/values.impress.yaml.gotmpl @@ -7,8 +7,8 @@ backend: replicas: 1 envVars: DJANGO_CSRF_TRUSTED_ORIGINS: https://impress.127.0.0.1.nip.io,http://impress.127.0.0.1.nip.io - DJANGO_CONFIGURATION: Production - DJANGO_ALLOWED_HOSTS: "*" + DJANGO_CONFIGURATION: Feature + DJANGO_ALLOWED_HOSTS: impress.127.0.0.1.nip.io DJANGO_SECRET_KEY: {{ .Values.djangoSecretKey }} DJANGO_SETTINGS_MODULE: impress.settings DJANGO_SUPERUSER_PASSWORD: admin @@ -113,7 +113,7 @@ ingressMedia: host: impress.127.0.0.1.nip.io annotations: - nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/retrieve-auth/ + nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/media-auth/ nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256" nginx.ingress.kubernetes.io/upstream-vhost: minio.impress.svc.cluster.local:9000 nginx.ingress.kubernetes.io/rewrite-target: /impress-media-storage/$1 diff --git a/src/helm/impress/values.yaml b/src/helm/impress/values.yaml index 0a27ed2624..7a994d872a 100644 --- a/src/helm/impress/values.yaml +++ b/src/helm/impress/values.yaml @@ -116,7 +116,7 @@ ingressMedia: additional: [] annotations: - nginx.ingress.kubernetes.io/auth-url: https://impress.example.com/api/v1.0/documents/retrieve-auth/ + nginx.ingress.kubernetes.io/auth-url: https://impress.example.com/api/v1.0/documents/media-auth/ nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256" nginx.ingress.kubernetes.io/upstream-vhost: minio.impress.svc.cluster.local:9000 From 8ae5f9509b6c24054d82050459e96b645d1331bc Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Mon, 18 Nov 2024 08:05:54 +0100 Subject: [PATCH 2/9] =?UTF-8?q?=E2=9C=A8(backend)=20add=20subrequest=20aut?= =?UTF-8?q?h=20view=20for=20collaboration=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We need to improve security on the access to The collaboration server We can use the same pattern as for media files leveraging the nginx subrequest feature. --- CHANGELOG.md | 1 + Makefile | 2 +- docker-compose.yml | 1 + docker/files/etc/nginx/conf.d/default.conf | 43 +++++++++++++++++++ env.d/development/common.dist | 3 +- src/backend/core/api/viewsets.py | 39 ++++++++++++++--- src/backend/core/models.py | 1 + .../documents/test_api_documents_retrieve.py | 2 + .../core/tests/test_models_documents.py | 8 ++++ src/backend/impress/settings.py | 27 +++++++----- src/helm/env.d/dev/values.impress.yaml.gotmpl | 7 +++ src/helm/impress/values.yaml | 2 + 12 files changed, 118 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a6657a777..fb7ab7cf50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to ## Changed +- 🔒️(collaboration) increase collaboration access security #472 - 🔨(frontend) encapsulated title to its own component #474 - 🐛(frontend) Fix hidden menu on Firefox #468 - ⚡️(backend) optimize number of queries on document list view #411 diff --git a/Makefile b/Makefile index f682bd0830..0e81087b3e 100644 --- a/Makefile +++ b/Makefile @@ -122,8 +122,8 @@ logs: ## display app-dev logs (follow mode) run: ## start the wsgi (production) and development server @$(COMPOSE) up --force-recreate -d celery-dev - @$(COMPOSE) up --force-recreate -d nginx @$(COMPOSE) up --force-recreate -d y-provider + @$(COMPOSE) up --force-recreate -d nginx @echo "Wait for postgresql to be up..." @$(WAIT_DB) .PHONY: run diff --git a/docker-compose.yml b/docker-compose.yml index 5c7e4d1ad0..dd2a1b9e82 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -118,6 +118,7 @@ services: depends_on: - keycloak - app-dev + - y-provider frontend-dev: user: "${DOCKER_USER:-1000}" diff --git a/docker/files/etc/nginx/conf.d/default.conf b/docker/files/etc/nginx/conf.d/default.conf index 9b5cf8db49..b984787f21 100644 --- a/docker/files/etc/nginx/conf.d/default.conf +++ b/docker/files/etc/nginx/conf.d/default.conf @@ -4,6 +4,49 @@ server { server_name localhost; charset utf-8; + # Proxy auth for collaboration server + location /collaboration/ws/ { + # Collaboration Auth request configuration + auth_request /collaboration-auth; + auth_request_set $authHeader $upstream_http_authorization; + auth_request_set $canEdit $upstream_http_x_can_edit; + auth_request_set $userId $upstream_http_x_user_id; + + # Pass specific headers from the auth response + proxy_set_header Authorization $authHeader; + proxy_set_header X-Can-Edit $canEdit; + proxy_set_header X-User-Id $userId; + + # Ensure WebSocket upgrade + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + + # Collaboration server + proxy_pass http://y-provider:4444; + + # Set appropriate timeout for WebSocket + proxy_read_timeout 86400; + proxy_send_timeout 86400; + + # Preserve original host and additional headers + proxy_set_header Host $host; + } + + location /collaboration-auth { + proxy_pass http://app-dev:8000/api/v1.0/documents/collaboration-auth/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Original-URL $request_uri; + + # Prevent the body from being passed + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-Method $request_method; + } + + # Proxy auth for media location /media/ { # Auth request configuration auth_request /media-auth; diff --git a/env.d/development/common.dist b/env.d/development/common.dist index 6833f09be5..ce6831070d 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -53,7 +53,8 @@ AI_API_KEY=password AI_MODEL=llama # Collaboration -COLLABORATION_WS_URL=ws://localhost:4444 +COLLABORATION_SERVER_SECRET=my-secret +COLLABORATION_WS_URL=ws://localhost:8083/collaboration/ws # Frontend FRONTEND_THEME=dsfr diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index fe163af3ef..969d1861eb 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -45,6 +45,7 @@ f"{settings.MEDIA_URL:s}(?P{UUID_REGEX:s})/" f"(?P{ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$" ) +COLLABORATION_WS_URL_PATTERN = re.compile(rf"(?:^|&)room=(?P{UUID_REGEX})(?:&|$)") # pylint: disable=too-many-ancestors @@ -620,6 +621,10 @@ def _authorize_subrequest(self, request, pattern): parsed_url = urlparse(original_url) match = pattern.search(parsed_url.path) + # If the path does not match the pattern, try to extract the parameters from the query + if not match: + match = pattern.search(parsed_url.query) + if not match: logger.debug( "Subrequest URL '%s' did not match pattern '%s'", @@ -645,17 +650,19 @@ def _authorize_subrequest(self, request, pattern): except models.Document.DoesNotExist as exc: logger.debug("Document with ID '%s' does not exist", pk) raise drf.exceptions.PermissionDenied() from exc - print(document) - if not document.get_abilities(request.user).get(self.action, False): + + user_abilities = document.get_abilities(request.user) + + if not user_abilities.get(self.action, False): logger.debug( "User '%s' lacks permission for document '%s'", request.user, pk ) - # raise drf.exceptions.PermissionDenied() + raise drf.exceptions.PermissionDenied() logger.debug( "Subrequest authorization successful. Extracted parameters: %s", url_params ) - return url_params + return url_params, user_abilities, request.user.id @drf.decorators.action(detail=False, methods=["get"], url_path="media-auth") def media_auth(self, request, *args, **kwargs): @@ -668,7 +675,9 @@ def media_auth(self, request, *args, **kwargs): annotation. The request will then be proxied to the object storage backend who will respond with the file after checking the signature included in headers. """ - url_params = self._authorize_subrequest(request, MEDIA_STORAGE_URL_PATTERN) + url_params, _, _ = self._authorize_subrequest( + request, MEDIA_STORAGE_URL_PATTERN + ) pk, key = url_params.values() # Generate S3 authorization headers using the extracted URL parameters @@ -676,6 +685,26 @@ def media_auth(self, request, *args, **kwargs): return drf.response.Response("authorized", headers=request.headers, status=200) + @drf.decorators.action(detail=False, methods=["get"], url_path="collaboration-auth") + def collaboration_auth(self, request, *args, **kwargs): + """ + This view is used by an Nginx subrequest to control access to a document's + collaboration server. + """ + _, user_abilities, user_id = self._authorize_subrequest( + request, COLLABORATION_WS_URL_PATTERN + ) + can_edit = user_abilities["partial_update"] + + # Add the collaboration server secret token to the headers + headers = { + "Authorization": settings.COLLABORATION_SERVER_SECRET, + "X-Can-Edit": str(can_edit), + "X-User-Id": str(user_id), + } + + return drf.response.Response("authorized", headers=headers, status=200) + @drf.decorators.action( detail=True, methods=["post"], diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 02f560735d..2f52b2fe07 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -520,6 +520,7 @@ def get_abilities(self, user): "ai_transform": can_update, "ai_translate": can_update, "attachment_upload": can_update, + "collaboration_auth": can_get, "destroy": RoleChoices.OWNER in roles, "favorite": can_get and user.is_authenticated, "link_configuration": is_owner_or_admin, 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 deb06038a0..4307ed69ed 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -26,6 +26,7 @@ def test_api_documents_retrieve_anonymous_public(): "ai_transform": document.link_role == "editor", "ai_translate": document.link_role == "editor", "attachment_upload": document.link_role == "editor", + "collaboration_auth": True, "destroy": False, # Anonymous user can't favorite a document even with read access "favorite": False, @@ -89,6 +90,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", + "collaboration_auth": True, "destroy": False, "favorite": True, "invite_owner": False, diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index 91c1726580..17cab6cd73 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -98,6 +98,7 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role) "ai_transform": False, "ai_translate": False, "attachment_upload": False, + "collaboration_auth": False, "destroy": False, "favorite": False, "invite_owner": False, @@ -134,6 +135,7 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach): "ai_transform": False, "ai_translate": False, "attachment_upload": False, + "collaboration_auth": True, "destroy": False, "favorite": is_authenticated, "invite_owner": False, @@ -170,6 +172,7 @@ def test_models_documents_get_abilities_editor(is_authenticated, reach): "ai_transform": True, "ai_translate": True, "attachment_upload": True, + "collaboration_auth": True, "destroy": False, "favorite": is_authenticated, "invite_owner": False, @@ -195,6 +198,7 @@ def test_models_documents_get_abilities_owner(): "ai_transform": True, "ai_translate": True, "attachment_upload": True, + "collaboration_auth": True, "destroy": True, "favorite": True, "invite_owner": True, @@ -219,6 +223,7 @@ def test_models_documents_get_abilities_administrator(): "ai_transform": True, "ai_translate": True, "attachment_upload": True, + "collaboration_auth": True, "destroy": False, "favorite": True, "invite_owner": False, @@ -246,6 +251,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): "ai_transform": True, "ai_translate": True, "attachment_upload": True, + "collaboration_auth": True, "destroy": False, "favorite": True, "invite_owner": False, @@ -275,6 +281,7 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries): "ai_transform": False, "ai_translate": False, "attachment_upload": False, + "collaboration_auth": True, "destroy": False, "favorite": True, "invite_owner": False, @@ -305,6 +312,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): "ai_transform": False, "ai_translate": False, "attachment_upload": False, + "collaboration_auth": True, "destroy": False, "favorite": True, "invite_owner": False, diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 50140620f8..4f65a60fe2 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -372,6 +372,9 @@ class Base(Configuration): SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None) # Collaboration + COLLABORATION_SERVER_SECRET = values.Value( + None, environ_name="COLLABORATION_SERVER_SECRET", environ_prefix=None + ) COLLABORATION_WS_URL = values.Value( None, environ_name="COLLABORATION_WS_URL", environ_prefix=None ) @@ -465,9 +468,22 @@ class Base(Configuration): environ_prefix=None, ) + USER_OIDC_FIELDS_TO_FULLNAME = values.ListValue( + default=["first_name", "last_name"], + environ_name="USER_OIDC_FIELDS_TO_FULLNAME", + environ_prefix=None, + ) + USER_OIDC_FIELD_TO_SHORTNAME = values.Value( + default="first_name", + environ_name="USER_OIDC_FIELD_TO_SHORTNAME", + environ_prefix=None, + ) + ALLOW_LOGOUT_GET_METHOD = values.BooleanValue( default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None ) + + # AI service AI_API_KEY = values.Value(None, environ_name="AI_API_KEY", environ_prefix=None) AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None) AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None) @@ -483,17 +499,6 @@ class Base(Configuration): "day": 200, } - USER_OIDC_FIELDS_TO_FULLNAME = values.ListValue( - default=["first_name", "last_name"], - environ_name="USER_OIDC_FIELDS_TO_FULLNAME", - environ_prefix=None, - ) - USER_OIDC_FIELD_TO_SHORTNAME = values.Value( - default="first_name", - environ_name="USER_OIDC_FIELD_TO_SHORTNAME", - 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. diff --git a/src/helm/env.d/dev/values.impress.yaml.gotmpl b/src/helm/env.d/dev/values.impress.yaml.gotmpl index 1b21ebc6d7..00af4575b4 100644 --- a/src/helm/env.d/dev/values.impress.yaml.gotmpl +++ b/src/helm/env.d/dev/values.impress.yaml.gotmpl @@ -6,6 +6,7 @@ image: backend: replicas: 1 envVars: + COLLABORATION_SERVER_SECRET: my-secret DJANGO_CSRF_TRUSTED_ORIGINS: https://impress.127.0.0.1.nip.io,http://impress.127.0.0.1.nip.io DJANGO_CONFIGURATION: Feature DJANGO_ALLOWED_HOSTS: impress.127.0.0.1.nip.io @@ -104,6 +105,12 @@ ingressWS: enabled: true host: impress.127.0.0.1.nip.io + annotations: + nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, Can-Edit, User-Id" + nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/collaboration-auth/ + nginx.ingress.kubernetes.io/enable-websocket: "true" + nginx.ingress.kubernetes.io/upstream-hash-by: "$request_uri" + ingressAdmin: enabled: true host: impress.127.0.0.1.nip.io diff --git a/src/helm/impress/values.yaml b/src/helm/impress/values.yaml index 7a994d872a..2e3d6c817f 100644 --- a/src/helm/impress/values.yaml +++ b/src/helm/impress/values.yaml @@ -72,6 +72,8 @@ ingressWS: customBackends: [] annotations: + nginx.ingress.kubernetes.io/auth-url: https://impress.example.com/api/v1.0/documents/collaboration-auth/ + nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, Can-Edit, User-Id" nginx.ingress.kubernetes.io/enable-websocket: "true" nginx.ingress.kubernetes.io/upstream-hash-by: "$request_uri" From 4ca556836e1d696974a197a358e159dfe8806c16 Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Mon, 18 Nov 2024 08:07:48 +0100 Subject: [PATCH 3/9] =?UTF-8?q?=F0=9F=A7=91=E2=80=8D=F0=9F=92=BB(helm)=20r?= =?UTF-8?q?ename=20minio=20root=20user=20password?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Using "impress" as the name of minio's root user in Tilt's dev environment, was triggering obfuscation of the logs in Tilt's console each time the word "impress" was used. This made the logs hard to read. --- src/helm/helmfile.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helm/helmfile.yaml b/src/helm/helmfile.yaml index a655483714..9f4c566ae0 100644 --- a/src/helm/helmfile.yaml +++ b/src/helm/helmfile.yaml @@ -25,7 +25,7 @@ releases: version: 12.10.10 values: - auth: - rootUser: impress + rootUser: root rootPassword: password - provisioning: enabled: true From 0297052c1f2e24cbc734f72af60a78edb88aaa0b Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Thu, 28 Nov 2024 17:00:06 +0100 Subject: [PATCH 4/9] =?UTF-8?q?=E2=9C=A8(y-provider)=20endpoint=20POST=20/?= =?UTF-8?q?collaboration/api/reset-connections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We want to be able to reset the connections of a document. To do this, we need to be able to send a request to the collaboration server. To do so, we added the endpoint POST "/collaboration/api/reset-connections" to the collaboration server thanks to "express". --- docker-compose.yml | 2 + env.d/development/common.dist | 1 + src/frontend/servers/y-provider/package.json | 6 +- src/frontend/servers/y-provider/src/env.ts | 7 + .../servers/y-provider/src/middlewares.ts | 59 +++ src/frontend/servers/y-provider/src/routes.ts | 4 + src/frontend/servers/y-provider/src/server.ts | 149 +++++- .../servers/y-provider/src/start-server.ts | 3 + src/frontend/servers/y-provider/src/utils.ts | 9 + src/frontend/yarn.lock | 439 +++++++++++++++++- src/helm/env.d/dev/values.impress.yaml.gotmpl | 5 + 11 files changed, 661 insertions(+), 23 deletions(-) create mode 100644 src/frontend/servers/y-provider/src/env.ts create mode 100644 src/frontend/servers/y-provider/src/middlewares.ts create mode 100644 src/frontend/servers/y-provider/src/routes.ts create mode 100644 src/frontend/servers/y-provider/src/start-server.ts create mode 100644 src/frontend/servers/y-provider/src/utils.ts diff --git a/docker-compose.yml b/docker-compose.yml index dd2a1b9e82..020bf10b45 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -162,6 +162,8 @@ services: dockerfile: ./src/frontend/Dockerfile target: y-provider restart: unless-stopped + env_file: + - env.d/development/common ports: - "4444:4444" volumes: diff --git a/env.d/development/common.dist b/env.d/development/common.dist index ce6831070d..ed183578a7 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -53,6 +53,7 @@ AI_API_KEY=password AI_MODEL=llama # Collaboration +COLLABORATION_SERVER_ORIGIN=http://localhost:3000 COLLABORATION_SERVER_SECRET=my-secret COLLABORATION_WS_URL=ws://localhost:8083/collaboration/ws diff --git a/src/frontend/servers/y-provider/package.json b/src/frontend/servers/y-provider/package.json index e15c38b0a8..d043d702d6 100644 --- a/src/frontend/servers/y-provider/package.json +++ b/src/frontend/servers/y-provider/package.json @@ -6,7 +6,7 @@ "license": "MIT", "type": "module", "scripts": { - "build": "tsc -p .", + "build": "tsc -p ./src", "dev": "nodemon --config nodemon.json", "start": "node ./dist/server.js", "lint": "eslint . --ext .ts" @@ -16,9 +16,13 @@ }, "dependencies": { "@hocuspocus/server": "2.14.0", + "express": "4.21.1", + "express-ws": "5.0.2", "y-protocols": "1.0.6" }, "devDependencies": { + "@types/express": "5.0.0", + "@types/express-ws": "3.0.5", "@types/node": "*", "eslint-config-impress": "*", "nodemon": "3.1.7", diff --git a/src/frontend/servers/y-provider/src/env.ts b/src/frontend/servers/y-provider/src/env.ts new file mode 100644 index 0000000000..571d52e05c --- /dev/null +++ b/src/frontend/servers/y-provider/src/env.ts @@ -0,0 +1,7 @@ +export const COLLABORATION_LOGGING = + process.env.COLLABORATION_LOGGING || 'false'; +export const COLLABORATION_SERVER_ORIGIN = + process.env.COLLABORATION_SERVER_ORIGIN || 'http://localhost:3000'; +export const COLLABORATION_SERVER_SECRET = + process.env.COLLABORATION_SERVER_SECRET || 'secret-api-key'; +export const PORT = Number(process.env.PORT || 4444); diff --git a/src/frontend/servers/y-provider/src/middlewares.ts b/src/frontend/servers/y-provider/src/middlewares.ts new file mode 100644 index 0000000000..49d9b472f5 --- /dev/null +++ b/src/frontend/servers/y-provider/src/middlewares.ts @@ -0,0 +1,59 @@ +import { NextFunction, Request, Response } from 'express'; +import * as ws from 'ws'; + +import { + COLLABORATION_SERVER_ORIGIN, + COLLABORATION_SERVER_SECRET, +} from '@/env'; + +import { logger } from './utils'; + +export const httpSecurity = ( + req: Request, + res: Response, + next: NextFunction, +): void => { + // Origin check + const origin = req.headers['origin']; + if (origin && COLLABORATION_SERVER_ORIGIN !== origin) { + logger('CORS policy violation: Invalid Origin', origin); + + res + .status(403) + .json({ error: 'CORS policy violation: Invalid Origin', origin }); + return; + } + + // Secret API Key check + const apiKey = req.headers['authorization']; + if (apiKey !== COLLABORATION_SERVER_SECRET) { + res.status(403).json({ error: 'Forbidden: Invalid API Key' }); + return; + } + + next(); +}; + +export const wsSecurity = ( + ws: ws.WebSocket, + req: Request, + next: NextFunction, +): void => { + // Origin check + const origin = req.headers['origin']; + if (COLLABORATION_SERVER_ORIGIN !== origin) { + console.error('CORS policy violation: Invalid Origin', origin); + ws.close(); + return; + } + + // Secret API Key check + const apiKey = req.headers['authorization']; + if (apiKey !== COLLABORATION_SERVER_SECRET) { + console.error('Forbidden: Invalid API Key'); + ws.close(); + return; + } + + next(); +}; diff --git a/src/frontend/servers/y-provider/src/routes.ts b/src/frontend/servers/y-provider/src/routes.ts new file mode 100644 index 0000000000..472c711dfe --- /dev/null +++ b/src/frontend/servers/y-provider/src/routes.ts @@ -0,0 +1,4 @@ +export const routes = { + COLLABORATION_WS: '/collaboration/ws/', + COLLABORATION_RESET_CONNECTIONS: '/collaboration/api/reset-connections/', +}; diff --git a/src/frontend/servers/y-provider/src/server.ts b/src/frontend/servers/y-provider/src/server.ts index c7dfa806e4..2ff1ad6ced 100644 --- a/src/frontend/servers/y-provider/src/server.ts +++ b/src/frontend/servers/y-provider/src/server.ts @@ -1,18 +1,147 @@ import { Server } from '@hocuspocus/server'; +import express, { Request, Response } from 'express'; +import expressWebsockets from 'express-ws'; -const port = Number(process.env.PORT || 4444); +import { PORT } from './env'; +import { httpSecurity, wsSecurity } from './middlewares'; +import { routes } from './routes'; +import { logger } from './utils'; -const server = Server.configure({ - name: 'docs-y-provider', - port: port, +export const hocuspocusServer = Server.configure({ + name: 'docs-y-server', timeout: 30000, - debounce: 2000, - maxDebounce: 30000, quiet: true, -}); + onConnect({ requestHeaders, connection, documentName, requestParameters }) { + const roomParam = requestParameters.get('room'); + const canEdit = requestHeaders['x-can-edit'] === 'True'; + + if (!canEdit) { + connection.readOnly = true; + } + + logger( + 'Connection established:', + documentName, + 'userId:', + requestHeaders['x-user-id'], + 'canEdit:', + canEdit, + 'room:', + requestParameters.get('room'), + ); + + if (documentName !== roomParam) { + console.error( + 'Invalid room name - Probable hacking attempt:', + documentName, + requestParameters.get('room'), + requestHeaders['x-user-id'], + ); + + return Promise.reject(new Error('Unauthorized')); + } -server.listen().catch((error) => { - console.error('Failed to start the server:', error); + return Promise.resolve(); + }, }); -console.log('Websocket server running on port :', port); +/** + * init the collaboration server. + * + * @param port - The port on which the server listens. + * @param serverSecret - The secret key for API authentication. + * @returns An object containing the Express app, Hocuspocus server, and HTTP server instance. + */ +export const initServer = () => { + const { app } = expressWebsockets(express()); + app.use(express.json()); + + /** + * Route to handle WebSocket connections + */ + app.ws(routes.COLLABORATION_WS, wsSecurity, (ws, req) => { + logger('Incoming Origin:', req.headers['origin']); + + try { + hocuspocusServer.handleConnection(ws, req); + } catch (error) { + console.error('Failed to handle WebSocket connection:', error); + ws.close(); + } + }); + + type ResetConnectionsRequestQuery = { + room?: string; + }; + + /** + * Route to reset connections in a room: + * - If no user ID is provided, close all connections in the room + * - If a user ID is provided, close connections for the user in the room + */ + app.post( + routes.COLLABORATION_RESET_CONNECTIONS, + httpSecurity, + ( + req: Request, + res: Response, + ) => { + const room = req.query.room; + const userId = req.headers['x-user-id']; + + logger( + 'Resetting connections in room:', + room, + 'for user:', + userId, + 'room:', + room, + ); + + if (!room) { + res.status(400).json({ error: 'Room name not provided' }); + return; + } + + /** + * If no user ID is provided, close all connections in the room + */ + if (!userId) { + hocuspocusServer.closeConnections(room); + } else { + /** + * Close connections for the user in the room + */ + hocuspocusServer.documents.forEach((doc) => { + if (doc.name !== room) { + return; + } + + doc.getConnections().forEach((connection) => { + const connectionUserId = connection.request.headers['x-user-id']; + if (connectionUserId === userId) { + connection.close(); + } + }); + }); + } + + res.status(200).json({ message: 'Connections reset' }); + }, + ); + + app.get('/ping', (req, res) => { + res.status(200).json({ message: 'pong' }); + }); + + app.use((req, res) => { + logger('Invalid route:', req.url); + res.status(403).json({ error: 'Forbidden' }); + }); + + const server = app.listen(PORT, () => + console.log('Listening on port :', PORT), + ); + + return { app, server }; +}; diff --git a/src/frontend/servers/y-provider/src/start-server.ts b/src/frontend/servers/y-provider/src/start-server.ts new file mode 100644 index 0000000000..e3d305198b --- /dev/null +++ b/src/frontend/servers/y-provider/src/start-server.ts @@ -0,0 +1,3 @@ +import { initServer } from './server'; + +initServer(); diff --git a/src/frontend/servers/y-provider/src/utils.ts b/src/frontend/servers/y-provider/src/utils.ts new file mode 100644 index 0000000000..53f750ddbe --- /dev/null +++ b/src/frontend/servers/y-provider/src/utils.ts @@ -0,0 +1,9 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { COLLABORATION_LOGGING } from './env'; + +export function logger(...args: any[]) { + if (COLLABORATION_LOGGING === 'true') { + console.log(...args); + } +} diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 4b5063ae20..1b282914b6 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -4493,6 +4493,21 @@ dependencies: "@babel/types" "^7.20.7" +"@types/body-parser@*": + version "1.19.5" + resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.5.tgz#04ce9a3b677dc8bd681a17da1ab9835dc9d3ede4" + integrity sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg== + dependencies: + "@types/connect" "*" + "@types/node" "*" + +"@types/connect@*": + version "3.4.38" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858" + integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== + dependencies: + "@types/node" "*" + "@types/connect@3.4.36": version "3.4.36" resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.36.tgz#e511558c15a39cb29bd5357eebb57bd1459cd1ab" @@ -4533,6 +4548,35 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== +"@types/express-serve-static-core@*", "@types/express-serve-static-core@^5.0.0": + version "5.0.2" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-5.0.2.tgz#812d2871e5eea17fb0bd5214dda7a7b748c0e12a" + integrity sha512-vluaspfvWEtE4vcSDlKRNer52DvOGrB2xv6diXy6UKyKW0lqZiWHGNApSyxOv+8DE5Z27IzVvE7hNkxg7EXIcg== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + "@types/send" "*" + +"@types/express-ws@3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@types/express-ws/-/express-ws-3.0.5.tgz#5abf3dda3acb0a339351f089c31aca708f234c7c" + integrity sha512-lbWMjoHrm/v85j81UCmb/GNZFO3genxRYBW1Ob7rjRI+zxUBR+4tcFuOpKKsYQ1LYTYiy3356epLeYi/5zxUwA== + dependencies: + "@types/express" "*" + "@types/express-serve-static-core" "*" + "@types/ws" "*" + +"@types/express@*", "@types/express@5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@types/express/-/express-5.0.0.tgz#13a7d1f75295e90d19ed6e74cab3678488eaa96c" + integrity sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^5.0.0" + "@types/qs" "*" + "@types/serve-static" "*" + "@types/extend@^3.0.0": version "3.0.4" resolved "https://registry.yarnpkg.com/@types/extend/-/extend-3.0.4.tgz#5f9aa502299e1b9beb9ade57ea9e36898de0ff52" @@ -4559,6 +4603,11 @@ dependencies: "@types/unist" "*" +"@types/http-errors@*": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.4.tgz#7eb47726c391b7345a6ec35ad7f4de469cf5ba4f" + integrity sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" @@ -4647,6 +4696,11 @@ resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd" integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg== +"@types/mime@^1": + version "1.3.5" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690" + integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w== + "@types/minimatch@^3.0.3": version "3.0.5" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40" @@ -4716,6 +4770,16 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.13.tgz#2af91918ee12d9d32914feb13f5326658461b451" integrity sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA== +"@types/qs@*": + version "6.9.17" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.17.tgz#fc560f60946d0aeff2f914eb41679659d3310e1a" + integrity sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ== + +"@types/range-parser@*": + version "1.2.7" + resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" + integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== + "@types/react-dom@*", "@types/react-dom@18.3.1": version "18.3.1" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.1.tgz#1e4654c08a9cdcfb6594c780ac59b55aad42fe07" @@ -4750,6 +4814,23 @@ resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.2.tgz#97d26e00cd4a0423b4af620abecf3e6f442b7975" integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q== +"@types/send@*": + version "0.17.4" + resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.4.tgz#6619cd24e7270793702e4e6a4b958a9010cfc57a" + integrity sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA== + dependencies: + "@types/mime" "^1" + "@types/node" "*" + +"@types/serve-static@*": + version "1.15.7" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.7.tgz#22174bbd74fb97fe303109738e9b5c2f3064f714" + integrity sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw== + dependencies: + "@types/http-errors" "*" + "@types/node" "*" + "@types/send" "*" + "@types/shimmer@^1.0.2", "@types/shimmer@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@types/shimmer/-/shimmer-1.2.0.tgz#9b706af96fa06416828842397a70dfbbf1c14ded" @@ -4802,6 +4883,13 @@ resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc" integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg== +"@types/ws@*": + version "8.5.13" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.13.tgz#6414c280875e2691d0d1e080b05addbf5cb91e20" + integrity sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA== + dependencies: + "@types/node" "*" + "@types/yargs-parser@*": version "21.0.3" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" @@ -5036,6 +5124,14 @@ abab@^2.0.6: resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== +accepts@~1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + acorn-globals@^7.0.0: version "7.0.1" resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-7.0.1.tgz#0dbf05c44fa7c94332914c02066d5beff62c40c3" @@ -5174,6 +5270,11 @@ array-buffer-byte-length@^1.0.1: call-bind "^1.0.5" is-array-buffer "^3.0.4" +array-flatten@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" + integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== + array-includes@^3.1.6, array-includes@^3.1.8: version "3.1.8" resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.8.tgz#5e370cbe172fdd5dd6530c1d4aadda25281ba97d" @@ -5447,6 +5548,24 @@ bluebird@^3.4.1: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== +body-parser@1.20.3: + version "1.20.3" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.3.tgz#1953431221c6fb5cd63c4b36d53fab0928e548c6" + integrity sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g== + dependencies: + bytes "3.1.2" + content-type "~1.0.5" + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + http-errors "2.0.0" + iconv-lite "0.4.24" + on-finished "2.4.1" + qs "6.13.0" + raw-body "2.5.2" + type-is "~1.6.18" + unpipe "1.0.0" + boolbase@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" @@ -5550,6 +5669,11 @@ busboy@1.6.0: dependencies: streamsearch "^1.1.0" +bytes@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" @@ -5825,6 +5949,18 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== +content-disposition@0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" + integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== + dependencies: + safe-buffer "5.2.1" + +content-type@~1.0.4, content-type@~1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + convert-source-map@^1.5.0: version "1.9.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" @@ -5842,6 +5978,16 @@ convert-stream@1.0.2: dependencies: bluebird "^3.4.1" +cookie-signature@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" + integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== + +cookie@0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.1.tgz#2f73c42142d5d5cf71310a74fc4ae61670e5dbc9" + integrity sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w== + core-js-compat@^3.38.0, core-js-compat@^3.38.1: version "3.39.0" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.39.0.tgz#b12dccb495f2601dc860bdbe7b4e3ffa8ba63f61" @@ -6093,6 +6239,13 @@ data-view-byte-offset@^1.0.0: es-errors "^1.3.0" is-data-view "^1.0.1" +debug@2.6.9, debug@^2.2.0: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + debug@4, debug@^4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.3.7: version "4.3.7" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" @@ -6100,13 +6253,6 @@ debug@4, debug@^4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug dependencies: ms "^2.1.3" -debug@^2.2.0: - version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - debug@^3.1.0, debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -6164,11 +6310,21 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== +depd@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + dequal@^2.0.0, dequal@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== +destroy@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + detect-libc@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700" @@ -6306,6 +6462,11 @@ downshift@9.0.8: react-is "18.2.0" tslib "^2.6.2" +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + ejs@^3.1.10, ejs@^3.1.6: version "3.1.10" resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.10.tgz#69ab8358b14e896f80cc39e62087b88500c3ac3b" @@ -6343,6 +6504,16 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== +encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== + +encodeurl@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" + integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + encoding-sniffer@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz#799569d66d443babe82af18c9f403498365ef1d5" @@ -6543,6 +6714,11 @@ escalade@^3.1.1, escalade@^3.2.0: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== +escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + escape-string-regexp@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" @@ -6848,6 +7024,11 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== +etag@~1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + events@^3.2.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" @@ -6889,6 +7070,50 @@ expect@^29.0.0, expect@^29.7.0: jest-message-util "^29.7.0" jest-util "^29.7.0" +express-ws@5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/express-ws/-/express-ws-5.0.2.tgz#5b02d41b937d05199c6c266d7cc931c823bda8eb" + integrity sha512-0uvmuk61O9HXgLhGl3QhNSEtRsQevtmbL94/eILaliEADZBHZOQUAiHFrGPrgsjikohyrmSG5g+sCfASTt0lkQ== + dependencies: + ws "^7.4.6" + +express@4.21.1: + version "4.21.1" + resolved "https://registry.yarnpkg.com/express/-/express-4.21.1.tgz#9dae5dda832f16b4eec941a4e44aa89ec481b281" + integrity sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ== + dependencies: + accepts "~1.3.8" + array-flatten "1.1.1" + body-parser "1.20.3" + content-disposition "0.5.4" + content-type "~1.0.4" + cookie "0.7.1" + cookie-signature "1.0.6" + debug "2.6.9" + depd "2.0.0" + encodeurl "~2.0.0" + escape-html "~1.0.3" + etag "~1.8.1" + finalhandler "1.3.1" + fresh "0.5.2" + http-errors "2.0.0" + merge-descriptors "1.0.3" + methods "~1.1.2" + on-finished "2.4.1" + parseurl "~1.3.3" + path-to-regexp "0.1.10" + proxy-addr "~2.0.7" + qs "6.13.0" + range-parser "~1.2.1" + safe-buffer "5.2.1" + send "0.19.0" + serve-static "1.16.2" + setprototypeof "1.2.0" + statuses "2.0.1" + type-is "~1.6.18" + utils-merge "1.0.1" + vary "~1.1.2" + extend@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" @@ -7019,6 +7244,19 @@ fill-range@^7.1.1: dependencies: to-regex-range "^5.0.1" +finalhandler@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.1.tgz#0c575f1d1d324ddd1da35ad7ece3df7d19088019" + integrity sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ== + dependencies: + debug "2.6.9" + encodeurl "~2.0.0" + escape-html "~1.0.3" + on-finished "2.4.1" + parseurl "~1.3.3" + statuses "2.0.1" + unpipe "~1.0.0" + find-root@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" @@ -7078,6 +7316,16 @@ form-data@^4.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +forwarded@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + +fresh@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== + fs-extra@^11.1.0: version "11.2.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.2.0.tgz#e70e17dfad64232287d01929399e0ea7c86b0e5b" @@ -7735,6 +7983,17 @@ htmlparser2@^9.1.0: domutils "^3.1.0" entities "^4.5.0" +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + http-proxy-agent@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" @@ -7817,6 +8076,13 @@ i18next@^23.5.1: dependencies: "@babel/runtime" "^7.23.2" +iconv-lite@0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + iconv-lite@0.6.3, iconv-lite@^0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" @@ -7898,7 +8164,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: +inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -7934,6 +8200,11 @@ invariant@^2.2.4: dependencies: loose-envify "^1.0.0" +ipaddr.js@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + is-array-buffer@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.4.tgz#7a1f92b3d61edd2bc65d24f130530ea93d7fae98" @@ -9262,6 +9533,11 @@ mdurl@^2.0.0: resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0" integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w== +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + memoize-one@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045" @@ -9272,6 +9548,11 @@ meow@^13.2.0: resolved "https://registry.yarnpkg.com/meow/-/meow-13.2.0.tgz#6b7d63f913f984063b3cc261b6e8800c4cd3474f" integrity sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA== +merge-descriptors@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5" + integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== + merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" @@ -9282,6 +9563,11 @@ merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== +methods@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + micromark-core-commonmark@^1.0.0, micromark-core-commonmark@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz#1386628df59946b2d39fb2edfd10f3e8e0a75bb8" @@ -9600,13 +9886,18 @@ mime-db@1.52.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-types@^2.1.12, mime-types@^2.1.27: +mime-types@^2.1.12, mime-types@^2.1.27, mime-types@~2.1.24, mime-types@~2.1.34: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== dependencies: mime-db "1.52.0" +mime@1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" @@ -9680,7 +9971,7 @@ ms@2.0.0: resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== -ms@^2.1.1, ms@^2.1.3: +ms@2.1.3, ms@^2.1.1, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -9695,6 +9986,11 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + neo-async@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" @@ -9867,6 +10163,13 @@ obuf@~1.1.2: resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== +on-finished@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + once@^1.3.0, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -9984,6 +10287,11 @@ parse5@^7.0.0, parse5@^7.1.1, parse5@^7.1.2: dependencies: entities "^4.5.0" +parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -10017,6 +10325,11 @@ path-scurry@^1.6.1: lru-cache "^10.2.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" +path-to-regexp@0.1.10: + version "0.1.10" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.10.tgz#67e9108c5c0551b9e5326064387de4763c4d5f8b" + integrity sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w== + path-to-regexp@^2.2.1: version "2.4.0" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-2.4.0.tgz#35ce7f333d5616f1c1e1bfe266c3aba2e5b2e704" @@ -10455,6 +10768,14 @@ prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.13.3, pros prosemirror-state "^1.0.0" prosemirror-transform "^1.1.0" +proxy-addr@~2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" + integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== + dependencies: + forwarded "0.2.0" + ipaddr.js "1.9.1" + proxy-from-env@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" @@ -10487,6 +10808,13 @@ pure-rand@^6.0.0: resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.1.0.tgz#d173cf23258231976ccbdb05247c9787957604f2" integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA== +qs@6.13.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" + integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg== + dependencies: + side-channel "^1.0.6" + querystring@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.1.tgz#40d77615bb09d16902a85c3e38aa8b5ed761c2dd" @@ -10523,6 +10851,21 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" +range-parser@~1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +raw-body@2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" + integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.4.24" + unpipe "1.0.0" + react-aria-components@1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/react-aria-components/-/react-aria-components-1.3.2.tgz#dee58665210330ec12843e6393ef5cc28ff9a9da" @@ -11250,7 +11593,7 @@ safe-array-concat@^1.1.2: has-symbols "^1.0.3" isarray "^2.0.5" -safe-buffer@^5.1.0, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@^5.1.0, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -11269,7 +11612,7 @@ safe-regex-test@^1.0.3: es-errors "^1.3.0" is-regex "^1.1.4" -"safer-buffer@>= 2.1.2 < 3.0.0": +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -11307,6 +11650,25 @@ semver@^7.5.2, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.3: resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== +send@0.19.0: + version "0.19.0" + resolved "https://registry.yarnpkg.com/send/-/send-0.19.0.tgz#bbc5a388c8ea6c048967049dbeac0e4a3f09d7f8" + integrity sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw== + dependencies: + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "2.0.0" + mime "1.6.0" + ms "2.1.3" + on-finished "2.4.1" + range-parser "~1.2.1" + statuses "2.0.1" + serialize-javascript@^6.0.1: version "6.0.2" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" @@ -11314,6 +11676,16 @@ serialize-javascript@^6.0.1: dependencies: randombytes "^2.1.0" +serve-static@1.16.2: + version "1.16.2" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.2.tgz#b6a5343da47f6bdd2673848bf45754941e803296" + integrity sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw== + dependencies: + encodeurl "~2.0.0" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "0.19.0" + set-function-length@^1.2.1: version "1.2.2" resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" @@ -11336,6 +11708,11 @@ set-function-name@^2.0.1, set-function-name@^2.0.2: functions-have-names "^1.2.3" has-property-descriptors "^1.0.2" +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + shallowequal@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" @@ -11554,6 +11931,11 @@ stacktrace-parser@^0.1.10: dependencies: type-fest "^0.7.1" +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + stream-composer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/stream-composer/-/stream-composer-1.0.2.tgz#7ee61ca1587bf5f31b2e29aa2093cbf11442d152" @@ -12026,6 +12408,11 @@ to-through@^3.0.0: dependencies: streamx "^2.12.5" +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + touch@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.1.tgz#097a23d7b161476435e5c1344a95c0f75b4a5694" @@ -12185,6 +12572,14 @@ type-fest@^4.27.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.30.0.tgz#cf411e7630578ad9e9884951dfaeef6588f970fe" integrity sha512-G6zXWS1dLj6eagy6sVhOMQiLtJdxQBHIA9Z6HFUNLOlr6MFOgzV8wvmidtPONfPtEUv0uZsy77XJNzTAfwPDaA== +type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + typed-array-buffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz#1867c5d83b20fcb5ccf32649e5e2fc7424474ff3" @@ -12420,6 +12815,11 @@ universalify@^2.0.0: resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== +unpipe@1.0.0, unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + unplugin@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/unplugin/-/unplugin-1.0.1.tgz#83b528b981cdcea1cad422a12cd02e695195ef3f" @@ -12500,6 +12900,11 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== +utils-merge@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" + integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== + uuid@^10.0.0: version "10.0.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" @@ -12544,6 +12949,11 @@ value-or-function@^4.0.0: resolved "https://registry.yarnpkg.com/value-or-function/-/value-or-function-4.0.0.tgz#70836b6a876a010dc3a2b884e7902e9db064378d" integrity sha512-aeVK81SIuT6aMJfNo9Vte8Dw0/FZINGBV8BfCraGtqVxIeLAEhJyoWs8SmvRVmXfGss2PmmOwZCuBPbZR+IYWg== +vary@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + vfile-location@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-4.1.0.tgz#69df82fb9ef0a38d0d02b90dd84620e120050dd0" @@ -13095,6 +13505,11 @@ write-file-atomic@^5.0.1: imurmurhash "^0.1.4" signal-exit "^4.0.1" +ws@^7.4.6: + version "7.5.10" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9" + integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== + ws@^8.11.0, ws@^8.17.1, ws@^8.18.0, ws@^8.5.0: version "8.18.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" diff --git a/src/helm/env.d/dev/values.impress.yaml.gotmpl b/src/helm/env.d/dev/values.impress.yaml.gotmpl index 00af4575b4..e7e1bca596 100644 --- a/src/helm/env.d/dev/values.impress.yaml.gotmpl +++ b/src/helm/env.d/dev/values.impress.yaml.gotmpl @@ -97,6 +97,11 @@ yProvider: pullPolicy: Always tag: "latest" + envVars: + COLLABORATION_LOGGING: true + COLLABORATION_SERVER_ORIGIN: https://impress.127.0.0.1.nip.io + COLLABORATION_SERVER_SECRET: my-secret + ingress: enabled: true host: impress.127.0.0.1.nip.io From e7565f06795b35036580aa9ace4e3d0d81c3795b Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Thu, 28 Nov 2024 17:11:51 +0100 Subject: [PATCH 5/9] =?UTF-8?q?=E2=9C=85(y-provider)=20add=20tests=20for?= =?UTF-8?q?=20y-provider=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We add jest tests for the y-provider server. The CI will be able to run the tests. --- .github/workflows/impress-frontend.yml | 9 +- src/frontend/package.json | 7 +- src/frontend/servers/y-provider/.eslintrc.cjs | 2 +- .../y-provider/__tests__/server.test.ts | 234 ++++++++++++++++++ .../servers/y-provider/jest.config.js | 11 + src/frontend/servers/y-provider/package.json | 17 +- .../servers/y-provider/src/helpers.ts | 8 + .../servers/y-provider/tsconfig.build.json | 7 + src/frontend/servers/y-provider/tsconfig.json | 7 + src/frontend/yarn.lock | 155 +++++++++++- 10 files changed, 440 insertions(+), 17 deletions(-) create mode 100644 src/frontend/servers/y-provider/__tests__/server.test.ts create mode 100644 src/frontend/servers/y-provider/jest.config.js create mode 100644 src/frontend/servers/y-provider/src/helpers.ts create mode 100644 src/frontend/servers/y-provider/tsconfig.build.json diff --git a/.github/workflows/impress-frontend.yml b/.github/workflows/impress-frontend.yml index 60c35696ce..8e0ac0b6ee 100644 --- a/.github/workflows/impress-frontend.yml +++ b/.github/workflows/impress-frontend.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "18.x" + node-version: "20.x" - name: Restore the frontend cache uses: actions/cache@v4 @@ -46,6 +46,11 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20.x" + - name: Restore the frontend cache uses: actions/cache@v4 id: front-node_modules @@ -54,7 +59,7 @@ jobs: key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }} - name: Test App - run: cd src/frontend/ && yarn app:test + run: cd src/frontend/ && yarn test lint-front: runs-on: ubuntu-latest diff --git a/src/frontend/package.json b/src/frontend/package.json index 1dddbd3f43..680d355ab5 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -13,16 +13,19 @@ "APP_IMPRESS": "yarn workspace app-impress", "APP_E2E": "yarn workspace app-e2e", "I18N": "yarn workspace packages-i18n", + "COLLABORATION_SERVER": "yarn workspace server-y-provider", "app:dev": "yarn APP_IMPRESS run dev", "app:start": "yarn APP_IMPRESS run start", "app:build": "yarn APP_IMPRESS run build", "app:test": "yarn APP_IMPRESS run test", "ci:build": "yarn APP_IMPRESS run build:ci", "e2e:test": "yarn APP_E2E run test", - "lint": "yarn APP_IMPRESS run lint && yarn APP_E2E run lint && yarn workspace eslint-config-impress run lint && yarn I18N run lint", + "lint": "yarn APP_IMPRESS run lint && yarn APP_E2E run lint && yarn workspace eslint-config-impress run lint && yarn I18N run lint && yarn COLLABORATION_SERVER run lint", "i18n:extract": "yarn I18N run extract-translation", "i18n:deploy": "yarn I18N run format-deploy && yarn APP_IMPRESS prettier", - "i18n:test": "yarn I18N run test" + "i18n:test": "yarn I18N run test", + "test": "yarn server:test && yarn app:test", + "server:test": "yarn COLLABORATION_SERVER run test" }, "resolutions": { "@blocknote/core": "0.20.0", diff --git a/src/frontend/servers/y-provider/.eslintrc.cjs b/src/frontend/servers/y-provider/.eslintrc.cjs index 41f13b7c1d..33a3b049fb 100644 --- a/src/frontend/servers/y-provider/.eslintrc.cjs +++ b/src/frontend/servers/y-provider/.eslintrc.cjs @@ -1,6 +1,6 @@ module.exports = { root: true, - extends: ['impress/next'], + extends: ['impress/jest', 'impress/next'], parserOptions: { tsconfigRootDir: __dirname, project: ['./tsconfig.json'], diff --git a/src/frontend/servers/y-provider/__tests__/server.test.ts b/src/frontend/servers/y-provider/__tests__/server.test.ts new file mode 100644 index 0000000000..523a7e2599 --- /dev/null +++ b/src/frontend/servers/y-provider/__tests__/server.test.ts @@ -0,0 +1,234 @@ +import { + HocuspocusProvider, + HocuspocusProviderWebsocket, +} from '@hocuspocus/provider'; +import request from 'supertest'; +import WebSocket from 'ws'; + +const port = 5555; +const portWS = 6666; +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 { promiseDone } from '../src/helpers'; +import { hocuspocusServer, initServer } from '../src/server'; // Adjust the path to your server file + +const { app, server } = initServer(); + +describe('Server Tests', () => { + beforeAll(async () => { + await hocuspocusServer.configure({ port: portWS }).listen(); + }); + + afterAll(() => { + server.close(); + void hocuspocusServer.destroy(); + }); + + test('Ping Pong', async () => { + const response = await request(app as any).get('/ping'); + + expect(response.status).toBe(200); + expect(response.body.message).toBe('pong'); + }); + + test('POST /collaboration/api/reset-connections?room=[ROOM_ID] invalid origin', async () => { + const response = await request(app as any) + .post('/collaboration/api/reset-connections/?room=test-room') + .set('Origin', 'http://invalid-origin.com') + .send({ document_id: 'test-document' }); + + expect(response.status).toBe(403); + expect(response.body.error).toBe('CORS policy violation: Invalid Origin'); + }); + + test('POST /collaboration/api/reset-connections?room=[ROOM_ID] with incorrect API key should return 403', async () => { + const response = await request(app as any) + .post('/collaboration/api/reset-connections/?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/reset-connections?room=[ROOM_ID] failed if room not indicated', async () => { + const response = await request(app as any) + .post('/collaboration/api/reset-connections/') + .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/reset-connections?room=[ROOM_ID] with correct API key should reset connections', async () => { + // eslint-disable-next-line jest/unbound-method + const { closeConnections } = hocuspocusServer; + const mockHandleConnection = jest.fn(); + (hocuspocusServer.closeConnections as jest.Mock) = mockHandleConnection; + + const response = await request(app as any) + .post('/collaboration/api/reset-connections?room=test-room') + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key'); + + expect(response.status).toBe(200); + expect(response.body.message).toBe('Connections reset'); + + expect(mockHandleConnection).toHaveBeenCalled(); + mockHandleConnection.mockClear(); + hocuspocusServer.closeConnections = closeConnections; + }); + + ['/collaboration/api/anything/', '/', '/anything'].forEach((path) => { + test(`"${path}" endpoint should be forbidden`, async () => { + const response = await request(app as any).post(path); + + expect(response.status).toBe(403); + expect(response.body.error).toBe('Forbidden'); + }); + }); + + test('WebSocket connection with correct API key can connect', () => { + const { promise, done } = promiseDone(); + + // eslint-disable-next-line jest/unbound-method + const { handleConnection } = hocuspocusServer; + const mockHandleConnection = jest.fn(); + (hocuspocusServer.handleConnection as jest.Mock) = mockHandleConnection; + + const clientWS = new WebSocket( + `ws://localhost:${port}/collaboration/ws/?room=test-room`, + { + headers: { + authorization: 'test-secret-api-key', + Origin: origin, + }, + }, + ); + + clientWS.on('open', () => { + expect(mockHandleConnection).toHaveBeenCalled(); + clientWS.close(); + mockHandleConnection.mockClear(); + hocuspocusServer.handleConnection = handleConnection; + done(); + }); + + return promise; + }); + + test('WebSocket connection with bad origin should be closed', () => { + const { promise, done } = promiseDone(); + + const ws = new WebSocket( + `ws://localhost:${port}/collaboration/ws/?room=test-room`, + { + headers: { + Origin: 'http://bad-origin.com', + }, + }, + ); + + ws.onclose = () => { + expect(ws.readyState).toBe(ws.CLOSED); + done(); + }; + + return promise; + }); + + test('WebSocket connection with incorrect API key should be closed', () => { + const { promise, done } = promiseDone(); + const ws = new WebSocket( + `ws://localhost:${port}/collaboration/ws/?room=test-room`, + { + headers: { + Authorization: 'wrong-api-key', + Origin: origin, + }, + }, + ); + + ws.onclose = () => { + expect(ws.readyState).toBe(ws.CLOSED); + done(); + }; + + return promise; + }); + + test('WebSocket connection not allowed if room not matching provider name', () => { + const { promise, done } = promiseDone(); + + const wsHocus = new HocuspocusProviderWebsocket({ + url: `ws://localhost:${portWS}/?room=my-test`, + WebSocketPolyfill: WebSocket, + maxAttempts: 1, + quiet: true, + }); + + const provider = new HocuspocusProvider({ + websocketProvider: wsHocus, + name: 'hocuspocus-test', + broadcast: false, + quiet: true, + preserveConnection: false, + onClose: (data) => { + wsHocus.stopConnectionAttempt(); + expect(data.event.reason).toBe('Forbidden'); + wsHocus.webSocket?.close(); + wsHocus.disconnect(); + provider.destroy(); + wsHocus.destroy(); + done(); + }, + }); + + return promise; + }); + + test('WebSocket connection read-only', () => { + const { promise, done } = promiseDone(); + + const wsHocus = new HocuspocusProviderWebsocket({ + url: `ws://localhost:${portWS}/?room=hocuspocus-test`, + WebSocketPolyfill: WebSocket, + }); + + const provider = new HocuspocusProvider({ + websocketProvider: wsHocus, + name: 'hocuspocus-test', + broadcast: false, + quiet: true, + onConnect: () => { + void hocuspocusServer + .openDirectConnection('hocuspocus-test') + .then((connection) => { + connection.document?.getConnections().forEach((connection) => { + expect(connection.readOnly).toBe(true); + }); + + void connection.disconnect(); + }); + + provider.destroy(); + wsHocus.destroy(); + done(); + }, + }); + + return promise; + }); +}); diff --git a/src/frontend/servers/y-provider/jest.config.js b/src/frontend/servers/y-provider/jest.config.js new file mode 100644 index 0000000000..e964e0b08b --- /dev/null +++ b/src/frontend/servers/y-provider/jest.config.js @@ -0,0 +1,11 @@ +var config = { + rootDir: './__tests__', + testEnvironment: 'node', + transform: { + '^.+\\.(ts)$': 'ts-jest', + }, + moduleNameMapper: { + '^@/(.*)$': '/../src/$1', + }, +}; +export default config; diff --git a/src/frontend/servers/y-provider/package.json b/src/frontend/servers/y-provider/package.json index d043d702d6..08586eb7fa 100644 --- a/src/frontend/servers/y-provider/package.json +++ b/src/frontend/servers/y-provider/package.json @@ -6,10 +6,11 @@ "license": "MIT", "type": "module", "scripts": { - "build": "tsc -p ./src", + "build": "tsc -p tsconfig.build.json && tsc-alias", "dev": "nodemon --config nodemon.json", - "start": "node ./dist/server.js", - "lint": "eslint . --ext .ts" + "start": "node ./dist/start-server.js", + "lint": "eslint . --ext .ts", + "test": "jest" }, "engines": { "node": ">=18" @@ -21,13 +22,21 @@ "y-protocols": "1.0.6" }, "devDependencies": { + "@hocuspocus/provider": "2.14.0", "@types/express": "5.0.0", "@types/express-ws": "3.0.5", + "@types/jest": "29.5.14", "@types/node": "*", + "@types/supertest": "6.0.2", + "@types/ws": "8.5.13", "eslint-config-impress": "*", + "jest": "29.7.0", "nodemon": "3.1.7", + "supertest": "7.0.0", "ts-jest": "29.2.5", "ts-node": "10.9.2", - "typescript": "*" + "tsc-alias": "1.8.10", + "typescript": "*", + "ws": "8.18.0" } } diff --git a/src/frontend/servers/y-provider/src/helpers.ts b/src/frontend/servers/y-provider/src/helpers.ts new file mode 100644 index 0000000000..c23b836868 --- /dev/null +++ b/src/frontend/servers/y-provider/src/helpers.ts @@ -0,0 +1,8 @@ +export const promiseDone = () => { + let done: (value: void | PromiseLike) => void = () => {}; + const promise = new Promise((resolve) => { + done = resolve; + }); + + return { done, promise }; +}; diff --git a/src/frontend/servers/y-provider/tsconfig.build.json b/src/frontend/servers/y-provider/tsconfig.build.json new file mode 100644 index 0000000000..c14ab5093f --- /dev/null +++ b/src/frontend/servers/y-provider/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + }, + "exclude": ["node_modules", "dist", "__tests__"], +} diff --git a/src/frontend/servers/y-provider/tsconfig.json b/src/frontend/servers/y-provider/tsconfig.json index b0cbd99c80..120bda63e4 100644 --- a/src/frontend/servers/y-provider/tsconfig.json +++ b/src/frontend/servers/y-provider/tsconfig.json @@ -13,6 +13,13 @@ "jsx": "preserve", "incremental": false, "outDir": "./dist", + "paths": { + "@/*": ["./src/*"] + } + }, + "tsc-alias": { + "resolveFullPaths": true, + "verbose": false }, "include": ["**/*.ts"], "exclude": ["node_modules"] diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 1b282914b6..d1525d01d0 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -4515,6 +4515,11 @@ dependencies: "@types/node" "*" +"@types/cookiejar@^2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.5.tgz#14a3e83fa641beb169a2dd8422d91c3c345a9a78" + integrity sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q== + "@types/debug@^4.0.0": version "4.1.12" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" @@ -4696,6 +4701,11 @@ resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd" integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg== +"@types/methods@^1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@types/methods/-/methods-1.1.4.tgz#d3b7ac30ac47c91054ea951ce9eed07b1051e547" + integrity sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ== + "@types/mime@^1": version "1.3.5" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690" @@ -4846,6 +4856,24 @@ resolved "https://registry.yarnpkg.com/@types/stylis/-/stylis-4.2.5.tgz#1daa6456f40959d06157698a653a9ab0a70281df" integrity sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw== +"@types/superagent@^8.1.0": + version "8.1.9" + resolved "https://registry.yarnpkg.com/@types/superagent/-/superagent-8.1.9.tgz#28bfe4658e469838ed0bf66d898354bcab21f49f" + integrity sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ== + dependencies: + "@types/cookiejar" "^2.1.5" + "@types/methods" "^1.1.4" + "@types/node" "*" + form-data "^4.0.0" + +"@types/supertest@6.0.2": + version "6.0.2" + resolved "https://registry.yarnpkg.com/@types/supertest/-/supertest-6.0.2.tgz#2af1c466456aaf82c7c6106c6b5cbd73a5e86588" + integrity sha512-137ypx2lk/wTQbW6An6safu9hXmajAifU/s7szAHLN/FeIm5w7yR0Wkl9fdJMRSHwOn4HLAI0DaB2TOORuhPDg== + dependencies: + "@types/methods" "^1.1.4" + "@types/superagent" "^8.1.0" + "@types/symlink-or-copy@^1.2.0": version "1.2.2" resolved "https://registry.yarnpkg.com/@types/symlink-or-copy/-/symlink-or-copy-1.2.2.tgz#51b1c00b516a5774ada5d611e65eb123f988ef8d" @@ -4883,7 +4911,7 @@ resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc" integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg== -"@types/ws@*": +"@types/ws@*", "@types/ws@8.5.13": version "8.5.13" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.13.tgz#6414c280875e2691d0d1e080b05addbf5cb91e20" integrity sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA== @@ -5361,6 +5389,11 @@ arraybuffer.prototype.slice@^1.0.3: is-array-buffer "^3.0.4" is-shared-array-buffer "^1.0.2" +asap@^2.0.0: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== + ast-types-flow@^0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.8.tgz#0a85e1c92695769ac13a428bb653e7538bea27d6" @@ -5924,6 +5957,11 @@ commander@^7.2.0: resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== +commander@^9.0.0: + version "9.5.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-9.5.0.tgz#bc08d1eb5cedf7ccb797a96199d41c7bc3e60d30" + integrity sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ== + commander@~12.1.0: version "12.1.0" resolved "https://registry.yarnpkg.com/commander/-/commander-12.1.0.tgz#01423b36f501259fdaac4d0e4d60c96c991585d3" @@ -5939,6 +5977,11 @@ commondir@^1.0.1: resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== +component-emitter@^1.3.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.1.tgz#ef1d5796f7d93f135ee6fb684340b26403c97d17" + integrity sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ== + compute-scroll-into-view@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.0.tgz#753f11d972596558d8fe7c6bcbc8497690ab4c87" @@ -5988,6 +6031,11 @@ cookie@0.7.1: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.1.tgz#2f73c42142d5d5cf71310a74fc4ae61670e5dbc9" integrity sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w== +cookiejar@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b" + integrity sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw== + core-js-compat@^3.38.0, core-js-compat@^3.38.1: version "3.39.0" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.39.0.tgz#b12dccb495f2601dc860bdbe7b4e3ffa8ba63f61" @@ -6347,6 +6395,14 @@ devlop@^1.0.0: dependencies: dequal "^2.0.0" +dezalgo@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81" + integrity sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig== + dependencies: + asap "^2.0.0" + wrappy "1" + diff-sequences@^29.6.3: version "29.6.3" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" @@ -7166,6 +7222,11 @@ fast-levenshtein@^2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +fast-safe-stringify@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" + integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== + fast-uri@^3.0.1: version "3.0.3" resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.0.3.tgz#892a1c91802d5d7860de728f18608a0573142241" @@ -7316,6 +7377,15 @@ form-data@^4.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +formidable@^3.5.1: + version "3.5.2" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-3.5.2.tgz#207c33fecdecb22044c82ba59d0c63a12fb81d77" + integrity sha512-Jqc1btCy3QzRbJaICGwKcBfGWuLADRerLzDqi2NwSt/UkXLsHJw2TVResiaoBufHVHy9aSgClOHCeJsSsFLTbg== + dependencies: + dezalgo "^1.0.4" + hexoid "^2.0.0" + once "^1.4.0" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -7567,7 +7637,7 @@ globalthis@^1.0.4: define-properties "^1.2.1" gopd "^1.0.1" -globby@^11.1.0: +globby@^11.0.4, globby@^11.1.0: version "11.1.0" resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== @@ -7920,6 +7990,11 @@ heimdalljs@^0.2.6: dependencies: rsvp "~3.2.1" +hexoid@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/hexoid/-/hexoid-2.0.0.tgz#fb36c740ebbf364403fa1ec0c7efd268460ec5b9" + integrity sha512-qlspKUK7IlSQv2o+5I7yhUd7TxlOG2Vr5LTa3ve2XSNVKAL/n/u/7KLvKmFNimomDIKvZFXWHv0T12mv7rT8Aw== + hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" @@ -9563,7 +9638,7 @@ merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -methods@~1.1.2: +methods@^1.1.2, methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== @@ -9898,6 +9973,11 @@ mime@1.6.0: resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== +mime@2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" + integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== + mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" @@ -9976,6 +10056,11 @@ ms@2.1.3, ms@^2.1.1, ms@^2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +mylas@^2.1.9: + version "2.1.13" + resolved "https://registry.yarnpkg.com/mylas/-/mylas-2.1.13.tgz#1e23b37d58fdcc76e15d8a5ed23f9ae9fc0cbdf4" + integrity sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg== + nanoid@^3.3.6, nanoid@^3.3.7: version "3.3.8" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" @@ -10428,6 +10513,13 @@ playwright@1.49.0: optionalDependencies: fsevents "2.3.2" +plimit-lit@^1.2.6: + version "1.6.1" + resolved "https://registry.yarnpkg.com/plimit-lit/-/plimit-lit-1.6.1.tgz#a34594671b31ee8e93c72d505dfb6852eb72374a" + integrity sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA== + dependencies: + queue-lit "^1.5.1" + possible-typed-array-names@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz#89bb63c6fada2c3e90adc4a647beeeb39cc7bf8f" @@ -10815,6 +10907,13 @@ qs@6.13.0: dependencies: side-channel "^1.0.6" +qs@^6.11.0: + version "6.13.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.1.tgz#3ce5fc72bd3a8171b85c99b93c65dd20b7d1b16e" + integrity sha512-EJPeIn0CYrGu+hli1xilKAPXODtJ12T0sP63Ijx2/khC2JtuaN3JyNIpvmnkmaEtha9ocbG4A4cMcr+TvqvwQg== + dependencies: + side-channel "^1.0.6" + querystring@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.1.tgz#40d77615bb09d16902a85c3e38aa8b5ed761c2dd" @@ -10825,6 +10924,11 @@ querystringify@^2.1.1: resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== +queue-lit@^1.5.1: + version "1.5.2" + resolved "https://registry.yarnpkg.com/queue-lit/-/queue-lit-1.5.2.tgz#83c24d4f4764802377b05a6e5c73017caf3f8747" + integrity sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw== + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" @@ -12204,6 +12308,29 @@ stylis@4.3.2: resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.3.2.tgz#8f76b70777dd53eb669c6f58c997bf0a9972e444" integrity sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg== +superagent@^9.0.1: + version "9.0.2" + resolved "https://registry.yarnpkg.com/superagent/-/superagent-9.0.2.tgz#a18799473fc57557289d6b63960610e358bdebc1" + integrity sha512-xuW7dzkUpcJq7QnhOsnNUgtYp3xRwpt2F7abdRYIpCsAt0hhUqia0EdxyXZQQpNmGtsCzYHryaKSV3q3GJnq7w== + dependencies: + component-emitter "^1.3.0" + cookiejar "^2.1.4" + debug "^4.3.4" + fast-safe-stringify "^2.1.1" + form-data "^4.0.0" + formidable "^3.5.1" + methods "^1.1.2" + mime "2.6.0" + qs "^6.11.0" + +supertest@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/supertest/-/supertest-7.0.0.tgz#cac53b3d6872a0b317980b2b0cfa820f09cd7634" + integrity sha512-qlsr7fIC0lSddmA3tzojvzubYxvlGtzumcdHgPwbFWMISQwL22MhM2Y3LNt+6w9Yyx7559VW5ab70dgphm8qQA== + dependencies: + methods "^1.1.2" + superagent "^9.0.1" + supports-color@^5.5.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" @@ -12515,6 +12642,18 @@ ts-node@10.9.2: v8-compile-cache-lib "^3.0.1" yn "3.1.1" +tsc-alias@1.8.10: + version "1.8.10" + resolved "https://registry.yarnpkg.com/tsc-alias/-/tsc-alias-1.8.10.tgz#279f9bf0dd8bc10fb27820393d4881db5a303938" + integrity sha512-Ibv4KAWfFkFdKJxnWfVtdOmB0Zi1RJVxcbPGiCDsFpCQSsmpWyuzHG3rQyI5YkobWwxFPEyQfu1hdo4qLG2zPw== + dependencies: + chokidar "^3.5.3" + commander "^9.0.0" + globby "^11.0.4" + mylas "^2.1.9" + normalize-path "^3.0.0" + plimit-lit "^1.2.6" + tsconfig-paths@^3.15.0: version "3.15.0" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz#5299ec605e55b1abb23ec939ef15edaf483070d4" @@ -13505,16 +13644,16 @@ write-file-atomic@^5.0.1: imurmurhash "^0.1.4" signal-exit "^4.0.1" +ws@8.18.0, ws@^8.11.0, ws@^8.17.1, ws@^8.18.0, ws@^8.5.0: + version "8.18.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" + integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== + ws@^7.4.6: version "7.5.10" resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9" integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== -ws@^8.11.0, ws@^8.17.1, ws@^8.18.0, ws@^8.5.0: - version "8.18.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" - integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== - xml-name-validator@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835" From 096837a123543a7cda9396168e3e0ae37bdf54bf Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Thu, 28 Nov 2024 16:35:48 +0100 Subject: [PATCH 6/9] =?UTF-8?q?=E2=9C=A8(backend)=20notify=20collaboration?= =?UTF-8?q?=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an access is updated or removed, the collaboration server is notified to reset the access connection; by being disconnected, the accesses will automatically reconnect by passing by the ngnix subrequest, and so get the good rights. We do the same system when the document link is updated, except here we reset every access connection. --- docker/files/etc/nginx/conf.d/default.conf | 6 + env.d/development/common.dist | 3 +- src/backend/core/api/viewsets.py | 27 +++ .../core/services/collaboration_services.py | 42 ++++ .../documents/test_api_document_accesses.py | 151 +++++++++----- .../test_api_documents_link_configuration.py | 30 +-- .../test_services_collaboration_services.py | 185 ++++++++++++++++++ src/backend/impress/settings.py | 3 + .../e2e/__tests__/app-impress/config.spec.ts | 6 +- .../__tests__/app-impress/doc-editor.spec.ts | 57 +++++- src/helm/env.d/dev/values.impress.yaml.gotmpl | 1 + 11 files changed, 436 insertions(+), 75 deletions(-) create mode 100644 src/backend/core/services/collaboration_services.py create mode 100644 src/backend/core/tests/test_services_collaboration_services.py diff --git a/docker/files/etc/nginx/conf.d/default.conf b/docker/files/etc/nginx/conf.d/default.conf index b984787f21..2644822ce2 100644 --- a/docker/files/etc/nginx/conf.d/default.conf +++ b/docker/files/etc/nginx/conf.d/default.conf @@ -46,6 +46,12 @@ server { proxy_set_header X-Original-Method $request_method; } + location /collaboration/api/ { + # Collaboration server + proxy_pass http://y-provider:4444; + proxy_set_header Host $host; + } + # Proxy auth for media location /media/ { # Auth request configuration diff --git a/env.d/development/common.dist b/env.d/development/common.dist index ed183578a7..29570b4a6c 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -53,9 +53,10 @@ AI_API_KEY=password AI_MODEL=llama # Collaboration +COLLABORATION_API_URL=http://nginx:8083/collaboration/api/ COLLABORATION_SERVER_ORIGIN=http://localhost:3000 COLLABORATION_SERVER_SECRET=my-secret -COLLABORATION_WS_URL=ws://localhost:8083/collaboration/ws +COLLABORATION_WS_URL=ws://localhost:8083/collaboration/ws/ # Frontend FRONTEND_THEME=dsfr diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 969d1861eb..4c71689ce6 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -30,6 +30,7 @@ from core import enums, models from core.services.ai_services import AIService +from core.services.collaboration_services import CollaborationService from . import permissions, serializers, utils from .filters import DocumentFilter @@ -520,6 +521,10 @@ def link_configuration(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) serializer.save() + + # Notify collaboration server about the link updated + CollaborationService().reset_connections(str(document.id)) + return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK) @drf.decorators.action(detail=True, methods=["post", "delete"], url_path="favorite") @@ -815,6 +820,28 @@ def perform_create(self, serializer): self.request.user, ) + def perform_update(self, serializer): + """Update an access to the document and notify the collaboration server.""" + access = serializer.save() + + access_user_id = None + if access.user: + access_user_id = str(access.user.id) + + # Notify collaboration server about the access change + CollaborationService().reset_connections( + str(access.document.id), access_user_id + ) + + def perform_destroy(self, instance): + """Delete an access to the document and notify the collaboration server.""" + instance.delete() + + # Notify collaboration server about the access removed + CollaborationService().reset_connections( + str(instance.document.id), str(instance.user.id) + ) + class TemplateViewSet( drf.mixins.CreateModelMixin, diff --git a/src/backend/core/services/collaboration_services.py b/src/backend/core/services/collaboration_services.py new file mode 100644 index 0000000000..9120321d65 --- /dev/null +++ b/src/backend/core/services/collaboration_services.py @@ -0,0 +1,42 @@ +"""Collaboration services.""" + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +import requests + + +class CollaborationService: + """Service class for Collaboration related operations.""" + + def __init__(self): + """Ensure that the collaboration configuration is set properly.""" + if settings.COLLABORATION_API_URL is None: + raise ImproperlyConfigured("Collaboration configuration not set") + + def reset_connections(self, room, user_id=None): + """ + Reset connections of a room in the collaboration server. + Reseting a connection means that the user will be disconnected and will + have to reconnect to the collaboration server, with updated rights. + """ + endpoint = "reset-connections" + + # room is necessary as a parameter, it is easier to stick to the + # same pod thanks to a parameter + endpoint_url = f"{settings.COLLABORATION_API_URL}{endpoint}/?room={room}" + + headers = {"Authorization": settings.COLLABORATION_SERVER_SECRET} + if user_id: + headers["X-User-Id"] = user_id + + try: + response = requests.post(endpoint_url, headers=headers, timeout=10) + except requests.RequestException as e: + raise requests.HTTPError("Failed to notify WebSocket server.") from e + + if response.status_code != 200: + raise requests.HTTPError( + f"Failed to notify WebSocket server. Status code: {response.status_code}, " + f"Response: {response.text}" + ) diff --git a/src/backend/core/tests/documents/test_api_document_accesses.py b/src/backend/core/tests/documents/test_api_document_accesses.py index 9d04d9240b..5b1ea2835f 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses.py +++ b/src/backend/core/tests/documents/test_api_document_accesses.py @@ -11,6 +11,9 @@ from core import factories, models from core.api import serializers from core.tests.conftest import TEAM, USER, VIA +from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import + mock_reset_connections, +) pytestmark = pytest.mark.django_db @@ -316,7 +319,11 @@ def test_api_document_accesses_update_authenticated_reader_or_editor( @pytest.mark.parametrize("via", VIA) -def test_api_document_accesses_update_administrator_except_owner(via, mock_user_teams): +def test_api_document_accesses_update_administrator_except_owner( + via, + mock_user_teams, + mock_reset_connections, # pylint: disable=redefined-outer-name +): """ A user who is a direct administrator in a document should be allowed to update a user access for this document, as long as they don't try to set the role to owner. @@ -351,18 +358,21 @@ def test_api_document_accesses_update_administrator_except_owner(via, mock_user_ for field, value in new_values.items(): new_data = {**old_values, field: value} - response = client.put( - f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", - data=new_data, - format="json", - ) - - if ( - new_data["role"] == old_values["role"] - ): # we are not really updating the role + if new_data["role"] == old_values["role"]: + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + ) assert response.status_code == 403 else: - assert response.status_code == 200 + with mock_reset_connections(document.id, str(access.user_id)): + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + ) + assert response.status_code == 200 access.refresh_from_db() updated_values = serializers.DocumentAccessSerializer(instance=access).data @@ -420,7 +430,11 @@ def test_api_document_accesses_update_administrator_from_owner(via, mock_user_te @pytest.mark.parametrize("via", VIA) -def test_api_document_accesses_update_administrator_to_owner(via, mock_user_teams): +def test_api_document_accesses_update_administrator_to_owner( + via, + mock_user_teams, + mock_reset_connections, # pylint: disable=redefined-outer-name +): """ A user who is an administrator in a document, should not be allowed to update the user access of another user to grant document ownership. @@ -457,16 +471,23 @@ def test_api_document_accesses_update_administrator_to_owner(via, mock_user_team for field, value in new_values.items(): new_data = {**old_values, field: value} - response = client.put( - f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", - data=new_data, - format="json", - ) # We are not allowed or not really updating the role if field == "role" or new_data["role"] == old_values["role"]: + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + ) + assert response.status_code == 403 else: - assert response.status_code == 200 + with mock_reset_connections(document.id, str(access.user_id)): + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + ) + assert response.status_code == 200 access.refresh_from_db() updated_values = serializers.DocumentAccessSerializer(instance=access).data @@ -474,7 +495,11 @@ def test_api_document_accesses_update_administrator_to_owner(via, mock_user_team @pytest.mark.parametrize("via", VIA) -def test_api_document_accesses_update_owner(via, mock_user_teams): +def test_api_document_accesses_update_owner( + via, + mock_user_teams, + mock_reset_connections, # pylint: disable=redefined-outer-name +): """ A user who is an owner in a document should be allowed to update a user access for this document whatever the role. @@ -507,18 +532,24 @@ def test_api_document_accesses_update_owner(via, mock_user_teams): for field, value in new_values.items(): new_data = {**old_values, field: value} - response = client.put( - f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", - data=new_data, - format="json", - ) - if ( new_data["role"] == old_values["role"] ): # we are not really updating the role + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + ) assert response.status_code == 403 else: - assert response.status_code == 200 + with mock_reset_connections(document.id, str(access.user_id)): + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + ) + + assert response.status_code == 200 access.refresh_from_db() updated_values = serializers.DocumentAccessSerializer(instance=access).data @@ -530,7 +561,11 @@ def test_api_document_accesses_update_owner(via, mock_user_teams): @pytest.mark.parametrize("via", VIA) -def test_api_document_accesses_update_owner_self(via, mock_user_teams): +def test_api_document_accesses_update_owner_self( + via, + mock_user_teams, + mock_reset_connections, # pylint: disable=redefined-outer-name +): """ A user who is owner of a document should be allowed to update their own user access provided there are other owners in the document. @@ -568,21 +603,23 @@ def test_api_document_accesses_update_owner_self(via, mock_user_teams): # Add another owner and it should now work factories.UserDocumentAccessFactory(document=document, role="owner") - response = client.put( - f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", - data={ - **old_values, - "role": new_role, - "user_id": old_values.get("user", {}).get("id") - if old_values.get("user") is not None - else None, - }, - format="json", - ) + user_id = str(access.user_id) if via == USER else None + with mock_reset_connections(document.id, user_id): + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data={ + **old_values, + "role": new_role, + "user_id": old_values.get("user", {}).get("id") + if old_values.get("user") is not None + else None, + }, + format="json", + ) - assert response.status_code == 200 - access.refresh_from_db() - assert access.role == new_role + assert response.status_code == 200 + access.refresh_from_db() + assert access.role == new_role # Delete @@ -656,7 +693,9 @@ def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_team @pytest.mark.parametrize("via", VIA) def test_api_document_accesses_delete_administrators_except_owners( - via, mock_user_teams + via, + mock_user_teams, + mock_reset_connections, # pylint: disable=redefined-outer-name ): """ Users who are administrators in a document should be allowed to delete an access @@ -685,12 +724,13 @@ def test_api_document_accesses_delete_administrators_except_owners( assert models.DocumentAccess.objects.count() == 2 assert models.DocumentAccess.objects.filter(user=access.user).exists() - response = client.delete( - f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", - ) + with mock_reset_connections(document.id, str(access.user_id)): + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + ) - assert response.status_code == 204 - assert models.DocumentAccess.objects.count() == 1 + assert response.status_code == 204 + assert models.DocumentAccess.objects.count() == 1 @pytest.mark.parametrize("via", VIA) @@ -729,7 +769,11 @@ def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_tea @pytest.mark.parametrize("via", VIA) -def test_api_document_accesses_delete_owners(via, mock_user_teams): +def test_api_document_accesses_delete_owners( + via, + mock_user_teams, + mock_reset_connections, # pylint: disable=redefined-outer-name +): """ Users should be able to delete the document access of another user for a document of which they are owner. @@ -753,12 +797,13 @@ def test_api_document_accesses_delete_owners(via, mock_user_teams): assert models.DocumentAccess.objects.count() == 2 assert models.DocumentAccess.objects.filter(user=access.user).exists() - response = client.delete( - f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", - ) + with mock_reset_connections(document.id, str(access.user_id)): + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + ) - assert response.status_code == 204 - assert models.DocumentAccess.objects.count() == 1 + assert response.status_code == 204 + assert models.DocumentAccess.objects.count() == 1 @pytest.mark.parametrize("via", VIA) diff --git a/src/backend/core/tests/documents/test_api_documents_link_configuration.py b/src/backend/core/tests/documents/test_api_documents_link_configuration.py index 91f4d7e636..7683880579 100644 --- a/src/backend/core/tests/documents/test_api_documents_link_configuration.py +++ b/src/backend/core/tests/documents/test_api_documents_link_configuration.py @@ -6,6 +6,9 @@ from core import factories, models from core.api import serializers from core.tests.conftest import TEAM, USER, VIA +from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import + mock_reset_connections, +) pytestmark = pytest.mark.django_db @@ -116,7 +119,10 @@ def test_api_documents_link_configuration_update_authenticated_related_forbidden @pytest.mark.parametrize("role", ["administrator", "owner"]) @pytest.mark.parametrize("via", VIA) def test_api_documents_link_configuration_update_authenticated_related_success( - via, role, mock_user_teams + via, + role, + mock_user_teams, + mock_reset_connections, # pylint: disable=redefined-outer-name ): """ A user who is administrator or owner of a document should be allowed to update @@ -139,14 +145,16 @@ def test_api_documents_link_configuration_update_authenticated_related_success( new_document_values = serializers.LinkDocumentSerializer( instance=factories.DocumentFactory() ).data - response = client.put( - f"/api/v1.0/documents/{document.id!s}/link-configuration/", - new_document_values, - format="json", - ) - assert response.status_code == 200 - document = models.Document.objects.get(pk=document.pk) - document_values = serializers.LinkDocumentSerializer(instance=document).data - for key, value in document_values.items(): - assert value == new_document_values[key] + with mock_reset_connections(document.id): + response = client.put( + f"/api/v1.0/documents/{document.id!s}/link-configuration/", + new_document_values, + format="json", + ) + assert response.status_code == 200 + + document = models.Document.objects.get(pk=document.pk) + document_values = serializers.LinkDocumentSerializer(instance=document).data + for key, value in document_values.items(): + assert value == new_document_values[key] diff --git a/src/backend/core/tests/test_services_collaboration_services.py b/src/backend/core/tests/test_services_collaboration_services.py new file mode 100644 index 0000000000..7d02e2521a --- /dev/null +++ b/src/backend/core/tests/test_services_collaboration_services.py @@ -0,0 +1,185 @@ +""" +This module contains tests for the CollaborationService class in the +core.services.collaboration_services module. +""" + +import json +import re +from contextlib import contextmanager + +from django.core.exceptions import ImproperlyConfigured + +import pytest +import requests +import responses + +from core.services.collaboration_services import CollaborationService + + +@pytest.fixture +def mock_reset_connections(settings): + """ + Creates a context manager to mock the reset-connections endpoint for collaboration services. + Args: + settings: A settings object that contains the configuration for the collaboration API. + Returns: + A context manager function that mocks the reset-connections endpoint. + The context manager function takes the following parameters: + document_id (str): The ID of the document for which connections are being reset. + user_id (str, optional): The ID of the user making the request. Defaults to None. + Usage: + with mock_reset_connections(settings)(document_id, user_id) as mock: + # Your test code here + The context manager performs the following actions: + - Mocks the reset-connections endpoint using responses.RequestsMock. + - Sets the COLLABORATION_API_URL and COLLABORATION_SERVER_SECRET in the settings. + - Verifies that the reset-connections endpoint is called exactly once. + - Checks that the request URL and headers are correct. + - If user_id is provided, checks that the X-User-Id header is correct. + """ + + @contextmanager + def _mock_reset_connections(document_id, user_id=None): + with responses.RequestsMock() as rsps: + # Mock the reset-connections endpoint + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}reset-connections/?room={document_id}" + ) + rsps.add( + responses.POST, + endpoint_url, + json={}, + status=200, + ) + yield + + assert ( + len(rsps.calls) == 1 + ), "Expected one call to reset-connections endpoint" + request = rsps.calls[0].request + assert request.url == endpoint_url, f"Unexpected URL called: {request.url}" + assert ( + request.headers.get("Authorization") + == settings.COLLABORATION_SERVER_SECRET + ), "Incorrect Authorization header" + + if user_id: + assert ( + request.headers.get("X-User-Id") == user_id + ), "Incorrect X-User-Id header" + + return _mock_reset_connections + + +def test_init_without_api_url(settings): + """Test that ImproperlyConfigured is raised when COLLABORATION_API_URL is None.""" + settings.COLLABORATION_API_URL = None + with pytest.raises(ImproperlyConfigured): + CollaborationService() + + +def test_init_with_api_url(settings): + """Test that the service initializes correctly when COLLABORATION_API_URL is set.""" + settings.COLLABORATION_API_URL = "http://example.com/" + service = CollaborationService() + assert isinstance(service, CollaborationService) + + +@responses.activate +def test_reset_connections_with_user_id(settings): + """Test reset_connections with a provided user_id.""" + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + service = CollaborationService() + + room = "room1" + user_id = "user123" + endpoint_url = "http://example.com/reset-connections/?room=" + room + + responses.add(responses.POST, endpoint_url, json={}, status=200) + + service.reset_connections(room, user_id) + + assert len(responses.calls) == 1 + request = responses.calls[0].request + + assert request.url == endpoint_url + assert request.headers.get("Authorization") == "secret-token" + assert request.headers.get("X-User-Id") == "user123" + + +@responses.activate +def test_reset_connections_without_user_id(settings): + """Test reset_connections without a user_id.""" + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + service = CollaborationService() + + room = "room1" + user_id = None + endpoint_url = "http://example.com/reset-connections/?room=" + room + + responses.add( + responses.POST, + endpoint_url, + json={}, + status=200, + ) + + service.reset_connections(room, user_id) + + assert len(responses.calls) == 1 + request = responses.calls[0].request + + assert request.url == endpoint_url + assert request.headers.get("Authorization") == "secret-token" + assert request.headers.get("X-User-Id") is None + + +@responses.activate +def test_reset_connections_non_200_response(settings): + """Test that an HTTPError is raised when the response status is not 200.""" + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + service = CollaborationService() + + room = "room1" + user_id = "user123" + endpoint_url = "http://example.com/reset-connections/?room=" + room + response_body = {"error": "Internal Server Error"} + + responses.add(responses.POST, endpoint_url, json=response_body, status=500) + + expected_exception_message = re.escape( + "Failed to notify WebSocket server. Status code: 500, Response: " + ) + re.escape(json.dumps(response_body)) + + with pytest.raises(requests.HTTPError, match=expected_exception_message): + service.reset_connections(room, user_id) + + assert len(responses.calls) == 1 + + +@responses.activate +def test_reset_connections_request_exception(settings): + """Test that an HTTPError is raised when a RequestException occurs.""" + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + service = CollaborationService() + + room = "room1" + user_id = "user123" + endpoint_url = "http://example.com/reset-connections?room=" + room + + responses.add( + responses.POST, + endpoint_url, + body=requests.exceptions.ConnectionError("Network error"), + ) + + with pytest.raises(requests.HTTPError, match="Failed to notify WebSocket server."): + service.reset_connections(room, user_id) + + assert len(responses.calls) == 1 diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 4f65a60fe2..0aa608e5f4 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -372,6 +372,9 @@ class Base(Configuration): SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None) # Collaboration + COLLABORATION_API_URL = values.Value( + None, environ_name="COLLABORATION_API_URL", environ_prefix=None + ) COLLABORATION_SERVER_SECRET = values.Value( None, environ_name="COLLABORATION_SERVER_SECRET", environ_prefix=None ) diff --git a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts index 1d8604df6c..16f1ffdba5 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts @@ -6,7 +6,7 @@ import { createDoc } from './common'; const config = { CRISP_WEBSITE_ID: null, - COLLABORATION_WS_URL: 'ws://localhost:4444', + COLLABORATION_WS_URL: 'ws://localhost:8083/collaboration/ws/', ENVIRONMENT: 'development', FRONTEND_THEME: 'dsfr', MEDIA_BASE_URL: 'http://localhost:8083', @@ -117,7 +117,7 @@ test.describe('Config', () => { browserName, }) => { const webSocketPromise = page.waitForEvent('websocket', (webSocket) => { - return webSocket.url().includes('ws://localhost:4444/'); + return webSocket.url().includes('ws://localhost:8083/collaboration/ws/'); }); await page.goto('/'); @@ -131,7 +131,7 @@ test.describe('Config', () => { await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible(); const webSocket = await webSocketPromise; - expect(webSocket.url()).toContain('ws://localhost:4444/'); + expect(webSocket.url()).toContain('ws://localhost:8083/collaboration/ws/'); }); test('it checks that Crisp is trying to init from config endpoint', async ({ diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index d41660a5d8..221b08005b 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -81,26 +81,69 @@ test.describe('Doc Editor', () => { ).toBeVisible(); }); - test('checks the Doc is connected to the provider server', async ({ + /** + * We check: + * - connection to the collaborative server + * - signal of the backend to the collaborative server (connection should close) + * - reconnection to the collaborative server + */ + test('checks the connection with collaborative server', async ({ page, browserName, }) => { - const webSocketPromise = page.waitForEvent('websocket', (webSocket) => { - return webSocket.url().includes('ws://localhost:4444/'); + let webSocketPromise = page.waitForEvent('websocket', (webSocket) => { + return webSocket + .url() + .includes('ws://localhost:8083/collaboration/ws/?room='); }); const randomDoc = await createDoc(page, 'doc-editor', browserName, 1); await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible(); - const webSocket = await webSocketPromise; - expect(webSocket.url()).toContain('ws://localhost:4444/'); + let webSocket = await webSocketPromise; + expect(webSocket.url()).toContain( + 'ws://localhost:8083/collaboration/ws/?room=', + ); - const framesentPromise = webSocket.waitForEvent('framesent'); + // Is connected + let framesentPromise = webSocket.waitForEvent('framesent'); await page.locator('.ProseMirror.bn-editor').click(); await page.locator('.ProseMirror.bn-editor').fill('Hello World'); - const framesent = await framesentPromise; + let framesent = await framesentPromise; + expect(framesent.payload).not.toBeNull(); + + await page.getByRole('button', { name: 'Share' }).click(); + + const selectVisibility = page.getByRole('combobox', { + name: 'Visibility', + }); + + // When the visibility is changed, the ws should closed the connection (backend signal) + const wsClosePromise = webSocket.waitForEvent('close'); + + await selectVisibility.click(); + await page + .getByRole('option', { + name: 'Authenticated', + }) + .click(); + + // Assert that the doc reconnects to the ws + const wsClose = await wsClosePromise; + expect(wsClose.isClosed()).toBeTruthy(); + + // Checkt the ws is connected again + webSocketPromise = page.waitForEvent('websocket', (webSocket) => { + return webSocket + .url() + .includes('ws://localhost:8083/collaboration/ws/?room='); + }); + + webSocket = await webSocketPromise; + framesentPromise = webSocket.waitForEvent('framesent'); + framesent = await framesentPromise; expect(framesent.payload).not.toBeNull(); }); diff --git a/src/helm/env.d/dev/values.impress.yaml.gotmpl b/src/helm/env.d/dev/values.impress.yaml.gotmpl index e7e1bca596..c49c39d5fd 100644 --- a/src/helm/env.d/dev/values.impress.yaml.gotmpl +++ b/src/helm/env.d/dev/values.impress.yaml.gotmpl @@ -6,6 +6,7 @@ image: backend: replicas: 1 envVars: + COLLABORATION_API_URL: https://impress.127.0.0.1.nip.io/collaboration/api/ COLLABORATION_SERVER_SECRET: my-secret DJANGO_CSRF_TRUSTED_ORIGINS: https://impress.127.0.0.1.nip.io,http://impress.127.0.0.1.nip.io DJANGO_CONFIGURATION: Feature From cb2bf25b52589c7216be414fe216a5f4886ee278 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Tue, 3 Dec 2024 15:10:05 +0100 Subject: [PATCH 7/9] =?UTF-8?q?=F0=9F=93=88(collaboration)=20add=20sentry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add sentry to the collaboration server. It will be used to log errors and exceptions. --- src/frontend/servers/y-provider/package.json | 2 + src/frontend/servers/y-provider/src/env.ts | 1 + src/frontend/servers/y-provider/src/server.ts | 5 ++ .../servers/y-provider/src/services/sentry.ts | 11 +++ src/frontend/yarn.lock | 84 ++++++++++++++++++- 5 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 src/frontend/servers/y-provider/src/services/sentry.ts diff --git a/src/frontend/servers/y-provider/package.json b/src/frontend/servers/y-provider/package.json index 08586eb7fa..b53a477cf4 100644 --- a/src/frontend/servers/y-provider/package.json +++ b/src/frontend/servers/y-provider/package.json @@ -17,6 +17,8 @@ }, "dependencies": { "@hocuspocus/server": "2.14.0", + "@sentry/node": "8.41.0", + "@sentry/profiling-node": "8.41.0", "express": "4.21.1", "express-ws": "5.0.2", "y-protocols": "1.0.6" diff --git a/src/frontend/servers/y-provider/src/env.ts b/src/frontend/servers/y-provider/src/env.ts index 571d52e05c..e2a8d782ed 100644 --- a/src/frontend/servers/y-provider/src/env.ts +++ b/src/frontend/servers/y-provider/src/env.ts @@ -5,3 +5,4 @@ export const COLLABORATION_SERVER_ORIGIN = export const COLLABORATION_SERVER_SECRET = process.env.COLLABORATION_SERVER_SECRET || 'secret-api-key'; export const PORT = Number(process.env.PORT || 4444); +export const SENTRY_DSN = process.env.SENTRY_DSN || ''; diff --git a/src/frontend/servers/y-provider/src/server.ts b/src/frontend/servers/y-provider/src/server.ts index 2ff1ad6ced..804a58bf73 100644 --- a/src/frontend/servers/y-provider/src/server.ts +++ b/src/frontend/servers/y-provider/src/server.ts @@ -1,4 +1,7 @@ +// eslint-disable-next-line import/order +import './services/sentry'; import { Server } from '@hocuspocus/server'; +import * as Sentry from '@sentry/node'; import express, { Request, Response } from 'express'; import expressWebsockets from 'express-ws'; @@ -130,6 +133,8 @@ export const initServer = () => { }, ); + Sentry.setupExpressErrorHandler(app); + app.get('/ping', (req, res) => { res.status(200).json({ message: 'pong' }); }); diff --git a/src/frontend/servers/y-provider/src/services/sentry.ts b/src/frontend/servers/y-provider/src/services/sentry.ts new file mode 100644 index 0000000000..e47068014c --- /dev/null +++ b/src/frontend/servers/y-provider/src/services/sentry.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { nodeProfilingIntegration } from '@sentry/profiling-node'; + +import { SENTRY_DSN } from '../env'; + +Sentry.init({ + dsn: SENTRY_DSN, + integrations: [nodeProfilingIntegration()], + tracesSampleRate: 1.0, + profilesSampleRate: 1.0, +}); diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index d1525d01d0..f7ca77b096 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -3906,6 +3906,13 @@ "@sentry/cli-win32-i686" "2.39.1" "@sentry/cli-win32-x64" "2.39.1" +"@sentry/core@8.41.0": + version "8.41.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-8.41.0.tgz#e8a25cacd25fe4358f3179e85f3c2697427fe5a6" + integrity sha512-3v7u3t4LozCA5SpZY4yqUN2U3jSrkXNoLgz6L2SUUiydyCuSwXZIFEwpLJfgQyidpNDifeQbBI5E1O910XkPsA== + dependencies: + "@sentry/types" "8.41.0" + "@sentry/core@8.42.0": version "8.42.0" resolved "https://registry.yarnpkg.com/@sentry/core/-/core-8.42.0.tgz#9fc0db6794186dc2d1167cf82e579e387198ba77" @@ -3932,6 +3939,48 @@ rollup "3.29.5" stacktrace-parser "^0.1.10" +"@sentry/node@8.41.0": + version "8.41.0" + resolved "https://registry.yarnpkg.com/@sentry/node/-/node-8.41.0.tgz#52f970648d961f6e82a1b23a88c0000aa72ba21a" + integrity sha512-eYD5S8Lti9efBHFSIhZ/0C5uI1DQtGqjuNWQ62CKC47G2qgJddBtb2HgqRFAnMajYL9FXEtiDT6uqQhKQnmLcQ== + dependencies: + "@opentelemetry/api" "^1.9.0" + "@opentelemetry/context-async-hooks" "^1.25.1" + "@opentelemetry/core" "^1.25.1" + "@opentelemetry/instrumentation" "^0.54.0" + "@opentelemetry/instrumentation-amqplib" "^0.43.0" + "@opentelemetry/instrumentation-connect" "0.40.0" + "@opentelemetry/instrumentation-dataloader" "0.12.0" + "@opentelemetry/instrumentation-express" "0.44.0" + "@opentelemetry/instrumentation-fastify" "0.41.0" + "@opentelemetry/instrumentation-fs" "0.16.0" + "@opentelemetry/instrumentation-generic-pool" "0.39.0" + "@opentelemetry/instrumentation-graphql" "0.44.0" + "@opentelemetry/instrumentation-hapi" "0.41.0" + "@opentelemetry/instrumentation-http" "0.53.0" + "@opentelemetry/instrumentation-ioredis" "0.43.0" + "@opentelemetry/instrumentation-kafkajs" "0.4.0" + "@opentelemetry/instrumentation-knex" "0.41.0" + "@opentelemetry/instrumentation-koa" "0.43.0" + "@opentelemetry/instrumentation-lru-memoizer" "0.40.0" + "@opentelemetry/instrumentation-mongodb" "0.48.0" + "@opentelemetry/instrumentation-mongoose" "0.42.0" + "@opentelemetry/instrumentation-mysql" "0.41.0" + "@opentelemetry/instrumentation-mysql2" "0.41.0" + "@opentelemetry/instrumentation-nestjs-core" "0.40.0" + "@opentelemetry/instrumentation-pg" "0.44.0" + "@opentelemetry/instrumentation-redis-4" "0.42.0" + "@opentelemetry/instrumentation-tedious" "0.15.0" + "@opentelemetry/instrumentation-undici" "0.6.0" + "@opentelemetry/resources" "^1.26.0" + "@opentelemetry/sdk-trace-base" "^1.26.0" + "@opentelemetry/semantic-conventions" "^1.27.0" + "@prisma/instrumentation" "5.19.1" + "@sentry/core" "8.41.0" + "@sentry/opentelemetry" "8.41.0" + "@sentry/types" "8.41.0" + import-in-the-middle "^1.11.2" + "@sentry/node@8.42.0": version "8.42.0" resolved "https://registry.yarnpkg.com/@sentry/node/-/node-8.42.0.tgz#829a02ce322bf7ee13e2bd09acc2eb56a8e04525" @@ -3973,6 +4022,14 @@ "@sentry/opentelemetry" "8.42.0" import-in-the-middle "^1.11.2" +"@sentry/opentelemetry@8.41.0": + version "8.41.0" + resolved "https://registry.yarnpkg.com/@sentry/opentelemetry/-/opentelemetry-8.41.0.tgz#0e5c4bb8d5b58f07eb80e312fc66b8709a688c61" + integrity sha512-Ld6KdBQsmSk2IfFSoZ7CMpmuQbfb3viV6nTDCz6+11wL9S+1b+hadCN+38yBW4CmI4/hEpYfwwWQPseQQTvBCg== + dependencies: + "@sentry/core" "8.41.0" + "@sentry/types" "8.41.0" + "@sentry/opentelemetry@8.42.0": version "8.42.0" resolved "https://registry.yarnpkg.com/@sentry/opentelemetry/-/opentelemetry-8.42.0.tgz#d4a5e988689b3c64370eff5763e7cf3af4e43cba" @@ -3980,6 +4037,17 @@ dependencies: "@sentry/core" "8.42.0" +"@sentry/profiling-node@8.41.0": + version "8.41.0" + resolved "https://registry.yarnpkg.com/@sentry/profiling-node/-/profiling-node-8.41.0.tgz#47acdac0373fc1dcce182370082bfd5f4f47720e" + integrity sha512-0/40Hzrm8XzCQL+CM65ETZMW+SV8Jp71+E+w2uh3xobSQJQSsZKOOwSj5Nmp5QL6v5ZOZZE0fceVaym1lhQylw== + dependencies: + "@sentry/core" "8.41.0" + "@sentry/node" "8.41.0" + "@sentry/types" "8.41.0" + detect-libc "^2.0.2" + node-abi "^3.61.0" + "@sentry/react@8.42.0": version "8.42.0" resolved "https://registry.yarnpkg.com/@sentry/react/-/react-8.42.0.tgz#7c3ae19e5d9ea3332303fcd347eb220ec1c69e93" @@ -3989,6 +4057,11 @@ "@sentry/core" "8.42.0" hoist-non-react-statics "^3.3.2" +"@sentry/types@8.41.0": + version "8.41.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-8.41.0.tgz#db40c93bcedad26569c5dfe10a4b31253c349a10" + integrity sha512-eqdnGr9k9H++b9CjVUoTNUVahPVWeNnMy0YGkqS5+cjWWC+x43p56202oidGFmWo6702ub/xwUNH6M5PC4kq6A== + "@sentry/vercel-edge@8.42.0": version "8.42.0" resolved "https://registry.yarnpkg.com/@sentry/vercel-edge/-/vercel-edge-8.42.0.tgz#02149bacf42522916aacfe75fcd86f05c39fa48a" @@ -6373,7 +6446,7 @@ destroy@1.2.0: resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== -detect-libc@^2.0.3: +detect-libc@^2.0.2, detect-libc@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700" integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== @@ -10112,6 +10185,13 @@ no-case@^3.0.4: lower-case "^2.0.2" tslib "^2.0.3" +node-abi@^3.61.0: + version "3.71.0" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.71.0.tgz#52d84bbcd8575efb71468fbaa1f9a49b2c242038" + integrity sha512-SZ40vRiy/+wRTf21hxkkEjPJZpARzUMVcJoQse2EF8qkUWbbO2z7vd5oA/H6bVH6SZQ5STGcu0KRDS7biNRfxw== + dependencies: + semver "^7.3.5" + node-ensure@^0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/node-ensure/-/node-ensure-0.0.0.tgz#ecae764150de99861ec5c810fd5d096b183932a7" @@ -11749,7 +11829,7 @@ semver@^6.3.0, semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.5.2, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.3: +semver@^7.3.5, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.3: version "7.6.3" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== From a4dfd7dde34dbfed2254a72869450b1437474d2e Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Tue, 3 Dec 2024 15:19:26 +0100 Subject: [PATCH 8/9] =?UTF-8?q?=F0=9F=94=A7(helm)=20add=20ingress=20collab?= =?UTF-8?q?oration=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We need to keep the stickyness between the collaboration api and the ws server, to do so, we will use "upstream-hash-by: $arg_room", meaning that the stickyness will be based on the room query. We need to ahve 2 ingress to handle the "collaboration_auth", only the ws routes has to use the "collaboration_auth" subrequest. --- src/helm/env.d/dev/values.impress.yaml.gotmpl | 19 ++--- .../templates/ingress_collaboration_api.yaml | 72 +++++++++++++++++++ ..._ws.yaml => ingress_collaboration_ws.yaml} | 30 ++++---- src/helm/impress/values.yaml | 54 ++++++++++---- 4 files changed, 138 insertions(+), 37 deletions(-) create mode 100644 src/helm/impress/templates/ingress_collaboration_api.yaml rename src/helm/impress/templates/{ingress_ws.yaml => ingress_collaboration_ws.yaml} (60%) diff --git a/src/helm/env.d/dev/values.impress.yaml.gotmpl b/src/helm/env.d/dev/values.impress.yaml.gotmpl index c49c39d5fd..fd049cb77e 100644 --- a/src/helm/env.d/dev/values.impress.yaml.gotmpl +++ b/src/helm/env.d/dev/values.impress.yaml.gotmpl @@ -8,7 +8,7 @@ backend: envVars: COLLABORATION_API_URL: https://impress.127.0.0.1.nip.io/collaboration/api/ COLLABORATION_SERVER_SECRET: my-secret - DJANGO_CSRF_TRUSTED_ORIGINS: https://impress.127.0.0.1.nip.io,http://impress.127.0.0.1.nip.io + DJANGO_CSRF_TRUSTED_ORIGINS: https://impress.127.0.0.1.nip.io DJANGO_CONFIGURATION: Feature DJANGO_ALLOWED_HOSTS: impress.127.0.0.1.nip.io DJANGO_SECRET_KEY: {{ .Values.djangoSecretKey }} @@ -28,7 +28,9 @@ backend: OIDC_RP_CLIENT_ID: {{ .Values.oidc.clientId }} OIDC_RP_CLIENT_SECRET: {{ .Values.oidc.clientSecret }} OIDC_RP_SIGN_ALGO: RS256 - OIDC_RP_SCOPES: "openid email" + OIDC_RP_SCOPES: "openid email given_name usual_name" + USER_OIDC_FIELD_TO_SHORTNAME: "given_name" + USER_OIDC_FIELDS_TO_FULLNAME: "given_name,usual_name" OIDC_REDIRECT_ALLOWED_HOSTS: https://impress.127.0.0.1.nip.io OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}" LOGIN_REDIRECT_URL: https://impress.127.0.0.1.nip.io @@ -77,8 +79,6 @@ frontend: envVars: PORT: 8080 NEXT_PUBLIC_API_ORIGIN: https://impress.127.0.0.1.nip.io - NEXT_PUBLIC_Y_PROVIDER_URL: wss://impress.127.0.0.1.nip.io/ws - NEXT_PUBLIC_MEDIA_URL: https://impress.127.0.0.1.nip.io replicas: 1 command: @@ -107,15 +107,16 @@ ingress: enabled: true host: impress.127.0.0.1.nip.io -ingressWS: +ingressCollaborationWS: enabled: true host: impress.127.0.0.1.nip.io - + annotations: - nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, Can-Edit, User-Id" nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/collaboration-auth/ - nginx.ingress.kubernetes.io/enable-websocket: "true" - nginx.ingress.kubernetes.io/upstream-hash-by: "$request_uri" + +ingressCollaborationApi: + enabled: true + host: impress.127.0.0.1.nip.io ingressAdmin: enabled: true diff --git a/src/helm/impress/templates/ingress_collaboration_api.yaml b/src/helm/impress/templates/ingress_collaboration_api.yaml new file mode 100644 index 0000000000..9d9c07e876 --- /dev/null +++ b/src/helm/impress/templates/ingress_collaboration_api.yaml @@ -0,0 +1,72 @@ +{{- if .Values.ingressCollaborationApi.enabled -}} +{{- $fullName := include "impress.fullname" . -}} +{{- if and .Values.ingressCollaborationApi.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.ingressCollaborationApi.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.ingressCollaborationApi.annotations "kubernetes.io/ingress.class" .Values.ingressCollaborationApi.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }}-collaboration-api + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "impress.labels" . | nindent 4 }} + {{- with .Values.ingressCollaborationApi.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.ingressCollaborationApi.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.ingressCollaborationApi.className }} + {{- end }} + {{- if .Values.ingressCollaborationApi.tls.enabled }} + tls: + {{- if .Values.ingressCollaborationApi.host }} + - secretName: {{ $fullName }}-tls + hosts: + - {{ .Values.ingressCollaborationApi.host | quote }} + {{- end }} + {{- range .Values.ingressCollaborationApi.tls.additional }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- if .Values.ingressCollaborationApi.host }} + - host: {{ .Values.ingressCollaborationApi.host | quote }} + http: + paths: + - path: {{ .Values.ingressCollaborationApi.path | quote }} + {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }} + pathType: ImplementationSpecific + {{- end }} + backend: + service: + name: {{ include "impress.yProvider.fullname" . }} + port: + number: {{ .Values.yProvider.service.port }} + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ include "impress.yProvider.fullname" . }} + port: + number: {{ .Values.yProvider.service.port }} + {{- else }} + serviceName: {{ include "impress.yProvider.fullname" . }} + servicePort: {{ .Values.yProvider.service.port }} + {{- end }} + {{- with .Values.ingressCollaborationApi.customBackends }} + {{- toYaml . | nindent 10 }} + {{- end }} + {{- end }} +{{- end }} + diff --git a/src/helm/impress/templates/ingress_ws.yaml b/src/helm/impress/templates/ingress_collaboration_ws.yaml similarity index 60% rename from src/helm/impress/templates/ingress_ws.yaml rename to src/helm/impress/templates/ingress_collaboration_ws.yaml index 363c943915..ecb60ce639 100644 --- a/src/helm/impress/templates/ingress_ws.yaml +++ b/src/helm/impress/templates/ingress_collaboration_ws.yaml @@ -1,8 +1,8 @@ -{{- if .Values.ingressWS.enabled -}} +{{- if .Values.ingressCollaborationWS.enabled -}} {{- $fullName := include "impress.fullname" . -}} -{{- if and .Values.ingressWS.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} - {{- if not (hasKey .Values.ingressWS.annotations "kubernetes.io/ingress.class") }} - {{- $_ := set .Values.ingressWS.annotations "kubernetes.io/ingress.class" .Values.ingressWS.className}} +{{- if and .Values.ingressCollaborationWS.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.ingressCollaborationWS.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.ingressCollaborationWS.annotations "kubernetes.io/ingress.class" .Values.ingressCollaborationWS.className}} {{- end }} {{- end }} {{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} @@ -18,22 +18,22 @@ metadata: namespace: {{ .Release.Namespace | quote }} labels: {{- include "impress.labels" . | nindent 4 }} - {{- with .Values.ingressWS.annotations }} + {{- with .Values.ingressCollaborationWS.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} spec: - {{- if and .Values.ingressWS.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} - ingressClassName: {{ .Values.ingressWS.className }} + {{- if and .Values.ingressCollaborationWS.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.ingressCollaborationWS.className }} {{- end }} - {{- if .Values.ingressWS.tls.enabled }} + {{- if .Values.ingressCollaborationWS.tls.enabled }} tls: - {{- if .Values.ingressWS.host }} + {{- if .Values.ingressCollaborationWS.host }} - secretName: {{ $fullName }}-tls hosts: - - {{ .Values.ingressWS.host | quote }} + - {{ .Values.ingressCollaborationWS.host | quote }} {{- end }} - {{- range .Values.ingressWS.tls.additional }} + {{- range .Values.ingressCollaborationWS.tls.additional }} - hosts: {{- range .hosts }} - {{ . | quote }} @@ -42,11 +42,11 @@ spec: {{- end }} {{- end }} rules: - {{- if .Values.ingressWS.host }} - - host: {{ .Values.ingressWS.host | quote }} + {{- if .Values.ingressCollaborationWS.host }} + - host: {{ .Values.ingressCollaborationWS.host | quote }} http: paths: - - path: {{ .Values.ingressWS.path | quote }} + - path: {{ .Values.ingressCollaborationWS.path | quote }} {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }} pathType: ImplementationSpecific {{- end }} @@ -64,7 +64,7 @@ spec: serviceName: {{ include "impress.yProvider.fullname" . }} servicePort: {{ .Values.yProvider.service.port }} {{- end }} - {{- with .Values.ingressWS.customBackends }} + {{- with .Values.ingressCollaborationWS.customBackends }} {{- toYaml . | nindent 10 }} {{- end }} {{- end }} diff --git a/src/helm/impress/values.yaml b/src/helm/impress/values.yaml index 2e3d6c817f..3ebfe1199e 100644 --- a/src/helm/impress/values.yaml +++ b/src/helm/impress/values.yaml @@ -48,34 +48,62 @@ ingress: ## @param ingress.customBackends Add custom backends to ingress customBackends: [] -## @param ingressWS.enabled whether to enable the Ingress or not -## @param ingressWS.className IngressClass to use for the Ingress -## @param ingressWS.host Host for the Ingress -## @param ingressWS.path Path to use for the Ingress -ingressWS: +## @param ingressCollaborationWS.enabled whether to enable the Ingress or not +## @param ingressCollaborationWS.className IngressClass to use for the Ingress +## @param ingressCollaborationWS.host Host for the Ingress +## @param ingressCollaborationWS.path Path to use for the Ingress +ingressCollaborationWS: enabled: false className: null host: impress.example.com - path: /ws + path: /collaboration/ws/ ## @param ingress.hosts Additional host to configure for the Ingress hosts: [] # - chart-example.local - ## @param ingressWS.tls.enabled Wether to enable TLS for the Ingress - ## @skip ingressWS.tls.additional - ## @extra ingressWS.tls.additional[].secretName Secret name for additional TLS config - ## @extra ingressWS.tls.additional[].hosts[] Hosts for additional TLS config + ## @param ingressCollaborationWS.tls.enabled Wether to enable TLS for the Ingress + ## @skip ingressCollaborationWS.tls.additional + ## @extra ingressCollaborationWS.tls.additional[].secretName Secret name for additional TLS config + ## @extra ingressCollaborationWS.tls.additional[].hosts[] Hosts for additional TLS config tls: enabled: true additional: [] - ## @param ingressWS.customBackends Add custom backends to ingress + ## @param ingressCollaborationWS.customBackends Add custom backends to ingress customBackends: [] annotations: + nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Can-Edit, X-User-Id" nginx.ingress.kubernetes.io/auth-url: https://impress.example.com/api/v1.0/documents/collaboration-auth/ - nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, Can-Edit, User-Id" nginx.ingress.kubernetes.io/enable-websocket: "true" - nginx.ingress.kubernetes.io/upstream-hash-by: "$request_uri" + nginx.ingress.kubernetes.io/proxy-read-timeout: "86400" + nginx.ingress.kubernetes.io/proxy-send-timeout: "86400" + nginx.ingress.kubernetes.io/upstream-hash-by: $arg_room + +## @param ingressCollaborationApi.enabled whether to enable the Ingress or not +## @param ingressCollaborationApi.className IngressClass to use for the Ingress +## @param ingressCollaborationApi.host Host for the Ingress +## @param ingressCollaborationApi.path Path to use for the Ingress +ingressCollaborationApi: + enabled: false + className: null + host: impress.example.com + path: /collaboration/api/ + ## @param ingress.hosts Additional host to configure for the Ingress + hosts: [] + # - chart-example.local + ## @param ingressCollaborationApi.tls.enabled Wether to enable TLS for the Ingress + ## @skip ingressCollaborationApi.tls.additional + ## @extra ingressCollaborationApi.tls.additional[].secretName Secret name for additional TLS config + ## @extra ingressCollaborationApi.tls.additional[].hosts[] Hosts for additional TLS config + tls: + enabled: true + additional: [] + + ## @param ingressCollaborationApi.customBackends Add custom backends to ingress + customBackends: [] + + annotations: + nginx.ingress.kubernetes.io/upstream-hash-by: $arg_room ## @param ingressAdmin.enabled whether to enable the Ingress or not ## @param ingressAdmin.className IngressClass to use for the Ingress From 1e5976e5ccff505d305165080212b332b63e5b3e Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Wed, 11 Dec 2024 11:09:50 +0100 Subject: [PATCH 9/9] =?UTF-8?q?=E2=9A=A1=EF=B8=8F(frontend)=20remove=20deb?= =?UTF-8?q?ounce=20on=20useHeadings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We remove the debounce on useHeadings, it decreases the user experience and it's not necessary a big performance improvement. --- .../src/features/docs/doc-editor/hook/useHeadings.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useHeadings.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useHeadings.tsx index 9c60848731..9468a7963e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useHeadings.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useHeadings.tsx @@ -9,14 +9,11 @@ export const useHeadings = (editor: BlockNoteEditor) => { useEffect(() => { setHeadings(editor); - let timeout: NodeJS.Timeout; editor?.onEditorContentChange(() => { - clearTimeout(timeout); - timeout = setTimeout(() => setHeadings(editor), 200); + setHeadings(editor); }); return () => { - clearTimeout(timeout); resetHeadings(); }; }, [editor, resetHeadings, setHeadings]);