• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

fiduswriter / fiduswriter / 30806833893

03 Aug 2026 10:45AM UTC coverage: 88.287% (-0.05%) from 88.335%
30806833893

push

github

johanneswilm
4.1.12

10967 of 12422 relevant lines covered (88.29%)

5.78 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

71.02
fiduswriter/document/consumers.py
1
import autobahn
19✔
2
import base64
19✔
3
import uuid
19✔
4
import atexit
19✔
5
import logging
19✔
6
import gc
19✔
7
import asyncio
19✔
8
import multiprocessing
19✔
9
from copy import deepcopy
19✔
10
from dataclasses import dataclass
19✔
11

12
from asgiref.sync import sync_to_async
19✔
13
from django.conf import settings
19✔
14

15
from base.helpers.ws import get_url_base
19✔
16
from document.helpers.session_user_info import SessionUserInfo
19✔
17
from django.conf import settings as _pm_settings
19✔
18

19
if getattr(_pm_settings, "PROSEMIRROR_BACKEND", "rust") == "rust":
19✔
20
    from document import prosemirror_rs as prosemirror
×
21
else:
22
    from document import prosemirror
19✔
23
from document.helpers import document_store
19✔
24

25
from base.base_consumer import BaseWebsocketConsumer, GuestUser, TokenUser
19✔
26
from document.models import (
19✔
27
    COMMENT_ONLY,
28
    CAN_UPDATE_DOCUMENT,
29
    CAN_COMMUNICATE,
30
    FW_DOCUMENT_VERSION,
31
)
32
from document.helpers.token_access import get_token_access
19✔
33
from user.helpers import Avatars
19✔
34

35
logger = logging.getLogger(__name__)
19✔
36

37

38
@dataclass
19✔
39
class SessionParticipantSnapshot:
19✔
40
    id: int
41
    messages: dict
42

43

44
class WebsocketConsumer(BaseWebsocketConsumer):
19✔
45
    sessions = dict()
19✔
46
    runtime_sessions = dict()
19✔
47
    snapshot_sessions = dict()
19✔
48
    snapshot_manager = None
19✔
49
    history_length = 1000  # Only keep the last 1000 diffs
19✔
50

51
    @classmethod
19✔
52
    def get_session(cls, document_id):
19✔
53
        return cls.runtime_sessions.get(document_id)
15✔
54

55
    @classmethod
19✔
56
    def set_session(cls, document_id, session):
19✔
57
        cls.runtime_sessions[document_id] = session
15✔
58
        cls.sync_session_snapshot(document_id)
15✔
59

60
    @classmethod
19✔
61
    def remove_session(cls, document_id):
19✔
62
        cls.runtime_sessions.pop(document_id, None)
15✔
63
        cls.snapshot_sessions.pop(document_id, None)
15✔
64
        try:
15✔
65
            cls.sessions.pop(document_id, None)
15✔
66
        except (AttributeError, FileNotFoundError):
×
67
            pass
×
68

69
    @classmethod
19✔
70
    def sync_session_snapshot(cls, document_id):
19✔
71
        session = cls.get_session(document_id)
15✔
72
        if not session:
15✔
73
            cls.remove_session(document_id)
15✔
74
            return
15✔
75
        manager = getattr(cls.sessions, "_manager", None)
15✔
76
        if not manager and not isinstance(cls.sessions, dict):
15✔
77
            if cls.snapshot_manager is None:
×
78
                cls.snapshot_manager = multiprocessing.Manager()
×
79
            manager = cls.snapshot_manager
×
80
        if manager:
15✔
81
            snapshot = cls.snapshot_sessions.get(document_id)
×
82
            if not snapshot:
×
83
                snapshot = {"participants": manager.dict()}
×
84
                cls.snapshot_sessions[document_id] = snapshot
×
85
            participants = snapshot["participants"]
×
86
            active_ids = set(session["participants"])
×
87
            for session_id in list(participants.keys()):
×
88
                if session_id not in active_ids:
×
89
                    participants.pop(session_id, None)
×
90
            for session_id, waiter in session["participants"].items():
×
91
                if session_id not in participants:
×
92
                    participants[session_id] = manager.Namespace()
×
93
                participant = participants[session_id]
×
94
                last_ten = manager.list()
×
95
                for item in waiter.messages["last_ten"]:
×
96
                    last_ten.append(deepcopy(item))
×
97
                participant.id = waiter.id
×
98
                participant.messages = manager.dict(
×
99
                    {
100
                        "server": waiter.messages["server"],
101
                        "client": waiter.messages["client"],
102
                        "last_ten": last_ten,
103
                    }
104
                )
105
            cls.sessions[document_id] = snapshot
×
106
            return
×
107
        try:
15✔
108
            cls.sessions[document_id] = {
15✔
109
                "participants": {
110
                    session_id: SessionParticipantSnapshot(
111
                        id=waiter.id, messages=deepcopy(waiter.messages)
112
                    )
113
                    for session_id, waiter in session["participants"].items()
114
                }
115
            }
116
        except (TypeError, FileNotFoundError):
×
117
            pass
×
118

119
    async def send_message(self, message):
19✔
120
        await super().send_message(message)
15✔
121
        if hasattr(self, "document_id"):
15✔
122
            self.sync_session_snapshot(self.document_id)
15✔
123

124
    async def connect(self):
