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

146 lines
7.7 KiB
Markdown

# #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](../ROADMAP.md) · [Changelog](../../CHANGELOG.md)
- **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)
- [x] Serveur WebSocket : endpoint `/ws/collab/{vault}/{path}` avec gestion des rooms
- [x] Intégration Yjs : `Y.Doc` partagé, `Y.Text` pour le contenu markdown
- [x] Awareness : curseurs colorés par utilisateur, sélections visibles
- [x] Synchro backend : persistance périodique du document (debounce 2 s)
- [x] Gestion des droits : vérification `check_vault_access` par connexion WS
- [x] UI : indicateur de présence (avatars dans la barre d'outils éditeur)
- [x] UI : curseurs distants dans CodeMirror (extension collaborative)
- [x] Gestion des déconnexions : reconnexion automatique, merge state au retour
- [x] 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
```powershell
.\.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
```