feat: collaboration temps reel - edition simultanee (#62)
CI / lint (push) Successful in 1m1s
CI / security (push) Successful in 41s
CI / test (push) Successful in 1m47s
CI / build (push) Successful in 1m20s
CI / e2e (push) Successful in 10m36s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 1m1s
CI / security (push) Successful in 41s
CI / test (push) Successful in 1m47s
CI / build (push) Successful in 1m20s
CI / e2e (push) Successful in 10m36s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
WebSocket /ws/collab/{vault}/{path} (rooms par fichier), relais Yjs/CRDT, awareness (curseurs colores + presence), persistance serveur debounce 2s, auth WS + check_vault_access, reconnexion automatique. Frontend frontend/js/collab.js, backend backend/collab.py. Tests: 17 backend (5 clients simultanes) + 10 frontend. Docs: CHANGELOG, ROADMAP, fiche features/collaboration.md, README FR/EN.
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
"""Collaboration temps réel — édition simultanée (ROADMAP #62).
|
||||
|
||||
Ce module implémente le cœur serveur de l'édition collaborative :
|
||||
|
||||
* un **relais WebSocket** : une *room* est créée par fichier ouvert
|
||||
(clé ``vault::chemin``) et tous les clients qui éditent le même fichier
|
||||
rejoignent la même room ;
|
||||
* un **relais de mises à jour Yjs** (CRDT) : le serveur ne décode pas le
|
||||
format binaire Yjs, il stocke le journal des mises à jour reçues et le
|
||||
rejoue aux nouveaux arrivants. La fusion sans conflit est assurée côté
|
||||
client par Yjs ;
|
||||
* un **awareness** (curseurs colorés + sélections) relayé entre clients ;
|
||||
* une **persistance différée** : le texte markdown reçu des clients est écrit
|
||||
sur disque après un debounce (2 s par défaut).
|
||||
|
||||
Sécurité : chaque connexion est authentifiée manuellement (les dépendances
|
||||
FastAPI ``Depends`` ne s'exécutent pas pour ``@app.websocket``), puis le
|
||||
chemin est validé via :func:`backend.services.paths.resolve_safe_path` et
|
||||
l'accès à la vault via ``check_vault_access``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import WebSocket
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
logger = logging.getLogger("obsigate.collab")
|
||||
|
||||
#: Délai (secondes) sans modification avant écriture sur disque.
|
||||
SAVE_DEBOUNCE_SECONDS = 2.0
|
||||
|
||||
#: Taille maximale d'une mise à jour Yjs encodée (protection anti-abus).
|
||||
MAX_UPDATE_BYTES = 8 * 1024 * 1024
|
||||
|
||||
#: Taille maximale d'un snapshot texte (protection anti-abus).
|
||||
MAX_TEXT_CHARS = 8 * 1024 * 1024
|
||||
|
||||
#: Palette de couleurs attribuées aux utilisateurs (curseurs + avatars).
|
||||
PEER_COLORS = [
|
||||
"#e6194b", "#3cb44b", "#4363d8", "#f58231", "#911eb4",
|
||||
"#008080", "#9a6324", "#800000", "#808000", "#000075",
|
||||
]
|
||||
|
||||
|
||||
def color_for_index(index: int) -> str:
|
||||
"""Return a deterministic cursor color for a peer index."""
|
||||
return PEER_COLORS[index % len(PEER_COLORS)]
|
||||
|
||||
|
||||
def _b64encode(data: bytes) -> str:
|
||||
return base64.b64encode(data).decode("ascii")
|
||||
|
||||
|
||||
def _b64decode(data: str) -> bytes:
|
||||
return base64.b64decode(data.encode("ascii"))
|
||||
|
||||
|
||||
def authenticate_websocket(websocket: WebSocket) -> dict[str, Any] | None:
|
||||
"""Authenticate a WebSocket connection.
|
||||
|
||||
Mirrors :func:`backend.auth.middleware.get_current_user` but works on the
|
||||
WebSocket scope: the JWT is read from the ``access_token`` cookie (sent
|
||||
automatically by same-origin browsers during the handshake) or, as a
|
||||
fallback, from the ``token`` query parameter.
|
||||
|
||||
Returns the user dict, or ``None`` if authentication fails.
|
||||
"""
|
||||
from backend.auth.jwt_handler import decode_token
|
||||
from backend.auth.middleware import is_auth_enabled
|
||||
from backend.auth.user_store import get_user
|
||||
|
||||
if not is_auth_enabled():
|
||||
return {
|
||||
"username": "anonymous",
|
||||
"display_name": "Anonymous",
|
||||
"role": "admin",
|
||||
"vaults": ["*"],
|
||||
"active": True,
|
||||
"_token_vaults": ["*"],
|
||||
}
|
||||
|
||||
token = websocket.query_params.get("token") or websocket.cookies.get("access_token")
|
||||
if not token:
|
||||
return None
|
||||
|
||||
payload = decode_token(token)
|
||||
if not payload or payload.get("type") != "access":
|
||||
return None
|
||||
|
||||
user = get_user(payload["sub"])
|
||||
if not user or not user.get("active"):
|
||||
return None
|
||||
|
||||
user["_token_vaults"] = payload.get("vaults", [])
|
||||
user["_token_jti"] = payload.get("jti")
|
||||
return user
|
||||
|
||||
|
||||
@dataclass
|
||||
class CollabClient:
|
||||
"""A single WebSocket connection inside a collaboration room."""
|
||||
|
||||
conn_id: int
|
||||
websocket: WebSocket
|
||||
username: str
|
||||
display_name: str
|
||||
color: str
|
||||
y_client_id: int | None = None
|
||||
awareness: dict[str, Any] | None = None
|
||||
|
||||
def peer(self) -> dict[str, Any]:
|
||||
return {
|
||||
"connId": self.conn_id,
|
||||
"clientId": self.y_client_id,
|
||||
"username": self.username,
|
||||
"displayName": self.display_name,
|
||||
"color": self.color,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CollabRoom:
|
||||
"""State shared by every client editing the same file."""
|
||||
|
||||
vault: str
|
||||
path: str
|
||||
file_path: Path
|
||||
initial_text: str = ""
|
||||
clients: dict[int, CollabClient] = field(default_factory=dict)
|
||||
#: Journal des mises à jour Yjs (binaires) depuis la création de la room.
|
||||
updates: list[bytes] = field(default_factory=list)
|
||||
has_updates: bool = False
|
||||
seed_sent: bool = False
|
||||
pending_text: str | None = None
|
||||
save_task: asyncio.Task | None = None
|
||||
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return f"{self.vault}::{self.path}"
|
||||
|
||||
def peers(self) -> list[dict[str, Any]]:
|
||||
return [client.peer() for client in self.clients.values()]
|
||||
|
||||
def awareness_snapshot(self) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{"clientId": c.y_client_id, "state": c.awareness}
|
||||
for c in self.clients.values()
|
||||
if c.y_client_id is not None and c.awareness is not None
|
||||
]
|
||||
|
||||
|
||||
class CollabManager:
|
||||
"""Manages collaboration rooms, broadcasting and disk persistence."""
|
||||
|
||||
def __init__(self, save_debounce: float = SAVE_DEBOUNCE_SECONDS) -> None:
|
||||
self._rooms: dict[str, CollabRoom] = {}
|
||||
self._save_debounce = save_debounce
|
||||
self._next_conn_id = 1
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
# -- introspection (used by tests / diagnostics) ------------------------
|
||||
@property
|
||||
def room_count(self) -> int:
|
||||
return len(self._rooms)
|
||||
|
||||
def room_peer_count(self, vault: str, path: str) -> int:
|
||||
room = self._rooms.get(f"{vault}::{path}")
|
||||
return len(room.clients) if room else 0
|
||||
|
||||
def get_room(self, vault: str, path: str) -> CollabRoom | None:
|
||||
return self._rooms.get(f"{vault}::{path}")
|
||||
|
||||
# -- lifecycle ----------------------------------------------------------
|
||||
async def connect(
|
||||
self,
|
||||
websocket: WebSocket,
|
||||
vault: str,
|
||||
path: str,
|
||||
file_path: Path,
|
||||
user: dict[str, Any],
|
||||
) -> None:
|
||||
"""Register *websocket* in the room and relay messages until it closes."""
|
||||
async with self._lock:
|
||||
key = f"{vault}::{path}"
|
||||
room = self._rooms.get(key)
|
||||
if room is None:
|
||||
try:
|
||||
initial_text = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
initial_text = ""
|
||||
room = CollabRoom(vault=vault, path=path, file_path=file_path, initial_text=initial_text)
|
||||
self._rooms[key] = room
|
||||
|
||||
conn_id = self._next_conn_id
|
||||
self._next_conn_id += 1
|
||||
client = CollabClient(
|
||||
conn_id=conn_id,
|
||||
websocket=websocket,
|
||||
username=user.get("username", "anonymous"),
|
||||
display_name=user.get("display_name") or user.get("username", "anonymous"),
|
||||
color=color_for_index(conn_id - 1),
|
||||
)
|
||||
room.clients[conn_id] = client
|
||||
|
||||
seed: str | None = None
|
||||
if not room.has_updates and not room.seed_sent:
|
||||
seed = room.initial_text
|
||||
room.seed_sent = True
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "init",
|
||||
"connId": conn_id,
|
||||
"color": client.color,
|
||||
"seed": seed,
|
||||
"updates": [_b64encode(u) for u in room.updates],
|
||||
"peers": room.peers(),
|
||||
"awareness": room.awareness_snapshot(),
|
||||
})
|
||||
await self._broadcast(room, {"type": "peer_joined", "peer": client.peer()}, exclude=conn_id)
|
||||
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
await self._on_message(room, client, raw)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.debug("Collab connection error (%s): %s", room.key, exc)
|
||||
finally:
|
||||
await self.disconnect(room, client)
|
||||
|
||||
async def disconnect(self, room: CollabRoom, client: CollabClient) -> None:
|
||||
"""Remove *client* from *room*, flushing and cleaning up if empty."""
|
||||
async with self._lock:
|
||||
room.clients.pop(client.conn_id, None)
|
||||
empty = not room.clients
|
||||
if empty:
|
||||
await self._flush(room)
|
||||
async with self._lock:
|
||||
# Only delete if nobody rejoined while we were flushing.
|
||||
if not room.clients and self._rooms.get(room.key) is room:
|
||||
if room.save_task:
|
||||
room.save_task.cancel()
|
||||
self._rooms.pop(room.key, None)
|
||||
else:
|
||||
await self._broadcast(
|
||||
room,
|
||||
{
|
||||
"type": "peer_left",
|
||||
"peer": client.peer(),
|
||||
"clientId": client.y_client_id,
|
||||
},
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Flush and cancel every room (called on application shutdown)."""
|
||||
async with self._lock:
|
||||
rooms = list(self._rooms.values())
|
||||
self._rooms.clear()
|
||||
for room in rooms:
|
||||
if room.save_task:
|
||||
room.save_task.cancel()
|
||||
await self._flush(room)
|
||||
|
||||
# -- message handling ---------------------------------------------------
|
||||
async def _on_message(self, room: CollabRoom, client: CollabClient, raw: str) -> None:
|
||||
try:
|
||||
message = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
return
|
||||
if not isinstance(message, dict):
|
||||
return
|
||||
|
||||
msg_type = message.get("type")
|
||||
|
||||
if msg_type in ("sync", "update"):
|
||||
encoded = message.get("update")
|
||||
if not isinstance(encoded, str):
|
||||
return
|
||||
try:
|
||||
update = _b64decode(encoded)
|
||||
except (ValueError, TypeError):
|
||||
return
|
||||
if not update or len(update) > MAX_UPDATE_BYTES:
|
||||
return
|
||||
async with self._lock:
|
||||
room.updates.append(update)
|
||||
room.has_updates = True
|
||||
await self._broadcast(
|
||||
room,
|
||||
{"type": "update", "update": encoded, "from": client.conn_id},
|
||||
exclude=client.conn_id,
|
||||
)
|
||||
|
||||
elif msg_type == "awareness":
|
||||
y_client_id = message.get("clientId")
|
||||
state = message.get("state")
|
||||
if not isinstance(y_client_id, int):
|
||||
return
|
||||
client.y_client_id = y_client_id
|
||||
client.awareness = state if isinstance(state, dict) else None
|
||||
await self._broadcast(
|
||||
room,
|
||||
{
|
||||
"type": "awareness",
|
||||
"clientId": y_client_id,
|
||||
"state": client.awareness,
|
||||
"from": client.conn_id,
|
||||
},
|
||||
exclude=client.conn_id,
|
||||
)
|
||||
|
||||
elif msg_type == "text":
|
||||
text = message.get("text")
|
||||
if not isinstance(text, str) or len(text) > MAX_TEXT_CHARS:
|
||||
return
|
||||
room.pending_text = text
|
||||
self._schedule_save(room)
|
||||
|
||||
elif msg_type == "ping":
|
||||
await client.websocket.send_json({"type": "pong", "t": int(time.time() * 1000)})
|
||||
|
||||
# -- broadcasting -------------------------------------------------------
|
||||
async def _broadcast(self, room: CollabRoom, message: dict[str, Any], exclude: int | None = None) -> None:
|
||||
dead: list[CollabClient] = []
|
||||
for client in list(room.clients.values()):
|
||||
if exclude is not None and client.conn_id == exclude:
|
||||
continue
|
||||
try:
|
||||
await client.websocket.send_json(message)
|
||||
except Exception:
|
||||
dead.append(client)
|
||||
for client in dead:
|
||||
room.clients.pop(client.conn_id, None)
|
||||
|
||||
# -- persistence --------------------------------------------------------
|
||||
def _schedule_save(self, room: CollabRoom) -> None:
|
||||
if room.save_task and not room.save_task.done():
|
||||
room.save_task.cancel()
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError: # pragma: no cover - no running loop (tests)
|
||||
return
|
||||
room.save_task = loop.create_task(self._debounced_save(room))
|
||||
|
||||
async def _debounced_save(self, room: CollabRoom) -> None:
|
||||
try:
|
||||
await asyncio.sleep(self._save_debounce)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
await self._flush(room)
|
||||
|
||||
async def _flush(self, room: CollabRoom) -> None:
|
||||
"""Write the last received text snapshot to disk (if any)."""
|
||||
async with room.lock:
|
||||
text = room.pending_text
|
||||
room.pending_text = None
|
||||
if text is None:
|
||||
return
|
||||
try:
|
||||
await asyncio.to_thread(room.file_path.write_text, text, encoding="utf-8")
|
||||
logger.debug("Collab persisted %s", room.key)
|
||||
except OSError as exc:
|
||||
logger.warning("Collab persist failed for %s: %s", room.key, exc)
|
||||
|
||||
|
||||
#: Process-wide singleton used by the WebSocket endpoint.
|
||||
collab_manager = CollabManager()
|
||||
+45
-1
@@ -19,13 +19,14 @@ from typing import Any
|
||||
|
||||
import frontmatter
|
||||
import mistune
|
||||
from fastapi import Body, Depends, FastAPI, HTTPException, Query, Request
|
||||
from fastapi import Body, Depends, FastAPI, HTTPException, Query, Request, WebSocket
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from backend.attachment_indexer import get_attachment_stats, rescan_vault_attachments
|
||||
from backend.collab import authenticate_websocket, collab_manager
|
||||
from backend.history import (
|
||||
get_bookmarks,
|
||||
record_open,
|
||||
@@ -739,6 +740,7 @@ async def lifespan(app: FastAPI):
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
await collab_manager.stop()
|
||||
if _vault_watcher:
|
||||
await _vault_watcher.stop()
|
||||
_vault_watcher = None
|
||||
@@ -4091,6 +4093,48 @@ async def api_conflict_resolve(body: dict = Body(...), current_user=Depends(requ
|
||||
raise HTTPException(500, f"Error resolving conflict: {e!s}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real-time collaboration — WebSocket endpoint (ROADMAP #62)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.websocket("/ws/collab/{vault_name}/{path:path}")
|
||||
async def collab_websocket(websocket: WebSocket, vault_name: str, path: str):
|
||||
"""Real-time collaborative editing over WebSocket (ROADMAP #62).
|
||||
|
||||
One *room* is created per ``vault::path``; all clients editing the same
|
||||
file share Yjs/CRDT updates, awareness (cursors/selection) and a debounced
|
||||
server-side persistence of the markdown content.
|
||||
|
||||
Authentication is performed manually (FastAPI ``Depends`` do not run for
|
||||
WebSocket routes) and vault access is enforced per connection.
|
||||
"""
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.vaults import get_vault_root
|
||||
|
||||
user = authenticate_websocket(websocket)
|
||||
if user is None:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
|
||||
if not check_vault_access(vault_name, user):
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
try:
|
||||
vault_root = get_vault_root(vault_name)
|
||||
file_path = _resolve_safe_path(vault_root, path)
|
||||
except ServiceError:
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
await collab_manager.connect(websocket, vault_name, path, file_path, user)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static files & SPA fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -33,6 +33,7 @@ TAGS_METADATA: list[dict[str, str]] = [
|
||||
{"name": "AI", "description": "AI-powered editor actions, provider status and model discovery."},
|
||||
{"name": "BooksLM", "description": "Directory-scoped AI chat (NotebookLM-style) over a vault folder."},
|
||||
{"name": "MCP", "description": "Model Context Protocol server (Streamable HTTP) exposing the shared AI tool layer to external clients (Claude Desktop, Cursor…)."},
|
||||
{"name": "Collaboration", "description": "Real-time collaborative editing over WebSocket (`/ws/collab/{vault}/{path}`): Yjs/CRDT updates, awareness (cursors) and debounced server-side persistence."},
|
||||
{"name": "Sharing", "description": "Create and manage public read-only share links for documents."},
|
||||
{"name": "Webhooks", "description": "HTTP callbacks signed with HMAC-SHA256 for file events."},
|
||||
{"name": "Conflicts", "description": "Detect and resolve Syncthing sync-conflict files."},
|
||||
@@ -57,6 +58,13 @@ Authorization: Bearer <access_token>
|
||||
The same token is also accepted as an HTTP-only cookie, so browser clients can
|
||||
simply use `credentials: "include"`.
|
||||
|
||||
### Real-time collaboration
|
||||
Besides the REST API, a WebSocket endpoint `GET /ws/collab/{vault}/{path}` (upgrade) powers
|
||||
simultaneous editing of the same file: clients exchange Yjs/CRDT updates and awareness (remote
|
||||
cursors), and the server persists the document 2 s after the last change. Authentication uses the
|
||||
`access_token` cookie (or a `token` query parameter) and vault access is enforced per connection.
|
||||
See the collaboration feature documentation (`docs/features/collaboration.md`) for the protocol.
|
||||
|
||||
### Interactive documentation
|
||||
* **Swagger UI** — [/docs](/docs): try requests directly from the browser.
|
||||
* **ReDoc** — [/redoc](/redoc): clean, reading-oriented reference.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
fastapi==0.110.3
|
||||
uvicorn==0.30.0
|
||||
websockets>=12.0
|
||||
python-frontmatter==1.1.0
|
||||
mistune==3.0.2
|
||||
python-multipart==0.0.9
|
||||
|
||||
Reference in New Issue
Block a user