19✔
125
        self.document_id = int(
15✔
126
            self.scope["url_route"]["kwargs"]["document_id"]
127
        )
128
        redirected = await self.check_server()
15✔
129
        if redirected:
15✔
130
            return
×
131
        connected = await super().connect()
15✔
132
        if not connected:
15✔
133
            return
×
134
        logger.debug(
15✔
135
            f"Action:Document socket opened by user. "
136
            f"URL:{self.endpoint} User:{self.user.id} ParticipantID:{self.id}"
137
        )
138

139
    async def check_server(self):
19✔
140
        # The correct server for the document may have changed since the client
141
        # received its initial connection information. For example, because the
142
        # number of servers has increased (all servers need to restart to have
143
        # the right setting).
144
        if len(settings.PORTS) < 2:
15✔
145
            return False
15✔
146
        server = self.scope.get("server")
×
147
        if not server or len(server) < 2:
×
148
            # Granian (and some other ASGI servers) may not populate
149
            # scope["server"] for WebSocket connections. Without a known
150
            # actual port we cannot verify routing, so assume correct.
151
            return False
×
152
        try:
×
153
            actual_port = int(server[1])
×
154
        except (ValueError, TypeError):
×
155
            logger.warning(
×
156
                f"Could not determine actual port from scope['server']: {server}"
157
            )
158
            return False
×
159
        expected_conn = settings.PORTS[self.document_id % len(settings.PORTS)]
×
160
        try:
×
161
            expected_port = int(
×
162
                expected_conn["internal"]
163
                if isinstance(expected_conn, dict)
164
                else expected_conn
165
            )
166
        except (ValueError, TypeError):
×
167
            logger.warning(
×
168
                f"Could not determine expected port from PORTS: {expected_conn}"
169
            )
170
            return False
×
171
        if actual_port != expected_port:
×
172
            # Redirect to the correct URL
173
            await self.init()
×
174
            origin = (
×
175
                dict(self.scope["headers"]).get(b"origin", b"").decode("utf-8")
176
            )
177
            expected = get_url_base(origin, expected_conn)
×
178
            logger.debug(f"Redirecting from {actual_port} to {expected}.")
×
179
            await self.send_message({"type": "redirect", "base": expected})
×
180
            await self.do_close()
×
181
            return True
×
182
        return False
×
183

184
    async def _resolve_guest_user(self, token_str):
19✔
185
        """
186
        Validate a share token and return a GuestUser if valid.
187
        This overrides the base class method to provide document-specific token validation.
188
        """
189
        document, rights = await sync_to_async(get_token_access)(token_str)
2✔
190
        if document and document.id == self.document_id:
2✔
191
            return GuestUser(
2✔
192
                id=str(token_str), token=str(token_str), token_rights=rights
193
            )
194
        return None
×
195

196
    async def _resolve_token_user(self, user, token_str):
19✔
197
        """
198
        Validate a share token for a logged-in user and return a TokenUser if valid.
199
        The user retains their real identity but also has token-based access rights.
200
        """
201
        document, rights = await sync_to_async(get_token_access)(token_str)
1✔
202
        if document and document.id == self.document_id:
1✔
203
            return TokenUser(
1✔
204
                user=user, token=str(token_str), token_rights=rights
205
            )
206
        return None
×
207

208
    async def confirm_diff(self, rid):
19✔
209
        response = {"type": "confirm_diff", "rid": rid}
15✔
210
        await self.send_message(response)
15✔
211

212
    async def subscribe(self, connection_count=0, client_version=None):
19✔
213
        # Create a new instance
214
        self.user_info = SessionUserInfo(self.user)
15✔
215

216
        # Initialize access asynchronously
217
        doc_db, can_access = await self.user_info.init_access(self.document_id)
15✔
218

219
        if not can_access or float(doc_db.doc_version) != FW_DOCUMENT_VERSION:
15✔
220
            await self.access_denied()
1✔
221
            return
1✔
222

223
        existing_session = WebsocketConsumer.get_session(doc_db.id)
15✔
224
        if existing_session and len(existing_session["participants"]) > 0:
15✔
225
            logger.debug(
5✔
226
                f"Action:Serving already opened document. "
227
                f"URL:{self.endpoint} User:{self.user.id} "
228
                f" ParticipantID:{self.id}"
229
            )
230
            self.session = existing_session
5✔
231
            self.id = max(self.session["participants"]) + 1
5✔
232
            self.session["participants"][self.id] = self
5✔
233
            WebsocketConsumer.sync_session_snapshot(doc_db.id)
5✔
234
            if isinstance(self.user, GuestUser):
5✔
235
                self.user.readable_name = f"Guest {self.id}"
×
236
        else:
237
            logger.debug(
15✔
238
                f"Action:Opening document from DB. "
239
                f"URL:{self.endpoint} User:{self.user.id} "
240
                f" ParticipantID:{self.id}"
241
            )
242
            self.id = 0
15✔
243
            # Initialize document content from template if empty.
244
            await document_store.initialize_document_content(doc_db)
15✔
245
            if doc_db.e2ee:
15✔
246
                # For E2EE documents, we cannot create a prosemirror
247
                # node from the content. The client will decrypt and
248
                # handle the content locally.
