Files
ObsiGate/docs/features/file-chat-169.md
T
bruno f4c8504c8d
CI / lint (push) Successful in 2m50s
CI / security (push) Successful in 1m37s
CI / test (push) Successful in 4m34s
CI / build (push) Successful in 1m31s
CI / e2e (push) Successful in 17m36s
feat: chat — les posts affichent le markdown rendu comme un document, code coloré #193
2026-10-09 15:48:04 -04:00

14 KiB

Chat intégré par fichier — #169

  • Statut : ✅ livré le 2026-10-08
  • Effort : 3-4 j (réalisé en 1 session) | Impact : 🟢
  • Transport : SSE existant (#62) — pas de second WebSocket

Ce qui a été livré

Backend

  • Store backend/file_chat.py — historique JSON par paire (vault, path) sous data/chats/ :
    • nom de fichier = SHA-256 de vault\0path (32 premiers hex) : aucun séparateur de chemin utilisateur dans le nom → traversal impossible ;
    • plafond 500 messages par fichier (les plus vieux supprimés) ;
    • texte tronqué à 4000 caractères ;
    • écriture atomique (tmp + move) ; fichier corrompu → historique vide (fallback, pas de crash).
  • Routes backend/routers/file_chat.py (tag OpenAPI « Files » dérivé) :
    • GET /api/file/{vault}/chat?path=… → historique chronologique ;
    • POST /api/file/{vault}/chat {path, text} → ajout + broadcast SSE ;
    • garde commune : require_auth + check_vault_access + resolve_safe_path (traversal → 403/500 via le handler ServiceError) ;
    • text ou path vides → 400 ; vault inconnu → 404.
  • Schémas ChatMessageItem / ChatHistoryResponse / ChatMessageResponse dans backend/schemas.py (response_model sur les deux routes).
  • Broadcast : sse_manager.broadcast("chat_message", {vault, path, message}).

Frontend

  • frontend/js/filechat.js — panneau latéral :
    • bouton 💬 (.btn-chat) dans le groupe nav de la toolbar fichier (viewer.js), toggle ouvre/ferme ;
    • historique rendu chronologiquement, bulles « mine » à droite, textContent pour le corps (pas d'injection HTML) ;
    • envoi POST optimiste + dédoublonnage par id à la réception SSE ;
    • panneau fermé / autre fichier → toast « Nouveau message de … » (silencier si c'est son propre message).
  • frontend/js/sync.js — listener SSE chat_message avec import dynamique de filechat.js (pas de cycle d'imports).
  • CSS bloc #169 : panneau fixe 340 px, plein écran ≤ 768 px, variables CSS avec fallbacks, input 16 px (anti-zoom iOS).
  • i18n : 10 clés chat.* dans fr.json et en.json.

Tests

  • tests/test_file_chat.py — 15 tests : roundtrip store, isolation par fichier, plafond de rétention, troncature, traversal hashée, fichier corrompu, routes (200/400/404/403), fixture autouse isolant CHAT_DIR.
  • tests/frontend/filechat.test.mjs — 6 tests JSDOM : empty state, rendu chronologique + classe mine, anti-injection HTML, append live + dédup par id, isolation inter-fichiers, toggle. Locales réellement chargées (fetch simulé vers frontend/locales/).
  • Les deux sont ajoutés au pipeline CI (.gitea/workflows/ci.yml).

#190 — Suivi du document, chat général, ordre des onglets (2026-10-08)

A. Le panneau suit le document

  • En-tête du panneau : .file-chat-target affiche le titre du document (l'attribut title porte le chemin complet).
  • followFileChat(vault, path, title) est appelé en tête de renderFile() : panneau ouvert + autre document → re-cible (reload de l'historique) ; panneau fermé → no-op. Le bouton 💬 transmet data.title.

B. Chat général en onglet sidebar

  • Onglet #sidebar-tab-chat (dernier, message-circle) + panneau #sidebar-panel-chat (liste + 📎 + saisie + Envoyer, Entrée = envoyer).
  • Store : rien de nouveau — les sentinelles GLOBAL_VAULT="__global__" / GLOBAL_PATH="general" réutilisent add_message/get_messages.
  • Routes : GET/POST /api/chat, POST /api/chat/upload, GET /api/chat/attachment/{name}.
  • Pièces jointes : allow-list d'extensions (11 : images + vidéos), 25 MB, nom de stockage = UUID (le nom client n'est jamais utilisé), résolution par comparaison au nom + allow-list (aucun chemin utilisateur).
  • Messages non lus : timestamp de dernière visite dans localStorage['obsigate-chat-unread'], comptage des messages plus récents d'un autre auteur, pastille rouge 99+ sur l'onglet, effacée à l'ouverture.
  • URL : linkification http(s):// au rendu (texte autour en TextNode, donc pas d'injection).
  • SSE : vault === "__global__" route vers la liste sidebar (append + clear) ou vers badge + toast selon que l'onglet est actif.

C. Ordre des onglets + filtre = recherche chat

  • index.html : le bloc .sidebar-filter est déplacé après .sidebar-tabs.
  • switchSidebarTab : placeholder sidebar.filter_chat, dispatch chat.
  • initSidebarFilter (sidebar.js) : routeFilter/routeClear routent vers filterChatMessages() (texte et auteur, insensible à la casse).

A. Suppression d'un post

  • DELETE /api/chat/{id} et DELETE /api/chat/dm/{peer}/{id} : auteur du message ou admin (403 sinon), 404 si l'id n'existe pas.
  • Store : delete_message(vault, path, id) — écriture atomique, False si absent ; broadcast SSE chat_deleted {vault, path, id}.
  • Frontend : bouton 🗑 (.file-chat-del, visible au survol) rendu dans _messageEl uniquement si l'URL de suppression est fournie et que l'utilisateur est auteur/admin ; confirm() natif ; retrait local
    • retrait en direct chez tous via onChatDeleted (relais sync.js).

B. Messages privés 2 utilisateurs

  • Store : DM_VAULT = "__dm__", dm_path(a, b) = paire triée → les deux participants lisent/écrivent le même document, indépendamment de l'ordre.
  • Routes : GET /api/chat/users (destinataires — usernames + display_names, sans soi-même, aucun hash/mot de passe exposé), GET/POST /api/chat/dm/{username} (404 utilisateur inconnu, 400 DM à soi), DELETE /api/chat/dm/{username}/{id}.
  • Frontend : rangée de pastilles .chat-channels (« Général » + un bouton par utilisateur) au-dessus du fil ; _channel état courant, _channelKey/_channelUrl/_channelDelUrl ; placeholder dédié chat.placeholder_dm ; non-lus par canal (compteurs dans localStorage, l'ancien mélange timestamp/compteur est devenu un compteur unique {"general": n, "dm:x": n}, remis à zéro à l'ouverture, pastille = total) ; les DM SSE routés par _pairOf(path).

C. Boîte d'édition compacte

  • Bouton d'envoi réduit à une icône send (.file-chat-send-icon) dans les deux formulaires (panneau document + sidebar) ; la saisie garde toute la largeur. safeCreateIcons() à la fin du _renderShell.
  • build_preview(text) (store) : 1ʳᵉ URL du texte → garde SSRF _assert_public_http_url (host privé/loopback rejeté, schémas non http refusés) → httpx.get 5 s, 512 Ko, follow_redirects (limite httpx = 20) → parse OG par regex (og:title avec repli <title>, og:description, og:image, og:site_name) → {url, title, description, image, site}.
  • Best-effort : n'importe quelle erreur (URL morte, timeout, garde) → None, le message passe quand même ; cache borné 200 entrées.
  • Frontend : _previewEl rend une carte cliquable (vignette lazy no-referrer, site en capitales, titre/description tronqués 2 lignes) sous le corps du message.
  • Schéma : ChatMessageItem.preview.

Tests #191

  • pytest TestDelete (3), TestPrivateChat (8), TestLinkPreview (8, dont le happy path avec httpx.get mocké — un max_redirects inexistante sur httpx.get levait un TypeError silencieusement avalé par le try, testé par test_build_preview_happy_path_parses_og).
  • JSDOM filechat.test.mjs +5 : carte preview, bouton supprimer (droit auteur/admin), onChatDeleted, compteurs non-lus, routage DM isolé du général (16 au total).

#192 — Saisie auto-agrandissante, accusé de réception, vignette de lien (2026-10-08)

A. Boîte de saisie qui grandit avec le texte

<input type="text"> → <textarea rows="1"> (panneau document créé dans _renderShell(), sidebar statique dans index.html). _autoGrow(el) pose height: auto puis min(scrollHeight, 160)px à chaque input, la règle CSS textarea.file-chat-input ajoute resize: none + overflow-y: auto (au-delà du plafond la zone défile) et la hauteur repasse à une ligne après l'envoi. Entrée (sans Maj) est interceptée dans les deux formulaires → envoi, Maj+Entrée garde le retour à la ligne. Le placeholder de la sidebar est aussi appliqué à l'init (t()), le HTML ne portant que le FR.

B. Accusé de réception (✓ envoyé / ✓✓ lu)

  • Store (backend/file_chat.py) : le document de conversation gagne read = {username: dernier_ts} (get_read() / mark_read()), sous le _LOCK du read-modify-write partagé avec l'ajout et la suppression.
  • API : les trois GET d'historique renvoient read (schéma ChatHistoryResponse.read) ; nouveau POST /api/chat/read (ChatReadResponse) avec _check_read() — général = tout membre, DM = pair uniquement (403 sinon), fichier = ACL vault + resolve_safe_path — qui marque la lecture et diffuse chat_read {vault, path, user, ts, read} en SSE.
  • Client (filechat.js) : carte de lecture par surface (_fileRead, _channelRead). Un message est « lu » dès qu'un autre participant a un ts ≥ celui du message (ma propre lecture ne compte pas). _messageEl() affiche ✓/✓✓ (.file-chat-status, bleu accent quand lu) ; _applyRead() fait basculer les indicateurs affichés ; _syncRead() fusionne une carte reçue ; _markRead(vault, path) (POST fire-and-forget) est émis à l'ouverture d'une conversation et à la réception d'un message pendant qu'elle est affichée. onChatRead() (relais chat_read ajouté dans sync.js) actualise en direct et notifie l'expéditeur par toast chat.read_by quand un de ses messages passe à ✓✓ — jamais pour son propre écho.

C. BUG-109 — vignette des tuiles de lien

img-src 'self' data: blob: interdit toute og:image distante, et une og:image relative était résolue contre l'origine d'ObsiGate (404). _proxy_image(img_url, page_url) téléverse la vignette à l'envoi dans chat_uploads via save_attachment() (allow-list + nom UUID) et renvoie /api/chat/attachment/<uuid> — résolution urljoin contre la page, garde SSRF réutilisée, plafond PREVIEW_IMAGE_MAX (2 Mo), dérivation de l'extension depuis le content-type. Échec isolé → image = "", la carte reste (jamais de message perdu).

Tests #192

  • pytest TestReadReceipts (7) : roundtrip store, messages préservés, POST /api/chat/read (enregistrement + restitution par le GET), 403 DM hors pair, 400 champs manquants, ACL vault, broadcast chat_read (spy sur sse_manager) ; TestLinkPreview +2 (proxy same-origin avec fichier écrit sur disque, dégradation sans vignette).
  • JSDOM filechat.test.mjs +3 (19) : indicateur ✓/✓✓ (ma propre carte de lecture exclue, aucun indicateur sur les messages des autres), bascule en direct + notification + pas d'écho, textarea (tagName/rows, hauteur pilotée, Entrée intercepté / Maj+Entrée non).

#193 — Rendu markdown des posts (2026-10-09)

Un post du chat s'affiche désormais comme un document : titres, listes, tableaux, citations, blocs de code colorés — au lieu du texte brut avec liens cliquables.

A. Le rendu vient du serveur (pas de second moteur markdown)

  • backend/file_chat.py::_decorate() ajoute un champ html à chaque message en réutilisant backend/render.py::_render_markdown() — le pipeline exact des documents : mistune (tableaux, strikethrough, notes, listes de tâches), résolution des wikilinks, normalisation des sauts de ligne, masquage des secrets (#188) et sanitizer BUG-021 en sortie. Aucun markdown côté client, donc aucun XSS nouveau à traiter : la sanitisation est déjà éprouvée.
  • ChatMessageItem.html (schéma de réponse) : sans ce champ, response_model filtrait la clé.
  • Le html est calculé à la lecture (get_messages()) et à l'ajout (_append()), donc il n'est jamais persisté dans data/chats/*.json : un message reste du texte, le rendu suit les évolutions du pipeline.
  • L'écho SSE part avec le html : les autres clients voient le rendu sans attendre un refresh.
  • Un échec de rendu n'emporte pas le message : repli silencieux (html = ""), le client affiche le texte brut.

B. Frontend : .md-content + highlight.js

  • _fillBody() injecte le html dans un <div class="md-content"> (la typographie des documents) puis appelle safeHighlight() sur chaque pre code — le même helper (et les mêmes alias de langages) que le viewer. Mermaid n'est pas déclenché dans le chat.
  • Repli intact : un message sans html (écho optimiste d'un autre client) repasse par l'ancien chemin texte + URL cliquable.
  • CSS : .file-chat-body.file-chat-md annule le white-space: pre-wrap hérité de la bulle (sinon les retours à la ligne du HTML source créaient des lignes vides) et compacte .md-content (tailles, marges, pre/table en overflow-x: auto) pour tenir dans une bulle de 92 % de large.

Tests #193

  • pytest TestMarkdownRendering (7) : html présent à l'ajout et à la lecture, html absent du JSON stocké, classe language-xxx conservée (ce sur quoi hljs se branche), tableaux/listes, <script> neutralisé, routes POST/GET (chat de fichier + chat général) et écho SSE porteur du html. Suite : 1689 passed.
  • JSDOM filechat.test.mjs +2 (27) : post avec html → .md-content, <strong>, pre code.language-python ; post sans html → texte brut et URL cliquable conservés.

Décisions

  • SSE plutôt qu'un second WebSocket : le transport de #62 (EventSource /api/events, auth par cookie) existe déjà côté client dans sync.js ; un WS par fichier aurait dupliqué reconnexion/heartbeat/auth pour zéro bénéfice sur du messages-postés.
  • Pas d'événement dans le panneau de sync (_addEvent) : les messages de chat gonfleraient l'historique d'événements d'index sans valeur.
  • Tag OpenAPI : pas de tag maison (file-chat) — non déclaré dans TAGS_METADATA, ce qui cassait test_used_tags_are_declared. Les routes /api/file/* sont dérivées automatiquement sous « Files ».