Files
ObsiGate/backend/file_chat.py
T
bruno f00a8bea8f
CI / lint (push) Successful in 2m50s
CI / security (push) Successful in 1m35s
CI / test (push) Successful in 4m28s
CI / build (push) Successful in 1m28s
CI / e2e (push) Successful in 17m11s
feat: chat — suppression de post, messages privés, boîte compacte, link preview #191
2026-10-08 22:33:29 -04:00

320 lines
11 KiB
Python

# 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 time
import uuid
from pathlib import Path
from typing import Any
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
# #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
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
doc = _read(vault, path)
return _append(vault, path, doc, msg)
def _append(
vault: str,
path: str,
doc: dict[str, Any],
msg: dict[str, Any],
) -> dict[str, Any]:
"""Cap, persist and return *msg* (shared by file and global chats)."""
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
# --- #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."""
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/``<title>`` value from an HTML head (regex)."""
for pattern in (
rf'<meta[^>]+(?:property|name)="{prop}"[^>]+content="([^"]*)"',
rf'<meta[^>]+content="([^"]*)"[^>]+(?:property|name)="{prop}"',
):
m = re.search(pattern, content, re.IGNORECASE)
if m:
return html.unescape(m.group(1)).strip()[:300]
if prop == "og:title":
m = re.search(r"<title[^>]*>([^<]*)</title>", content, re.IGNORECASE)
if m:
return html.unescape(m.group(1)).strip()[:300]
return ""
def build_preview(text: str) -> dict[str, Any] | None:
"""Fetch OpenGraph metadata for the first URL in *text* (#191).
SSRF-guarded (reuses the web-tool guard), size/time capped, cached in a
bounded dict. Returns ``{url, title, description, image, site}`` or
``None`` when there is no URL / the fetch fails (never raises: a dead
link must not block the message).
"""
m = _URL_RE.search(text or "")
if not m:
return None
url = m.group(0).rstrip(".,;:!?)")
if url in _PREVIEW_CACHE:
cached = _PREVIEW_CACHE[url]
return dict(cached) if cached else None
try:
from backend.tools.web import USER_AGENT, _assert_public_http_url
_assert_public_http_url(url)
resp = httpx.get(
url,
headers={"User-Agent": USER_AGENT, "Accept": "text/html,*/*"},
timeout=PREVIEW_TIMEOUT,
follow_redirects=True,
)
if resp.status_code >= 400:
raise ValueError(f"HTTP {resp.status_code}")
body = resp.text[:PREVIEW_MAX_BYTES]
preview = {
"url": url,
"title": _og(body, "og:title") or _og(body, "og:site_name"),
"description": _og(body, "og:description"),
"image": _og(body, "og:image"),
"site": _og(body, "og:site_name") or (url.split("/")[2] if "/" in url[8:] else url),
}
if not preview["title"]:
raise ValueError("pas de titre")
except Exception as e:
logger.debug("link preview failed for %s: %s", url, e)
preview = None
if len(_PREVIEW_CACHE) >= PREVIEW_CACHE_MAX:
_PREVIEW_CACHE.pop(next(iter(_PREVIEW_CACHE))) # oldest first (dict order)
_PREVIEW_CACHE[url] = preview
return dict(preview) if preview else None
def attachment_path(name: str) -> Path | None:
"""Resolve an attachment by its stored name (UUID+ext only, no traversal)."""
p = Path(name)
if p.name != name or p.suffix.lower() not in ALLOWED_ATTACH_EXT:
return None
file = UPLOAD_DIR / p.name
return file if file.exists() else None
# Extension → MIME (literals only; ``mimetypes`` guesses poorly for a few).
_MIME_BY_EXT = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
".mp4": "video/mp4",
".webm": "video/webm",
".ogg": "video/ogg",
".mov": "video/quicktime",
".m4v": "video/x-m4v",
}