diff --git a/CHANGELOG.md b/CHANGELOG.md index 289e1a9d1..ebf80bfc0 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 0f1a092f8..5f239ff2c 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/env.d/development/common.dist b/env.d/development/common.dist index b1c44194b..a0cf0fe5c 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -60,6 +60,7 @@ COLLABORATION_API_URL=http://y-provider-development:4444/collaboration/api/ COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000 COLLABORATION_SERVER_ORIGIN=http://localhost:3000 COLLABORATION_SERVER_SECRET=my-secret +COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=true COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/ DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index b9cf1c08e..c75a1f2f8 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -239,6 +239,7 @@ class DocumentSerializer(ListDocumentSerializer): """Serialize documents with all fields for display in detail views.""" content = serializers.CharField(required=False) + websocket = serializers.BooleanField(required=False, write_only=True) class Meta: model = models.Document @@ -260,6 +261,7 @@ class Meta: "title", "updated_at", "user_roles", + "websocket", ] read_only_fields = [ "id", diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 61d3f20f6..03c1752ce 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -634,6 +634,83 @@ def perform_destroy(self, instance): """Override to implement a soft delete instead of dumping the record in database.""" instance.soft_delete() + def _can_user_edit_document(self, document_id, set_cache=False): + """Check if the user can edit the document.""" + try: + count, exists = CollaborationService().get_document_connection_info( + document_id, + self.request.session.session_key, + ) + except requests.HTTPError as e: + logger.exception("Failed to call collaboration server: %s", e) + count = 0 + exists = False + + if count == 0: + # Nobody is connected to the websocket server + logger.debug("update without connection found in the websocket server") + cache_key = f"docs:no-websocket:{document_id}" + current_editor = cache.get(cache_key) + + if not current_editor: + if set_cache: + cache.set( + cache_key, + self.request.session.session_key, + settings.NO_WEBSOCKET_CACHE_TIMEOUT, + ) + return True + + if current_editor != self.request.session.session_key: + return False + + if set_cache: + cache.touch(cache_key, settings.NO_WEBSOCKET_CACHE_TIMEOUT) + return True + + if exists: + # Current user is connected to the websocket server + logger.debug("session key found in the websocket server") + return True + + logger.debug( + "Users connected to the websocket but current editor not connected to it. Can not edit." + ) + + return False + + def perform_update(self, serializer): + """Check rules about collaboration.""" + if ( + serializer.validated_data.get("websocket", False) + or not settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY + ): + return super().perform_update(serializer) + + if self._can_user_edit_document(serializer.instance.id, set_cache=True): + return super().perform_update(serializer) + + raise drf.exceptions.PermissionDenied( + "You are not allowed to edit this document." + ) + + @drf.decorators.action( + detail=True, + methods=["get"], + url_path="can-edit", + ) + def can_edit(self, request, *args, **kwargs): + """Check if the current user can edit the document.""" + document = self.get_object() + + can_edit = ( + True + if not settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY + else self._can_user_edit_document(document.id) + ) + + return drf.response.Response({"can_edit": can_edit}) + @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 000000000..afb5a1006 --- /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/models.py b/src/backend/core/models.py index 9d8d2db5f..ead7161e9 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -836,6 +836,7 @@ def get_abilities(self, user, ancestors_links=None): "ai_translate": ai_access, "attachment_upload": can_update, "media_check": can_get, + "can_edit": can_update, "children_list": can_get, "children_create": can_update and user.is_authenticated, "collaboration_auth": can_get, diff --git a/src/backend/core/services/collaboration_services.py b/src/backend/core/services/collaboration_services.py index dac16fa6d..ae4df1d5e 100644 --- a/src/backend/core/services/collaboration_services.py +++ b/src/backend/core/services/collaboration_services.py @@ -41,3 +41,31 @@ def reset_connections(self, room, user_id=None): f"Failed to notify WebSocket server. Status code: {response.status_code}, " f"Response: {response.text}" ) + + def get_document_connection_info(self, room, session_key): + """ + Get the connection info for a document. + """ + endpoint = "get-connections" + querystring = { + "room": room, + "sessionKey": session_key, + } + endpoint_url = f"{settings.COLLABORATION_API_URL}{endpoint}/" + + headers = {"Authorization": settings.COLLABORATION_SERVER_SECRET} + + try: + response = requests.get( + endpoint_url, headers=headers, params=querystring, timeout=10 + ) + except requests.RequestException as e: + raise requests.HTTPError("Failed to get document connection info.") from e + + if response.status_code != 200: + raise requests.HTTPError( + f"Failed to get document connection info. Status code: {response.status_code}, " + f"Response: {response.text}" + ) + result = response.json() + return result.get("count", 0), result.get("exists", False) diff --git a/src/backend/core/tests/documents/test_api_documents_can_edit.py b/src/backend/core/tests/documents/test_api_documents_can_edit.py new file mode 100644 index 000000000..7db0a13c5 --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_can_edit.py @@ -0,0 +1,248 @@ +"""Test the can_edit endpoint in the viewset DocumentViewSet.""" + +from django.core.cache import cache + +import pytest +import responses +from rest_framework.test import APIClient + +from core import factories + +pytestmark = pytest.mark.django_db + + +@responses.activate +@pytest.mark.parametrize("ws_not_connected_ready_only", [True, False]) +@pytest.mark.parametrize("role", ["editor", "reader"]) +def test_api_documents_can_edit_anonymous(settings, ws_not_connected_ready_only, role): + """Anonymous users can edit documents when link_role is editor.""" + document = factories.DocumentFactory(link_reach="public", link_role=role) + client = APIClient() + session_key = client.session.session_key + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = ws_not_connected_ready_only + 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}) + + response = client.get(f"/api/v1.0/documents/{document.id!s}/can-edit/") + + if role == "reader": + assert response.status_code == 401 + else: + assert response.status_code == 200 + assert response.json() == {"can_edit": True} + assert ws_resp.call_count == (1 if ws_not_connected_ready_only else 0) + + +@responses.activate +@pytest.mark.parametrize("ws_not_connected_ready_only", [True, False]) +def test_api_documents_can_edit_authenticated_no_websocket( + settings, ws_not_connected_ready_only +): + """ + A user not connected to the websocket and no other user have already updated the document, + the document can be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = ws_not_connected_ready_only + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + + ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False}) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/can-edit/", + ) + assert response.status_code == 200 + + assert response.json() == {"can_edit": True} + assert ws_resp.call_count == (1 if ws_not_connected_ready_only else 0) + + +@responses.activate +def test_api_documents_can_edit_authenticated_no_websocket_user_already_editing( + settings, +): + """ + A user not connected to the websocket and another user have already updated the document, + the document can not be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False}) + + cache.set(f"docs:no-websocket:{document.id}", "other_session_key") + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/can-edit/", + ) + assert response.status_code == 200 + assert response.json() == {"can_edit": False} + + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_can_edit_no_websocket_other_user_connected_to_websocket( + settings, +): + """ + A user not connected to the websocket and another user is connected to the websocket, + the document can not be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False}) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/can-edit/", + ) + assert response.status_code == 200 + assert response.json() == {"can_edit": False} + assert cache.get(f"docs:no-websocket:{document.id}") is None + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_can_edit_user_connected_to_websocket(settings): + """ + A user connected to the websocket, the document can be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True}) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/can-edit/", + ) + assert response.status_code == 200 + assert response.json() == {"can_edit": True} + assert cache.get(f"docs:no-websocket:{document.id}") is None + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket( + settings, +): + """ + When the websocket server is unreachable, the document can be updated like if the user was + not connected to the websocket. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, status=500) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/can-edit/", + ) + assert response.status_code == 200 + assert response.json() == {"can_edit": True} + + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket_other_users( + settings, +): + """ + When the websocket server is unreachable, the behavior fallback to the no websocket one. + If an other user is already editing, the document can not be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, status=500) + + cache.set(f"docs:no-websocket:{document.id}", "other_session_key") + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/can-edit/", + ) + assert response.status_code == 200 + assert response.json() == {"can_edit": False} + + assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key" + assert ws_resp.call_count == 1 diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py index 80b135d3b..fbecf2f5b 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -31,6 +31,7 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "ai_transform": False, "ai_translate": False, "attachment_upload": document.link_role == "editor", + "can_edit": document.link_role == "editor", "children_create": False, "children_list": True, "collaboration_auth": True, @@ -99,6 +100,7 @@ def test_api_documents_retrieve_anonymous_public_parent(): "ai_transform": False, "ai_translate": False, "attachment_upload": grand_parent.link_role == "editor", + "can_edit": grand_parent.link_role == "editor", "children_create": False, "children_list": True, "collaboration_auth": True, @@ -196,6 +198,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "ai_transform": document.link_role == "editor", "ai_translate": document.link_role == "editor", "attachment_upload": document.link_role == "editor", + "can_edit": document.link_role == "editor", "children_create": document.link_role == "editor", "children_list": True, "collaboration_auth": True, @@ -271,6 +274,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea "ai_transform": grand_parent.link_role == "editor", "ai_translate": grand_parent.link_role == "editor", "attachment_upload": grand_parent.link_role == "editor", + "can_edit": grand_parent.link_role == "editor", "children_create": grand_parent.link_role == "editor", "children_list": True, "collaboration_auth": True, @@ -452,6 +456,7 @@ def test_api_documents_retrieve_authenticated_related_parent(): "ai_transform": access.role != "reader", "ai_translate": access.role != "reader", "attachment_upload": access.role != "reader", + "can_edit": access.role != "reader", "children_create": access.role != "reader", "children_list": True, "collaboration_auth": True, diff --git a/src/backend/core/tests/documents/test_api_documents_trashbin.py b/src/backend/core/tests/documents/test_api_documents_trashbin.py index 4e4eb2769..61ccc0214 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -75,6 +75,7 @@ def test_api_documents_trashbin_format(): "ai_transform": True, "ai_translate": True, "attachment_upload": True, + "can_edit": True, "children_create": True, "children_list": True, "collaboration_auth": True, diff --git a/src/backend/core/tests/documents/test_api_documents_update.py b/src/backend/core/tests/documents/test_api_documents_update.py index 1c583bc95..03b1891bd 100644 --- a/src/backend/core/tests/documents/test_api_documents_update.py +++ b/src/backend/core/tests/documents/test_api_documents_update.py @@ -5,8 +5,10 @@ import random from django.contrib.auth.models import AnonymousUser +from django.core.cache import cache import pytest +import responses from rest_framework.test import APIClient from core import factories, models @@ -44,6 +46,7 @@ def test_api_documents_update_anonymous_forbidden(reach, role, via_parent): new_document_values = serializers.DocumentSerializer( instance=factories.DocumentFactory() ).data + new_document_values["websocket"] = True response = APIClient().put( f"/api/v1.0/documents/{document.id!s}/", new_document_values, @@ -90,8 +93,9 @@ def test_api_documents_update_authenticated_unrelated_forbidden( old_document_values = serializers.DocumentSerializer(instance=document).data new_document_values = serializers.DocumentSerializer( - instance=factories.DocumentFactory() + instance=factories.DocumentFactory(), ).data + new_document_values["websocket"] = True response = client.put( f"/api/v1.0/documents/{document.id!s}/", new_document_values, @@ -141,8 +145,9 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated( old_document_values = serializers.DocumentSerializer(instance=document).data new_document_values = serializers.DocumentSerializer( - instance=factories.DocumentFactory() + instance=factories.DocumentFactory(), ).data + new_document_values["websocket"] = True response = client.put( f"/api/v1.0/documents/{document.id!s}/", new_document_values, @@ -206,6 +211,7 @@ def test_api_documents_update_authenticated_reader(via, via_parent, mock_user_te new_document_values = serializers.DocumentSerializer( instance=factories.DocumentFactory() ).data + new_document_values["websocket"] = True response = client.put( f"/api/v1.0/documents/{document.id!s}/", new_document_values, @@ -258,6 +264,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner( new_document_values = serializers.DocumentSerializer( instance=factories.DocumentFactory() ).data + new_document_values["websocket"] = True response = client.put( f"/api/v1.0/documents/{document.id!s}/", new_document_values, @@ -287,6 +294,318 @@ 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" + settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True + 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" + settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True + 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" + settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True + 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" + settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True + 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" + settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True + 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" + settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True + 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 + + +@responses.activate +def test_api_documents_update_feature_flag_disabled(settings): + """ + When the feature flag is disabled, 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"] = False + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = False + 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 +636,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 cf30b5e66..5623749fd 100644 --- a/src/backend/core/tests/documents/test_api_documents_update_extract_attachments.py +++ b/src/backend/core/tests/documents/test_api_documents_update_extract_attachments.py @@ -50,7 +50,7 @@ def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_qu with django_assert_num_queries(11): response = APIClient().put( f"/api/v1.0/documents/{document.id!s}/", - {"content": get_ydoc_with_mages(image_keys)}, + {"content": get_ydoc_with_mages(image_keys), "websocket": True}, format="json", ) assert response.status_code == 200 @@ -63,7 +63,7 @@ def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_qu with django_assert_num_queries(7): response = APIClient().put( f"/api/v1.0/documents/{document.id!s}/", - {"content": get_ydoc_with_mages(image_keys[:2])}, + {"content": get_ydoc_with_mages(image_keys[:2]), "websocket": True}, format="json", ) assert response.status_code == 200 diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index 1e81e83c8..a791f3d82 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -155,6 +155,7 @@ def test_models_documents_get_abilities_forbidden( "ai_transform": False, "ai_translate": False, "attachment_upload": False, + "can_edit": False, "children_create": False, "children_list": False, "collaboration_auth": False, @@ -216,6 +217,7 @@ def test_models_documents_get_abilities_reader( "ai_transform": False, "ai_translate": False, "attachment_upload": False, + "can_edit": False, "children_create": False, "children_list": True, "collaboration_auth": True, @@ -279,6 +281,7 @@ def test_models_documents_get_abilities_editor( "ai_transform": is_authenticated, "ai_translate": is_authenticated, "attachment_upload": True, + "can_edit": True, "children_create": is_authenticated, "children_list": True, "collaboration_auth": True, @@ -331,6 +334,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): "ai_transform": True, "ai_translate": True, "attachment_upload": True, + "can_edit": True, "children_create": True, "children_list": True, "collaboration_auth": True, @@ -380,6 +384,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries) "ai_transform": True, "ai_translate": True, "attachment_upload": True, + "can_edit": True, "children_create": True, "children_list": True, "collaboration_auth": True, @@ -432,6 +437,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): "ai_transform": True, "ai_translate": True, "attachment_upload": True, + "can_edit": True, "children_create": True, "children_list": True, "collaboration_auth": True, @@ -491,6 +497,7 @@ def test_models_documents_get_abilities_reader_user( "ai_transform": access_from_link and ai_access_setting != "restricted", "ai_translate": access_from_link and ai_access_setting != "restricted", "attachment_upload": access_from_link, + "can_edit": access_from_link, "children_create": access_from_link, "children_list": True, "collaboration_auth": True, @@ -548,6 +555,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): "ai_transform": False, "ai_translate": False, "attachment_upload": False, + "can_edit": False, "children_create": False, "children_list": True, "collaboration_auth": True, diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 7a0635a2f..730574e3d 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", diff --git a/src/frontend/apps/e2e/__tests__/app-impress/common.ts b/src/frontend/apps/e2e/__tests__/app-impress/common.ts index c91c42d78..fede1a9b1 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/common.ts @@ -6,7 +6,7 @@ export const CONFIG = { AI_FEATURE_ENABLED: true, CRISP_WEBSITE_ID: null, COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/', - COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: false, + COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true, ENVIRONMENT: 'development', FRONTEND_CSS_URL: null, FRONTEND_HOMEPAGE_FEATURE_ENABLED: true, 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 f64139b79..12da91e88 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 @@ -1,14 +1,13 @@ import path from 'path'; -import { expect, test } from '@playwright/test'; +import { chromium, expect, test } from '@playwright/test'; import cs from 'convert-stream'; import { - CONFIG, - addNewMember, createDoc, goToGridDoc, mockedDocument, + overrideConfig, verifyDocName, } from './common'; @@ -522,52 +521,141 @@ test.describe('Doc Editor', () => { test('it checks block editing when not connected to collab server', async ({ page, + browserName, }) => { - await page.route('**/api/v1.0/config/', async (route) => { - const request = route.request(); - if (request.method().includes('GET')) { - await route.fulfill({ - json: { - ...CONFIG, - COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/', - COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true, - }, - }); - } else { - await route.continue(); - } + /** + * The good port is 4444, but we want to simulate a not connected + * collaborative server. + * So we use a port that is not used by the collaborative server. + * The server will not be able to connect to the collaborative server. + */ + await overrideConfig(page, { + COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/', }); await page.goto('/'); - void page - .getByRole('button', { - name: 'New doc', - }) - .click(); + const [title] = await createDoc(page, 'editing-blocking', browserName, 1); const card = page.getByLabel('It is the card information'); await expect( - card.getByText('Your network do not allow you to edit'), + card.getByText('Others are editing. Your network prevent changes.'), ).toBeHidden(); const editor = page.locator('.ProseMirror'); await expect(editor).toHaveAttribute('contenteditable', 'true'); + let responseCanEditPromise = page.waitForResponse( + (response) => + response.url().includes(`/can-edit/`) && response.status() === 200, + ); + await page.getByRole('button', { name: 'Share' }).click(); - await addNewMember(page, 0, 'Editor', 'impress'); + await page.getByLabel('Visibility', { exact: true }).click(); + + await page + .getByRole('menuitem', { + name: 'Public', + }) + .click(); + + await expect( + page.getByText('The document visibility has been updated.'), + ).toBeVisible(); + + await page.getByLabel('Visibility mode').click(); + await page.getByRole('menuitem', { name: 'Editing' }).click(); // Close the modal await page.getByRole('button', { name: 'close' }).first().click(); + let responseCanEdit = await responseCanEditPromise; + expect(responseCanEdit.ok()).toBeTruthy(); + let jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean }; + expect(jsonCanEdit.can_edit).toBeTruthy(); + + const urlDoc = page.url(); + + /** + * We open another browser that will connect to the collaborative server + * and will block the current browser to edit the doc. + */ + const otherBrowser = await chromium.launch({ headless: true }); + const otherContext = await otherBrowser.newContext({ + locale: 'en-US', + timezoneId: 'Europe/Paris', + permissions: [], + storageState: { + cookies: [], + origins: [], + }, + }); + const otherPage = await otherContext.newPage(); + + const webSocketPromise = otherPage.waitForEvent( + 'websocket', + (webSocket) => { + return webSocket + .url() + .includes('ws://localhost:4444/collaboration/ws/?room='); + }, + ); + + await otherPage.goto(urlDoc); + + const webSocket = await webSocketPromise; + expect(webSocket.url()).toContain( + 'ws://localhost:4444/collaboration/ws/?room=', + ); + + await verifyDocName(otherPage, title); + + await page.reload(); + + responseCanEditPromise = page.waitForResponse( + (response) => + response.url().includes(`/can-edit/`) && response.status() === 200, + ); + + responseCanEdit = await responseCanEditPromise; + expect(responseCanEdit.ok()).toBeTruthy(); + + jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean }; + expect(jsonCanEdit.can_edit).toBeFalsy(); + await expect( - card.getByText('Your network do not allow you to edit'), + card.getByText('Others are editing. Your network prevent changes.'), ).toBeVisible({ timeout: 10000, }); await expect(editor).toHaveAttribute('contenteditable', 'false'); + + await page.getByRole('button', { name: 'Share' }).click(); + + await page.getByLabel('Visibility mode').click(); + await page.getByRole('menuitem', { name: 'Reading' }).click(); + + // Close the modal + await page.getByRole('button', { name: 'close' }).first().click(); + + await page.reload(); + + responseCanEditPromise = page.waitForResponse( + (response) => + response.url().includes(`/can-edit/`) && response.status() === 200, + ); + + responseCanEdit = await responseCanEditPromise; + expect(responseCanEdit.ok()).toBeTruthy(); + + jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean }; + expect(jsonCanEdit.can_edit).toBeTruthy(); + + await expect( + card.getByText('Others are editing. Your network prevent changes.'), + ).toBeHidden(); }); test('it checks if callout custom block', async ({ page, browserName }) => { diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts index 0eec24556..feaf18a6a 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts @@ -15,49 +15,11 @@ test.beforeEach(async ({ page }) => { }); test.describe('Doc Header', () => { - test('it checks the element are correctly displayed', async ({ page }) => { - await mockedDocument(page, { - accesses: [ - { - id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg', - role: 'owner', - user: { - email: 'super@owner.com', - full_name: 'Super Owner', - }, - }, - { - id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg', - role: 'admin', - user: { - email: 'super@admin.com', - }, - }, - { - id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg', - role: 'owner', - user: { - email: 'super2@owner.com', - }, - }, - ], - abilities: { - destroy: true, // Means owner - link_configuration: true, - versions_destroy: true, - versions_list: true, - versions_retrieve: true, - accesses_manage: true, - accesses_view: true, - update: true, - partial_update: true, - retrieve: true, - }, - link_reach: 'public', - created_at: '2021-09-01T09:00:00Z', - }); - - await goToGridDoc(page); + test('it checks the element are correctly displayed', async ({ + page, + browserName, + }) => { + await createDoc(page, 'doc-update', browserName, 1); const card = page.getByLabel( 'It is the card information about the document.', @@ -66,6 +28,18 @@ test.describe('Doc Header', () => { const docTitle = card.getByRole('textbox', { name: 'doc title input' }); await expect(docTitle).toBeVisible(); + await page.getByRole('button', { name: 'Share' }).click(); + + await page.getByLabel('Visibility', { exact: true }).click(); + + await page + .getByRole('menuitem', { + name: 'Public', + }) + .click(); + + await page.getByRole('button', { name: 'close' }).first().click(); + await expect(card.getByText('Public document')).toBeVisible(); await expect(card.getByText('Owner ·')).toBeVisible(); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index 9a486b586..fe419b809 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -50,9 +50,10 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const { t } = useTranslation(); const { isEditable, isLoading } = useIsCollaborativeEditable(doc); + const isConnectedToCollabServer = provider.isSynced; const readOnly = !doc.abilities.partial_update || !isEditable || isLoading; - useSaveDoc(doc.id, provider.document, !readOnly); + useSaveDoc(doc.id, provider.document, !readOnly, isConnectedToCollabServer); const { i18n } = useTranslation(); const lang = i18n.resolvedLanguage; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx index 0a20001db..a2f419381 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx @@ -41,7 +41,7 @@ describe('useSaveDoc', () => { const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); - renderHook(() => useSaveDoc(docId, yDoc, true), { + renderHook(() => useSaveDoc(docId, yDoc, true, true), { wrapper: AppWrapper, }); @@ -73,7 +73,7 @@ describe('useSaveDoc', () => { }), }); - renderHook(() => useSaveDoc(docId, yDoc, false), { + renderHook(() => useSaveDoc(docId, yDoc, false, true), { wrapper: AppWrapper, }); @@ -107,7 +107,7 @@ describe('useSaveDoc', () => { }), }); - renderHook(() => useSaveDoc(docId, yDoc, true), { + renderHook(() => useSaveDoc(docId, yDoc, true, true), { wrapper: AppWrapper, }); @@ -143,7 +143,7 @@ describe('useSaveDoc', () => { }), }); - renderHook(() => useSaveDoc(docId, yDoc, true), { + renderHook(() => useSaveDoc(docId, yDoc, true, true), { wrapper: AppWrapper, }); @@ -164,7 +164,7 @@ describe('useSaveDoc', () => { const docId = 'test-doc-id'; const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); - const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true), { + const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true, true), { wrapper: AppWrapper, }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx index 274adcfff..c6ca782e1 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx @@ -10,7 +10,12 @@ import { toBase64 } from '../utils'; const SAVE_INTERVAL = 60000; -const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => { +const useSaveDoc = ( + docId: string, + yDoc: Y.Doc, + canSave: boolean, + isConnectedToCollabServer: boolean, +) => { const { mutate: updateDoc } = useUpdateDoc({ listInvalideQueries: [KEY_LIST_DOC_VERSIONS], onSuccess: () => { @@ -49,10 +54,18 @@ const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => { updateDoc({ id: docId, content: toBase64(Y.encodeStateAsUpdate(yDoc)), + websocket: isConnectedToCollabServer, }); return true; - }, [canSave, yDoc, docId, isLocalChange, updateDoc]); + }, [ + canSave, + isLocalChange, + updateDoc, + docId, + yDoc, + isConnectedToCollabServer, + ]); const router = useRouter(); diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertNetwork.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertNetwork.tsx index b070356c6..dd9cb3eb4 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertNetwork.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertNetwork.tsx @@ -32,7 +32,7 @@ export const AlertNetwork = () => { - {t('Your network do not allow you to edit')} + {t('Others are editing. Your network prevent changes.')} { $margin={{ top: 'auto' }} /> - {t('Know more')} + {t('Learn more')} @@ -74,8 +74,8 @@ export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => { onClose={() => onClose()} rightActions={ <> - } @@ -88,24 +88,39 @@ export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => { $align="flex-start" $variation="1000" > - {t("Why can't I edit?")} + {t("Why you can't edit the document?")} } > {t( - 'The network configuration of your workstation or internet connection does not allow editing shared documents.', + 'Others are editing this document. Unfortunately your network blocks WebSockets, the technology enabling real-time co-editing.', )} - - {t( - 'Docs use WebSockets to enable real-time editing. These communication channels allow instant and bidirectional exchanges between your browser and our servers. To access collaborative editing, please contact your IT department to enable WebSockets.', - )} + + {t("This means you can't edit until others leave.")}{' '} + + {t( + 'If you wish to be able to co-edit in real-time, contact your Information Systems Security Manager about allowing WebSockets.', + )} + diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateFavoriteDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateFavoriteDoc.tsx index 05f82cea2..fe69c58f6 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateFavoriteDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateFavoriteDoc.tsx @@ -1,7 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { APIError, errorCauses, fetchAPI } from '@/api'; -import { Doc } from '@/features/docs'; +import { Doc } from '@/docs/doc-management'; export type CreateFavoriteDocParams = Pick; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDeleteFavoriteDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDeleteFavoriteDoc.tsx index 63d55e8db..b332fd428 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDeleteFavoriteDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDeleteFavoriteDoc.tsx @@ -1,7 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { APIError, errorCauses, fetchAPI } from '@/api'; -import { Doc } from '@/features/docs'; +import { Doc } from '@/docs/doc-management'; export type DeleteFavoriteDocParams = Pick; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocCanEdit.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocCanEdit.tsx new file mode 100644 index 000000000..8847ef94e --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocCanEdit.tsx @@ -0,0 +1,32 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +type DocCanEditResponse = { can_edit: boolean }; + +export const docCanEdit = async (id: string): Promise => { + const response = await fetchAPI(`documents/${id}/can-edit/`); + + if (!response.ok) { + throw new APIError('Failed to get the doc', await errorCauses(response)); + } + + return response.json() as Promise; +}; + +export const KEY_CAN_EDIT = 'doc-can-edit'; + +export function useDocCanEdit( + param: string, + queryConfig?: UseQueryOptions< + DocCanEditResponse, + APIError, + DocCanEditResponse + >, +) { + return useQuery({ + queryKey: [KEY_CAN_EDIT, param], + queryFn: () => docCanEdit(param), + ...queryConfig, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDoc.tsx index a012cf26d..12a87367f 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDoc.tsx @@ -1,10 +1,18 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + UseMutationOptions, + useMutation, + useQueryClient, +} from '@tanstack/react-query'; import { APIError, errorCauses, fetchAPI } from '@/api'; -import { Doc } from '@/features/docs'; +import { Doc } from '@/docs/doc-management'; + +import { KEY_CAN_EDIT } from './useDocCanEdit'; export type UpdateDocParams = Pick & - Partial>; + Partial> & { + websocket?: boolean; + }; export const updateDoc = async ({ id, @@ -24,25 +32,30 @@ export const updateDoc = async ({ return response.json() as Promise; }; -interface UpdateDocProps { - onSuccess?: (data: Doc) => void; +type UseUpdateDoc = UseMutationOptions> & { listInvalideQueries?: string[]; -} +}; -export function useUpdateDoc({ - onSuccess, - listInvalideQueries, -}: UpdateDocProps = {}) { +export function useUpdateDoc(queryConfig?: UseUpdateDoc) { const queryClient = useQueryClient(); return useMutation({ mutationFn: updateDoc, - onSuccess: (data) => { - listInvalideQueries?.forEach((queryKey) => { + ...queryConfig, + onSuccess: (data, variables, context) => { + queryConfig?.listInvalideQueries?.forEach((queryKey) => { void queryClient.invalidateQueries({ queryKey: [queryKey], }); }); - onSuccess?.(data); + + if (queryConfig?.onSuccess) { + void queryConfig.onSuccess(data, variables, context); + } + }, + onError: () => { + void queryClient.invalidateQueries({ + queryKey: [KEY_CAN_EDIT], + }); }, }); } diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useIsCollaborativeEditable.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useIsCollaborativeEditable.tsx index 0bb4f8f2e..1064e8669 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useIsCollaborativeEditable.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useIsCollaborativeEditable.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { useConfig } from '@/core'; import { useIsOffline } from '@/features/service-worker'; +import { KEY_CAN_EDIT, useDocCanEdit } from '../api/useDocCanEdit'; import { useProviderStore } from '../stores'; import { Doc, LinkReach } from '../types'; @@ -13,31 +14,30 @@ export const useIsCollaborativeEditable = (doc: Doc) => { const docIsPublic = doc.link_reach === LinkReach.PUBLIC; const docIsAuth = doc.link_reach === LinkReach.AUTHENTICATED; const docHasMember = doc.nb_accesses_direct > 1; + const isUserReader = !doc.abilities.partial_update; const isShared = docIsPublic || docIsAuth || docHasMember; - const [isEditable, setIsEditable] = useState(true); - const [isLoading, setIsLoading] = useState(true); const { isOffline } = useIsOffline(); + const _isEditable = isUserReader || isConnected || !isShared || isOffline; + const [isEditable, setIsEditable] = useState(true); + const [isLoading, setIsLoading] = useState(!_isEditable); - /** - * Connection can take a few seconds - */ - useEffect(() => { - const _isEditable = isConnected || !isShared || isOffline; - setIsLoading(true); + const { + data: { can_edit } = { can_edit: _isEditable }, + isLoading: isLoadingCanEdit, + } = useDocCanEdit(doc.id, { + enabled: !_isEditable, + queryKey: [KEY_CAN_EDIT, doc.id], + staleTime: 0, + }); - if (_isEditable) { - setIsEditable(true); - setIsLoading(false); + useEffect(() => { + if (isLoadingCanEdit) { return; } - const timer = setTimeout(() => { - setIsEditable(false); - setIsLoading(false); - }, 5000); - - return () => clearTimeout(timer); - }, [isConnected, isOffline, isShared]); + setIsEditable(can_edit); + setIsLoading(false); + }, [can_edit, isLoadingCanEdit]); if (!conf?.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY) { return { diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx index fdded178d..dac98831f 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx @@ -10,8 +10,8 @@ import { css } from 'styled-components'; import { APIError } from '@/api'; import { Box } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; +import { Doc, Role } from '@/docs/doc-management'; import { User } from '@/features/auth'; -import { Doc, Role } from '@/features/docs'; import { useCreateDocAccess, useCreateDocInvitation } from '../api'; import { OptionType } from '../types'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx index 19d0fcc6e..591d7e547 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx @@ -10,8 +10,8 @@ import { QuickSearchData, QuickSearchGroup, } from '@/components/quick-search/'; +import { Doc } from '@/docs/doc-management'; import { User } from '@/features/auth'; -import { Doc } from '@/features/docs'; import { useResponsiveStore } from '@/stores'; import { isValidEmail } from '@/utils'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalFooter.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalFooter.tsx index 181c60445..6930a68f7 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalFooter.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalFooter.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; import { Box, HorizontalSeparator } from '@/components'; -import { Doc, useCopyDocLink } from '@/features/docs'; +import { Doc, useCopyDocLink } from '@/docs/doc-management'; import { DocVisibility } from './DocVisibility'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocVisibility.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocVisibility.tsx index 14fa8cc1f..ca081ce25 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocVisibility.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocVisibility.tsx @@ -18,7 +18,7 @@ import { LinkReach, LinkRole, useUpdateDocLink, -} from '@/features/docs'; +} from '@/docs/doc-management'; import { useResponsiveStore } from '@/stores'; import { useTranslatedShareSettings } from '../hooks/'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/types.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/types.tsx index e86796931..f79fdb284 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/types.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/types.tsx @@ -1,5 +1,5 @@ +import { Role } from '@/docs/doc-management'; import { User } from '@/features/auth'; -import { Role } from '@/features/docs'; export interface Invitation { id: string; diff --git a/src/frontend/apps/impress/src/features/docs/index.ts b/src/frontend/apps/impress/src/features/docs/index.ts deleted file mode 100644 index 527b47116..000000000 --- a/src/frontend/apps/impress/src/features/docs/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './doc-editor'; -export * from './doc-management'; -export * from './docs-grid'; diff --git a/src/frontend/apps/impress/src/features/service-worker/DocsDB.ts b/src/frontend/apps/impress/src/features/service-worker/DocsDB.ts index 4d0e85f1a..36465345f 100644 --- a/src/frontend/apps/impress/src/features/service-worker/DocsDB.ts +++ b/src/frontend/apps/impress/src/features/service-worker/DocsDB.ts @@ -1,6 +1,6 @@ import { DBSchema, IDBPDatabase, deleteDB, openDB } from 'idb'; -import { Doc, DocsResponse } from '@/features/docs'; +import { Doc, DocsResponse } from '@/docs/doc-management'; import { RequestData, RequestSerializer } from './RequestSerializer'; diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/403.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/403.tsx index 145c03bc3..e760fe495 100644 --- a/src/frontend/apps/impress/src/pages/docs/[id]/403.tsx +++ b/src/frontend/apps/impress/src/pages/docs/[id]/403.tsx @@ -8,9 +8,9 @@ import styled from 'styled-components'; import img403 from '@/assets/icons/icon-403.png'; import { Box, Icon, Loading, StyledLink, Text } from '@/components'; import { DEFAULT_QUERY_RETRY } from '@/core'; -import { KEY_DOC, useDoc } from '@/features/docs'; -import { ButtonAccessRequest } from '@/features/docs/doc-share'; -import { useDocAccessRequests } from '@/features/docs/doc-share/api/useDocAccessRequest'; +import { KEY_DOC, useDoc } from '@/docs/doc-management'; +import { ButtonAccessRequest } from '@/docs/doc-share'; +import { useDocAccessRequests } from '@/docs/doc-share/api/useDocAccessRequest'; import { MainLayout } from '@/layouts'; import { NextPageWithLayout } from '@/types/next'; diff --git a/src/frontend/apps/impress/src/pages/docs/index.tsx b/src/frontend/apps/impress/src/pages/docs/index.tsx index 53abd9427..fd45e1141 100644 --- a/src/frontend/apps/impress/src/pages/docs/index.tsx +++ b/src/frontend/apps/impress/src/pages/docs/index.tsx @@ -1,8 +1,8 @@ import { useSearchParams } from 'next/navigation'; import type { ReactElement } from 'react'; +import { DocDefaultFilter } from '@/docs/doc-management'; import { DocsGrid } from '@/docs/docs-grid'; -import { DocDefaultFilter } from '@/features/docs'; import { MainLayout } from '@/layouts'; import { NextPageWithLayout } from '@/types/next'; diff --git a/src/frontend/servers/y-provider/__tests__/getDocumentConnectionInfoHandler.test.ts b/src/frontend/servers/y-provider/__tests__/getDocumentConnectionInfoHandler.test.ts new file mode 100644 index 000000000..c17d9be15 --- /dev/null +++ b/src/frontend/servers/y-provider/__tests__/getDocumentConnectionInfoHandler.test.ts @@ -0,0 +1,275 @@ +import request from 'supertest'; +import { v4 as uuid } from 'uuid'; +import { describe, expect, test, vi } from 'vitest'; + +vi.mock('../src/env', async (importOriginal) => { + return { + ...(await importOriginal()), + PORT: 5556, + COLLABORATION_SERVER_ORIGIN: 'http://localhost:3000', + COLLABORATION_SERVER_SECRET: 'test-secret-api-key', + }; +}); + +console.error = vi.fn(); + +import { COLLABORATION_SERVER_ORIGIN as origin } from '@/env'; +import { hocuspocusServer, initApp } from '@/servers'; + +const apiEndpoint = '/collaboration/api/get-connections/'; + +describe('Server Tests', () => { + test('POST /collaboration/api/get-connections?room=[ROOM_ID] with incorrect API key should return 403', async () => { + const app = initApp(); + + const response = await request(app) + .get(`${apiEndpoint}?room=test-room`) + .set('Origin', origin) + .set('Authorization', 'wrong-api-key'); + + expect(response.status).toBe(401); + expect(response.body.error).toBe('Unauthorized: Invalid API Key'); + }); + + test('POST /collaboration/api/get-connections?room=[ROOM_ID] failed if room not indicated', async () => { + const app = initApp(); + + const response = await request(app) + .get(`${apiEndpoint}`) + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key') + .send({ document_id: 'test-document' }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Room name not provided'); + }); + + test('POST /collaboration/api/get-connections?room=[ROOM_ID] failed if session key not indicated', async () => { + const app = initApp(); + + const response = await request(app) + .get(`${apiEndpoint}?room=test-room`) + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key') + .send({ document_id: 'test-document' }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Session key not provided'); + }); + + test('POST /collaboration/api/get-connections?room=[ROOM_ID] return a 404 if room not found', async () => { + const app = initApp(); + + const response = await request(app) + .get(`${apiEndpoint}?room=test-room&sessionKey=test-session-key`) + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key'); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Room not found'); + }); + + test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key existing', async () => { + const document = await hocuspocusServer.createDocument( + 'test-room', + {}, + uuid(), + { isAuthenticated: true, readOnly: false, requiresAuthentication: true }, + {}, + ); + + document.addConnection({ + webSocket: 1, + context: { sessionKey: 'test-session-key' }, + document: document, + pongReceived: false, + readOnly: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 2, + context: { sessionKey: 'other-session-key' }, + document: document, + pongReceived: false, + readOnly: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 3, + context: { sessionKey: 'last-session-key' }, + document: document, + pongReceived: false, + readOnly: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 4, + context: { sessionKey: 'session-read-only' }, + document: document, + pongReceived: false, + readOnly: true, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + + const app = initApp(); + + const response = await request(app) + .get(`${apiEndpoint}?room=test-room&sessionKey=test-session-key`) + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + count: 3, + exists: true, + }); + }); + + test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key not existing', async () => { + const document = await hocuspocusServer.createDocument( + 'test-room', + {}, + uuid(), + { isAuthenticated: true, readOnly: false, requiresAuthentication: true }, + {}, + ); + + document.addConnection({ + webSocket: 1, + context: { sessionKey: 'test-session-key' }, + document: document, + pongReceived: false, + readOnly: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 2, + context: { sessionKey: 'other-session-key' }, + document: document, + pongReceived: false, + readOnly: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 3, + context: { sessionKey: 'last-session-key' }, + document: document, + pongReceived: false, + readOnly: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 4, + context: { sessionKey: 'session-read-only' }, + document: document, + pongReceived: false, + readOnly: true, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + + const app = initApp(); + + const response = await request(app) + .get(`${apiEndpoint}?room=test-room&sessionKey=non-existing-session-key`) + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + count: 3, + exists: false, + }); + }); + + test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key not existing, read only connection', async () => { + const document = await hocuspocusServer.createDocument( + 'test-room', + {}, + uuid(), + { isAuthenticated: true, readOnly: false, requiresAuthentication: true }, + {}, + ); + + document.addConnection({ + webSocket: 1, + context: { sessionKey: 'test-session-key' }, + document: document, + pongReceived: false, + readOnly: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 2, + context: { sessionKey: 'other-session-key' }, + document: document, + pongReceived: false, + readOnly: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 3, + context: { sessionKey: 'last-session-key' }, + document: document, + pongReceived: false, + readOnly: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 4, + context: { sessionKey: 'session-read-only' }, + document: document, + pongReceived: false, + readOnly: true, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + + const app = initApp(); + + const response = await request(app) + .get(`${apiEndpoint}?room=test-room&sessionKey=session-read-only`) + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + count: 3, + exists: false, + }); + }); +}); diff --git a/src/frontend/servers/y-provider/src/handlers/getDocumentConnectionInfoHandler.ts b/src/frontend/servers/y-provider/src/handlers/getDocumentConnectionInfoHandler.ts new file mode 100644 index 000000000..dcf33be56 --- /dev/null +++ b/src/frontend/servers/y-provider/src/handlers/getDocumentConnectionInfoHandler.ts @@ -0,0 +1,48 @@ +import { Request, Response } from 'express'; + +import { hocuspocusServer } from '@/servers'; +import { logger } from '@/utils'; + +type getDocumentConnectionInfoRequestQuery = { + room?: string; + sessionKey?: string; +}; + +export const getDocumentConnectionInfoHandler = ( + req: Request, + res: Response, +) => { + const room = req.query.room; + const sessionKey = req.query.sessionKey; + + if (!room) { + res.status(400).json({ error: 'Room name not provided' }); + return; + } + + if (!req.query.sessionKey) { + res.status(400).json({ error: 'Session key not provided' }); + return; + } + + logger('Getting document connection info for room:', room); + + const roomInfo = hocuspocusServer.documents.get(room); + + if (!roomInfo) { + logger('Room not found:', room); + res.status(404).json({ error: 'Room not found' }); + return; + } + const connections = roomInfo + .getConnections() + .filter((connection) => connection.readOnly === false); + + res.status(200).json({ + count: connections.length, + exists: connections.some( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (connection) => connection.context.sessionKey === sessionKey, + ), + }); +}; diff --git a/src/frontend/servers/y-provider/src/handlers/index.ts b/src/frontend/servers/y-provider/src/handlers/index.ts index 296fefa29..26b0ebeda 100644 --- a/src/frontend/servers/y-provider/src/handlers/index.ts +++ b/src/frontend/servers/y-provider/src/handlers/index.ts @@ -1,3 +1,4 @@ export * from './collaborationResetConnectionsHandler'; export * from './collaborationWSHandler'; export * from './convertHandler'; +export * from './getDocumentConnectionInfoHandler'; diff --git a/src/frontend/servers/y-provider/src/routes.ts b/src/frontend/servers/y-provider/src/routes.ts index 7d219e2e8..5bb73365f 100644 --- a/src/frontend/servers/y-provider/src/routes.ts +++ b/src/frontend/servers/y-provider/src/routes.ts @@ -2,4 +2,5 @@ export const routes = { COLLABORATION_WS: '/collaboration/ws/', COLLABORATION_RESET_CONNECTIONS: '/collaboration/api/reset-connections/', CONVERT: '/api/convert/', + COLLABORATION_GET_CONNECTIONS: '/collaboration/api/get-connections/', }; diff --git a/src/frontend/servers/y-provider/src/servers/appServer.ts b/src/frontend/servers/y-provider/src/servers/appServer.ts index 2dbfba99a..0c355fee8 100644 --- a/src/frontend/servers/y-provider/src/servers/appServer.ts +++ b/src/frontend/servers/y-provider/src/servers/appServer.ts @@ -9,6 +9,7 @@ import { collaborationResetConnectionsHandler, collaborationWSHandler, convertHandler, + getDocumentConnectionInfoHandler, } from '@/handlers'; import { corsMiddleware, httpSecurity, wsSecurity } from '@/middlewares'; import { routes } from '@/routes'; @@ -41,6 +42,12 @@ export const initApp = () => { collaborationResetConnectionsHandler, ); + app.get( + routes.COLLABORATION_GET_CONNECTIONS, + httpSecurity, + getDocumentConnectionInfoHandler, + ); + /** * Route to convert Markdown or BlockNote blocks */ diff --git a/src/frontend/servers/y-provider/src/servers/hocuspocusServer.ts b/src/frontend/servers/y-provider/src/servers/hocuspocusServer.ts index 484d055a0..376e8ae03 100644 --- a/src/frontend/servers/y-provider/src/servers/hocuspocusServer.ts +++ b/src/frontend/servers/y-provider/src/servers/hocuspocusServer.ts @@ -60,6 +60,14 @@ export const hocuspocusServer = Server.configure({ connection.readOnly = !canEdit; + const session = requestHeaders['cookie'] + ?.split('; ') + .find((cookie) => cookie.startsWith('docs_sessionid=')); + if (session) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + context.sessionKey = session.split('=')[1]; + } + /* * Unauthenticated users can be allowed to connect * so we flag only authenticated users