249
                self.session = {
1✔
250
                    "doc": doc_db,
251
                    "node": None,
252
                    "node_updates": False,
253
                    "participants": {0: self},
254
                    "last_saved_version": doc_db.version,
255
                }
256
            else:
257
                node = prosemirror.from_json(doc_db.content)
14✔
258
                self.session = {
14✔
259
                    "doc": doc_db,
260
                    "node": node,
261
                    "node_updates": False,
262
                    "participants": {0: self},
263
                    "last_saved_version": doc_db.version,
264
                }
265
            WebsocketConsumer.set_session(doc_db.id, self.session)
15✔
266
            if isinstance(self.user, GuestUser):
15✔
267
                self.user.readable_name = f"Guest {self.id}"
2✔
268
        logger.debug(
15✔
269
            f"Action:Participant ID Assigned. URL:{self.endpoint} "
270
            f"User:{self.user.id} ParticipantID:{self.id}"
271
        )
272
        await self.send_message({"type": "subscribed"})
15✔
273
        if connection_count < 1:
15✔
274
            # Send session_id so the client knows its participant ID
275
            session_info_msg = {
15✔
276
                "type": "session_info",
277
                "session_id": self.id,
278
                "access_right": self.user_info.access_rights,
279
            }
280
            if doc_db.e2ee:
15✔
281

282
                session_info_msg["e2ee"] = True
1✔
283
                session_info_msg["e2ee_salt"] = (
1✔
284
                    base64.b64encode(doc_db.e2ee_salt).decode("ascii")
285
                    if doc_db.e2ee_salt
286
                    else None
287
                )
288
                session_info_msg["e2ee_iterations"] = doc_db.e2ee_iterations
1✔
289
            await self.send_message(session_info_msg)
15✔
290
            # Reconcile version: send missing diffs if client is behind
291
            await self.check_version(client_version)
15✔
292
        else:
293
            # Reconnecting user — send access rights for comparison
294
            await self.send_message(
2✔
295
                {
296
                    "type": "access_right",
297
                    "access_right": self.user_info.access_rights,
298
                }
299
            )
300
        if await self.can_communicate():
15✔
301
            await self.handle_participant_update()
15✔
302

303
    async def unfixable(self):
19✔
304
        await WebsocketConsumer.save_document_async(self.user_info.document_id)
2✔
305
        await self.send_message({"type": "refetch_doc"})
2✔
306

307
    async def check_version(self, client_version, offline=False):
19✔
308
        """Reconcile the client's document version with the server's.
309

310
        Called on subscribe (with the version from the subscribe message) and
311
        when the client sends a check_version message. If the client is
312
        behind, sends the missing diffs. If too many diffs are missing,
313
        asks the client to re-fetch via REST.
314
        """
315
        server_version = self.session["doc"].version
15✔
316

317
        logger.debug(
15✔
318
            f"Action:Reconcile version. URL:{self.endpoint} "
319
            f"User:{self.user.id} ParticipantID:{self.id} "
320
            f"Client version:{client_version} Server version:{server_version}"
321
        )
322

323
        if client_version == server_version:
15✔
324
            # Client is up to date — nothing to send
325
            await self.send_message(
15✔
326
                {
327
                    "type": "confirm_version",
328
                    "v": server_version,
329
                }
330
            )
331
            return
15✔
332

333
        if client_version < server_version and not offline:
4✔
334
            diffs_behind = server_version - client_version
4✔
335
            if (
4✔
336
                client_version + len(self.session["doc"].diffs)
337
                >= server_version
338
            ):
339
                # We have enough diffs to catch the client up
340
                logger.debug(
4✔
341
                    f"Action:Sending {diffs_behind} diffs to catch client up. "
342
                    f"URL:{self.endpoint} User:{self.user.id} "
343
                    f"ParticipantID:{self.id}"
344
                )
345
                messages = self.session["doc"].diffs[-diffs_behind:]
4✔
346
                for msg in messages:
4✔
347
                    new_message = msg.copy()
4✔
348
                    new_message["server_fix"] = True
4✔
349
                    await self.send_message(new_message)
4✔
350
                await self.send_message(
4✔
351
                    {
352
                        "type": "confirm_version",
353
                        "v": server_version,
354
                    }
355
                )
356
            else:
357
                logger.debug(
×
358
                    f"Action:Client too far behind ({diffs_behind} diffs, "
359
                    f"only {len(self.session['doc'].diffs)} stored). "
360
                    f"URL:{self.endpoint} User:{self.user.id} "
361
                    f"ParticipantID:{self.id}"
362
                )
363
                # Too many diffs — client needs a full document reset
364
                await self.unfixable()
×
365
            return
4✔
366

367
        # client_version > server_version should not happen
368
        logger.debug(
2✔
369
            f"Action:Client version ahead of server. "
370
            f"URL:{self.endpoint} User:{self.user.id} "
371
            f"ParticipantID:{self.id}"
372
        )
373
        await self.unfixable()
2✔
374

375
    async def reject_message(self, message):
19✔
376
        if message["type"] == "diff":
2✔
377
            await self.send_message(
2✔
378
                {"type": "reject_diff", "rid": message["rid"]}
379
            )
380

381
    async def handle_message(self, message):
19✔
382
        if not WebsocketConsumer.get_session(self.user_info.document_id):
15✔
383
            logger.debug(
×
384
                f"Action:Receiving message for closed document. "
385
                f"URL:{self.endpoint} User:{self.user.id} "
386
                f"ParticipantID:{self.id}"
387
            )
