244 lines
10 KiB
Python
244 lines
10 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.auth.user_store import get_all_users, get_user
|
|
from backend.indexer import get_vault_data
|
|
from backend.schemas import ChatHistoryResponse, ChatMessageResponse, 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)}
|
|
|
|
|
|
@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")
|
|
# #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)}
|
|
|
|
|
|
@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"}
|