# 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). ## #191 — Suppression, messages privés, boîte compacte, link preview (2026-10-08) ### 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`. ### 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.get` 5 s, 512 Ko, `follow_redirects` (limite httpx = 20) → parse OG par regex (`og:title` avec repli ``, `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` (6, 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). ## 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 ».