diff --git a/CHANGELOG.md b/CHANGELOG.md index 289e1a9d..ebf80bfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,11 @@ and this project adheres to - 📝(project) add troubleshoot doc #1066 - 📝(project) add system-requirement doc #1066 - 🔧(front) configure x-frame-options to DENY in nginx conf #1084 -- (doc) add documentation to install with compose #855 - ✨(backend) allow to disable checking unsafe mimetype on attachment upload - ✨Ask for access #1081 +- ✨(doc) add documentation to install with compose #855 +- ✨ Give priority to users connected to collaboration server + (aka no websocket feature) #1093 ### Changed diff --git a/docs/env.md b/docs/env.md index 0f1a092f..5f239ff2 100644 --- a/docs/env.md +++ b/docs/env.md @@ -6,104 +6,104 @@ Here we describe all environment variables that can be set for the docs applicat These are the environment variables you can set for the `impress-backend` container. -| Option | Description | default | -| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated | -| AI_API_KEY | AI key to be used for AI Base url | | -| AI_BASE_URL | OpenAI compatible AI base url | | -| AI_FEATURE_ENABLED | Enable AI options | false | -| AI_MODEL | AI Model to use | | -| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true | -| API_USERS_LIST_LIMIT | Limit on API users | 5 | -| API_USERS_LIST_THROTTLE_RATE_BURST | throttle rate for api on burst | 30/minute | -| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | throttle rate for api | 180/hour | -| AWS_S3_ACCESS_KEY_ID | access id for s3 endpoint | | -| AWS_S3_ENDPOINT_URL | S3 endpoint | | -| AWS_S3_REGION_NAME | region name for s3 endpoint | | -| AWS_S3_SECRET_ACCESS_KEY | access key for s3 endpoint | | -| AWS_STORAGE_BUCKET_NAME | bucket name for s3 endpoint | impress-media-storage | -| CACHES_DEFAULT_TIMEOUT | cache default timeout | 30 | -| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs | -| COLLABORATION_API_URL | collaboration api host | | -| COLLABORATION_SERVER_SECRET | collaboration api secret | | -| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false | -| COLLABORATION_WS_URL | collaboration websocket url | | -| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content | -| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert | -| CONVERSION_API_SECURE | Require secure conversion api | false | -| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 | -| CONTENT_SECURITY_POLICY_DIRECTIVES | A dict of directives set in the Content-Security-Policy header | All directives are set to 'none' | -| CONTENT_SECURITY_POLICY_EXCLUDE_URL_PREFIXES | Url with this prefix will not have the header Content-Security-Policy included | | -| CRISP_WEBSITE_ID | crisp website id for support | | -| DB_ENGINE | engine to use for database connections | django.db.backends.postgresql_psycopg2 | -| DB_HOST | host of the database | localhost | -| DB_NAME | name of the database | impress | -| DB_PASSWORD | password to authenticate with | pass | -| DB_PORT | port of the database | 5432 | -| DB_USER | user to authenticate with | dinum | -| DJANGO_ALLOWED_HOSTS | allowed hosts | [] | -| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | celery broker transport options | {} | -| DJANGO_CELERY_BROKER_URL | celery broker url | redis://redis:6379/0 | -| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all CORS origins | false | -| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | list of origins allowed for CORS using regulair expressions | [] | -| DJANGO_CORS_ALLOWED_ORIGINS | list of origins allowed for CORS | [] | -| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] | -| DJANGO_EMAIL_BACKEND | email backend library | django.core.mail.backends.smtp.EmailBackend | -| DJANGO_EMAIL_BRAND_NAME | brand name for email | | -| DJANGO_EMAIL_FROM | email address used as sender | from@example.com | -| DJANGO_EMAIL_HOST | host name of email | | -| DJANGO_EMAIL_HOST_PASSWORD | password to authenticate with on the email host | | -| DJANGO_EMAIL_HOST_USER | user to authenticate with on the email host | | -| DJANGO_EMAIL_LOGO_IMG | logo for the email | | -| DJANGO_EMAIL_PORT | port used to connect to email host | | -| DJANGO_EMAIL_USE_SSL | use sstl for email host connection | false | -| DJANGO_EMAIL_USE_TLS | use tls for email host connection | false | -| DJANGO_SECRET_KEY | secret key | | -| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] | -| DOCUMENT_IMAGE_MAX_SIZE | maximum size of document in bytes | 10485760 | -| FRONTEND_CSS_URL | To add a external css file to the app | | -| FRONTEND_HOMEPAGE_FEATURE_ENABLED | frontend feature flag to display the homepage | false | -| FRONTEND_THEME | frontend theme to use | | -| LANGUAGE_CODE | default language | en-us | -| LOGGING_LEVEL_LOGGERS_APP | application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | -| LOGGING_LEVEL_LOGGERS_ROOT | default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | -| LOGIN_REDIRECT_URL | login redirect url | | -| LOGIN_REDIRECT_URL_FAILURE | login redirect url on failure | | -| LOGOUT_REDIRECT_URL | logout redirect url | | -| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend | -| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} | -| MEDIA_BASE_URL | | | -| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false | -| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} | -| OIDC_CREATE_USER | create used on OIDC | false | -| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | faillback to email for identification | true | -| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | | -| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | | -| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | | -| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | | -| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | | -| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] | -| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false | -| OIDC_RP_CLIENT_ID | client id used for OIDC | impress | -| OIDC_RP_CLIENT_SECRET | client secret used for OIDC | | -| OIDC_RP_SCOPES | scopes requested for OIDC | openid email | -| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 | -| OIDC_STORE_ID_TOKEN | Store OIDC token | true | -| OIDC_USE_NONCE | use nonce for OIDC | true | -| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] | -| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name | -| POSTHOG_KEY | posthog key for analytics | | -| REDIS_URL | cache url | redis://redis:6379/1 | -| SENTRY_DSN | sentry host | | -| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 | -| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false | -| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage | -| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 | -| THEME_CUSTOMIZATION_FILE_PATH | full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json | -| TRASHBIN_CUTOFF_DAYS | trashbin cutoff | 30 | -| USER_OIDC_ESSENTIAL_CLAIMS | essential claims in OIDC token | [] | -| Y_PROVIDER_API_BASE_URL | Y Provider url | | -| Y_PROVIDER_API_KEY | Y provider API key | | +| Option | Description | default | +|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------| +| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated | +| AI_API_KEY | AI key to be used for AI Base url | | +| AI_BASE_URL | OpenAI compatible AI base url | | +| AI_FEATURE_ENABLED | Enable AI options | false | +| AI_MODEL | AI Model to use | | +| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true | +| API_USERS_LIST_LIMIT | Limit on API users | 5 | +| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute | +| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour | +| AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | | +| AWS_S3_ENDPOINT_URL | S3 endpoint | | +| AWS_S3_REGION_NAME | Region name for s3 endpoint | | +| AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | | +| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage | +| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 | +| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs | +| COLLABORATION_API_URL | Collaboration api host | | +| COLLABORATION_SERVER_SECRET | Collaboration api secret | | +| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false | +| COLLABORATION_WS_URL | Collaboration websocket url | | +| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content | +| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert | +| CONVERSION_API_SECURE | Require secure conversion api | false | +| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 | +| CRISP_WEBSITE_ID | Crisp website id for support | | +| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 | +| DB_HOST | Host of the database | localhost | +| DB_NAME | Name of the database | impress | +| DB_PASSWORD | Password to authenticate with | pass | +| DB_PORT | Port of the database | 5432 | +| DB_USER | User to authenticate with | dinum | +| DJANGO_ALLOWED_HOSTS | Allowed hosts | [] | +| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} | +| DJANGO_CELERY_BROKER_URL | Celery broker url | redis://redis:6379/0 | +| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false | +| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | List of origins allowed for CORS using regulair expressions | [] | +| DJANGO_CORS_ALLOWED_ORIGINS | List of origins allowed for CORS | [] | +| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] | +| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend | +| DJANGO_EMAIL_BRAND_NAME | Brand name for email | | +| DJANGO_EMAIL_FROM | Email address used as sender | from@example.com | +| DJANGO_EMAIL_HOST | Hostname of email | | +| DJANGO_EMAIL_HOST_PASSWORD | Password to authenticate with on the email host | | +| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | | +| DJANGO_EMAIL_LOGO_IMG | Logo for the email | | +| DJANGO_EMAIL_PORT | Port used to connect to email host | | +| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false | +| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false | +| DJANGO_SECRET_KEY | Secret key | | +| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] | +| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 | +| FRONTEND_CSS_URL | To add a external css file to the app | | +| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false | +| FRONTEND_THEME | Frontend theme to use | | +| LANGUAGE_CODE | Default language | en-us | +| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | +| LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | +| LOGIN_REDIRECT_URL | Login redirect url | | +| LOGIN_REDIRECT_URL_FAILURE | Login redirect url on failure | | +| LOGOUT_REDIRECT_URL | Logout redirect url | | +| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend | +| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} | +| MEDIA_BASE_URL | | | +| NO_WEBSOCKET_CACHE_TIMEOUT | Cache used to store current editor session key when only users without websocket are editing a document | 120 | +| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false | +| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} | +| OIDC_CREATE_USER | Create used on OIDC | false | +| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | true | +| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | | +| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | | +| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | | +| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | | +| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | | +| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] | +| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false | +| OIDC_RP_CLIENT_ID | Client id used for OIDC | impress | +| OIDC_RP_CLIENT_SECRET | Client secret used for OIDC | | +| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email | +| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 | +| OIDC_STORE_ID_TOKEN | Store OIDC token | true | +| OIDC_USE_NONCE | Use nonce for OIDC | true | +| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] | +| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name | +| POSTHOG_KEY | Posthog key for analytics | | +| REDIS_URL | Cache url | redis://redis:6379/1 | +| SENTRY_DSN | Sentry host | | +| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 | +| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false | +| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage | +| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 | +| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json | +| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 | +| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] | +| Y_PROVIDER_API_BASE_URL | Y Provider url | | +| Y_PROVIDER_API_KEY | Y provider API key | | + ## impress-frontend image diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index b9cf1c08..c75a1f2f 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -239,6 +239,7 @@ class DocumentSerializer(ListDocumentSerializer): """Serialize documents with all fields for display in detail views.""" content = serializers.CharField(required=False) + websocket = serializers.BooleanField(required=False, write_only=True) class Meta: model = models.Document @@ -260,6 +261,7 @@ class DocumentSerializer(ListDocumentSerializer): "title", "updated_at", "user_roles", + "websocket", ] read_only_fields = [ "id", diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 61d3f20f..1d66ffcc 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -32,6 +32,7 @@ from rest_framework import filters, status, viewsets from rest_framework import response as drf_response from rest_framework.permissions import AllowAny from rest_framework.throttling import UserRateThrottle +from sentry_sdk import capture_exception from core import authentication, enums, models from core.services.ai_services import AIService @@ -634,6 +635,54 @@ class DocumentViewSet( """Override to implement a soft delete instead of dumping the record in database.""" instance.soft_delete() + def perform_update(self, serializer): + """Check rules about collaboration.""" + if serializer.validated_data.get("websocket"): + return super().perform_update(serializer) + + try: + connection_info = CollaborationService().get_document_connection_info( + serializer.instance.id, + self.request.session.session_key, + ) + except requests.HTTPError as e: + capture_exception(e) + connection_info = { + "count": 0, + "exists": False, + } + + if connection_info["count"] == 0: + # No websocket mode + logger.debug("update without connection found in the websocket server") + cache_key = f"docs:no-websocket:{serializer.instance.id}" + current_editor = cache.get(cache_key) + if not current_editor: + cache.set( + cache_key, + self.request.session.session_key, + settings.NO_WEBSOCKET_CACHE_TIMEOUT, + ) + elif current_editor != self.request.session.session_key: + raise drf.exceptions.PermissionDenied( + "You are not allowed to edit this document." + ) + cache.touch(cache_key, settings.NO_WEBSOCKET_CACHE_TIMEOUT) + return super().perform_update(serializer) + + if connection_info["exists"]: + # Websocket mode + logger.debug("session key found in the websocket server") + return super().perform_update(serializer) + + logger.debug( + "Users connected to the websocket but current editor not connected to it. Can not edit." + ) + + raise drf.exceptions.PermissionDenied( + "You are not allowed to edit this document." + ) + @drf.decorators.action( detail=False, methods=["get"], diff --git a/src/backend/core/middleware.py b/src/backend/core/middleware.py new file mode 100644 index 00000000..afb5a100 --- /dev/null +++ b/src/backend/core/middleware.py @@ -0,0 +1,21 @@ +"""Force session creation for all requests.""" + + +class ForceSessionMiddleware: + """ + Force session creation for unauthenticated users. + Must be used after Authentication middleware. + """ + + def __init__(self, get_response): + """Initialize the middleware.""" + self.get_response = get_response + + def __call__(self, request): + """Force session creation for unauthenticated users.""" + + if not request.user.is_authenticated and request.session.session_key is None: + request.session.create() + + response = self.get_response(request) + return response diff --git a/src/backend/core/services/collaboration_services.py b/src/backend/core/services/collaboration_services.py index dac16fa6..fe6229c5 100644 --- a/src/backend/core/services/collaboration_services.py +++ b/src/backend/core/services/collaboration_services.py @@ -41,3 +41,31 @@ class CollaborationService: f"Failed to notify WebSocket server. Status code: {response.status_code}, " f"Response: {response.text}" ) + + def get_document_connection_info(self, room, session_key): + """ + Get the connection info for a document. + """ + endpoint = "get-connections" + querystring = { + "room": room, + "sessionKey": session_key, + } + endpoint_url = f"{settings.COLLABORATION_API_URL}{endpoint}/" + + headers = {"Authorization": settings.COLLABORATION_SERVER_SECRET} + + try: + response = requests.get( + endpoint_url, headers=headers, params=querystring, timeout=10 + ) + except requests.RequestException as e: + raise requests.HTTPError("Failed to get document connection info.") from e + + if response.status_code != 200: + raise requests.HTTPError( + f"Failed to get document connection info. Status code: {response.status_code}, " + f"Response: {response.text}" + ) + + return response.json() diff --git a/src/backend/core/tests/documents/test_api_documents_update.py b/src/backend/core/tests/documents/test_api_documents_update.py index 1c583bc9..b24b8e73 100644 --- a/src/backend/core/tests/documents/test_api_documents_update.py +++ b/src/backend/core/tests/documents/test_api_documents_update.py @@ -5,8 +5,10 @@ Tests for Documents API endpoint in impress's core app: update import random from django.contrib.auth.models import AnonymousUser +from django.core.cache import cache import pytest +import responses from rest_framework.test import APIClient from core import factories, models @@ -44,6 +46,7 @@ def test_api_documents_update_anonymous_forbidden(reach, role, via_parent): new_document_values = serializers.DocumentSerializer( instance=factories.DocumentFactory() ).data + new_document_values["websocket"] = True response = APIClient().put( f"/api/v1.0/documents/{document.id!s}/", new_document_values, @@ -90,8 +93,9 @@ def test_api_documents_update_authenticated_unrelated_forbidden( old_document_values = serializers.DocumentSerializer(instance=document).data new_document_values = serializers.DocumentSerializer( - instance=factories.DocumentFactory() + instance=factories.DocumentFactory(), ).data + new_document_values["websocket"] = True response = client.put( f"/api/v1.0/documents/{document.id!s}/", new_document_values, @@ -141,8 +145,9 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated( old_document_values = serializers.DocumentSerializer(instance=document).data new_document_values = serializers.DocumentSerializer( - instance=factories.DocumentFactory() + instance=factories.DocumentFactory(), ).data + new_document_values["websocket"] = True response = client.put( f"/api/v1.0/documents/{document.id!s}/", new_document_values, @@ -206,6 +211,7 @@ def test_api_documents_update_authenticated_reader(via, via_parent, mock_user_te new_document_values = serializers.DocumentSerializer( instance=factories.DocumentFactory() ).data + new_document_values["websocket"] = True response = client.put( f"/api/v1.0/documents/{document.id!s}/", new_document_values, @@ -258,6 +264,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner( new_document_values = serializers.DocumentSerializer( instance=factories.DocumentFactory() ).data + new_document_values["websocket"] = True response = client.put( f"/api/v1.0/documents/{document.id!s}/", new_document_values, @@ -287,6 +294,274 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner( assert value == new_document_values[key] +@responses.activate +def test_api_documents_update_authenticated_no_websocket(settings): + """ + When a user updates the document, not connected to the websocket and is the first to update, + the document should be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + new_document_values["websocket"] = False + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + + ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False}) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 200 + + assert cache.get(f"docs:no-websocket:{document.id}") == session_key + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_update_authenticated_no_websocket_user_already_editing(settings): + """ + When a user updates the document, not connected to the websocket and is not the first to update, + the document should not be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + new_document_values["websocket"] = False + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False}) + + cache.set(f"docs:no-websocket:{document.id}", "other_session_key") + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 403 + assert response.json() == {"detail": "You are not allowed to edit this document."} + + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_update_no_websocket_other_user_connected_to_websocket(settings): + """ + When a user updates the document, not connected to the websocket and another user is connected + to the websocket, the document should not be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + new_document_values["websocket"] = False + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False}) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 403 + assert response.json() == {"detail": "You are not allowed to edit this document."} + assert cache.get(f"docs:no-websocket:{document.id}") is None + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_update_user_connected_to_websocket(settings): + """ + When a user updates the document, connected to the websocket, the document should be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + new_document_values["websocket"] = False + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True}) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 200 + assert cache.get(f"docs:no-websocket:{document.id}") is None + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websocket( + settings, +): + """ + When the websocket server is unreachable, the document should be updated like if the user was + not connected to the websocket. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + new_document_values["websocket"] = False + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, status=500) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 200 + + assert cache.get(f"docs:no-websocket:{document.id}") == session_key + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websocket_other_users( + settings, +): + """ + When the websocket server is unreachable, the behavior fallback to the no websocket one. + If an other user is already editing, the document should not be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + new_document_values["websocket"] = False + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, status=500) + + cache.set(f"docs:no-websocket:{document.id}", "other_session_key") + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 403 + + assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key" + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_update_force_websocket_param_to_true(settings): + """ + When the websocket parameter is set to true, the document should be updated without any check. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + new_document_values["websocket"] = True + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, status=500) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 200 + + assert cache.get(f"docs:no-websocket:{document.id}") is None + assert ws_resp.call_count == 0 + + @pytest.mark.parametrize("via", VIA) def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_teams): """ @@ -317,6 +592,7 @@ def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_t new_document_values = serializers.DocumentSerializer( instance=factories.DocumentFactory() ).data + new_document_values["websocket"] = True response = client.put( f"/api/v1.0/documents/{other_document.id!s}/", new_document_values, diff --git a/src/backend/core/tests/documents/test_api_documents_update_extract_attachments.py b/src/backend/core/tests/documents/test_api_documents_update_extract_attachments.py index cf30b5e6..5623749f 100644 --- a/src/backend/core/tests/documents/test_api_documents_update_extract_attachments.py +++ b/src/backend/core/tests/documents/test_api_documents_update_extract_attachments.py @@ -50,7 +50,7 @@ def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_qu with django_assert_num_queries(11): response = APIClient().put( f"/api/v1.0/documents/{document.id!s}/", - {"content": get_ydoc_with_mages(image_keys)}, + {"content": get_ydoc_with_mages(image_keys), "websocket": True}, format="json", ) assert response.status_code == 200 @@ -63,7 +63,7 @@ def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_qu with django_assert_num_queries(7): response = APIClient().put( f"/api/v1.0/documents/{document.id!s}/", - {"content": get_ydoc_with_mages(image_keys[:2])}, + {"content": get_ydoc_with_mages(image_keys[:2]), "websocket": True}, format="json", ) assert response.status_code == 200 diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 7a0635a2..730574e3 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -291,6 +291,7 @@ class Base(Configuration): "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "core.middleware.ForceSessionMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "dockerflow.django.middleware.DockerflowMiddleware", "csp.middleware.CSPMiddleware", @@ -480,6 +481,7 @@ class Base(Configuration): SESSION_COOKIE_AGE = values.PositiveIntegerValue( default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None ) + SESSION_COOKIE_NAME = "docs_sessionid" # OIDC - Authorization Code Flow OIDC_CREATE_USER = values.BooleanValue( @@ -659,6 +661,12 @@ class Base(Configuration): environ_prefix=None, ) + NO_WEBSOCKET_CACHE_TIMEOUT = values.Value( + default=120, + environ_name="NO_WEBSOCKET_CACHE_TIMEOUT", + environ_prefix=None, + ) + # Logging # We want to make it easy to log to console but by default we log production # to Sentry and don't want to log to console. @@ -853,15 +861,9 @@ class Development(Base): CSRF_TRUSTED_ORIGINS = ["http://localhost:8072", "http://localhost:3000"] DEBUG = True - SESSION_COOKIE_NAME = "impress_sessionid" - USE_SWAGGER = True - SESSION_CACHE_ALIAS = "session" CACHES = { "default": { - "BACKEND": "django.core.cache.backends.dummy.DummyCache", - }, - "session": { "BACKEND": "django_redis.cache.RedisCache", "LOCATION": values.Value( "redis://redis:6379/2",