273 lines
14 KiB
Markdown
273 lines
14 KiB
Markdown
# 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 `<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 ».
|