Files
bruno 2e2a33cef3
CI / lint (push) Successful in 1m36s
CI / security (push) Successful in 1m4s
CI / test (push) Successful in 3m41s
CI / build (push) Successful in 59s
CI / e2e (push) Successful in 11m8s
fix: corrige 6 bugs mineurs (BUG-035 a BUG-040)
2026-09-17 20:05:08 -04:00

7.7 KiB

#62 - Collaboration temps réel — Édition simultanée

Statut : ✅ Terminé — 100 % implémenté + 17 tests backend + 10 tests frontend (2026-09-11) Effort : 5-7 jours (réalisé) | Impact : 🟢 Références : Roadmap · Changelog

  • Fichiers clés :

    • backend/collab.py — CollabManager, CollabRoom, CollabClient, auth WS, persistance
    • backend/main.py — endpoint @app.websocket("/ws/collab/{vault_name}/{path:path}"), hook lifespan
    • frontend/js/collab.js — provider WebSocket, liaison Y.Text ↔ CodeMirror, curseurs distants, présence
    • frontend/js/utils.js — démarrage/arrêt de la session à l'ouverture/fermeture de l'éditeur
    • frontend/index.html — import map yjs, window.CodeMirror (Decoration/ViewPlugin/WidgetType/StateEffect), conteneur #collab-presence
    • frontend/style.css — styles présence + curseurs distants
    • tests/test_collab.py — 17 tests (manager, auth, ACL, persistance, 5 clients simultanés)
    • tests/frontend/collab.test.mjs — 10 tests (diff de texte, URL, avatars)
  • Description : plusieurs utilisateurs peuvent éditer le même document markdown en même temps, comme Google Docs. Chaque personne voit en temps réel les modifications des autres, avec leur curseur affiché en couleur et un indicateur de présence dans la barre d'outils de l'éditeur.


Architecture

Navigateur A ──┐
Navigateur B ──┼── WebSocket /ws/collab/{vault}/{path} ──► CollabManager (room par fichier)
Navigateur C ──┘                                             │
                                                             ├── relais des updates Yjs (opaque)
                                                             ├── relais de l'awareness (curseurs)
                                                             └── persistance différée (debounce 2 s)

Backend — backend/collab.py

  • CollabManager (singleton collab_manager) :
    • connect() enregistre la connexion dans la room vault::path, envoie le message init (journal des updates, seed pour le premier client, pairs, awareness) et boucle sur receive_text().
    • disconnect() retire le client, diffuse peer_left, et flush le fichier si la room devient vide (puis supprime la room).
    • stop() annule les tâches et flush toutes les rooms (arrêt de l'application).
    • _on_message() traite sync/update (Yjs), awareness, text (persistance), ping.
    • _broadcast() diffuse aux autres clients et purge les connexions mortes.
    • _schedule_save() / _debounced_save() / _flush() : écriture disque après 2 s d'inactivité (asyncio.to_thread pour ne pas bloquer l'event loop).
  • authenticate_websocket() : les dépendances FastAPI Depends ne s'exécutent pas sur les routes WebSocket. Le JWT est donc lu manuellement depuis le cookie access_token (envoyé automatiquement par le navigateur same-origin) ou, en repli, depuis ?token=. Si OBSIGATE_AUTH_ENABLED=false, un utilisateur anonyme admin est retourné (comme les routes REST).
  • Sécurité par connexion : check_vault_access() puis resolve_safe_path() (anti path traversal) avant tout accès disque.

Protocole WebSocket

Client → serveur :

Message Contenu Rôle
update {update: <base64 Yjs>} mise à jour incrémentale
sync {update: <base64 Yjs>} état complet (à la connexion / reconnexion)
awareness {clientId, state} curseur + nom + couleur
text {text} snapshot markdown pour la persistance
ping {} keepalive

Serveur → client :