388
            return
×
389
        if (
15✔
390
            message["type"] == "participant_update"
391
            and await self.can_communicate()
392
        ):
393
            await self.handle_participant_update()
×
394
        elif message["type"] == "chat" and await self.can_communicate():
15✔
395
            await self.handle_chat(message)
1✔
396
        elif message["type"] == "check_version":
15✔
397
            await self.check_version(
2✔
398
                message["v"], message.get("offline", False)
399
            )
400
        elif message["type"] == "selection_change":
15✔
401
            await self.handle_selection_change(message)
12✔
402
        elif message["type"] == "diff" and await self.can_update_document():
15✔
403
            await self.handle_diff(message)
15✔
404
        elif message["type"] == "path_change":
3✔
405
            await self.handle_path_change(message)
2✔
406
        elif message["type"] == "e2ee_snapshot":
1✔
407
            await self.handle_e2ee_snapshot(message)
1✔
408

409
    async def update_bibliography(self, bibliography_updates):
19✔
410
        for bu in bibliography_updates:
3✔
411
            if "id" not in bu:
3✔
412
                continue
×
413
            id = bu["id"]
3✔
414
            if bu["type"] == "update":
3✔
415
                self.session["doc"].bibliography[id] = bu["reference"]
3✔
416
            elif bu["type"] == "delete":
×
417
                del self.session["doc"].bibliography[id]
×
418

419
    async def update_images(self, image_updates):
19✔
420
        await document_store.update_document_images(
4✔
421
            self.session["doc"].id,
422
            image_updates,
423
            self.user_info.user,
424
            doc_e2ee=self.session["doc"].e2ee,
425
        )
426

427
    async def update_comments(self, comments_updates):
19✔
428
        comments_updates = deepcopy(comments_updates)
2✔
429
        for cd in comments_updates:
2✔
430
            if "id" not in cd:
2✔
431
                # ignore
432
                continue
×
433
            id = cd["id"]
2✔
434
            if cd["type"] == "create":
2✔
435
                self.session["doc"].comments[id] = {
2✔
436
                    "user": cd["user"],
437
                    "username": cd["username"],
438
                    "assignedUser": cd["assignedUser"],
439
                    "assignedUsername": cd["assignedUsername"],
440
                    "date": cd["date"],
441
                    "comment": cd["comment"],
442
                    "isMajor": cd["isMajor"],
443
                    "resolved": cd["resolved"],
444
                }
445
            elif cd["type"] == "delete":
1✔
446
                del self.session["doc"].comments[id]
1✔
447
            elif cd["type"] == "update":
1✔
448
                self.session["doc"].comments[id]["comment"] = cd["comment"]
1✔
449
                if "isMajor" in cd:
1✔
450
                    self.session["doc"].comments[id]["isMajor"] = cd["isMajor"]
1✔
451
                if "assignedUser" in cd and "assignedUsername" in cd:
1✔
452
                    self.session["doc"].comments[id]["assignedUser"] = cd[
1✔
453
                        "assignedUser"
454
                    ]
455
                    self.session["doc"].comments[id]["assignedUsername"] = cd[
1✔
456
                        "assignedUsername"
457
                    ]
458
                if "resolved" in cd:
1✔
459
                    self.session["doc"].comments[id]["resolved"] = cd[
1✔
460
                        "resolved"
461
                    ]
462
            elif cd["type"] == "add_answer":
1✔
463
                if "answers" not in self.session["doc"].comments[id]:
1✔
464
                    self.session["doc"].comments[id]["answers"] = []
1✔
465
                self.session["doc"].comments[id]["answers"].append(
1✔
466
                    {
467
                        "id": cd["answerId"],
468
                        "user": cd["user"],
469
                        "username": cd["username"],
470
                        "date": cd["date"],
471
                        "answer": cd["answer"],
472
                    }
473
                )
474
            elif cd["type"] == "delete_answer":
1✔
475
                answer_id = cd["answerId"]
1✔
476
                for answer in self.session["doc"].comments[id]["answers"]:
1✔
477
                    if answer["id"] == answer_id:
1✔
478
                        self.session["doc"].comments[id]["answers"].remove(
1✔
479
                            answer
480
                        )
481
            elif cd["type"] == "update_answer":
1✔
482
                answer_id = cd["answerId"]
1✔
483
                for answer in self.session["doc"].comments[id]["answers"]:
1✔
484
                    if answer["id"] == answer_id:
1✔
485
                        answer["answer"] = cd["answer"]
1✔
486

487
    async def handle_participant_update(self):
19✔
488
        await WebsocketConsumer.send_participant_list(
15✔
489
            self.user_info.document_id
490
        )
491

492
    async def handle_chat(self, message):
19✔
493
        chat = {
1✔
494
            "id": str(uuid.uuid4()),
495
            "body": message["body"],
496
            "from": self.user_info.user.id,
497
            "type": "chat",
498
        }
499
        # Pass through the e2ee flag for encrypted chat messages.
500
        # The server does not read the message body — it just relays
501
        # the encrypted blob to other clients.
502
        if message.get("e2ee"):
1✔
503
            chat["e2ee"] = True
×
504
        await WebsocketConsumer.send_updates(chat, self.user_info.document_id)
1✔
505

