# 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.auth.user_store import get_all_users, get_user from backend.indexer import get_vault_data from backend.schemas import ChatHistoryResponse, ChatMessageResponse, ChatReadResponse, StatusResponse 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), "read": _store.get_read(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(), "read": _store.get_read(_store.GLOBAL_VAULT, _store.GLOBAL_PATH), } # --- #192 : accusé de réception --------------------------------------------- def _check_read(vault: str, path: str, current_user: dict[str, Any]) -> None: """Authorization for marking a conversation read (#192). General chat is open to any member, a DM only to the two participants, a file chat follows the vault ACL. """ if vault == _store.GLOBAL_VAULT: return if vault == _store.DM_VAULT: if current_user.get("username") not in str(path).split("|"): raise HTTPException(403, "Accès refusé à cette conversation privée") return _check(vault, path, current_user) @router.post("/api/chat/read", response_model=ChatReadResponse) async def api_chat_read( body: dict[str, Any] = Body(...), current_user: dict[str, Any] = Depends(require_auth), ): """Record that the caller has seen a conversation (#192). Broadcast on SSE (``chat_read``) so the sender's own messages flip to ✓✓ live on every connected client. """ vault = str(body.get("vault") or "") path = str(body.get("path") or "") if not vault or not path: raise HTTPException(400, "vault and path are required") _check_read(vault, path, current_user) username = current_user.get("username", "") read = _store.mark_read(vault, path, username) await sse_manager.broadcast( "chat_read", {"vault": vault, "path": path, "user": username, "ts": read.get(username, 0.0), "read": read}, ) return {"read": read, "status": "ok"} @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") # #191 — best-effort link preview: a dead/slow URL never blocks the post. msg = _store.add_global_message( current_user.get("username", ""), text, _attachment(body), _store.build_preview(text), ) 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) # --- #191 : suppression + messages privés ----------------------------------- def _owner_or_admin(msg_user: str, current_user: dict[str, Any]) -> None: """A post may be deleted by its author or by an admin.""" if current_user.get("role") != "admin" and current_user.get("username") != msg_user: raise HTTPException(403, "Seul l'auteur ou un administrateur peut supprimer ce message") def _find_and_authorize(vault: str, path: str, message_id: str, current_user: dict[str, Any]) -> dict[str, Any]: """Locate *message_id* in the conversation and check the delete right.""" for m in _store.get_messages(vault, path): if m.get("id") == message_id: _owner_or_admin(m.get("user", ""), current_user) return m raise HTTPException(404, "Message not found") @router.delete("/api/chat/{message_id}", response_model=StatusResponse) async def api_chat_delete(message_id: str, current_user: dict[str, Any] = Depends(require_auth)): """Delete a message from the general chat (author or admin, #191).""" _find_and_authorize(_store.GLOBAL_VAULT, _store.GLOBAL_PATH, message_id, current_user) if not _store.delete_message(_store.GLOBAL_VAULT, _store.GLOBAL_PATH, message_id): raise HTTPException(404, "Message not found") await sse_manager.broadcast( "chat_deleted", {"vault": _store.GLOBAL_VAULT, "path": _store.GLOBAL_PATH, "id": message_id}, ) return {"status": "deleted"} @router.get("/api/chat/users", response_model=list[dict[str, Any]]) async def api_chat_users(current_user: dict[str, Any] = Depends(require_auth)): """Usernames available for a private conversation (chat DM picker, #191). Every authenticated member may see who else is around — this is a self-hosted portal, not a directory that needs hiding. """ me = current_user.get("username", "") return [ {"username": u.get("username", ""), "display_name": u.get("display_name") or u.get("username", "")} for u in get_all_users() if u.get("username") and u.get("username") != me ] def _dm_peer(username: str, current_user: dict[str, Any]) -> str: """Validate the DM peer exists and is not ourselves.""" if not username or username == current_user.get("username"): raise HTTPException(400, "Destinataire invalide") if not get_user(username): raise HTTPException(404, "Utilisateur inconnu") return username @router.get("/api/chat/dm/{username}", response_model=ChatHistoryResponse) async def api_chat_dm_history(username: str, current_user: dict[str, Any] = Depends(require_auth)): """Private history with *username* (#191).""" peer = _dm_peer(username, current_user) return { "messages": _store.get_dm_messages(current_user["username"], peer), "read": _store.get_read(_store.DM_VAULT, _store.dm_path(current_user["username"], peer)), } @router.post("/api/chat/dm/{username}", response_model=ChatMessageResponse) async def api_chat_dm_post( username: str, body: dict[str, Any] = Body(...), current_user: dict[str, Any] = Depends(require_auth), ): """Post a private message and broadcast it to both participants (#191).""" peer = _dm_peer(username, current_user) text = str(body.get("text") or "") if not text.strip(): raise HTTPException(400, "text is required") msg = _store.add_dm_message( current_user["username"], peer, current_user.get("username", ""), text, _attachment(body), _store.build_preview(text), ) # Same shape as the general chat so the client routes on vault/path. await sse_manager.broadcast( "chat_message", {"vault": _store.DM_VAULT, "path": _store.dm_path(current_user["username"], peer), "message": msg}, ) return {"message": msg, "status": "ok"} @router.delete("/api/chat/dm/{username}/{message_id}", response_model=StatusResponse) async def api_chat_dm_delete( username: str, message_id: str, current_user: dict[str, Any] = Depends(require_auth), ): """Delete one private message (author or admin, #191).""" peer = _dm_peer(username, current_user) vault, path = _store.DM_VAULT, _store.dm_path(current_user["username"], peer) _find_and_authorize(vault, path, message_id, current_user) if not _store.delete_message(vault, path, message_id): raise HTTPException(404, "Message not found") await sse_manager.broadcast( "chat_deleted", {"vault": vault, "path": path, "id": message_id} ) return {"status": "deleted"}