# backend/file_chat.py — historique de discussion (#169, #190) """Chat history persisted under ``data/chats/``. One JSON document per ``(vault, path)`` pair, keyed by a SHA-256 of both so the filename never carries user-controlled path separators. Writes are atomic (tmp + move) and the message list is capped at :data:`MAX_MESSAGES` to bound growth. #190 adds the **general chat**: the same store addressed with the reserved sentinels (:data:`GLOBAL_VAULT` / :data:`GLOBAL_PATH`), so no second implementation. Messages may carry an ``attachment`` (image/video/url) uploaded under ``data/chat_uploads/``. """ from __future__ import annotations import hashlib import html import json import logging import re import shutil import threading import time import uuid from pathlib import Path from typing import Any from urllib.parse import urljoin, urlparse import httpx logger = logging.getLogger("obsigate.file_chat") CHAT_DIR = Path("data/chats") MAX_MESSAGES = 500 # retention ceiling per file (oldest dropped first) MAX_TEXT = 4000 # characters per message # ponytail: global lock over read-modify-write — chat writes are HTTP-only and # serialized anyway, this just makes losing a message to a future thread (or a # watcher hook) impossible; per-file locks if it ever becomes contended. _LOCK = threading.RLock() # #190 — general (non file-bound) conversation, stored like any other one. GLOBAL_VAULT = "__global__" GLOBAL_PATH = "general" # #191 — private (2 users) conversations reuse the same store: the vault is # the reserved sentinel and the path is the sorted username pair, so the # storage key never depends on who asks. DM_VAULT = "__dm__" # #190 — attachments (image/video) live outside the vaults. UPLOAD_DIR = Path("data/chat_uploads") MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB per attachment ALLOWED_ATTACH_EXT = { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".mp4", ".webm", ".ogg", ".mov", ".m4v", } # #191 — link preview fetch budget PREVIEW_TIMEOUT = 5.0 # seconds PREVIEW_MAX_BYTES = 512 * 1024 # only the head of the page is parsed PREVIEW_IMAGE_MAX = 2 * 1024 * 1024 # ponytail: 2 MB ceiling on a thumbnail def _chat_file(vault: str, path: str) -> Path: """Return the chat file for *(vault, path)* (hashed, traversal-proof).""" CHAT_DIR.mkdir(parents=True, exist_ok=True) key = hashlib.sha256(f"{vault}\0{path}".encode()).hexdigest()[:32] return CHAT_DIR / f"{key}.json" def _read(vault: str, path: str) -> dict[str, Any]: """Load the raw chat document (empty structure when missing/corrupt).""" file = _chat_file(vault, path) if not file.exists(): return {"vault": vault, "path": path, "messages": []} try: doc = json.loads(file.read_text(encoding="utf-8")) if not isinstance(doc.get("messages"), list): raise TypeError("messages is not a list") # caught by the handler below return doc except Exception as e: logger.error("Failed to read chat for %s/%s: %s", vault, path, e) return {"vault": vault, "path": path, "messages": []} def _write(file: Path, doc: dict[str, Any]) -> None: """Atomically persist *doc* (tmp file + rename).""" try: tmp = file.with_suffix(".tmp") tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=1), encoding="utf-8") shutil.move(str(tmp), str(file)) except Exception as e: logger.error("Failed to write chat %s: %s", file.name, e) def get_messages(vault: str, path: str) -> list[dict[str, Any]]: """Return the chat history for *(vault, path)* (chronological).""" return list(_read(vault, path).get("messages", [])) def add_message( vault: str, path: str, user: str, text: str, attachment: dict[str, Any] | None = None, preview: dict[str, Any] | None = None, ) -> dict[str, Any]: """Append a message and persist it. Returns the stored message. The list is capped at :data:`MAX_MESSAGES` (oldest dropped first). *attachment* (#190) is ``{name, url, mime, kind}`` for image/video/url; *preview* (#191) is the OpenGraph card of the first URL in *text*. """ text = (text or "").strip()[:MAX_TEXT] msg: dict[str, Any] = { "id": uuid.uuid4().hex[:12], "user": user or "anonyme", "text": text, "ts": time.time(), } if attachment: msg["attachment"] = attachment if preview: msg["preview"] = preview return _append(vault, path, msg) def _append( vault: str, path: str, msg: dict[str, Any], ) -> dict[str, Any]: """Cap, persist and return *msg* (shared by file, global and DM chats). The read-modify-write of the whole document happens under ``_LOCK`` so a concurrent writer can never drop a message (same class of bug as BUG-029 on ``users.json``). """ with _LOCK: doc = _read(vault, path) messages = list(doc.get("messages", [])) messages.append(msg) if len(messages) > MAX_MESSAGES: messages = messages[-MAX_MESSAGES:] doc["messages"] = messages _write(_chat_file(vault, path), doc) return msg # --- #192 : accusé de réception --------------------------------------------- def get_read(vault: str, path: str) -> dict[str, float]: """Return ``{username: last_read_ts}`` for a conversation (#192).""" return {str(u): float(ts) for u, ts in (_read(vault, path).get("read") or {}).items()} def mark_read(vault: str, path: str, user: str) -> dict[str, float]: """Record that *user* has seen the conversation (#192). Returns the whole read map so the caller can broadcast it on SSE: a sender learns their messages were received as soon as the recipient displays the conversation. """ if not user: return get_read(vault, path) with _LOCK: doc = _read(vault, path) read = {str(u): float(ts) for u, ts in (doc.get("read") or {}).items()} read[user] = time.time() doc["read"] = read _write(_chat_file(vault, path), doc) return read # --- #191 : messages privés (2 utilisateurs) ------------------------------- def dm_path(user_a: str, user_b: str) -> str: """Storage path for the private conversation between two users. The pair is sorted so both participants address the same document. """ return "|".join(sorted([user_a, user_b])) def get_dm_messages(user_a: str, user_b: str) -> list[dict[str, Any]]: """Return the private history between two users (chronological).""" return get_messages(DM_VAULT, dm_path(user_a, user_b)) def add_dm_message( user_a: str, user_b: str, author: str, text: str, attachment: dict[str, Any] | None = None, preview: dict[str, Any] | None = None, ) -> dict[str, Any]: """Append a private message. Returns the stored message.""" return add_message(DM_VAULT, dm_path(user_a, user_b), author, text, attachment, preview) # --- #191 : suppression ----------------------------------------------------- def delete_message(vault: str, path: str, message_id: str) -> bool: """Remove one message from a conversation. True when it existed.""" with _LOCK: doc = _read(vault, path) messages = list(doc.get("messages", [])) kept = [m for m in messages if m.get("id") != message_id] if len(kept) == len(messages): return False doc["messages"] = kept _write(_chat_file(vault, path), doc) return True # --- #190 : chat général (conversation centrale, hors fichier) ------------- def get_global_messages() -> list[dict[str, Any]]: """Return the general-chat history (chronological).""" return get_messages(GLOBAL_VAULT, GLOBAL_PATH) def add_global_message( user: str, text: str, attachment: dict[str, Any] | None = None, preview: dict[str, Any] | None = None, ) -> dict[str, Any]: """Append a message to the general chat. Returns the stored message.""" return add_message(GLOBAL_VAULT, GLOBAL_PATH, user, text, attachment, preview) def save_attachment(filename: str, data: bytes) -> dict[str, Any]: """Persist an uploaded attachment under :data:`UPLOAD_DIR`. Returns ``{name, url, mime, kind}``. The stored name is a fresh UUID (never the client name), the extension must be in :data:`ALLOWED_ATTACH_EXT` and the size is capped at :data:`MAX_UPLOAD_BYTES`. Raises: ValueError: extension refused, empty file or size exceeded. """ ext = Path(filename or "").suffix.lower() if ext not in ALLOWED_ATTACH_EXT: raise ValueError(f"extension refusée : {ext or '(aucune)'}") if not data: raise ValueError("fichier vide") if len(data) > MAX_UPLOAD_BYTES: raise ValueError(f"fichier trop lourd (max {MAX_UPLOAD_BYTES // (1024 * 1024)} MB)") UPLOAD_DIR.mkdir(parents=True, exist_ok=True) name = f"{uuid.uuid4().hex}{ext}" (UPLOAD_DIR / name).write_bytes(data) kind = "video" if ext in {".mp4", ".webm", ".ogg", ".mov", ".m4v"} else "image" return { "name": name, "url": f"/api/chat/attachment/{name}", "mime": _MIME_BY_EXT.get(ext, "application/octet-stream"), "kind": kind, } # --- #191 : link preview ---------------------------------------------------- _URL_RE = re.compile(r"https?://[^\s<>\"']+") _PREVIEW_CACHE: dict[str, dict[str, Any] | None] = {} PREVIEW_CACHE_MAX = 200 def _og(content: str, prop: str) -> str: """Extract one OpenGraph/``