506
    async def handle_selection_change(self, message):
19✔
507
        if (
12✔
508
            WebsocketConsumer.get_session(self.user_info.document_id)
509
            and message["v"] == self.session["doc"].version
510
        ):
511
            await WebsocketConsumer.send_updates(
12✔
512
                message,
513
                self.user_info.document_id,
514
                self.id,
515
                self.user_info.user.id,
516
            )
517

518
    async def handle_path_change(self, message):
19✔
519
        if (
2✔
520
            WebsocketConsumer.get_session(self.user_info.document_id)
521
            and self.user_info.path_object
522
        ):
523
            await document_store.save_path_object(
2✔
524
                self.user_info.path_object, message["path"]
525
            )
526
            await WebsocketConsumer.send_updates(
2✔
527
                message,
528
                self.user_info.document_id,
529
                self.id,
530
                self.user_info.user.id,
531
            )
532

533
    # Checks if the diff only contains changes to comments.
534
    def only_comments(self, message):
19✔
535
        allowed_operations = ["addMark", "removeMark"]
×
536
        only_comment = True
×
537
        if "ds" in message:  # ds = document steps
×
538
            for step in message["ds"]:
×
539
                if not (
×
540
                    step["stepType"] in allowed_operations
541
                    and step["mark"]["type"] == "comment"
542
                ):
543
                    only_comment = False
×
544
        return only_comment
×
545

546
    async def handle_diff(self, message):
19✔
547
        pv = message["v"]
15✔
548
        dv = self.session["doc"].version
15✔
549
        logger.debug(
15✔
550
            f"Action:Handling Diff. URL:{self.endpoint} User:{self.user.id} "
551
            f"ParticipantID:{self.id} Client version:{pv} "
552
            f"Server version:{dv} Message:{message}"
553
        )
554
        if (
15✔
555
            self.user_info.access_rights in COMMENT_ONLY
556
            and not self.only_comments(message)
557
        ):
558
            # Note: For E2EE documents, the diff content is encrypted
559
            # (has "ep" field instead of "ds"), so only_comments() will
560
            # return True (no plaintext steps to check). Access rights
561
            # enforcement for E2EE documents is handled client-side,
562
            # since the server cannot read the encrypted diff content.
563
            logger.error(
×
564
                f"Action:Received non-comment diff from comment-only "
565
                f"collaborator.Discarding URL:{self.endpoint} "
566
                f"User:{self.user.id} ParticipantID:{self.id}"
567
            )
568
            return
×
569
        if pv == dv:
15✔
570
            if self.session["doc"].e2ee:
15✔
571
                # For E2EE documents, we cannot apply diffs server-side.
572
                # Instead, we relay the encrypted diff to other clients
573
                # and store it for catch-up. The diff content is opaque
574
                # to the server.
575
                # Validate that the diff was encrypted with the current salt.
576
                msg_salt = message.get("e2ee_salt")
1✔
577
                if msg_salt is not None:
1✔
578
                    try:
1✔
579
                        decoded_salt = base64.b64decode(msg_salt)
1✔
580
                    except Exception:
×
581
                        await self.reject_message(message)
×
582
                        return
×
583
                    if decoded_salt != self.session["doc"].e2ee_salt:
1✔
584
                        await self.reject_message(message)
×
585
                        return
×
586
                self.session["doc"].diffs.append(message)
1✔
587
                self.session["doc"].diffs = self.session["doc"].diffs[
1✔
588
                    -self.history_length :
589
                ]
590
                self.session["doc"].version += 1
1✔
591
                # Check if we should request a snapshot from a client
592
                if (
1✔
593
                    self.session["doc"].version % settings.DOC_SAVE_INTERVAL
594
                    == 0
595
                ):
596
                    await self.request_snapshot()
×
597
                if "rid" in message:
1✔
598
                    await self.confirm_diff(message["rid"])
1✔
599
                await WebsocketConsumer.send_updates(
1✔
600
                    message,
601
                    self.user_info.document_id,
602
                    self.id,
603
                    self.user_info.user.id,
604
                )
605
            else:
606
                # Original unencrypted diff handling
607
                if "ds" in message:  # ds = document steps
14✔
608
                    updated_node = prosemirror.apply(
14✔
609
                        message["ds"], self.session["node"]
610
                    )
611
                    if updated_node:
14✔
612
                        self.session["node"] = updated_node
14✔
613
                        self.session["node_updates"] = True
14✔
614
                    else:
615
                        await self.unfixable()
×
616
                        patch_msg = {
×
617
                            "type": "patch_error",
618
                            "user_id": self.user.id,
619
                        }
620
                        await self.send_message(patch_msg)
×
621
                        # Reset collaboration to avoid any data loss issues.
622
                        await self.reset_collaboration(
×
623
                            patch_msg, self.user_info.document_id, self.id
624
                        )
625
                        return
×
626
                self.session["doc"].diffs.append(message)
14✔
627
                self.session["doc"].diffs = self.session["doc"].diffs[
14✔
628
                    -self.history_length :
629
                ]
630
                self.session["doc"].version += 1
14✔
631
                if "ti" in message:  # ti = title
14✔
632
                    await document_store.save_document_title(
10✔
633
                        self.session["doc"], message["ti"]
634
                    )
635
                if "cu" in message:  # cu = comment updates
14✔
636
                    await self.update_comments(message["cu"])
