From 54caa48d1f277275141459f0db6adcd8ee8839bb Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 8 Oct 2026 23:28:06 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20chat=20=E2=80=94=20saisie=20auto-agrand?= =?UTF-8?q?issante,=20accus=C3=A9=20de=20r=C3=A9ception=20=E2=9C=93?= =?UTF-8?q?=E2=9C=93,=20vignette=20de=20tuile=20#192=20(BUG-109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 39 +++++++- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- backend/file_chat.py | 127 ++++++++++++++++++++++---- backend/routers/file_chat.py | 55 ++++++++++- backend/schemas.py | 11 +++ desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ISSUES_TODOLIST.md | 2 + docs/ROADMAP.md | 3 +- docs/features/file-chat-169.md | 62 ++++++++++++- frontend/index.html | 6 +- frontend/js/filechat.js | 152 +++++++++++++++++++++++++++++-- frontend/js/sync.js | 10 ++ frontend/locales/en.json | 3 + frontend/locales/fr.json | 3 + frontend/style.css | 19 ++++ package.json | 2 +- tests/frontend/filechat.test.mjs | 64 ++++++++++++- tests/test_file_chat.py | 119 +++++++++++++++++++++++- 22 files changed, 645 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9114242..b54eb9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.60.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.61.0**. --- @@ -14,6 +14,43 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.61.0] — 2026-10-08 + +### Ajouté + +- **#192 - Chat : boîte de saisie auto-agrandissante + accusé de réception** + - **Saisie auto-agrandissante** : la boîte d'envoi (panneau document + + sidebar) est un `textarea` dont la hauteur suit le contenu (plafond + 160 px puis défilement) — on relit son message entier avant l'envoi. + `Entrée` envoie, `Maj+Entrée` saute une ligne, la hauteur repasse à une + ligne après l'envoi. + - **Accusé de réception** : chaque conversation stocke + `{utilisateur: dernier_ts_de_lecture}` ; l'expéditeur voit **✓ envoyé** + puis **✓✓ lu** sur ses propres messages (un message est lu dès qu'un + *autre* participant ouvre la conversation). Le `GET` d'historique renvoie + la carte de lecture, `POST /api/chat/read` la met à jour et la diffuse + en SSE (`chat_read`) — les indicateurs basculent en direct. + - **Notification** : quand un message passe à ✓✓, l'expéditeur reçoit le + toast « {user} a lu votre message » (FR/EN) ; un message reçu pendant + l'affichage du canal accuse réception aussitôt. + - Verrou global sur le read-modify-write du store de chat (une lecture ne + peut plus faire disparaître un message). + - Tests : pytest `TestReadReceipts` (7) + JSDOM `filechat.test.mjs` +3 + (19). + +### Corrigé + +- **BUG-109 - Tuile de lien : l'image de la carte ne s'affichait jamais** — + la CSP (`img-src 'self' data: blob:`) bloque toute `og:image` distante et + les images relatives étaient résolues contre l'origine d'ObsiGate (404). + La vignette est désormais téléversée à l'envoi dans `chat_uploads` + (`/api/chat/attachment/`, même origine, garde SSRF + plafond 2 Mo + + allow-list d'extensions) ; échec du téléchargement → carte conservée sans + vignette. Les cartes déjà publiées gardent leur ancien lien (elles + étaient déjà muettes). + +--- + ## [2.60.0] — 2026-10-08 --- diff --git a/README.fr.md b/README.fr.md index 1c81a1d..3535b1d 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.60.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.61.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.60.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.61.0). --- -*Projet : ObsiGate | Version : 2.60.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.61.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 9a36880..cdb1d81 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.60.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.61.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.60.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.61.0). --- -*Project: ObsiGate | Version: 2.60.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.61.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 54bbd78..26c0144 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.60.0 +2.61.0 diff --git a/backend/file_chat.py b/backend/file_chat.py index 3390813..c69cfd4 100644 --- a/backend/file_chat.py +++ b/backend/file_chat.py @@ -20,10 +20,12 @@ import json import logging import re import shutil +import threading import time import uuid from pathlib import Path from typing import Any +from urllib.parse import urljoin, urlparse import httpx @@ -33,6 +35,11 @@ CHAT_DIR = Path("data/chats") MAX_MESSAGES = 500 # retention ceiling per file (oldest dropped first) MAX_TEXT = 4000 # characters per message +# ponytail: global lock over read-modify-write — chat writes are HTTP-only and +# serialized anyway, this just makes losing a message to a future thread (or a +# watcher hook) impossible; per-file locks if it ever becomes contended. +_LOCK = threading.RLock() + # #190 — general (non file-bound) conversation, stored like any other one. GLOBAL_VAULT = "__global__" GLOBAL_PATH = "general" @@ -53,6 +60,7 @@ ALLOWED_ATTACH_EXT = { # #191 — link preview fetch budget PREVIEW_TIMEOUT = 5.0 # seconds PREVIEW_MAX_BYTES = 512 * 1024 # only the head of the page is parsed +PREVIEW_IMAGE_MAX = 2 * 1024 * 1024 # ponytail: 2 MB ceiling on a thumbnail def _chat_file(vault: str, path: str) -> Path: @@ -117,26 +125,56 @@ def add_message( msg["attachment"] = attachment if preview: msg["preview"] = preview - doc = _read(vault, path) - return _append(vault, path, doc, msg) + return _append(vault, path, msg) def _append( vault: str, path: str, - doc: dict[str, Any], msg: dict[str, Any], ) -> dict[str, Any]: - """Cap, persist and return *msg* (shared by file and global chats).""" - messages = list(doc.get("messages", [])) - messages.append(msg) - if len(messages) > MAX_MESSAGES: - messages = messages[-MAX_MESSAGES:] - doc["messages"] = messages - _write(_chat_file(vault, path), doc) + """Cap, persist and return *msg* (shared by file, global and DM chats). + + The read-modify-write of the whole document happens under ``_LOCK`` so a + concurrent writer can never drop a message (same class of bug as + BUG-029 on ``users.json``). + """ + with _LOCK: + doc = _read(vault, path) + messages = list(doc.get("messages", [])) + messages.append(msg) + if len(messages) > MAX_MESSAGES: + messages = messages[-MAX_MESSAGES:] + doc["messages"] = messages + _write(_chat_file(vault, path), doc) return msg +# --- #192 : accusé de réception --------------------------------------------- + +def get_read(vault: str, path: str) -> dict[str, float]: + """Return ``{username: last_read_ts}`` for a conversation (#192).""" + return {str(u): float(ts) for u, ts in (_read(vault, path).get("read") or {}).items()} + + +def mark_read(vault: str, path: str, user: str) -> dict[str, float]: + """Record that *user* has seen the conversation (#192). + + Returns the whole read map so the caller can broadcast it on SSE: a + sender learns their messages were received as soon as the recipient + displays the conversation. + """ + if not user: + return get_read(vault, path) + with _LOCK: + doc = _read(vault, path) + read = {str(u): float(ts) for u, ts in (doc.get("read") or {}).items()} + read[user] = time.time() + doc["read"] = read + _write(_chat_file(vault, path), doc) + return read + + # --- #191 : messages privés (2 utilisateurs) ------------------------------- def dm_path(user_a: str, user_b: str) -> str: @@ -168,13 +206,14 @@ def add_dm_message( def delete_message(vault: str, path: str, message_id: str) -> bool: """Remove one message from a conversation. True when it existed.""" - doc = _read(vault, path) - messages = list(doc.get("messages", [])) - kept = [m for m in messages if m.get("id") != message_id] - if len(kept) == len(messages): - return False - doc["messages"] = kept - _write(_chat_file(vault, path), doc) + with _LOCK: + doc = _read(vault, path) + messages = list(doc.get("messages", [])) + kept = [m for m in messages if m.get("id") != message_id] + if len(kept) == len(messages): + return False + doc["messages"] = kept + _write(_chat_file(vault, path), doc) return True @@ -248,13 +287,55 @@ def _og(content: str, prop: str) -> str: return "" +# BUG-109 — content-type → extension (the attachment allow-list decides). +_IMG_EXT_BY_MIME = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", + "image/svg+xml": ".svg", +} + + +def _proxy_image(img_url: str, page_url: str) -> str: + """Download *img_url* into ``chat_uploads`` and return a same-origin URL. + + The response CSP is ``img-src 'self' data: blob:``: a remote ``og:image`` + would be blocked by the browser (BUG-109). Relative and protocol-relative + values are resolved against *page_url* first. Raises ``ValueError`` / + ``SSRFError`` on any failure — the caller keeps the card and drops only + the thumbnail. + """ + from backend.tools.web import USER_AGENT, _assert_public_http_url + + full = urljoin(page_url, img_url) + _assert_public_http_url(full) + resp = httpx.get( + full, + headers={"User-Agent": USER_AGENT}, + timeout=PREVIEW_TIMEOUT, + follow_redirects=True, + ) + if resp.status_code >= 400: + raise ValueError(f"HTTP {resp.status_code}") + data = resp.content + if not data or len(data) > PREVIEW_IMAGE_MAX: + raise ValueError("image vide ou trop lourde") + mime = (resp.headers.get("content-type") or "").split(";")[0].strip().lower() + ext = _IMG_EXT_BY_MIME.get(mime) or Path(urlparse(full).path).suffix.lower() + # save_attachment(): allow-list d'extensions + nom UUID (jamais le nom distant) + return str(save_attachment(f"preview{ext}", data)["url"]) + + def build_preview(text: str) -> dict[str, Any] | None: """Fetch OpenGraph metadata for the first URL in *text* (#191). SSRF-guarded (reuses the web-tool guard), size/time capped, cached in a bounded dict. Returns ``{url, title, description, image, site}`` or ``None`` when there is no URL / the fetch fails (never raises: a dead - link must not block the message). + link must not block the message). ``image`` is a **same-origin** + ``/api/chat/attachment/...`` URL (BUG-109), empty when the thumbnail + could not be fetched. """ m = _URL_RE.search(text or "") if not m: @@ -276,11 +357,19 @@ def build_preview(text: str) -> dict[str, Any] | None: if resp.status_code >= 400: raise ValueError(f"HTTP {resp.status_code}") body = resp.text[:PREVIEW_MAX_BYTES] + # BUG-109 : vignette téléchargée côté serveur — une image distante + # échouerait à la CSP. Échec isolé = carte sans vignette. + image = _og(body, "og:image") + try: + image = _proxy_image(image, url) if image else "" + except Exception as ie: + logger.debug("preview image failed for %s: %s", url, ie) + image = "" preview = { "url": url, "title": _og(body, "og:title") or _og(body, "og:site_name"), "description": _og(body, "og:description"), - "image": _og(body, "og:image"), + "image": image, "site": _og(body, "og:site_name") or (url.split("/")[2] if "/" in url[8:] else url), } if not preview["title"]: diff --git a/backend/routers/file_chat.py b/backend/routers/file_chat.py index b785960..95628fd 100644 --- a/backend/routers/file_chat.py +++ b/backend/routers/file_chat.py @@ -24,7 +24,7 @@ from backend import file_chat as _store from backend.auth.middleware import check_vault_access, require_auth from backend.auth.user_store import get_all_users, get_user from backend.indexer import get_vault_data -from backend.schemas import ChatHistoryResponse, ChatMessageResponse, StatusResponse +from backend.schemas import ChatHistoryResponse, ChatMessageResponse, ChatReadResponse, StatusResponse from backend.services.paths import resolve_safe_path from backend.sse import sse_manager @@ -49,7 +49,7 @@ async def api_file_chat_history( ): """Return the chat history for a file (chronological).""" _check(vault_name, path, current_user) - return {"messages": _store.get_messages(vault_name, path)} + return {"messages": _store.get_messages(vault_name, path), "read": _store.get_read(vault_name, path)} @router.post("/api/file/{vault_name}/chat", response_model=ChatMessageResponse) @@ -93,7 +93,51 @@ def _attachment(body: dict[str, Any]) -> dict[str, Any] | None: @router.get("/api/chat", response_model=ChatHistoryResponse) async def api_chat_history(current_user: dict[str, Any] = Depends(require_auth)): """Return the general chat history (#190, chronological).""" - return {"messages": _store.get_global_messages()} + return { + "messages": _store.get_global_messages(), + "read": _store.get_read(_store.GLOBAL_VAULT, _store.GLOBAL_PATH), + } + + +# --- #192 : accusé de réception --------------------------------------------- + +def _check_read(vault: str, path: str, current_user: dict[str, Any]) -> None: + """Authorization for marking a conversation read (#192). + + General chat is open to any member, a DM only to the two participants, + a file chat follows the vault ACL. + """ + if vault == _store.GLOBAL_VAULT: + return + if vault == _store.DM_VAULT: + if current_user.get("username") not in str(path).split("|"): + raise HTTPException(403, "Accès refusé à cette conversation privée") + return + _check(vault, path, current_user) + + +@router.post("/api/chat/read", response_model=ChatReadResponse) +async def api_chat_read( + body: dict[str, Any] = Body(...), + current_user: dict[str, Any] = Depends(require_auth), +): + """Record that the caller has seen a conversation (#192). + + Broadcast on SSE (``chat_read``) so the sender's own messages flip to + ✓✓ live on every connected client. + """ + vault = str(body.get("vault") or "") + path = str(body.get("path") or "") + if not vault or not path: + raise HTTPException(400, "vault and path are required") + _check_read(vault, path, current_user) + username = current_user.get("username", "") + read = _store.mark_read(vault, path, username) + await sse_manager.broadcast( + "chat_read", + {"vault": vault, "path": path, "user": username, "ts": read.get(username, 0.0), "read": read}, + ) + return {"read": read, "status": "ok"} @router.post("/api/chat", response_model=ChatMessageResponse) @@ -199,7 +243,10 @@ def _dm_peer(username: str, current_user: dict[str, Any]) -> str: async def api_chat_dm_history(username: str, current_user: dict[str, Any] = Depends(require_auth)): """Private history with *username* (#191).""" peer = _dm_peer(username, current_user) - return {"messages": _store.get_dm_messages(current_user["username"], peer)} + return { + "messages": _store.get_dm_messages(current_user["username"], peer), + "read": _store.get_read(_store.DM_VAULT, _store.dm_path(current_user["username"], peer)), + } @router.post("/api/chat/dm/{username}", response_model=ChatMessageResponse) diff --git a/backend/schemas.py b/backend/schemas.py index 98c7c91..455d1a2 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -172,6 +172,17 @@ class ChatHistoryResponse(BaseModel): """Response for ``GET /api/file/{vault}/chat``.""" messages: list[ChatMessageItem] = Field(description="Messages, chronological") + read: dict[str, float] = Field( + default_factory=dict, + description="{username: last read unix ts} per participant (#192)", + ) + + +class ChatReadResponse(BaseModel): + """Response for ``POST /api/chat/read`` (#192 read receipts).""" + + read: dict[str, float] = Field(description="{username: last read unix ts}") + status: str = Field(description="'ok'") class ChatMessageResponse(BaseModel): diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index b96ab3c..8e5c106 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.60.0" +version = "2.61.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 6be4377..f615318 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.60.0" +version = "2.61.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index c32669d..c41f768 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.60.0", + "version": "2.61.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 6f8c3cd..3821488 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -216,6 +216,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | *BUG-106* | Page blanche sur `/docs` (Swagger UI) : la CSP `script-src` sans `unsafe-inline` (#87 T5c) refuse le script inline d'init de FastAPI, jamais noncé | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` (`SecurityHeadersMiddleware`) | Ouvrir `https://og.dracodev.net/docs` : HTML servi (200) mais aucune UI, console `Refused to execute inline script … Content Security Policy` | Injection du nonce dans le HTML de `/docs` et `/redoc` depuis le middleware, via le helper existant `inject_csp_nonce` ; corps décompressé/recompressé (GZipMiddleware est plus proche de la route) | `pytest tests/test_csp_nonce.py` (3 tests ajoutés : nonce sur `/docs`, `/redoc` sans injection, regex sur bundle externe) + 45 passed sur les 3 suites sécurité ; ruff/mypy 0 | | *BUG-107* | Drag & drop de fichiers depuis l'Explorateur inutilisable sur desktop : Tauri/wry pose son propre `IDropTarget` par-dessus de celui du WebView2 et aucun événement natif n'est écouté — #89 ne fonctionnait que sur le web | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/tauri.conf.json` (`create: false`), `desktop/src/main.rs` (`WebviewWindowBuilder::…disable_drag_drop_handler`) | App desktop : glisser un fichier de l'Explorateur sur la fenêtre → aucun survol, aucun dépôt — les gestionnaires HTML5 de `dragdrop.js` ne sont jamais déclenchés | Création manuelle de la fenêtre avec `disable_drag_drop_handler()` (doc Tauri : « required to use HTML5 drag and drop APIs on the frontend on Windows ») ; `create: false` évite la double création par la boucle Tauri | `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires) + lancement OK (fenêtre unique, boot normal) ; dépôt réel à valider par l'utilisateur | | *BUG-108* | Tableur : le « Coller » du menu contextuel ne fait rien (copier-coller par menu muet) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/xlsx/context-menu.js` (`closeContextMenu`) | Ouvrir un `.xlsx`, clic droit « Copier » sur une plage, clic droit cible puis « Coller » → rien n'est collé (le menu se ferme sans action) | `closeContextMenu()` retirait le nœud du DOM **avant** de remettre `_menu` à `null` : retirer le menu qui tient le focus émet `focusout` en synchrone, qui rappelle `closeContextMenu()` → second `remove()` sur un nœud déjà démonté → `NotFoundError` qui avorte le handler de l'action « Coller » du viewer. Introduit par #179 (fermeture au focus). Correctif : `_menu = null` avant `menu.remove()` | Reproduit en local sur HEAD (e2e « coller une plage » : attendu `Date`, obtenu la valeur datée) puis vert après fix : E2E `-g "coller une plage"` 1/1, `xlsx-menus` 11/11, `ai-quick-actions` 13/13 | +| *BUG-109* | Chat : dans la tuile d'une URL (link preview) l'image ne s'affiche jamais | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/file_chat.py` (`build_preview`, `_proxy_image`) | Poster une URL avec `og:image` dans le chat général → la carte s'affiche mais sans vignette (console : `Refused to load the image … Content Security Policy`) | La CSP de réponse (`img-src 'self' data: blob:`) interdit toute image externe, et les `og:image` **relatives** étaient résolues contre l'origine d'ObsiGate (404). La vignette est désormais **téléversée à l'envoi** dans `chat_uploads` et servie same-origin (`/api/chat/attachment/`) : résolution `urljoin` contre la page, garde SSRF réutilisée, plafond 2 Mo, allow-list d'extensions ; échec du téléchargement → carte conservée sans vignette (jamais de message perdu) | Tests `test_preview_image_is_proxied_same_origin` + `test_preview_image_failure_keeps_the_card` (contre-preuve : proxy neutralisé → `image == ""`). Cartes antérieures inchangées : leur URL distante était déjà bloquée | | | | | | | | | | | | ### TODOs techniques (améliorations / nouvelles tâches) @@ -332,6 +333,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-10-08 | #191 | Fonctionnalité | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/js/sync.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#191 — chat : suppression de post, messages privés, boîte compacte, link preview** : (A) `DELETE /api/chat/{id}` + `/api/chat/dm/{peer}/{id}` (auteur ou admin, 403 sinon) avec diffusion SSE `chat_deleted` et bouton 🗑 au survol côté client (`confirm()` natif) ; (B) DM 2 utilisateurs : vault `__dm__` + `dm_path(a,b)` trié, `GET /api/chat/users` (destinataires sans soi-même, aucun hash exposé), routes `GET/POST /api/chat/dm/{user}` (404 inconnu, 400 DM à soi), rangée de canaux `.chat-channels` dans la sidebar, non-lus par canal (compteurs localStorage, anciens timestamps/compteurs mélangés unifiés) ; (C) bouton d'envoi réduit à une icône dans les deux formulaires ; (D) `build_preview(text)` : 1ʳᵉ URL → garde SSRF `_assert_public_http_url` → `httpx.get` 5 s/512 Ko → OG (titre/desc/image/site) en carte cliquable, best-effort (échec → message sans carte), cache 200 entrées. Bug trouvé en cours de route : `max_redirects` n'existe pas sur `httpx.get` → TypeError avalé par le `try` = previews jamais générées en prod, invisible des tests (ils mockaient `build_preview`) ; arg retiré + `test_build_preview_happy_path_parses_og` (httpx mocké) ajouté. Tests : pytest 45 file_chat (44→45, +13 sur #191), JSDOM filechat 16 (+5) | | 2026-10-08 | #190 | Fonctionnalité | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js` (réécrit), `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/js/sidebar.js`, `frontend/js/app.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `CHANGELOG.md` | **#190 — chat : suivi du document, chat général en onglet sidebar, onglets au-dessus du filtre** : (A) l'en-tête du panneau affiche le document ciblé (titre + chemin) et `followFileChat()` appelé par `renderFile()` re-cible le panneau ouvert à chaque changement de document ; (B) onglet **Chat** (dernier) → conversation générale stockée dans le même store #169 via les sentinelles `__global__/general` (`GET/POST /api/chat`), **pastille de messages non lus** (`localStorage` + badge sur l'onglet), **pièces jointes image/vidéo** (`POST /api/chat/upload` : allow-list d'extensions, 25 MB, nom UUID — jamais le nom client ; `GET /api/chat/attachment/{name}` résolu contre l'allow-list), **URL cliquables** dans le rendu (linkification `http(s)://`), date/heure d'envoi ; (C) barre de filtre **déplacée sous les onglets** et routée vers `filterChatMessages()` (texte + auteur) quand l'onglet Chat est actif. Transport : broadcast SSE `chat_message` réutilisé (vault `__global__` route vers le panneau sidebar, sinon panneau fichier) — pas de second WebSocket. Contre-preuves : `__global__` non routé → test « routes __global__ » échoue ; panneau sans classe `active` → le message part en toast au lieu du rendu (révélé par le stub JSDOM). Vérifié : pytest **1655 passed / 2 skipped** (27 tests chat dont 12 nouveaux), ruff 0, mypy 0 (113 fichiers), validate-imports 42 modules/368 exports, unit 13/13, filechat.test.mjs **11/11** (5 nouveaux), toolbar-order, sidebar-filters 8/8 | ✅ livré (en attente vérif utilisateur) | | 2026-10-08 | #169 | Fonctionnalité | `backend/file_chat.py` (nouveau), `backend/routers/file_chat.py` (nouveau), `backend/schemas.py`, `backend/main.py`, `frontend/js/filechat.js` (nouveau), `frontend/js/viewer.js`, `frontend/js/sync.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py` (nouveau), `tests/frontend/filechat.test.mjs` (nouveau), `tests/frontend/toolbar-order.test.mjs`, `.gitea/workflows/ci.yml`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md` (nouveau), `docs/GUIDES/PRISE_EN_MAIN.md`, `CHANGELOG.md` | **#169 — chat intégré par fichier** : store JSON par (vault, path) sous `data/chats/` (nom SHA-256 → traversal impossible, plafond 500 msgs, texte 4000 car., écriture atomique, fichier corrompu → vide) ; `GET/POST /api/file/{vault}/chat` (auth + `check_vault_access` + `resolve_safe_path`, 400/404/403, `response_model`) + broadcast SSE `chat_message` sur le transport #62 (**pas de 2ᵉ WebSocket**) ; panneau latéral `filechat.js` (bouton 💬 toolbar, rendu chronologique `textContent`, envoi optimiste + dédup par id, toast si panneau fermé/autre fichier, plein écran ≤ 768 px) ; relais SSE en import dynamique dans `sync.js` ; i18n FR/EN 10 clés `chat.*`. En route : tag OpenAPI maison `file-chat` non déclaré → `test_used_tags_are_declared` rouge (retiré, dérivé « Files »), regex navBtns de `toolbar-order.test.mjs` (échappements → `includes`), ruff UP012/TRY004/I001. Vérifié : `test_file_chat.py` 15 passed, `filechat.test.mjs` 6/6, suite 1643 passed, ruff/mypy 0, validate-imports 42 modules, CI run #1938 5/5 success (v2.57.0) | ✅ livré (en attente vérif utilisateur) | +| 2026-10-08 | #192 + BUG-109 | Fonctionnalité + correction | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/js/sync.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#192 — chat : saisie auto-agrandissante + accusé de réception.** La boîte d'envoi (panneau document **et** sidebar) devient un `textarea` dont la hauteur suit le contenu (plafond 160 px puis défilement, repasse à 1 ligne après l'envoi), `Entrée` envoie / `Maj+Entrée` saute une ligne ; placeholder sidebar désormais traduit à l'init. **Accusé de réception** : chaque document de conversation porte `read = {utilisateur: ts}` (verrou global sur le read-modify-write — une lecture ne peut plus faire disparaître un message), `GET` d'historique renvoie la carte, nouveau `POST /api/chat/read` (400 sans vault/path, 403 DM hors pair, ACL vault pour un chat de fichier) marque la lecture et diffuse `chat_read` en SSE ; côté client `✓` envoyé / `✓✓` lu sur **mes** messages (exclut ma propre carte de lecture), bascule en direct via `onChatRead()` + toast « {user} a lu votre message », accusé émis à l'ouverture d'une conversation et à la réception d'un message déjà affiché. **BUG-109** : la vignette des tuiles de lien ne s'affichait jamais (CSP `img-src 'self'` + `og:image` relative résolue contre ObsiGate) → `_proxy_image()` téléverse l'image dans `chat_uploads` (same-origin, `urljoin` contre la page, garde SSRF, 2 Mo max, allow-list) et la carte reste affichée si le téléchargement échoue. Tests : pytest `test_file_chat.py` 45→**54** (`TestReadReceipts` 7, proxy image 2, 2 assertions de réponse adaptées), JSDOM `filechat.test.mjs` 16→**19** ; ruff 0, mypy 0 (113 fichiers), validate-imports 42/371, unit 13/13 | ✅ livré (en attente vérif utilisateur) | --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e75959e..a69f1e6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.60.0 | **Dernière mise à jour :** 2026-10-08 +> **Version :** 2.61.0 | **Dernière mise à jour :** 2026-10-08 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -733,6 +733,7 @@ | 169 | Chat intégré par fichier (panneau latéral, historique, temps réel SSE, toast de notification) | 2.57.0 | [features/file-chat-169.md](./features/file-chat-169.md) | | 190 | Chat — suivi du document, chat général en onglet sidebar (pièces jointes, non lus, recherche), onglets au-dessus du filtre | 2.58.0 | [features/file-chat-169.md](./features/file-chat-169.md) | | 191 | Chat — suppression de post, messages privés 2 utilisateurs (canaux), boîte d'édition compacte, link preview OG | 2.60.0 | [features/file-chat-169.md](./features/file-chat-169.md) | +| 192 | Chat — boîte de saisie auto-agrandissante + accusé de réception ✓✓ (retour live SSE, notification de lecture) · BUG-109 (image de tuile de lien) | 2.61.0 | [features/file-chat-169.md](./features/file-chat-169.md) | | 159 | Desktop — gestion des vaults & dossiers : retrait par menu contextuel + section Configuration (ajout vault/dossier racine) | 2.50.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) | | 160 | Desktop — premier lancement professionnel (répertoire `%USERPROFILE%\ObsiGate` + `Prise en main.md`) et section Configuration harmonisée | 2.51.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) | | BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) | diff --git a/docs/features/file-chat-169.md b/docs/features/file-chat-169.md index cc5ac6f..124343e 100644 --- a/docs/features/file-chat-169.md +++ b/docs/features/file-chat-169.md @@ -143,7 +143,7 @@ ### Tests #191 -- pytest `TestDelete` (3), `TestPrivateChat` (8), `TestLinkPreview` (6, +- 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`). @@ -151,6 +151,66 @@ (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 + +`` → ` `, @@ -77,7 +77,7 @@ const mod = await import("../../frontend/js/filechat.js"); const { renderChatMessages, onChatMessage, openFileChat, closeFileChat, toggleFileChat, followFileChat, openSidebarChat, filterChatMessages, refreshUnreadBadge, - onChatDeleted, + onChatDeleted, onChatRead, } = mod; let pass = 0; @@ -278,6 +278,66 @@ test("#191 — DM routed by pair path, isolated from general", async () => { assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, before); }); +// --- #192 : accusé de réception + boîte de saisie auto-agrandie -------------- + +test("#192 — receipt shows ✓ then ✓✓ once another participant read it", () => { + const list = document.createElement("div"); + renderChatMessages(list, [{ id: "r1", user: "bruno", text: "coucou", ts: 100 }], "bruno", null, {}); + let st = list.querySelector(".file-chat-status"); + assert.strictEqual(st.textContent, "✓"); + assert.strictEqual(st.classList.contains("read"), false); + // MY OWN read timestamp must never count as "the peer read it" + renderChatMessages(list, [{ id: "r2", user: "bruno", text: "coucou", ts: 100 }], "bruno", null, { bruno: 500 }); + assert.strictEqual(list.querySelector(".file-chat-status").textContent, "✓"); + renderChatMessages(list, [{ id: "r3", user: "bruno", text: "coucou", ts: 100 }], "bruno", null, { alice: 150 }); + st = list.querySelector(".file-chat-status"); + assert.strictEqual(st.textContent, "✓✓"); + assert.strictEqual(st.classList.contains("read"), true); + // messages of others carry no receipt + renderChatMessages(list, [{ id: "r4", user: "alice", text: "ok", ts: 100 }], "bruno", null, {}); + assert.strictEqual(list.querySelector(".file-chat-status"), null); +}); + +test("#192 — onChatRead flips ✓ → ✓✓ live and notifies the sender", async () => { + await openSidebarChat(); + const list = document.getElementById("sidebar-panel-chat-list"); + renderChatMessages(list, [{ id: "s1", user: "bruno", text: "msg", ts: 200 }], "bruno", "/api/chat", {}); + assert.strictEqual(list.querySelector(".file-chat-status").textContent, "✓"); + const container = document.getElementById("toast-container"); + const before = container.children.length; + onChatRead({ vault: "__global__", path: "general", user: "alice", ts: 300, read: { bruno: 250, alice: 300 } }); + assert.strictEqual(list.querySelector(".file-chat-status").textContent, "✓✓"); + assert.ok(container.children.length > before, "notification de lecture affichée"); + // notre propre écho ne notifie pas + const same = container.children.length; + onChatRead({ vault: "__global__", path: "general", user: "bruno", ts: 310, read: { bruno: 310 } }); + assert.strictEqual(container.children.length, same); +}); + +test("#192 — saisie en textarea auto-agrandie, Enter envoie", async () => { + assert.strictEqual(document.getElementById("sidebar-panel-chat-input").tagName, "TEXTAREA"); + await openFileChat("V", "grow.md"); + const panel = document.getElementById("file-chat-panel"); + const input = panel.querySelector(".file-chat-input"); + assert.strictEqual(input.tagName, "TEXTAREA"); + assert.strictEqual(input.rows, 1); + input.value = "ligne1\nligne2"; + input.dispatchEvent(new w.Event("input", { bubbles: true })); + assert.ok(input.style.height, "hauteur pilotée en JS"); + let prevented = false; + const ev = new w.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }); + Object.defineProperty(ev, "preventDefault", { value: () => { prevented = true; } }); + input.value = ""; // rien à envoyer : on ne teste que le geste + input.dispatchEvent(ev); + assert.strictEqual(prevented, true, "Enter (sans Shift) est intercepté"); + const nl = new w.KeyboardEvent("keydown", { key: "Enter", shiftKey: true, bubbles: true, cancelable: true }); + let nlPrevented = false; + Object.defineProperty(nl, "preventDefault", { value: () => { nlPrevented = true; } }); + input.dispatchEvent(nl); + assert.strictEqual(nlPrevented, false, "Shift+Enter garde le retour à la ligne"); + closeFileChat(); +}); + // Sequential execution: each case depends on panel state left by the previous. for (const [name, fn] of cases) { try { diff --git a/tests/test_file_chat.py b/tests/test_file_chat.py index cb4dd9b..26f9703 100644 --- a/tests/test_file_chat.py +++ b/tests/test_file_chat.py @@ -85,7 +85,7 @@ class TestRoutes: def test_history_requires_existing_vault(self, client, test_vault_dir): r = client.get("/api/file/TestVault/chat", params={"path": "note1.md"}) assert r.status_code == 200 - assert r.json() == {"messages": []} + assert r.json() == {"messages": [], "read": {}} def test_post_and_get_roundtrip(self, client, test_vault_dir): r = client.post( @@ -137,7 +137,7 @@ class TestGlobalChat: def test_history_empty_by_default(self, client, test_vault_dir): r = client.get("/api/chat") assert r.status_code == 200 - assert r.json() == {"messages": []} + assert r.json() == {"messages": [], "read": {}} def test_post_and_get_roundtrip(self, client, test_vault_dir): r = client.post("/api/chat", json={"text": "Bonjour à tous"}) @@ -364,3 +364,118 @@ class TestLinkPreview: "image": "", "site": "Site", } + + def test_preview_image_is_proxied_same_origin(self, monkeypatch): + """BUG-109: a remote og:image is blocked by ``img-src 'self'``. + + A *relative* og:image must be resolved against the page and downloaded + into ``chat_uploads`` so the browser can load it same-origin. + """ + monkeypatch.setattr(_store, "_PREVIEW_CACHE", {}) + from backend.tools import web as _web + + monkeypatch.setattr(_web, "_assert_public_http_url", lambda url: None) + + class _Page: + status_code = 200 + text = ( + '' + '' + ) + + class _Img: + status_code = 200 + content = b"\x89PNG-fake-bytes" + headers = {"content-type": "image/png"} + + def _fake_get(url, **kw): + return _Img() if url.endswith(".png") else _Page() + + monkeypatch.setattr(_store.httpx, "get", _fake_get) + preview = _store.build_preview("voir https://site.test/a") + assert preview["title"] == "Titre" + assert preview["image"].startswith("/api/chat/attachment/") + assert preview["image"].endswith(".png") + stored = _store.UPLOAD_DIR / preview["image"].rsplit("/", 1)[1] + assert stored.exists() + assert stored.read_bytes() == b"\x89PNG-fake-bytes" + + def test_preview_image_failure_keeps_the_card(self, monkeypatch): + """A dead thumbnail degrades to an empty image, never a lost card.""" + monkeypatch.setattr(_store, "_PREVIEW_CACHE", {}) + from backend.tools import web as _web + + monkeypatch.setattr(_web, "_assert_public_http_url", lambda url: None) + + class _Page: + status_code = 200 + text = ( + '' + '' + ) + + class _Dead: + status_code = 404 + content = b"" + headers = {} + + def _fake_get(url, **kw): + return _Dead() if url.endswith(".jpg") else _Page() + + monkeypatch.setattr(_store.httpx, "get", _fake_get) + preview = _store.build_preview("voir https://site.test/b") + assert preview["title"] == "Titre" + assert preview["image"] == "" + + +# --- #192 : accusé de réception --------------------------------------------- + +class TestReadReceipts: + def test_mark_read_roundtrip(self, tmp_path, monkeypatch): + monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats") + assert _store.get_read("V", "a.md") == {} + read = _store.mark_read("V", "a.md", "bruno") + assert read["bruno"] > 0 + assert _store.get_read("V", "a.md") == read + + def test_mark_read_preserves_messages(self, tmp_path, monkeypatch): + monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats") + _store.add_message("V", "a.md", "alice", "salut") + _store.mark_read("V", "a.md", "bruno") + assert [m["text"] for m in _store.get_messages("V", "a.md")] == ["salut"] + assert "bruno" in _store.get_read("V", "a.md") + + def test_post_read_records_for_the_caller(self, client, test_vault_dir): + r = client.post("/api/chat/read", json={"vault": "__global__", "path": "general"}) + assert r.status_code == 200, r.text + read = r.json()["read"] + assert read["anonymous"] > 0 # auth disabled in tests → fake user + # the history endpoint exposes the same map (#192) + assert client.get("/api/chat").json()["read"] == read + + def test_post_read_refuses_a_dm_we_are_not_in(self, client, test_vault_dir): + r = client.post("/api/chat/read", json={"vault": "__dm__", "path": "alice|bob"}) + assert r.status_code == 403 + + def test_post_read_requires_vault_and_path(self, client, test_vault_dir): + assert client.post("/api/chat/read", json={}).status_code == 400 + + def test_post_read_follows_vault_acl(self, client, test_vault_dir): + r = client.post("/api/chat/read", json={"vault": "TestVault", "path": "note1.md"}) + assert r.status_code == 200, r.text + assert "anonymous" in r.json()["read"] + + def test_post_read_broadcasts_chat_read(self, client, test_vault_dir, monkeypatch): + from backend.routers import file_chat as _router + + events = [] + + class _Spy: + async def broadcast(self, event, data): + events.append((event, data)) + + monkeypatch.setattr(_router, "sse_manager", _Spy()) + client.post("/api/chat/read", json={"vault": "__global__", "path": "general"}) + assert [e for e, _ in events] == ["chat_read"] + assert events[0][1]["user"] == "anonymous" + assert events[0][1]["vault"] == "__global__"