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/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..020bf10b45 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}" @@ -161,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/docker/files/etc/nginx/conf.d/default.conf b/docker/files/etc/nginx/conf.d/default.conf index 4255965553..2644822ce2 100644 --- a/docker/files/etc/nginx/conf.d/default.conf +++ b/docker/files/etc/nginx/conf.d/default.conf @@ -4,9 +4,58 @@ 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; + } + + 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 - 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 +70,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/env.d/development/common.dist b/env.d/development/common.dist index 6833f09be5..29570b4a6c 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -53,7 +53,10 @@ AI_API_KEY=password AI_MODEL=llama # Collaboration -COLLABORATION_WS_URL=ws://localhost:4444 +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/ # Frontend FRONTEND_THEME=dsfr diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 7b63a7f3a6..4c71689ce6 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,48 +22,36 @@ ) 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 from core.services.ai_services import AIService +from core.services.collaboration_services import CollaborationService 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})$" ) +COLLABORATION_WS_URL_PATTERN = re.compile(rf"(?:^|&)room=(?P{UUID_REGEX})(?:&|$)") # 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 +123,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 +132,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 +173,7 @@ def get_queryset(self): return queryset - @decorators.action( + @drf.decorators.action( detail=False, methods=["get"], url_name="me", @@ -196,7 +185,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 +220,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 +234,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 +255,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 +278,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 +301,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 +324,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 +380,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 +396,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 +419,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 +430,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 +438,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 +449,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 +465,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 +488,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 +496,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 +508,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 +521,13 @@ 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) - @decorators.action(detail=True, methods=["post", "delete"], url_path="favorite") + # 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") 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 +541,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 +555,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 +589,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 +609,108 @@ def retrieve_auth(self, request, *args, **kwargs): a 403 error). Note that we return 403 errors without any further details for security reasons. + 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. + """ + # 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 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'", + parsed_url.path, + pattern, + ) + raise drf.exceptions.PermissionDenied() + + try: + 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() + + # Fetch the document and check if the user has access + try: + 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 + + 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() + + logger.debug( + "Subrequest authorization successful. Extracted parameters: %s", 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): + """ + 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. """ - original_url = urlparse(request.META.get("HTTP_X_ORIGINAL_URL")) - match = MEDIA_URL_PATTERN.search(original_url.path) + url_params, _, _ = self._authorize_subrequest( + request, MEDIA_STORAGE_URL_PATTERN + ) + pk, key = url_params.values() - try: - pk, attachment_key = match.groups() - except AttributeError as excpt: - raise exceptions.PermissionDenied() from excpt + # Generate S3 authorization headers using the extracted URL parameters + request = utils.generate_s3_authorization_headers(f"{pk:s}/{key:s}") - # Check permission - try: - document = models.Document.objects.get(pk=pk) - except models.Document.DoesNotExist as excpt: - raise exceptions.PermissionDenied() from excpt + return drf.response.Response("authorized", headers=request.headers, status=200) - if not document.get_abilities(request.user).get("retrieve", False): - raise exceptions.PermissionDenied() + @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), + } - # 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=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 +736,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 +765,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. @@ -750,17 +820,39 @@ 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( - 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 +887,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 +900,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 +911,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 +934,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 +948,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 +988,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 +1045,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 +1056,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 +1083,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 +1108,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..2f52b2fe07 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -511,22 +511,24 @@ 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, + "collaboration_auth": can_get, "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 +712,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/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/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..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,11 +26,13 @@ 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, "invite_owner": False, "link_configuration": False, + "media_auth": True, "partial_update": document.link_role == "editor", "retrieve": True, "update": document.link_role == "editor", @@ -88,9 +90,11 @@ 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, + "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..17cab6cd73 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -98,9 +98,11 @@ 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, + "media_auth": False, "link_configuration": False, "partial_update": False, "retrieve": False, @@ -133,10 +135,12 @@ 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, "link_configuration": False, + "media_auth": True, "partial_update": False, "retrieve": True, "update": False, @@ -168,10 +172,12 @@ 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, "link_configuration": False, + "media_auth": True, "partial_update": True, "retrieve": True, "update": True, @@ -192,10 +198,12 @@ 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, "link_configuration": True, + "media_auth": True, "partial_update": True, "retrieve": True, "update": True, @@ -215,10 +223,12 @@ 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, "link_configuration": True, + "media_auth": True, "partial_update": True, "retrieve": True, "update": True, @@ -241,10 +251,12 @@ 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, "link_configuration": False, + "media_auth": True, "partial_update": True, "retrieve": True, "update": True, @@ -269,10 +281,12 @@ 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, "link_configuration": False, + "media_auth": True, "partial_update": False, "retrieve": True, "update": False, @@ -298,10 +312,12 @@ 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, "link_configuration": False, + "media_auth": True, "partial_update": False, "retrieve": True, "update": False, 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 50140620f8..0aa608e5f4 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -372,6 +372,12 @@ 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 + ) COLLABORATION_WS_URL = values.Value( None, environ_name="COLLABORATION_WS_URL", environ_prefix=None ) @@ -465,9 +471,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 +502,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/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/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]); 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 e15c38b0a8..b53a477cf4 100644 --- a/src/frontend/servers/y-provider/package.json +++ b/src/frontend/servers/y-provider/package.json @@ -6,24 +6,39 @@ "license": "MIT", "type": "module", "scripts": { - "build": "tsc -p .", + "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" }, "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" }, "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/env.ts b/src/frontend/servers/y-provider/src/env.ts new file mode 100644 index 0000000000..e2a8d782ed --- /dev/null +++ b/src/frontend/servers/y-provider/src/env.ts @@ -0,0 +1,8 @@ +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); +export const SENTRY_DSN = process.env.SENTRY_DSN || ''; 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/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..804a58bf73 100644 --- a/src/frontend/servers/y-provider/src/server.ts +++ b/src/frontend/servers/y-provider/src/server.ts @@ -1,18 +1,152 @@ +// 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'; -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' }); + }, + ); + + Sentry.setupExpressErrorHandler(app); + + 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/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/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/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 4b5063ae20..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" @@ -4493,6 +4566,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" @@ -4500,6 +4588,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" @@ -4533,6 +4626,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 +4681,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 +4774,16 @@ 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" + 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 +4853,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 +4897,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" @@ -4765,6 +4929,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" @@ -4802,6 +4984,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@*", "@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== + 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 +5225,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 +5371,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" @@ -5260,6 +5462,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" @@ -5447,6 +5654,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 +5775,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" @@ -5800,6 +6030,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" @@ -5815,6 +6050,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" @@ -5825,6 +6065,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 +6094,21 @@ 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== + +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" @@ -6093,6 +6360,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 +6374,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,12 +6431,22 @@ 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== -detect-libc@^2.0.3: +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.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== @@ -6191,6 +6468,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" @@ -6306,6 +6591,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 +6633,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 +6843,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 +7153,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 +7199,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" @@ -6941,6 +7295,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" @@ -7019,6 +7378,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 +7450,25 @@ 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" + 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" @@ -7319,7 +7710,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== @@ -7672,6 +8063,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" @@ -7735,6 +8131,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 +8224,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 +8312,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 +8348,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 +9681,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 +9696,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 +9711,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, 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 +10034,23 @@ 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== + +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" @@ -9680,11 +10124,16 @@ 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== +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" @@ -9695,6 +10144,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" @@ -9731,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" @@ -9867,6 +10328,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 +10452,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 +10490,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" @@ -10115,6 +10593,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" @@ -10455,6 +10940,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 +10980,20 @@ 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" + +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" @@ -10497,6 +11004,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" @@ -10523,6 +11035,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 +11777,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 +11796,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== @@ -11302,11 +11829,30 @@ 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== +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 +11860,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 +11892,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 +12115,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" @@ -11822,6 +12388,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" @@ -12026,6 +12615,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" @@ -12128,6 +12722,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" @@ -12185,6 +12791,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 +13034,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 +13119,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 +13168,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,11 +13724,16 @@ write-file-atomic@^5.0.1: imurmurhash "^0.1.4" signal-exit "^4.0.1" -ws@^8.11.0, ws@^8.17.1, ws@^8.18.0, ws@^8.5.0: +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== + 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" diff --git a/src/helm/env.d/dev/values.impress.yaml.gotmpl b/src/helm/env.d/dev/values.impress.yaml.gotmpl index 96d29c936c..fd049cb77e 100644 --- a/src/helm/env.d/dev/values.impress.yaml.gotmpl +++ b/src/helm/env.d/dev/values.impress.yaml.gotmpl @@ -6,9 +6,11 @@ image: 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: "*" + 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 + 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 @@ -26,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 @@ -75,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: @@ -96,11 +98,23 @@ 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 -ingressWS: +ingressCollaborationWS: + enabled: true + 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/collaboration-auth/ + +ingressCollaborationApi: enabled: true host: impress.127.0.0.1.nip.io @@ -113,7 +127,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/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 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 0a27ed2624..3ebfe1199e 100644 --- a/src/helm/impress/values.yaml +++ b/src/helm/impress/values.yaml @@ -48,32 +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/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 @@ -116,7 +146,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