2✔
637
                if "bu" in message:  # bu = bibliography updates
14✔
638
                    await self.update_bibliography(message["bu"])
3✔
639
                if "iu" in message:  # iu = image updates
14✔
640
                    await self.update_images(message["iu"])
4✔
641
                if (
14✔
642
                    self.session["doc"].version % settings.DOC_SAVE_INTERVAL
643
                    == 0
644
                ):
645
                    await WebsocketConsumer.save_document_async(
3✔
646
                        self.user_info.document_id
647
                    )
648
                await self.confirm_diff(message["rid"])
14✔
649
                await WebsocketConsumer.send_updates(
14✔
650
                    message,
651
                    self.user_info.document_id,
652
                    self.id,
653
                    self.user_info.user.id,
654
                )
655
        elif pv < dv:
×
656
            if pv + len(self.session["doc"].diffs) >= dv:
×
657
                # We have enough diffs stored to fix it.
658
                number_diffs = dv - pv
×
659
                logger.debug(
×
660
                    f"Action:Resending document diffs. URL:{self.endpoint} "
661
                    f"User:{self.user.id} ParticipantID:{self.id} "
662
                    f"number of messages to be resent:{number_diffs}"
663
                )
664
                messages = self.session["doc"].diffs[-number_diffs:]
×
665
                for message in messages:
×
666
                    new_message = message.copy()
×
667
                    new_message["server_fix"] = True
×
668
                    await self.send_message(new_message)
×
669
            else:
670
                logger.debug(
×
671
                    f"Action:User is on a very old version of the document. "
672
                    f"URL:{self.endpoint} User:{self.user.id} "
673
                    f"ParticipantID:{self.id}"
674
                )
675
                # Client has a version that is too old to be fixed
676
                await self.unfixable()
×
677
                return
×
678
        else:
679
            # Client has a higher version than server. Something is fishy!
680
            logger.debug(
×
681
                f"Action:User has higher document version than server.Fishy! "
682
                f"URL:{self.endpoint} User:{self.user.id} "
683
                f"ParticipantID:{self.id}"
684
            )
685
            await self.unfixable()
×
686
            return
×
687

688
    async def handle_e2ee_snapshot(self, message):
19✔
689
        """Handle a full encrypted snapshot from a client.
690

691
        For E2EE documents, the server cannot process content, so clients
692
        are responsible for saving encrypted snapshots periodically.
693
        The server stores the encrypted content as opaque data.
694
        """
695
        doc = self.session["doc"]
1✔
696

697
        # Verify sender has write access
698
        if self.user_info.access_rights not in ["write"]:
1✔
699
            return
×
700

701
        # Validate salt for E2EE documents.
702
        # All E2EE snapshots must include e2ee_salt so the server can
703
        # verify they match the current key or detect a password change.
704
        salt_changed = False
1✔
705
        if doc.e2ee:
1✔
706
            msg_salt = message.get("e2ee_salt")
1✔
707
            if not msg_salt:
1✔
708
                return
×
709
            try:
1✔
710
                decoded_salt = base64.b64decode(msg_salt)
1✔
711
            except Exception:
×
712
                return
×
713
            if decoded_salt != doc.e2ee_salt:
1✔
714
                salt_changed = True
1✔
715
                doc.e2ee_salt = decoded_salt
1✔
716
            if "e2ee_iterations" in message:
1✔
717
                new_iterations = int(message["e2ee_iterations"])
1✔
718
                if new_iterations != doc.e2ee_iterations:
1✔
719
                    salt_changed = True
×
720
                doc.e2ee_iterations = new_iterations
1✔
721

722
        # Store the encrypted snapshot
723
        doc.content = message["content"]  # Encrypted, opaque to server
1✔
724
        doc.comments = message.get("comments", {})  # Encrypted
1✔
725
        doc.bibliography = message.get("bibliography", {})  # Encrypted
1✔
726

727
        # Store the encrypted title if provided (for display in overview).
728
        # For E2EE documents the title field holds the encrypted ciphertext;
729
        # the client decrypts it when the key is available.
730
        if "title" in message:
1✔
731
            doc.title = message["title"]
1✔
732

733
        # Record the version this snapshot covers.
734
        snapshot_v = message["v"]
1✔
735
        doc.e2ee_snapshot_version = snapshot_v
1✔
736

737
        if salt_changed:
1✔
738
            # Old diffs are encrypted with the old key and useless.
739
            # The snapshot becomes the new baseline.
740
            doc.diffs = []
1✔
741

742
        # Save to database. Force save because the encrypted content may
743
        # have changed even if the version number hasn't (e.g. initial
744
        # snapshot for a newly created E2EE document).
745
        await WebsocketConsumer.save_document_async(
1✔
746
            self.user_info.document_id, force=True
747
        )
748

749
        if salt_changed:
1✔
750
            # Force other clients to refetch so they prompt for the new
751
            # password. This prevents outdated clients from sending
752
            # diffs encrypted with the old key.
753
            for participant in self.session["participants"].values():
1✔
754
                if participant.id != self.id:
1✔
755
                    await participant.send_message({"type": "refetch_doc"})
×
756
        else:
757
            # Notify other clients of the new snapshot version
