Skip to content

Commit 651f2d1

Browse files
committed
✨(back) check on document update if user can save it
When a document is updated, users not connected to the collaboration server can override work made by other people connected to the collaboration server. To avoid this, the priority is given to user connected to the collaboration server. If the websocket property in the request payload is missing or set to False, the backend fetch the collaboration server to now if the user can save or not. If users are already connected, the user can't save. Also, only one user without websocket can save a connect, the first user saving acquire a lock and all other users can't save. To implement this behavior, we need to track all users, connected and not, so a session is created for every user in the ForceSessionMiddleware.
1 parent b96de36 commit 651f2d1

File tree

9 files changed

+489
-109
lines changed

9 files changed

+489
-109
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ and this project adheres to
1515
- 📝(project) add troubleshoot doc #1066
1616
- 📝(project) add system-requirement doc #1066
1717
- 🔧(front) configure x-frame-options to DENY in nginx conf #1084
18-
- (doc) add documentation to install with compose #855
1918
- ✨(backend) allow to disable checking unsafe mimetype on attachment upload
2019
- ✨Ask for access #1081
20+
- ✨(doc) add documentation to install with compose #855
21+
- ✨ Give priority to users connected to collaboration server
22+
(aka no websocket feature) #1093
2123

2224
### Changed
2325

docs/env.md

Lines changed: 98 additions & 98 deletions
Large diffs are not rendered by default.

src/backend/core/api/serializers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ class DocumentSerializer(ListDocumentSerializer):
239239
"""Serialize documents with all fields for display in detail views."""
240240

241241
content = serializers.CharField(required=False)
242+
websocket = serializers.BooleanField(required=False, write_only=True)
242243

243244
class Meta:
244245
model = models.Document
@@ -260,6 +261,7 @@ class Meta:
260261
"title",
261262
"updated_at",
262263
"user_roles",
264+
"websocket",
263265
]
264266
read_only_fields = [
265267
"id",

src/backend/core/api/viewsets.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from rest_framework import response as drf_response
3333
from rest_framework.permissions import AllowAny
3434
from rest_framework.throttling import UserRateThrottle
35+
from sentry_sdk import capture_exception
3536

3637
from core import authentication, enums, models
3738
from core.services.ai_services import AIService
@@ -634,6 +635,54 @@ def perform_destroy(self, instance):
634635
"""Override to implement a soft delete instead of dumping the record in database."""
635636
instance.soft_delete()
636637

638+
def perform_update(self, serializer):
639+
"""Check rules about collaboration."""
640+
if serializer.validated_data.get("websocket"):
641+
return super().perform_update(serializer)
642+
643+
try:
644+
connection_info = CollaborationService().get_document_connection_info(
645+
serializer.instance.id,
646+
self.request.session.session_key,
647+
)
648+
except requests.HTTPError as e:
649+
capture_exception(e)
650+
connection_info = {
651+
"count": 0,
652+
"exists": False,
653+
}
654+
655+
if connection_info["count"] == 0:
656+
# No websocket mode
657+
logger.debug("update without connection found in the websocket server")
658+
cache_key = f"docs:no-websocket:{serializer.instance.id}"
659+
current_editor = cache.get(cache_key)
660+
if not current_editor:
661+
cache.set(
662+
cache_key,
663+
self.request.session.session_key,
664+
settings.NO_WEBSOCKET_CACHE_TIMEOUT,
665+
)
666+
elif current_editor != self.request.session.session_key:
667+
raise drf.exceptions.PermissionDenied(
668+
"You are not allowed to edit this document."
669+
)
670+
cache.touch(cache_key, settings.NO_WEBSOCKET_CACHE_TIMEOUT)
671+
return super().perform_update(serializer)
672+
673+
if connection_info["exists"]:
674+
# Websocket mode
675+
logger.debug("session key found in the websocket server")
676+
return super().perform_update(serializer)
677+
678+
logger.debug(
679+
"Users connected to the websocket but current editor not connected to it. Can not edit."
680+
)
681+
682+
raise drf.exceptions.PermissionDenied(
683+
"You are not allowed to edit this document."
684+
)
685+
637686
@drf.decorators.action(
638687
detail=False,
639688
methods=["get"],

src/backend/core/middleware.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""Force session creation for all requests."""
2+
3+
4+
class ForceSessionMiddleware:
5+
"""
6+
Force session creation for unauthenticated users.
7+
Must be used after Authentication middleware.
8+
"""
9+
10+
def __init__(self, get_response):
11+
"""Initialize the middleware."""
12+
self.get_response = get_response
13+
14+
def __call__(self, request):
15+
"""Force session creation for unauthenticated users."""
16+
17+
if not request.user.is_authenticated and request.session.session_key is None:
18+
request.session.create()
19+
20+
response = self.get_response(request)
21+
return response

src/backend/core/services/collaboration_services.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,31 @@ def reset_connections(self, room, user_id=None):
4141
f"Failed to notify WebSocket server. Status code: {response.status_code}, "
4242
f"Response: {response.text}"
4343
)
44+
45+
def get_document_connection_info(self, room, session_key):
46+
"""
47+
Get the connection info for a document.
48+
"""
49+
endpoint = "get-connections"
50+
querystring = {
51+
"room": room,
52+
"sessionKey": session_key,
53+
}
54+
endpoint_url = f"{settings.COLLABORATION_API_URL}{endpoint}/"
55+
56+
headers = {"Authorization": settings.COLLABORATION_SERVER_SECRET}
57+
58+
try:
59+
response = requests.get(
60+
endpoint_url, headers=headers, params=querystring, timeout=10
61+
)
62+
except requests.RequestException as e:
63+
raise requests.HTTPError("Failed to get document connection info.") from e
64+
65+
if response.status_code != 200:
66+
raise requests.HTTPError(
67+
f"Failed to get document connection info. Status code: {response.status_code}, "
68+
f"Response: {response.text}"
69+
)
70+
71+
return response.json()

0 commit comments

Comments
 (0)