# 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)