758
            await WebsocketConsumer.send_updates(
1✔
759
                {
760
                    "type": "e2ee_snapshot_received",
761
                    "v": snapshot_v,
762
                },
763
                self.user_info.document_id,
764
                self.id,
765
                self.user_info.user.id,
766
            )
767

768
    async def request_snapshot(self):
19✔
769
        """Request a snapshot from a write-capable client.
770

771
        For E2EE documents, the server cannot save document content
772
        because it's encrypted. Instead, the server asks one of the
773
        write-capable clients to send an encrypted snapshot.
774
        """
775
        participants = self.session["participants"]
×
776
        for participant_id, participant in participants.items():
×
777
            if participant.user_info.access_rights in ["write"]:
×
778
                await participant.send_message(
×
779
                    {
780
                        "type": "request_snapshot",
781
                        "v": self.session["doc"].version,
782
                    }
783
                )
784
                return  # Only ask one client
×
785

786
    async def can_update_document(self):
19✔
787
        return self.user_info.access_rights in CAN_UPDATE_DOCUMENT
15✔
788

789
    async def can_communicate(self):
19✔
790
        return self.user_info.access_rights in CAN_COMMUNICATE
15✔
791

792
    async def disconnect(self, code):
19✔
793
        if (
15✔
794
            hasattr(self, "endpoint")
795
            and hasattr(self, "user")
796
            and hasattr(self, "id")
797
        ):
798
            logger.debug(
15✔
799
                f"Action:Closing websocket. URL:{self.endpoint} "
800
                f"User:{self.user.id} ParticipantID:{self.id}"
801
            )
802
        if (
15✔
803
            hasattr(self, "session")
804
            and hasattr(self, "user_info")
805
            and hasattr(self.user_info, "document_id")
806
        ):
807
            doc_id = self.user_info.document_id
15✔
808
            if WebsocketConsumer.get_session(doc_id):
15✔
809
                # Clear this participant's specific resources
810
                if (
15✔
811
                    hasattr(self, "id")
812
                    and self.id in self.session["participants"]
813
                ):
814
                    # Remove this participant
815
                    self.session["participants"].pop(self.id)
15✔
816
                    WebsocketConsumer.sync_session_snapshot(doc_id)
15✔
817

818
                # Complete document cleanup if no participants remain
819
                if len(self.session["participants"]) == 0:
15✔
820
                    # Save before cleanup
821
                    await WebsocketConsumer.save_document_async(doc_id)
15✔
822

823
                    # Break references manually before deleting
824
                    session = WebsocketConsumer.get_session(doc_id)
15✔
825

826
                    # Clear prosemirror node structure
827
                    if "node" in session:
15✔
828
                        # Recursively break node references if possible
829
                        session["node"] = None
15✔
830

831
                    # Clear diff history completely
832
                    if "doc" in session and hasattr(session["doc"], "diffs"):
15✔
833
                        session["doc"].diffs = None  # Not just an empty list
15✔
834

835
                    # Remove complete session
836
                    WebsocketConsumer.remove_session(doc_id)
15✔
837

838
                    # Force garbage collection
839
                    gc.collect()
15✔
840

841
                else:
842
                    try:
5✔
843
                        # Update participant list if there are still participants
844
                        await WebsocketConsumer.send_participant_list(
5✔
845
                            self.user_info.document_id
846
                        )
847
                    except autobahn.exception.Disconnected:
×
848
                        logger.error(
×
849
                            "Error sending participant list over disconnected session"
850
                        )
851

852
            # Clear any remaining references to large objects
853
            if hasattr(self, "session"):
15✔
854
                self.session = None
15✔
855
            if hasattr(self, "user_info"):
15✔
856
                self.user_info = None
15✔
857
            await self.close()
15✔
858

859
    @classmethod
19✔
860
    async def send_participant_list(cls, document_id):
19✔
861
        session = cls.get_session(document_id)
15✔
862
        if session:
15✔
863
            avatars = Avatars()
15✔
864
            participant_list = []
15✔
865

866
            # Create a list of tasks to get avatars
867
            avatar_tasks = []
15✔
868
            participants_data = []
15✔
869

870
            for session_id, waiter in list(session["participants"].items()):
15✔
871
                access_rights = waiter.user_info.access_rights
15✔
872
                if access_rights not in CAN_COMMUNICATE:
15✔
873
                    continue
×
874
                participants_data.append(
15✔
875
                    {
876
                        "session_id": session_id,
877
                        "id": waiter.user_info.user.id,
878
                        "name": waiter.user_info.user.readable_name,
879
                        "user": waiter.user_info.user,
880
                    }
881
                )
882
                # Add task to get avatar
883
                avatar_tasks.append(
15✔
884
                    avatars.get_url_async(waiter.user_info.user)
885
                )
886

887
            # Get all avatars in parallel
888
            if avatar_tasks:
15✔
889
                avatar_urls = await asyncio.gather(*avatar_tasks)
15✔
890

891
                # Now build the participant list with avatars
892
                for i, data in enumerate(participants_data):
15✔
893
                    participant_list.append(
15✔
894
                        {
895
                            "session_id": data["session_id"],
896
                            "id": data["id"],
897
                            "name": data["name"],
898
                            "avatar": avatar_urls[i],
899
                        }
900
                    )
901

902
            message = {
15✔
903
                "participant_list": participant_list,
904
                "type": "connections",
905
            }
906
            await WebsocketConsumer.send_updates(message, document_id)
