8.3 KiB
8.3 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(6, 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).
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 ».