Message Contenu Rôle
init {connId, color, seed, updates[], peers[], awareness[]} état initial de la room
update {update, from} mise à jour d'un autre client
awareness {clientId, state, from} curseur d'un autre client
peer_joined / peer_left {peer, clientId} présence
pong {t} réponse keepalive

Frontend — frontend/js/collab.js

  • Yjs chargé dynamiquement (import('yjs'), résolu par l'import map de index.html) : aucune dépendance statique externe, ce qui rend les fonctions pures testables en Node/JSDOM.
  • Liaison Y.Text ↔ CodeMirror : un EditorView.updateListener applique les changements locaux au Y.Text (ydoc.transact, origin local) ; un observateur Y.Text applique les changements distants à l'éditeur via un diff minimal à remplacement unique (computeTextDiff). Un flag applying évite les boucles.
  • Curseurs distants : extension CodeMirror (ViewPlugin + Decoration.widget) qui place un caret coloré + étiquette de nom à la position head de chaque pair. Un StateEffect force le rafraîchissement quand l'awareness change.
  • Présence : #collab-presence affiche un point de statut (connecté / connexion / interrompu), les avatars colorés et le nombre de personnes.
  • Reconnexion : backoff exponentiel 1 s → 30 s, puis init + renvoi de l'état complet (sync) et de l'awareness → merge CRDT au retour.
  • Persistance : le texte est envoyé au serveur 300 ms après la dernière modification ; le serveur écrit le fichier après 2 s d'inactivité.

Sous-tâches (ROADMAP #62)

  • Serveur WebSocket : endpoint /ws/collab/{vault}/{path} avec gestion des rooms
  • Intégration Yjs : Y.Doc partagé, Y.Text pour le contenu markdown
  • Awareness : curseurs colorés par utilisateur, sélections visibles
  • Synchro backend : persistance périodique du document (debounce 2 s)
  • Gestion des droits : vérification check_vault_access par connexion WS
  • UI : indicateur de présence (avatars dans la barre d'outils éditeur)
  • UI : curseurs distants dans CodeMirror (extension collaborative)
  • Gestion des déconnexions : reconnexion automatique, merge state au retour
  • Tests de charge : 5+ utilisateurs simultanés sur le même fichier

Sécurité

  • Authentification obligatoire si OBSIGATE_AUTH_ENABLED=true : le jeton est lu depuis le cookie HttpOnly access_token (envoyé lors du handshake same-origin). Le jeton en query string (?token=) n'est plus accepté (BUG-036 : URLs journalisées par les proxies).
  • Vérification check_vault_access() par connexion (un utilisateur ne peut pas rejoindre une room d'une vault non autorisée).
  • resolve_safe_path() empêche toute traversée de chemin (../../).
  • Bornes anti-abus : MAX_UPDATE_BYTES (8 Mo) par mise à jour, MAX_TEXT_CHARS (8 Mio) par snapshot, MAX_MESSAGE_CHARS (16 Mio) par trame brute.
  • Le serveur ne décode pas le binaire Yjs : il le stocke et le relaie tel quel (pas de surface d'attaque supplémentaire côté parsing).

Limites connues

  • Le journal des mises à jour Yjs est conservé en mémoire tant qu'au moins un client est connecté. Quand la room devient vide, le document est persisté sur disque et la room est supprimée : au redémarrage du serveur, l'état CRDT est réinitialisé à partir du contenu du fichier.
  • L'éditeur « fallback » (textarea, quand CodeMirror ne charge pas) n'est pas collaboratif : la session n'est démarrée que si state.editorView existe.
  • Yjs est chargé depuis esm.sh (comme CodeMirror) : une connexion Internet est requise au premier chargement, sauf mise en cache par le service worker.

Vérifications

.\.venv\Scripts\python.exe -m pytest tests/test_collab.py -q
.\.venv\Scripts\python.exe -m ruff check backend/
.\.venv\Scripts\python.exe -m mypy backend/ --ignore-missing-imports
node tests/frontend/collab.test.mjs
node tests/frontend/validate-imports.mjs