- **A — Suivi du document** : l'en-tête du panneau chat affiche le document ciblé (titre + chemin) et `followFileChat()` appelé par `renderFile()` re-cible le panneau ouvert à chaque changement de document. - **B — Chat général** : onglet « Chat » (dernier) dans la sidebar, conversation centrale stockée dans le store #169 via les sentinelles `__global__/general` (`GET/POST /api/chat`) ; pastille de messages non lus sur l'onglet ; pièces jointes image/vidéo (`POST /api/chat/upload` : allow-list d'extensions, 25 MB, nom UUID ; `GET /api/chat/attachment/{name}` résolu contre l'allow-list) ; URL cliquables au rendu ; date/heure d'envoi. - **C — Ordre des onglets** : la barre de filtre passe sous les onglets et sert de recherche dans le chat (texte + auteur) quand l'onglet Chat est actif (`switchSidebarTab` + `routeFilter`). - Transport : broadcast SSE `chat_message` réutilisé (`vault __global__` route vers la sidebar, sinon le panneau fichier) — pas de 2ᵉ WebSocket. - i18n FR/EN (6 clés), CSS bloc #190, `initSidebarChat()` dans l'orchestrateur. - Tests : pytest 27 chat (12 nouveaux) ; JSDOM `filechat.test.mjs` 11 (5 nouveaux) ; suite 1655 passed, ruff/mypy 0, validate-imports 42 modules. - Docs : CHANGELOG [Unreleased], ROADMAP #190 → index, fiche `file-chat-169.md` §#190, guide « Chat général », journal des interventions.
139 lines
5.4 KiB
Python
139 lines
5.4 KiB
Python
# backend/routers/file_chat.py — chat (#169, #190)
|
|
"""Chat endpoints: history read + message post with SSE fan-out.
|
|
|
|
- ``GET/POST /api/file/{vault_name}/chat`` — per-file chat: auth + vault
|
|
access + path traversal check (``resolve_safe_path`` raises
|
|
``ServiceError`` mapped by the app-level handler).
|
|
- ``GET/POST /api/chat`` — the **general chat** (#190), not bound to a file.
|
|
- ``POST /api/chat/upload`` / ``GET /api/chat/attachment/{name}`` (#190):
|
|
image/video attachments (extension allow-list, size cap, UUID name).
|
|
|
|
Every post is broadcast on the existing SSE channel (``chat_message``) so
|
|
all connected clients update live without a second WebSocket.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile
|
|
from fastapi.responses import FileResponse
|
|
|
|
from backend import file_chat as _store
|
|
from backend.auth.middleware import check_vault_access, require_auth
|
|
from backend.indexer import get_vault_data
|
|
from backend.schemas import ChatHistoryResponse, ChatMessageResponse
|
|
from backend.services.paths import resolve_safe_path
|
|
from backend.sse import sse_manager
|
|
|
|
router = APIRouter() # tags dérivés de `tag_for_path` → « Files »
|
|
|
|
|
|
def _check(vault_name: str, path: str, current_user: dict[str, Any]) -> None:
|
|
"""Authz + traversal guard shared by both verbs."""
|
|
if not check_vault_access(vault_name, current_user):
|
|
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
|
|
vault_data = get_vault_data(vault_name)
|
|
if not vault_data:
|
|
raise HTTPException(404, "Vault not found")
|
|
resolve_safe_path(Path(vault_data["path"]), path) # ServiceError → 403/500
|
|
|
|
|
|
@router.get("/api/file/{vault_name}/chat", response_model=ChatHistoryResponse)
|
|
async def api_file_chat_history(
|
|
vault_name: str,
|
|
path: str,
|
|
current_user: dict[str, Any] = Depends(require_auth),
|
|
):
|
|
"""Return the chat history for a file (chronological)."""
|
|
_check(vault_name, path, current_user)
|
|
return {"messages": _store.get_messages(vault_name, path)}
|
|
|
|
|
|
@router.post("/api/file/{vault_name}/chat", response_model=ChatMessageResponse)
|
|
async def api_file_chat_post(
|
|
vault_name: str,
|
|
body: dict[str, Any] = Body(...),
|
|
current_user: dict[str, Any] = Depends(require_auth),
|
|
):
|
|
"""Post a chat message and broadcast it on SSE (``chat_message``)."""
|
|
path = str(body.get("path") or "")
|
|
text = str(body.get("text") or "")
|
|
if not path:
|
|
raise HTTPException(400, "path is required")
|
|
if not text.strip():
|
|
raise HTTPException(400, "text is required")
|
|
_check(vault_name, path, current_user)
|
|
msg = _store.add_message(vault_name, path, current_user.get("username", ""), text)
|
|
await sse_manager.broadcast("chat_message", {"vault": vault_name, "path": path, "message": msg})
|
|
return {"message": msg, "status": "ok"}
|
|
|
|
|
|
# --- #190 : chat général ----------------------------------------------------
|
|
|
|
def _attachment(body: dict[str, Any]) -> dict[str, Any] | None:
|
|
"""Validate the optional ``attachment`` object sent by the client."""
|
|
raw = body.get("attachment")
|
|
if not raw or not isinstance(raw, dict):
|
|
return None
|
|
name = str(raw.get("name") or "")
|
|
# Only an already-uploaded file (or an http(s) URL) may travel along.
|
|
if not _store.attachment_path(name) and not str(raw.get("url", "")).startswith(("http://", "https://", "/api/")):
|
|
raise HTTPException(400, "attachment inconnu")
|
|
return {
|
|
"name": name,
|
|
"url": str(raw.get("url") or ""),
|
|
"mime": str(raw.get("mime") or ""),
|
|
"kind": str(raw.get("kind") or "file"),
|
|
}
|
|
|
|
|
|
@router.get("/api/chat", response_model=ChatHistoryResponse)
|
|
async def api_chat_history(current_user: dict[str, Any] = Depends(require_auth)):
|
|
"""Return the general chat history (#190, chronological)."""
|
|
return {"messages": _store.get_global_messages()}
|
|
|
|
|
|
@router.post("/api/chat", response_model=ChatMessageResponse)
|
|
async def api_chat_post(
|
|
body: dict[str, Any] = Body(...),
|
|
current_user: dict[str, Any] = Depends(require_auth),
|
|
):
|
|
"""Post to the general chat and broadcast it on SSE (``chat_message``)."""
|
|
text = str(body.get("text") or "")
|
|
if not text.strip():
|
|
raise HTTPException(400, "text is required")
|
|
msg = _store.add_global_message(
|
|
current_user.get("username", ""), text, _attachment(body)
|
|
)
|
|
await sse_manager.broadcast(
|
|
"chat_message",
|
|
{"vault": _store.GLOBAL_VAULT, "path": _store.GLOBAL_PATH, "message": msg},
|
|
)
|
|
return {"message": msg, "status": "ok"}
|
|
|
|
|
|
@router.post("/api/chat/upload")
|
|
async def api_chat_upload(
|
|
file: UploadFile = File(...),
|
|
current_user: dict[str, Any] = Depends(require_auth),
|
|
):
|
|
"""Store an image/video attachment (#190). Returns ``{attachment}``."""
|
|
data = await file.read()
|
|
try:
|
|
info = _store.save_attachment(file.filename or "", data)
|
|
except ValueError as e:
|
|
raise HTTPException(400, str(e)) from e
|
|
return {"attachment": info}
|
|
|
|
|
|
@router.get("/api/chat/attachment/{name}")
|
|
async def api_chat_attachment(name: str, current_user: dict[str, Any] = Depends(require_auth)):
|
|
"""Serve an uploaded attachment (name validated against the allow-list)."""
|
|
path = _store.attachment_path(name)
|
|
if not path:
|
|
raise HTTPException(404, "Attachment not found")
|
|
info = _store._MIME_BY_EXT.get(path.suffix.lower(), "application/octet-stream")
|
|
return FileResponse(str(path), media_type=info)
|