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)sousdata/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).
- nom de fichier = SHA-256 de
- 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 handlerServiceError) ; textoupathvides → 400 ; vault inconnu → 404.
- Schémas
ChatMessageItem/ChatHistoryResponse/ChatMessageResponsedansbackend/schemas.py(response_modelsur 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,
textContentpour 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).
- bouton 💬 (
frontend/js/sync.js— listener SSEchat_messageavec import dynamique defilechat.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.*dansfr.jsoneten.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 isolantCHAT_DIR.tests/frontend/filechat.test.mjs— 6 tests JSDOM : empty state, rendu chronologique + classemine, anti-injection HTML, append live + dédup par id, isolation inter-fichiers, toggle. Locales réellement chargées (fetch simulé versfrontend/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-targetaffiche le titre du document (l'attributtitleporte le chemin complet). followFileChat(vault, path, title)est appelé en tête derenderFile(): panneau ouvert + autre document → re-cible (reload de l'historique) ; panneau fermé → no-op. Le bouton 💬 transmetdata.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éutilisentadd_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 rouge99+sur l'onglet, effacée à l'ouverture. - URL : linkification
http(s)://au rendu (texte autour enTextNode, 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-filterest déplacé après.sidebar-tabs.switchSidebarTab: placeholdersidebar.filter_chat, dispatchchat.initSidebarFilter(sidebar.js) :routeFilter/routeClearroutent versfilterChatMessages()(texte et auteur, insensible à la casse).
#191 — Suppression, messages privés, boîte compacte, link preview (2026-10-08)
A. Suppression d'un post
DELETE /api/chat/{id}etDELETE /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,Falsesi absent ; broadcast SSEchat_deleted{vault, path, id}. - Frontend : bouton 🗑 (
.file-chat-del, visible au survol) rendu dans_messageEluniquement 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(relaissync.js).
- retrait en direct chez tous via
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 danslocalStorage, 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.
D. Link preview
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.get5 s, 512 Ko,follow_redirects(limite httpx = 20) → parse OG par regex (og:titleavec 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 :
_previewElrend une carte cliquable (vignette lazyno-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 avechttpx.getmocké — unmax_redirectsinexistante surhttpx.getlevait un TypeError silencieusement avalé par letry, testé partest_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 gagneread = {username: dernier_ts}(get_read()/mark_read()), sous le_LOCKdu read-modify-write partagé avec l'ajout et la suppression. - API : les trois
GETd'historique renvoientread(schémaChatHistoryResponse.read) ; nouveauPOST /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 diffusechat_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 unts ≥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()(relaischat_readajouté danssync.js) actualise en direct et notifie l'expéditeur par toastchat.read_byquand 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 leGET), 403 DM hors pair, 400 champs manquants, ACL vault, broadcastchat_read(spy sursse_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éeintercepté /Maj+Entréenon).
#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 champhtmlà chaque message en réutilisantbackend/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_modelfiltrait la clé.- Le
htmlest calculé à la lecture (get_messages()) et à l'ajout (_append()), donc il n'est jamais persisté dansdata/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 lehtmldans un<div class="md-content">(la typographie des documents) puis appellesafeHighlight()sur chaquepre 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-mdannule lewhite-space: pre-wraphérité de la bulle (sinon les retours à la ligne du HTML source créaient des lignes vides) et compacte.md-content(tailles, marges,pre/tableenoverflow-x: auto) pour tenir dans une bulle de 92 % de large.
Tests #193
- pytest
TestMarkdownRendering(7) :htmlprésent à l'ajout et à la lecture,htmlabsent du JSON stocké, classelanguage-xxxconservée (ce sur quoi hljs se branche), tableaux/listes,<script>neutralisé, routesPOST/GET(chat de fichier + chat général) et écho SSE porteur duhtml. Suite : 1689 passed. - JSDOM
filechat.test.mjs+2 (27) : post avechtml→.md-content,<strong>,pre code.language-python; post sanshtml→ 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 danssync.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é dansTAGS_METADATA, ce qui cassaittest_used_tags_are_declared. Les routes/api/file/*sont dérivées automatiquement sous « Files ».