15✔
907

908
    @classmethod
19✔
909
    async def reset_collaboration(
19✔
910
        cls, patch_exception_msg, document_id, sender_id
911
    ):
912
        session = cls.get_session(document_id)
×
913
        if not session:
×
914
            return
×
915
        logger.debug(
×
916
            f"Action:Resetting collaboration. DocumentID:{document_id} "
917
            f"Patch conflict triggered. ParticipantID:{sender_id} "
918
            f"waiters:{len(session['participants'])}"
919
        )
920

921
        # Create a list of coroutines to execute
922
        tasks = []
×
923

924
        for waiter in list(session["participants"].values()):
×
925
            if waiter.id != sender_id:
×
926
                tasks.append(waiter.unfixable())
×
927
                tasks.append(waiter.send_message(patch_exception_msg))
×
928

929
        # Execute all tasks concurrently
930
        if tasks:
×
931
            await asyncio.gather(*tasks)
×
932

933
    @classmethod
19✔
934
    async def send_updates(
19✔
935
        cls, message, document_id, sender_id=None, user_id=None
936
    ):
937
        session = cls.get_session(document_id)
15✔
938
        if not session:
15✔
939
            return
×
940
        logger.debug(
15✔
941
            f"Action:Sending message to waiters. DocumentID:{document_id} "
942
            f"waiters:{len(session['participants'])}"
943
        )
944

945
        # Create a list of send tasks to execute concurrently
946
        send_tasks = []
15✔
947

948
        for waiter in list(session["participants"].values()):
15✔
949
            if waiter.id != sender_id:
15✔
950
                access_rights = waiter.user_info.access_rights
15✔
951
                msg_to_send = message
15✔
952

953
                # Check if we need to modify the message based on access rights
954
                need_copy = False
15✔
955

956
                # For E2EE documents, we cannot filter content (it's all
957
                # encrypted), so skip comment filtering entirely.
958
                if (
15✔
959
                    not session["doc"].e2ee
960
                    and "comments" in message
961
                    and len(message["comments"]) > 0
962
                ):
963
                    # Filter comments if needed
964
                    if access_rights == "read-without-comments":
×
965
                        need_copy = True
×
966
                    elif (
×
967
                        access_rights in ["review", "review-tracked"]
968
                        and user_id != waiter.user_info.user.id
969
                    ):
970
                        need_copy = True
×
971
                elif (
15✔
972
                    message["type"] in ["chat", "connections"]
973
                    and access_rights not in CAN_COMMUNICATE
974
                ):
975
                    continue
×
976
                elif (
15✔
977
                    message["type"] == "selection_change"
978
                    and access_rights not in CAN_COMMUNICATE
979
                    and user_id != waiter.user_info.user.id
980
                ):
981
                    continue
×
982
                elif (
15✔
983
                    message["type"] == "path_change"
984
                    and user_id != waiter.user_info.user.id
985
                ):
986
                    continue
×
987

988
                # Create a copy of the message if needed to modify it
989
                if need_copy:
15✔
990
                    msg_to_send = deepcopy(message)
×
991
                    msg_to_send["comments"] = []
×
992

993
                # Add the send task to our list
994
                send_tasks.append(waiter.send_message(msg_to_send))
15✔
995

996
        # Execute all send tasks concurrently
997
        if send_tasks:
15✔
998
            await asyncio.gather(*send_tasks)
15✔
999

1000
    @classmethod
19✔
1001
    async def save_document_async(cls, document_id, force=False):
19✔
1002
        session = cls.get_session(document_id)
15✔
1003
        if not session:
15✔
1004
            return
×
1005
        node = None
15✔
1006
        if not session["doc"].e2ee and session.get("node_updates"):
15✔
1007
            node = session["node"]
14✔
1008
        saved = await document_store.save_document_async(
15✔
1009
            doc=session["doc"],
1010
            node=node,
1011
            force=force,
1012
            last_saved_version=session["last_saved_version"],
1013
        )
1014
        if saved:
15✔
1015
            session["last_saved_version"] = session["doc"].version
15✔
1016
            if not session["doc"].e2ee:
15✔
1017
                session["node_updates"] = False
14✔
1018

1019
    @classmethod
19✔
1020
    def save_document(cls, document_id, force=False):
19✔
1021
        session = cls.get_session(document_id)
×
1022
        if not session:
×
1023
            return
×
1024
        node = None
×
1025
        if not session["doc"].e2ee and session.get("node_updates"):
×
1026
            node = session["node"]
×
1027
        saved = document_store.save_document(
×
1028
            doc=session["doc"],
1029
            node=node,
1030
            force=force,
1031
            last_saved_version=session["last_saved_version"],
1032
        )
1033
        if saved:
×
1034
            session["last_saved_version"] = session["doc"].version
×
1035
            if not session["doc"].e2ee:
×
1036
                session["node_updates"] = False
×
1037

1038
    @classmethod
19✔
1039
    def save_all_docs(cls):
19✔
1040
        try:
19✔
1041
            document_ids = list(cls.runtime_sessions)
19✔
1042
        except (TypeError, FileNotFoundError):
×
1043
            return
×
1044
        for document_id in document_ids:
19✔
1045
            cls.save_document(document_id)
×
1046

1047

1048
atexit.register(WebsocketConsumer.save_all_docs)
19✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc