Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8662d23ec8 | ||
|
|
10453d8dfe | ||
|
|
8235d632b8 | ||
|
|
f4c8504c8d | ||
|
|
4fb7c43e06 |
@@ -44,6 +44,7 @@ test_dir/video/
|
||||
test_vault/images/
|
||||
test_vault/markdown/
|
||||
test_vault/budget.xlsx
|
||||
test_home/
|
||||
|
||||
# Tauri updater signing keys (private key — never commit)
|
||||
desktop/*.key
|
||||
|
||||
+129
-1
@@ -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.61.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.65.1**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,134 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.65.1] — 2026-10-10
|
||||
|
||||
---
|
||||
|
||||
## [2.65.0] — 2026-10-10
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#194 (UI) — Dossier personnel visible dans l'arborescence**
|
||||
- Les vaults `home-<user>` s'affichent **en premier** dans l'arborescence de
|
||||
la barre latérale (tri stable appliqué à la source, `frontend/js/sidebar.js`,
|
||||
donc aussi les sélecteurs de vault).
|
||||
- Icône **maison** lucide (`data-lucide="home"`, même style/`stroke:
|
||||
currentColor` que les autres icônes) via `getVaultIcon()` —
|
||||
`frontend/js/viewer.js`.
|
||||
- `docker-compose.test-win.yml` : montage `test_home:/vaults/Home` +
|
||||
`OBSIGATE_HOME_ROOT=/vaults/Home` pour tester la feature en local
|
||||
(dossier `test_home/` ignoré par git).
|
||||
- Tests : `sidebar-filters.test.mjs` +2 (icône maison pour `home-*`,
|
||||
gemme violette inchangée ailleurs).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **#194 — Un dossier perso n'est plus visible par les autres comptes**
|
||||
- `check_vault_access` (`backend/auth/middleware.py`) refuse désormais tout
|
||||
vault `home-*` qui n'est pas **explicitement** octroyé au compte : le
|
||||
`vaults: ["*"]` de l'admin ne couvre plus les dossiers persos (avant,
|
||||
l'admin voyait `home-<user>` de chaque utilisateur dans sa barre latérale).
|
||||
- Les filtres `« * »` écrits en dur dans les routes ont été remplacés par
|
||||
cet appel unique — dashboard, conflits, liens retour, favoris,
|
||||
abonnements push : partout la même règle.
|
||||
- `/api/search` filtre les résultats par vault accessible **avant**
|
||||
pagination (`search_vaults(is_allowed=…)`), sinon les notes personnelles
|
||||
d'un autre pouvait apparaître dans les résultats.
|
||||
- `_grant` octroie désormais le home aussi aux comptes `« * »` : l'admin
|
||||
voit **son** `home-admin`, réparé au démarrage pour les comptes existants.
|
||||
- Tests : `test_user_home.py` +2 (`« * »` sans homes, filtrage de la
|
||||
recherche), assertion API inversée dans `test_auth_api.py`.
|
||||
|
||||
---
|
||||
|
||||
## [2.64.0] — 2026-10-10
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#194 — Dossier personnel par utilisateur**
|
||||
- Chaque utilisateur créé reçoit `<OBSIGATE_HOME_ROOT>/<username>` (ex.
|
||||
`/NFS/OBSIDIAN_DOC/Home/<user>` côté hôte) monté comme un vault propre
|
||||
`home-<user>` : l'isolation utilise l'**ACL par vault existante**
|
||||
(`check_vault_access`), sans nouvelle ACL par chemin.
|
||||
- `backend/user_home.py` : `ensure_user_home()` idempotent (mkdir + registre
|
||||
vault + watcher + octroi dans `user.vaults`), appelé à la création d'un
|
||||
compte (`POST /api/auth/admin/users`) **et** au démarrage
|
||||
(`ensure_all_user_homes()` : migration des users existants + réparation
|
||||
d'un dossier ou d'un registre perdu).
|
||||
- **Registre persistant des vaults dynamiques** `data/vaults.json`
|
||||
(`load_vault_config()` le fusionne avec les env `VAULT_N_*`) : auparavant,
|
||||
tout vault ajouté à la volée (`/api/vaults/add`) disparaissait au premier
|
||||
reindex complet et au redémarrage.
|
||||
- Suppression d'un compte → vault refermé (index/watcher/registre) mais
|
||||
**dossier conservé** (aucune perte de données).
|
||||
- Correctif : `reload_single_vault` remet la config du vault après
|
||||
`remove_vault_from_index` (sinon le vault disparaissait de
|
||||
`vault_config` jusqu'au rechargement complet).
|
||||
- Déploiement : montage unique `/NFS/OBSIDIAN_DOC/Home:/vaults/Home` +
|
||||
variable `OBSIGATE_HOME_ROOT` (fonctionnalité inactive sans cette
|
||||
variable — dev, tests, desktop inchangés).
|
||||
- Tests : `tests/test_user_home.py` (10) + 2 tests API dans
|
||||
`test_auth_api.py` (provisionnement, isolation, retrait).
|
||||
|
||||
---
|
||||
|
||||
## [2.63.0] — 2026-10-09
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#193 — Chat : un post s'affiche comme un document markdown**
|
||||
- Le rendu vient du **serveur** : `backend/file_chat.py` ajoute un champ
|
||||
`html` à chaque message en réutilisant le pipeline des documents
|
||||
(`backend/render.py::_render_markdown` → mistune + wikilinks + masquage des
|
||||
secrets + **sanitizer BUG-021**). Pas de second moteur markdown côté client,
|
||||
donc pas de nouvelle surface XSS.
|
||||
- Titres, listes, tableaux, citations, notes, listes de tâches et **blocs de
|
||||
code colorés** : le front injecte le HTML dans un `.md-content` puis passe
|
||||
`safeHighlight()` (highlight.js + alias de langages) sur chaque `pre code`,
|
||||
exactement comme la visionneuse de documents.
|
||||
- Le `html` est calculé **à la lecture** (et à l'ajout, pour l'écho SSE) :
|
||||
rien n'est persisté dans `data/chats/*.json`, un message reste du texte.
|
||||
- Repli conservé : sans `html`, le texte brut + liens cliquables s'affichent
|
||||
comme avant ; un échec de rendu n'emporte jamais le message.
|
||||
- CSS : `.file-chat-md` annule le `pre-wrap` de la bulle (sinon les retours à
|
||||
la ligne du HTML source devenaient des lignes vides) et compacte
|
||||
`.md-content` (tailles, `pre`/`table` défilants) pour une bulle de chat.
|
||||
- Tests : pytest `TestMarkdownRendering` +7 (54 → **61** dans
|
||||
`test_file_chat.py`), JSDOM `filechat.test.mjs` +2 (25 → **27**).
|
||||
|
||||
---
|
||||
|
||||
## [2.62.0] — 2026-10-09
|
||||
|
||||
### Modifié
|
||||
|
||||
- **BUG-110 — Chat : refonte visuelle des posts et des indicateurs**
|
||||
- **Pièces jointes image** : boutons **icônes seules** (24 px, `aria-label`)
|
||||
rangés **sous l'image**, alignés à droite — télécharger, ouvrir.
|
||||
- **Clic sur une image = onglet de l'app** : le clic (ou le bouton « ouvrir »)
|
||||
affiche la pièce jointe dans un **onglet**, avec le même visualiseur que les
|
||||
images de vault (zoom/pan/plein écran/lightbox) — plus d'ouverture dans un
|
||||
onglet du navigateur. `TabManager.openChatImage()` + branche `chatImage` de
|
||||
`activate()` ; `renderImageViewer()` accepte une `url` directe (une pièce
|
||||
jointe de chat ne vit pas dans un vault).
|
||||
- **Indicateur de non-lus** : point de couleur pulsant sur l'icône « Chat » de
|
||||
la sidebar tant qu'il reste des messages non lus, en plus du badge chiffré.
|
||||
- **Notification OS** : quand l'app n'est pas au premier plan (`document.hidden`
|
||||
ou hors focus), un message entrant déclenche une notification navigateur
|
||||
(titre « Nouveau message de {user} », aperçu du texte, clic → retour sur
|
||||
l'app). Permission demandée au premier clic d'ouverture du chat.
|
||||
- **Suppression réellement annulable** : le `DELETE` est **différé de 10 s** ;
|
||||
la ligne passe à « Ce message a été supprimé » avec un bouton « Annuler » qui
|
||||
réarme le message, et un échec réseau le rend. (L'ancien « Annuler »
|
||||
restaurait une copie DOM d'un message déjà détruit côté serveur.)
|
||||
- i18n FR/EN : `chat.deleted_message`, `chat.undo`, `chat.download_image`,
|
||||
`chat.view_image` — clés mortes retirées (`chat.undo_success`,
|
||||
`chat.undo_error`, `chat.new_messages_indicator`).
|
||||
- Tests : `tests/frontend/filechat.test.mjs` 19 → **25** (6 tests #110).
|
||||
|
||||
---
|
||||
|
||||
## [2.61.0] — 2026-10-08
|
||||
|
||||
### Ajouté
|
||||
|
||||
+4
-3
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -222,6 +222,7 @@ Les vaults sont configurées par paires `VAULT_N_NAME` / `VAULT_N_PATH` (N = 1,
|
||||
| `VAULT_1_PATH` | Chemin dans le conteneur | `/vaults/Obsidian-RECETTES` |
|
||||
| `VAULT_1_ATTACHMENTS_PATH` | Dossier d'attachements (optionnel) | `06_Boite_a_Outils/6.2_Attachments` |
|
||||
| `VAULT_1_SCAN_ATTACHMENTS` | Scan d'images au démarrage (défaut : true) | `true` |
|
||||
| `OBSIGATE_HOME_ROOT` | Racine des dossiers personnels (un vault `home-<user>` par compte). Absente = fonctionnalité désactivée. | `/vaults/Home` |
|
||||
|
||||
**Règles de nommage :** lettres, chiffres et tirets uniquement ; pas d'espaces ; le nom doit correspondre au chemin dans le conteneur.
|
||||
|
||||
@@ -976,8 +977,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.61.0).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.65.1).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.61.0 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.65.1 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -238,6 +238,7 @@ Vaults are configured using pairs of `VAULT_N_NAME` / `VAULT_N_PATH` variables (
|
||||
| `VAULT_1_SCAN_ATTACHMENTS` | Enable image scanning on startup (optional, default: true) | `true` |
|
||||
| `VAULT_2_NAME` | Display name of the vault | `IT` |
|
||||
| `VAULT_2_PATH` | Path inside the container | `/vaults/Obsidian_IT` |
|
||||
| `OBSIGATE_HOME_ROOT` | Root folder for per-user home directories (one `home-<user>` vault per account). Unset = feature disabled. | `/vaults/Home` |
|
||||
|
||||
**Naming rules:**
|
||||
- Use only letters, numbers, and hyphens
|
||||
@@ -1151,8 +1152,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.61.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.65.1).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.61.0 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.65.1 | Last updated: September 2026*
|
||||
|
||||
@@ -142,6 +142,11 @@ def require_admin(current_user=Depends(require_auth)):
|
||||
return current_user
|
||||
|
||||
|
||||
def is_home_vault(vault_name: str) -> bool:
|
||||
"""Un dossier personnel (#194) : vault « home-<user> »."""
|
||||
return vault_name.startswith("home-")
|
||||
|
||||
|
||||
def check_vault_access(vault_name: str, user: dict) -> bool:
|
||||
"""Check if a user has access to a specific vault.
|
||||
|
||||
@@ -149,8 +154,13 @@ def check_vault_access(vault_name: str, user: dict) -> bool:
|
||||
- vaults == ["*"] → full access (admin default)
|
||||
- vault_name in vaults → access granted
|
||||
- otherwise → denied
|
||||
|
||||
#194 : un dossier personnel n'est **jamais** couvert par ``*`` — sinon
|
||||
un admin (``vaults: ["*"]``) verrait le dossier de chaque utilisateur.
|
||||
"""
|
||||
vaults = user.get("_token_vaults") or user.get("vaults", [])
|
||||
if is_home_vault(vault_name):
|
||||
return vault_name in vaults
|
||||
if "*" in vaults:
|
||||
return True
|
||||
return vault_name in vaults
|
||||
|
||||
+17
-2
@@ -946,9 +946,20 @@ async def create_user_endpoint(
|
||||
user = create_user(
|
||||
req.username, req.password, req.role, req.vaults, req.display_name
|
||||
)
|
||||
return user
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
# #194 : dossier perso — best effort, réparé au démarrage si le disque
|
||||
# (NFS) est indisponible (ensure_user_home journalise l'erreur).
|
||||
from backend.auth.user_store import get_user
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
await ensure_user_home(req.username)
|
||||
# La réponse doit refléter l'octroi du vault perso (#194) : create_user a
|
||||
# renvoyé un instantané construit avant l'octroi.
|
||||
fresh = get_user(req.username)
|
||||
if fresh:
|
||||
user["vaults"] = fresh.get("vaults", [])
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/admin/users/{username}")
|
||||
@@ -975,9 +986,13 @@ async def delete_user_endpoint(
|
||||
raise HTTPException(400, "Impossible de supprimer son propre compte")
|
||||
try:
|
||||
delete_user(username)
|
||||
return {"message": f"Utilisateur '{username}' supprimé"}
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
# #194 : fermer le vault du dossier perso (le dossier est conservé).
|
||||
from backend.user_home import release_user_home
|
||||
|
||||
await release_user_home(username)
|
||||
return {"message": f"Utilisateur '{username}' supprimé"}
|
||||
|
||||
|
||||
# ── API / MCP tokens (feature #107) ──────────────────────────────────
|
||||
|
||||
+25
-3
@@ -29,6 +29,8 @@ from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.render import _render_markdown
|
||||
|
||||
logger = logging.getLogger("obsigate.file_chat")
|
||||
|
||||
CHAT_DIR = Path("data/chats")
|
||||
@@ -96,8 +98,28 @@ def _write(file: Path, doc: dict[str, Any]) -> None:
|
||||
|
||||
|
||||
def get_messages(vault: str, path: str) -> list[dict[str, Any]]:
|
||||
"""Return the chat history for *(vault, path)* (chronological)."""
|
||||
return list(_read(vault, path).get("messages", []))
|
||||
"""Return the chat history for *(vault, path)* (chronological).
|
||||
|
||||
Every message carries its rendered ``html`` (#193): same markdown pipeline
|
||||
as a document (mistune + sanitizer), computed on read so a template change
|
||||
applies to the whole history without rewriting the JSON store.
|
||||
"""
|
||||
return [_decorate(m, vault) for m in _read(vault, path).get("messages", [])]
|
||||
|
||||
|
||||
def _decorate(msg: dict[str, Any], vault: str) -> dict[str, Any]:
|
||||
"""Return a copy of *msg* with its sanitized markdown ``html`` (#193).
|
||||
|
||||
The stored message is left untouched (``html`` is never persisted). A
|
||||
rendering failure must never break the chat: the message goes out with an
|
||||
empty ``html`` and the client falls back to plain text.
|
||||
"""
|
||||
try:
|
||||
html = _render_markdown(msg.get("text", ""), vault)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
logger.warning("chat markdown rendering failed: %s", e)
|
||||
html = ""
|
||||
return {**msg, "html": html}
|
||||
|
||||
|
||||
def add_message(
|
||||
@@ -147,7 +169,7 @@ def _append(
|
||||
messages = messages[-MAX_MESSAGES:]
|
||||
doc["messages"] = messages
|
||||
_write(_chat_file(vault, path), doc)
|
||||
return msg
|
||||
return _decorate(msg, vault) # #193 — le html part avec l'écho SSE
|
||||
|
||||
|
||||
# --- #192 : accusé de réception ---------------------------------------------
|
||||
|
||||
+68
-1
@@ -37,6 +37,12 @@ _last_full_index_ts: str = ""
|
||||
# Hook for incremental inverted index updates: called as (action, vault, path, file_info)
|
||||
_on_index_change: Callable[..., None] | None = None
|
||||
|
||||
# Registre des vaults ajoutés à la volée (#194) : les env VAULT_N_*/DIR_N_*
|
||||
# ne couvrent que le déploiement, tout ce qui est créé à runtime
|
||||
# (/api/vaults/add, dossiers perso) vivrait uniquement en mémoire sinon et
|
||||
# disparaîtrait au prochain rebuild ou redémarrage.
|
||||
DYNAMIC_VAULTS_FILE = Path("data/vaults.json")
|
||||
|
||||
|
||||
def set_index_change_hook(hook):
|
||||
"""Register a callback for incremental inverted index updates.
|
||||
@@ -134,7 +140,64 @@ def load_vault_config() -> dict[str, dict[str, Any]]:
|
||||
}
|
||||
n += 1
|
||||
|
||||
return vaults
|
||||
# Registre dynamique (#194) : les vaults créés à runtime (dossiers
|
||||
# persos, /api/vaults/add) sont chargés en premier, les env gagnent en
|
||||
# cas de collision de nom (vérité du déploiement).
|
||||
merged = _load_dynamic_vaults()
|
||||
merged.update(vaults)
|
||||
return merged
|
||||
|
||||
|
||||
def _load_dynamic_vaults() -> dict[str, dict[str, Any]]:
|
||||
"""Read vaults registered at runtime from ``data/vaults.json`` (#194)."""
|
||||
if not DYNAMIC_VAULTS_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(DYNAMIC_VAULTS_FILE.read_text(encoding="utf-8"))
|
||||
vaults = data.get("vaults", {})
|
||||
return vaults if isinstance(vaults, dict) else {}
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.error(f"Failed to read {DYNAMIC_VAULTS_FILE}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def _save_dynamic_vaults(vaults: dict[str, dict[str, Any]]) -> None:
|
||||
"""Atomic write of the dynamic vault registry (tmp + rename, as users.json)."""
|
||||
try:
|
||||
DYNAMIC_VAULTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = DYNAMIC_VAULTS_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(
|
||||
json.dumps({"version": 1, "vaults": vaults}, indent=2, default=str),
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(tmp, DYNAMIC_VAULTS_FILE)
|
||||
except OSError as e:
|
||||
# Le registre est un filet : vault_config mémoire reste valable
|
||||
# jusqu'au prochain rebuild, qui se reparera du dossier manquant.
|
||||
logger.error(f"Failed to write {DYNAMIC_VAULTS_FILE}: {e}")
|
||||
|
||||
|
||||
def persist_vault(vault_name: str) -> None:
|
||||
"""Persist *vault_name* so it survives rebuild/restart (#194).
|
||||
|
||||
Idempotent. Env-declared vaults are re-added by :func:`load_vault_config`
|
||||
anyway; persisting them too is harmless (single source after merge).
|
||||
"""
|
||||
cfg = vault_config.get(vault_name)
|
||||
if not cfg:
|
||||
return
|
||||
vaults = _load_dynamic_vaults()
|
||||
vaults[vault_name] = cfg
|
||||
_save_dynamic_vaults(vaults)
|
||||
|
||||
|
||||
def unpersist_vault(vault_name: str) -> None:
|
||||
"""Drop *vault_name* from the dynamic registry (#194). No-op if absent."""
|
||||
vaults = _load_dynamic_vaults()
|
||||
if vault_name not in vaults:
|
||||
return
|
||||
vaults.pop(vault_name, None)
|
||||
_save_dynamic_vaults(vaults)
|
||||
|
||||
|
||||
|
||||
@@ -867,6 +930,10 @@ async def reload_single_vault(vault_name: str) -> dict[str, Any]:
|
||||
|
||||
# Remove old vault data from index structures
|
||||
await remove_vault_from_index(vault_name)
|
||||
# remove_vault_from_index a poppé la config : la remettre, sinon le vault
|
||||
# disparaît de vault_config jusqu'au prochain reload complet (#194 — les
|
||||
# vaults dynamiques n'y reviennent que par data/vaults.json).
|
||||
vault_config[vault_name] = config
|
||||
|
||||
# Re-add the vault with updated configuration
|
||||
import functools
|
||||
|
||||
@@ -364,6 +364,12 @@ async def lifespan(app: FastAPI):
|
||||
else:
|
||||
logger.info("File watcher disabled by configuration.")
|
||||
|
||||
# #194 : dossier personnel par utilisateur — réparation/migration
|
||||
# (mkdir manquant, registre perdu, user créé hors API). Après le
|
||||
# watcher pour que les vaults ajoutés soient surveillés.
|
||||
from backend.user_home import ensure_all_user_homes
|
||||
await ensure_all_user_homes()
|
||||
|
||||
logger.info("Background startup complete.")
|
||||
|
||||
asyncio.create_task(_background_startup())
|
||||
|
||||
+4
-4
@@ -13,7 +13,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.auth.middleware import require_auth
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
|
||||
logger = logging.getLogger("obsigate.push")
|
||||
|
||||
@@ -167,9 +167,9 @@ async def subscribe_push(
|
||||
"""Subscribe to push notifications for a vault."""
|
||||
username = current_user.get("username", "unknown")
|
||||
|
||||
# Check if user has access to this vault
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
if "*" not in user_vaults and request.vault not in user_vaults:
|
||||
# Check if user has access to this vault (#194 : "*" n'inclut pas les
|
||||
# dossiers persos, check_vault_access est la source unique).
|
||||
if not check_vault_access(request.vault, current_user):
|
||||
raise HTTPException(status_code=403, detail="No access to this vault")
|
||||
|
||||
# Check if subscription already exists
|
||||
|
||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
|
||||
from backend.ai import PROVIDERS, _read_ai_keys, get_ai_key
|
||||
from backend.auth.middleware import require_admin, require_auth
|
||||
from backend.auth.middleware import check_vault_access, require_admin, require_auth
|
||||
from backend.indexer import index
|
||||
from backend.media_types import IMAGE_EXTENSIONS
|
||||
from backend.schemas import (
|
||||
@@ -495,14 +495,13 @@ async def api_diagnostics(current_user=Depends(require_admin)):
|
||||
@router.get("/api/dashboard", response_model=DashboardResponse)
|
||||
async def api_dashboard(current_user=Depends(require_auth)):
|
||||
"""Aggregated dashboard statistics across all accessible vaults."""
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
vault_stats = []
|
||||
total_files = 0
|
||||
total_tags = set()
|
||||
total_size = 0
|
||||
total_images = 0
|
||||
for vname, vdata in index.items():
|
||||
if "*" not in user_vaults and vname not in user_vaults:
|
||||
if not check_vault_access(vname, current_user):
|
||||
continue
|
||||
files = vdata.get("files", [])
|
||||
fc = len(files)
|
||||
|
||||
@@ -31,10 +31,9 @@ router = APIRouter(tags=["conflicts"])
|
||||
@router.get("/api/conflicts", response_model=ConflictsResponse)
|
||||
async def api_conflicts(current_user=Depends(require_auth)):
|
||||
"""List sync-conflict files across accessible vaults."""
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
all_conflicts = get_conflicts()
|
||||
if "*" not in user_vaults:
|
||||
all_conflicts = [c for c in all_conflicts if c["vault"] in user_vaults]
|
||||
# #194 : filtrage via check_vault_access ("*" n'inclut pas les homes).
|
||||
all_conflicts = [c for c in all_conflicts if check_vault_access(c["vault"], current_user)]
|
||||
return {"conflicts": all_conflicts, "total": len(all_conflicts)}
|
||||
|
||||
|
||||
|
||||
@@ -166,12 +166,11 @@ async def api_file_backlinks(
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
backlinks = get_backlinks(vault_name, path)
|
||||
|
||||
# Filter by user-accessible vaults
|
||||
if "*" not in user_vaults:
|
||||
backlinks = [b for b in backlinks if b["vault"] in user_vaults]
|
||||
# Filter by user-accessible vaults (#194 : check_vault_access, "*" sans
|
||||
# les dossiers persos).
|
||||
backlinks = [b for b in backlinks if check_vault_access(b["vault"], current_user)]
|
||||
|
||||
return {
|
||||
"vault": vault_name,
|
||||
|
||||
@@ -59,7 +59,6 @@ async def api_recent(limit: int | None = Query(None), vault: str | None = Query(
|
||||
@router.get("/api/bookmarks", response_model=BookmarksResponse)
|
||||
async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
|
||||
if not username:
|
||||
return {"files": []}
|
||||
@@ -68,7 +67,8 @@ async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(re
|
||||
files_resp = []
|
||||
for item in history:
|
||||
v_name = item["vault"]
|
||||
if "*" not in user_vaults and v_name not in user_vaults:
|
||||
# #194 : check_vault_access — "*" n'inclut pas les dossiers persos.
|
||||
if not check_vault_access(v_name, current_user):
|
||||
continue
|
||||
|
||||
# Find in index to get metadata
|
||||
|
||||
@@ -82,7 +82,12 @@ async def api_search(
|
||||
# paginate in the shared service so routes and tools share the same logic.
|
||||
return await loop.run_in_executor(
|
||||
get_search_executor(),
|
||||
partial(search_vaults, q, vault, tag, limit, offset),
|
||||
# #194 : filtre par vault accessible AVANT pagination ("*" sans les
|
||||
# dossiers persos) — sinon un user voyait les notes des autres.
|
||||
partial(
|
||||
search_vaults, q, vault, tag, limit, offset,
|
||||
is_allowed=lambda v: check_vault_access(v, current_user),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -55,6 +55,11 @@ async def api_add_vault(body: dict = Body(...), current_user=Depends(require_adm
|
||||
|
||||
stats = await add_vault_to_index(name, vault_path)
|
||||
|
||||
# #194 : persister, sinon ce vault disparaît au prochain rebuild/redémarrage.
|
||||
from backend.indexer import persist_vault
|
||||
|
||||
persist_vault(name)
|
||||
|
||||
# Start watching the new vault
|
||||
watcher = get_watcher()
|
||||
if watcher:
|
||||
@@ -80,6 +85,10 @@ async def api_remove_vault(vault_name: str, current_user=Depends(require_admin))
|
||||
await watcher.remove_vault(vault_name)
|
||||
|
||||
await remove_vault_from_index(vault_name)
|
||||
# #194 : plus de trace au redémarrage (les vaults d'env, eux, reviennent).
|
||||
from backend.indexer import unpersist_vault
|
||||
|
||||
unpersist_vault(vault_name)
|
||||
await sse_manager.broadcast("vault_removed", {"vault": vault_name})
|
||||
return {"status": "ok", "vault": vault_name}
|
||||
|
||||
|
||||
@@ -166,6 +166,13 @@ class ChatMessageItem(BaseModel):
|
||||
default=None,
|
||||
description="Link preview card {url, title, description, image, site} (#191)",
|
||||
)
|
||||
html: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
"Rendered (mistune + sanitized) HTML of ``text``, like a document "
|
||||
"preview — tables, lists, fenced code blocks (#193)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ChatHistoryResponse(BaseModel):
|
||||
|
||||
@@ -12,6 +12,7 @@ import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from backend.auth.middleware import is_home_vault
|
||||
from backend.history import get_recent_opened, is_bookmarked
|
||||
from backend.indexer import find_file_in_index, index
|
||||
|
||||
@@ -31,6 +32,10 @@ def humanize_mtime(mtime: float) -> str:
|
||||
|
||||
|
||||
def _can_access(vault: str, user_vaults: list[str]) -> bool:
|
||||
# #194 : un dossier perso ne bénéficie jamais de "*" (même règle que
|
||||
# backend.auth.middleware.check_vault_access).
|
||||
if is_home_vault(vault):
|
||||
return vault in user_vaults
|
||||
return "*" in user_vaults or vault in user_vaults
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -11,15 +12,19 @@ def search_vaults(
|
||||
tag: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
is_allowed: Callable[[str], bool] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Full-text search with pagination, returned as the API response payload.
|
||||
|
||||
No permission filtering is applied here: callers that need it (the tool
|
||||
layer) filter the ``results`` list themselves.
|
||||
``is_allowed`` (#194) filters the raw hits **before** pagination, so a
|
||||
restricted vault set neither distorts ``total`` nor the returned page.
|
||||
The tool layer filters on its own and leaves it as ``None``.
|
||||
"""
|
||||
from backend.search import search
|
||||
|
||||
all_results = search(q, vault_filter=vault, tag_filter=tag)
|
||||
if is_allowed is not None:
|
||||
all_results = [r for r in all_results if is_allowed(r.get("vault", ""))]
|
||||
total = len(all_results)
|
||||
page = all_results[offset: offset + limit]
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Dossier personnel par utilisateur (#194).
|
||||
|
||||
Chaque utilisateur reçoit ``<OBSIGATE_HOME_ROOT>/<username>`` monté comme un
|
||||
vault propre ``home-<username>`` : l'isolation profite de l'ACL par vault
|
||||
déjà en place (``check_vault_access``), aucune ACL par chemin à inventer.
|
||||
|
||||
Tout est idempotent (``ensure_user_home``) pour être rappelé à la création
|
||||
d'un compte ET au démarrage : un dossier supprimé, un registre perdu ou un
|
||||
user créé hors API se réparent au boot.
|
||||
|
||||
La fonctionnalité est inactive tant que ``OBSIGATE_HOME_ROOT`` n'est pas
|
||||
défini (dev, tests, desktop) — comportement inchangé.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("obsigate.user_home")
|
||||
|
||||
# Même grammaire que CreateUserRequest.username_valid (backend/auth/router.py) :
|
||||
# segment de chemin sûr, déjà passé par la validation de l'API. Re-vérifié ici
|
||||
# car le nom sert à construire un chemin (défense en profondeur).
|
||||
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_-]{2,32}$")
|
||||
|
||||
|
||||
def home_root() -> Path | None:
|
||||
"""Racine des dossiers persos, ou ``None`` si la fonctionnalité est inactive."""
|
||||
root = os.environ.get("OBSIGATE_HOME_ROOT", "").strip()
|
||||
return Path(root) if root else None
|
||||
|
||||
|
||||
def home_vault_name(username: str) -> str:
|
||||
"""Nom de vault du dossier perso — pas de ``/`` ni ``::`` (segment d'URL, clé d'index)."""
|
||||
return f"home-{username}"
|
||||
|
||||
|
||||
async def ensure_user_home(username: str) -> str | None:
|
||||
"""Crée (si besoin) le dossier perso de *username*, son vault et son octroi.
|
||||
|
||||
Retourne le nom de vault, ou ``None`` si désactivé / nom invalide /
|
||||
erreur disque (journalisée, réparée au prochain démarrage).
|
||||
"""
|
||||
root = home_root()
|
||||
if root is None:
|
||||
return None
|
||||
if not _USERNAME_RE.match(username):
|
||||
logger.warning(f"Home folder skipped: invalid username {username!r}")
|
||||
return None
|
||||
|
||||
home = root / username
|
||||
try:
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
logger.exception(f"Cannot create home folder {home} for user '{username}'")
|
||||
return None
|
||||
|
||||
name = home_vault_name(username)
|
||||
try:
|
||||
from backend.indexer import add_vault_to_index, index, persist_vault, vault_config
|
||||
from backend.sse import sse_manager
|
||||
|
||||
vault_path = str(home)
|
||||
if name not in index:
|
||||
await add_vault_to_index(name, vault_path)
|
||||
persist_vault(name)
|
||||
from backend.watcher_state import get_watcher
|
||||
|
||||
watcher = get_watcher()
|
||||
if watcher:
|
||||
await watcher.add_vault(name, vault_path)
|
||||
await sse_manager.broadcast("vault_added", {"vault": name})
|
||||
logger.info(f"Home vault '{name}' registered at {vault_path}")
|
||||
elif name not in vault_config:
|
||||
vault_config[name] = {"path": vault_path, "attachmentsPath": None,
|
||||
"scanAttachmentsOnStartup": True}
|
||||
except Exception:
|
||||
logger.exception(f"Cannot register home vault '{name}' for user '{username}'")
|
||||
return None
|
||||
|
||||
_grant(username, name)
|
||||
return name
|
||||
|
||||
|
||||
async def release_user_home(username: str) -> None:
|
||||
"""Retire le vault du dossier perso à la suppression du compte (#194).
|
||||
|
||||
Le dossier sur disque est **conservé** (décision produit : pas de perte
|
||||
de données) ; seul l'index, le watcher et le registre le referment.
|
||||
"""
|
||||
name = home_vault_name(username)
|
||||
try:
|
||||
from backend.indexer import index, remove_vault_from_index, unpersist_vault
|
||||
|
||||
unpersist_vault(name)
|
||||
if name not in index:
|
||||
return
|
||||
await remove_vault_from_index(name)
|
||||
from backend.sse import sse_manager
|
||||
from backend.watcher_state import get_watcher
|
||||
|
||||
watcher = get_watcher()
|
||||
if watcher:
|
||||
await watcher.remove_vault(name)
|
||||
await sse_manager.broadcast("vault_removed", {"vault": name})
|
||||
logger.info(f"Home vault '{name}' released (folder kept)")
|
||||
except Exception:
|
||||
logger.exception(f"Cannot release home vault '{name}'")
|
||||
|
||||
|
||||
def _grant(username: str, vault_name: str) -> None:
|
||||
"""Ajoute le vault à ``user.vaults`` s'il n'y est pas déjà (#194).
|
||||
|
||||
L'octroi est explicite même pour un admin (``vaults: ["*"]``) : ``*`` ne
|
||||
couvre jamais un dossier perso (voir ``check_vault_access``).
|
||||
"""
|
||||
from backend.auth.user_store import get_user, update_user
|
||||
|
||||
user = get_user(username)
|
||||
if not user:
|
||||
return # créé hors API (bootstrap avant users.json) → réparé au boot suivant
|
||||
vaults = user.get("vaults") or []
|
||||
if vault_name in vaults:
|
||||
return
|
||||
update_user(username, {"vaults": [*vaults, vault_name]})
|
||||
|
||||
|
||||
async def ensure_all_user_homes() -> int:
|
||||
"""Passe de réparation/migration au démarrage : un home par user existant.
|
||||
|
||||
Balaye aussi les vaults orphelins (compte supprimé hors route, ex.
|
||||
``create_admin.py delete``) : ils sont refermés, dossier conservé.
|
||||
"""
|
||||
from backend.auth.user_store import get_all_users
|
||||
from backend.indexer import index, vault_config
|
||||
|
||||
root = home_root()
|
||||
if root is None:
|
||||
return 0
|
||||
users = get_all_users()
|
||||
usernames = {u.get("username") for u in users}
|
||||
created = 0
|
||||
for user in users:
|
||||
username = user.get("username")
|
||||
if not username:
|
||||
continue
|
||||
if await ensure_user_home(username):
|
||||
created += 1
|
||||
if created:
|
||||
logger.info(f"User home folders ensured for {created} user(s)")
|
||||
|
||||
# Orphelins : vault home-<x> toujours indexé mais <x> n'existe plus.
|
||||
# Path.parent == root → on ne touche qu'aux dossiers sous la racine Home,
|
||||
# jamais à un vault admin nommé « home-… » par ailleurs.
|
||||
for name in list(index):
|
||||
if not name.startswith("home-"):
|
||||
continue
|
||||
owner = name[len("home-"):]
|
||||
if owner in usernames:
|
||||
continue
|
||||
cfg_path = Path((vault_config.get(name) or {}).get("path", ""))
|
||||
if cfg_path.parent != root:
|
||||
continue
|
||||
logger.warning(f"Orphan home vault '{name}' released (user deleted?)")
|
||||
await release_user_home(owner)
|
||||
return created
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.61.0"
|
||||
version = "2.65.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.61.0"
|
||||
version = "2.65.1"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.61.0",
|
||||
"version": "2.65.1",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -21,12 +21,17 @@ services:
|
||||
volumes:
|
||||
- /c/dev/git/python/ObsiGate/test_vault:/vaults/TestVault
|
||||
- /c/dev/git/python/ObsiGate/test_dir:/vaults/TestDir
|
||||
# #194 : racine des dossiers persos — UN seul montage pour tous les
|
||||
# utilisateurs (dossier hôte : mkdir test_home à côté du dépôt).
|
||||
- /c/dev/git/python/ObsiGate/test_home:/vaults/Home
|
||||
- obsigate_data:/app/data
|
||||
environment:
|
||||
- VAULT_1_NAME=TestVault
|
||||
- VAULT_1_PATH=/vaults/TestVault
|
||||
- DIR_1_NAME=TestDir
|
||||
- DIR_1_PATH=/vaults/TestDir
|
||||
# #194 : dossier personnel par utilisateur = <racine>/home-<user>
|
||||
- OBSIGATE_HOME_ROOT=/vaults/Home
|
||||
- OBSIGATE_AUTH_ENABLED=true
|
||||
- OBSIGATE_ADMIN_USER=admin
|
||||
- OBSIGATE_ADMIN_PASSWORD=test123
|
||||
|
||||
@@ -36,6 +36,9 @@ services:
|
||||
- /NFS/OBSIDIAN_DOC/Obsidian_WORKOUT:/vaults/Obsidian_WORKOUT
|
||||
- /NFS/OBSIDIAN_DOC/SessionsManager:/vaults/SessionsManager
|
||||
- /home/bruno:/vaults/bruno
|
||||
# #194 : racine des dossiers perso — UN seul montage pour tous les
|
||||
# utilisateurs (un montage par user demanderait un redémarrage).
|
||||
- /NFS/OBSIDIAN_DOC/Home:/vaults/Home
|
||||
- /DOCKER_CONFIG/ObsiGate/data:/app/data
|
||||
environment:
|
||||
- VAULT_1_NAME=Recettes
|
||||
@@ -50,6 +53,8 @@ services:
|
||||
- VAULT_5_PATH=/vaults/SessionsManager
|
||||
- DIR_1_NAME=Bruno
|
||||
- DIR_1_PATH=/vaults/bruno
|
||||
# #194 : dossier personnel par utilisateur = <racine>/home-<user>
|
||||
- OBSIGATE_HOME_ROOT=/vaults/Home
|
||||
- OBSIGATE_AUTH_ENABLED=true
|
||||
- OBSIGATE_ADMIN_USER=admin
|
||||
# OBSIGATE_ADMIN_PASSWORD → .env
|
||||
|
||||
+11
-1
@@ -217,6 +217,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *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/<uuid>`) : 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 |
|
||||
| *BUG-110* | Chat : refonte visuelle des posts — boutons discrets sous l'image, image ouverte dans un onglet de l'app, point de non-lus sur l'icône chat, notification OS hors focus, annulation **réelle** d'une suppression | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/filechat.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/filechat.test.mjs` | Clic sur une image du chat → ouverture dans un onglet du navigateur au lieu du visualiseur de l'app ; boutons de pièce jointe bavards (icône + libellé) ; aucun repère visuel persistant sur l'icône chat ; aucune alerte OS quand l'app est en arrière-plan ; le bouton « Annuler » d'une suppression ne restaurait rien (copie DOM d'un message déjà supprimé côté serveur) | `TabManager.openChatImage()` (+ branche `chatImage` de `activate()`, `renderImageViewer()` accepte une `url` directe) ouvre la pièce jointe dans un **onglet** avec le visualiseur des images de vault (zoom/pan/plein écran) ; boutons **icônes seules** (24 px, `aria-label`) sous l'image, alignés à droite ; **point rouge pulsant** (`.chat-has-unread`) tant qu'il reste des non-lus, en plus du badge chiffré ; `_notifyNewMessage()` — notification OS si `document.hidden`/hors focus, permission demandée à l'ouverture du chat ; suppression **différée de 10 s** (ligne « Ce message a été supprimé » + « Annuler », DELETE envoyé à l'échéance seulement, échec réseau → le message revient) | Vérifié : `filechat.test.mjs` 25/25 (dont 6 tests #110), `validate-imports` 42 modules / 372 exports 0 erreur, `unit.test.mjs` 13/13 |
|
||||
| | | | | | | | | | |
|
||||
### TODOs techniques (améliorations / nouvelles tâches)
|
||||
|
||||
@@ -294,7 +295,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-09-17 | BUG-065, #78 (complément) | Correction + feature | `frontend/js/excalidraw-viewer.js`, `frontend/js/utils.js`, `frontend/excalidraw-editor.html`, `tests/frontend/excalidraw-viewer.test.mjs`, `docs/features/excalidraw.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-065** : l'auto-save Excalidraw (débounce 2 s) déclenchait `PUT save` → SSE `index_updated` → `reloadExternalWrite` → `openFile` → recréation de l'iframe = refresh visible pendant le dessin. Auto-save retirée (`excalidraw-viewer.js` : plus de `requestSave`/`saveTimer`), sauvegarde explicite (bouton 💾 / Ctrl+S) ; `reloadExternalWrite` (utils.js) court-circuite le re-rendu si un iframe Excalidraw est ouvert sur ce fichier (attributs `data-excalidraw-vault`/`data-excalidraw-path`) ; le badge « Modified » suit désormais une signature des éléments (`id:versionNonce`) au lieu de tout `onChange` — resize/zoom/plein écran ne marquent plus le fichier modifié. **#78 (complément)** : bouton **plein écran** `#btn-fullscreen` dans la barre d'outils de l'éditeur (`requestFullscreen` sur le document de l'iframe) + iframe créée avec `allow="fullscreen" allowfullscreen`. Vérifié Playwright : bascule plein écran OK (`document.fullscreenElement` true→false), badge non modifié après bascule ; tests statiques `excalidraw-viewer.test.mjs` 12/12, validate-imports 38 modules, unit 9/9. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-17 | #78 (complément) | UI | `frontend/excalidraw-editor.html`, `docs/features/excalidraw.md`, `CHANGELOG.md` | **#78 (complément)** : la barre d'outils de l'éditeur Excalidraw passe en **colonne d'icônes** (34×34 px, SVG seuls), **collée au bord droit** (`right: 0` ; `top: 45%` ; empilement vertical), avec `title`/`aria-label`. L'icône du bouton Save est remplacée par une coche pendant 1,2 s après une sauvegarde réussie. Badge « Modifié » réduit à une pastille. Vérifié Playwright : bord droit au bord de l'iframe, haut 45 %, 4 boutons empilés ; bascule plein écran OK, cycle d'icône Save + `PUT save` observés. | 🟢 livré (en attente vérif utilisateur) |
|
||||
| 2026-09-18 | BUG-066 | Correction | `frontend/locales/fr.json`, `frontend/locales/en.json`, `tests/frontend/unit.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-066** : la table des matières de la page de configuration n'affichait aucune icône pour « Fichiers cachés » et « Partages publics ». Les libellés du sommaire proviennent de clés i18n (`config.section_hidden`, `config.section_shares`) distinctes des titres de section qui, eux, portaient déjà l'icône. Alignement : 🗂️ / 📤 en FR **et** EN. Test de non-régression : `unit.test.mjs` vérifie que **toutes** les entrées `.help-nav-link` du sommaire portent une icône dans les deux langues (17/17). Vérifié : `unit.test.mjs` 10/10, `validate-imports` 38 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-18 | #105, BUG-067 | Documentation + correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/main.py`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **#105** : audit complet de couverture du Guide d'utilisation — 8 nouvelles sections (Architecture + diagramme Mermaid, API & intégrations, Diagrammes Mermaid & Excalidraw, Hors-ligne & synchronisation, Collaboration temps réel, Application desktop, Bibliothèque & signets, Multilingue) et compléments (recherche sémantique, MFA/WebAuthn, notifications push, exports HTML/ePub/ZIP, PDF, vue multi-panneaux, admin). Téléchargement du guide en Markdown et PDF (`GET /api/guide/download?format=md|pdf`, FR/EN, rendu par le moteur d'export existant). Guide plus large en desktop. **BUG-067** : ancre morte `#help-mobile-editor` → section dédiée créée. | 🟢 corrigé (en attente vérif utilisateur)
|
||||
| 2026-09-18 | #105, BUG-067 | Documentation + correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/main.py`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **#105** : audit complet de couverture du Guide d'utilisation — 8 nouvelles sections (Architecture + diagramme Mermaid, API & intégrations, Diagrammes Mermaid & Excalidraw, Hors-ligne & synchronisation, Collaboration temps réel, Application desktop, Bibliothèque & signets, Multilingue) et compléments (recherche sémantique, MFA/WebAuthn, notifications push, exports HTML/ePub/ZIP, PDF, vue multi-panneaux, admin). Téléchargement du guide en Markdown et PDF (`GET /api/guide/download?format=md|pdf`, FR/EN, rendu par le moteur d'export existant). Guide plus large en desktop. **BUG-067** : ancre morte `#help-mobile-editor` → section dédiée créée. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-18 | #105 (ajustements) | Amélioration | `frontend/index.html`, `frontend/js/config.js`, `frontend/sw.js`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/pdf_export.py`, `Dockerfile`, `scripts/build_guide_diagrams.py`, `scripts/render_guide_diagram.mjs`, `scripts/guide_content.py`, `backend/assets/guide_diagrams/df7366a40db6a5a2.png`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md` | **#105 (retour utilisateur)** : 1) boutons de téléchargement du guide passés en icônes seules (tooltips i18n conservés) ; 2) le diagramme Mermaid de la section Architecture est désormais rendu en **vraie image** dans le PDF (pipeline de pré-rendu PNG Chromium+mermaid v11, PNG commité sous `backend/assets/guide_diagrams/<sha1>.png`, résolu par `diagram_png_for()` ; le Markdown garde le fenced mermaid) ; 3) emoji du PDF rendus **en couleur** au lieu de rectangles : `fonts-noto-color-emoji` ajouté au Dockerfile + `"Noto Color Emoji"` en fin de pile de polices PDF. Vérifié : pytest 1218 (test_guide ×13), ruff/mypy 0, validate-imports 38, unit 10/10 ; PDF live conteneur 2020 : 24 pages, 0 glyphes tofu, diagramme 3568x1174 embarqué. | 🟢 livré
|
||||
| 2026-09-22 | BUG-068 | Correction | `backend/auth/router.py`, `backend/requirements.txt`, `frontend/js/auth.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_mfa.py`, `tests/frontend/mfa-settings.test.mjs` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-068** : section « 🔒 Sécurité du compte » finalisée. (1) Boutons hors thème : `config-btn-primary`/`config-btn-danger` n'existaient pas en CSS → définis depuis les variables du thème (+ états disabled). (2) QR invisible : l'image tierce était bloquée par la CSP (`img-src 'self' data: blob:`) et exposait le secret TOTP → QR SVG `data:` généré en local par le backend (`qr_data_url`, segno) avec repli saisie manuelle. (3) Codes de récupération perdus à la 1re activation WebAuthn → `_showRecoveryCodes(codes, targetId)` avec repli `webauthn-flow-area`. (4) Carte « Mot de passe » ajoutée (endpoint `change-password` existant, jusque-là sans UI) + échappement des libellés de clés WebAuthn. Vérifié : pytest 1241 passed / 6 skipped, ruff 0, mypy 0 (78 fichiers), `mfa-settings.test.mjs` 9/9, unit 10/10, validate-imports 39 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-23 | BUG-069 | Correction | `frontend/js/auth.js`, `frontend/locales/{fr,en}.json`, `tests/frontend/mfa-settings.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-069** : login 2FA bloqué sans erreur — après user+pwd corrects, `showMfaChallenge` cherchait `.login-box` (inexistant dans `index.html`, marquage réel `#login-screen > .login-card`) et faisait un `return` silencieux : page de login figée, aucune erreur. Correctif : montage dans `.login-card` (repli `#login-screen`) + erreur visible `mfa.challenge_unavailable` (FR/EN) si le point de montage manque. **Reproduit au navigateur** (Playwright, instance Docker `obsigate-test`, compte jetable avec TOTP) : avant → challenge jamais affiché ; après → challenge affiché, code erroné → erreur, code valide (verify 200) → app. Tests : `mfa-settings.test.mjs` 11/11 (+2 ancrage DOM), unit 10/10, validate-imports 39 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
@@ -334,6 +335,15 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 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) |
|
||||
| 2026-10-09 | BUG-110 | Correction + refonte UI chat | `frontend/js/filechat.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/filechat.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-110 — refonte visuelle du chat** : (1) les boutons de pièce jointe deviennent des **icônes seules** (24 px, `aria-label`) rangées **sous l'image, alignées à droite** — plus de gros boutons à libellé ; (2) le clic sur une image ouvre le **même visualiseur que pour une image de vault** (zoom/pan/plein écran) mais dans un **onglet de l'app** (`TabManager.openChatImage()`, branche `chatImage` de `activate()`, `renderImageViewer()` accepte désormais une `url` directe : une pièce jointe n'est pas dans un vault) au lieu d'un onglet du navigateur ; (3) **point rouge pulsant sur l'icône chat** de la sidebar tant qu'il reste des non-lus (classe `chat-has-unread`, en plus du badge chiffré) ; (4) **notification OS** (`Notification`) quand l'app n'est pas au premier plan (`document.hidden`/hors focus), permission demandée au premier clic d'ouverture du chat ; (5) la suppression n'est plus mensongère : le `DELETE` est **différé de 10 s**, la ligne passe à « Ce message a été supprimé » avec « Annuler » — annuler réarme le message (l'ancien code restaurait une copie DOM d'un message déjà détruit côté serveur en affichant « Message restauré ») ; échec réseau → le message revient. Vérifié : `filechat.test.mjs` 25/25 (dont 6 tests #110 : icônes seules, clic image → `openChatImage`, aucun `DELETE` pendant la fenêtre d'annulation, notification OS), `validate-imports` 42 modules / 372 exports 0 erreur, `unit.test.mjs` 13/13 | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
|
||||
| 2026-10-09 | #193 | Fonctionnalité | `backend/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/style.css`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#193 — un post du chat s'affiche comme un document markdown** : le serveur rend le texte de chaque message avec le **pipeline des documents** (`backend/render.py::_render_markdown` : mistune — tableaux, listes de tâches, notes —, wikilinks, masquage des secrets #188, **sanitizer BUG-021**) et le renvoie dans un champ `html` (ajouté à `ChatMessageItem`, sinon `response_model` le filtrait). Le rendu est calculé **à la lecture** (`get_messages`) et à l'ajout (`_append`) — il n'est donc **jamais persisté** dans `data/chats/*.json` — et l'écho SSE part avec : les autres clients voient le rendu sans refresh. Côté front, `_fillBody()` injecte le HTML dans un `<div class="md-content">` puis appelle `safeHighlight()` sur chaque `pre code` (highlight.js + alias de langages, exactement comme le viewer ; mermaid non déclenché). Repli intact si `html` manque (écho optimiste → texte brut + URL cliquable) et un échec de rendu n'emporte jamais le message. CSS : `.file-chat-md` annule le `pre-wrap` hérité de la bulle (les retours à la ligne du HTML source produisaient des lignes vides) et compacte `.md-content` (`pre`/`table` en `overflow-x: auto`). Vérifié : `test_file_chat.py` 54→**61** (`TestMarkdownRendering` ×7 : html à l'ajout et à la lecture, **absent du JSON**, classe `language-xxx`, listes, `<script>` neutralisé, routes POST/GET, broadcast SSE), `filechat.test.mjs` 25→**27**, suite 1689 passed, ruff 0, mypy 0 (113 fichiers), validate-imports 42 modules / 372 exports | ✅ livré (en attente vérif utilisateur) |
|
||||
|
||||
| 2026-10-09 | #194 | Fonctionnalité | `backend/user_home.py` (nouveau), `backend/indexer.py`, `backend/auth/router.py`, `backend/routers/vaults.py`, `backend/main.py`, `docker-compose.yml`, `tests/test_user_home.py` (nouveau), `tests/test_auth_api.py`, `docs/features/user-home-194.md` (nouveau), `docs/ROADMAP.md`, `CHANGELOG.md`, `README.md`, `README.fr.md` | **#194 — dossier personnel par utilisateur** : chaque compte reçoit `<OBSIGATE_HOME_ROOT>/<user>` monté comme vault propre `home-<user>` (option A validée) — l'isolation s'appuie sur l'ACL par vault **existante** (`check_vault_access`), aucune ACL par chemin inventée. `backend/user_home.py` : `ensure_user_home()` idempotent (mkdir → `add_vault_to_index` → `persist_vault` → watcher → SSE → octroi dans `user.vaults`) appelé à `POST /api/auth/admin/users` **et** au démarrage (`ensure_all_user_homes()` : migration des users existants, réparation des dossiers/registres manquants, sweep des vaults `home-*` orphelins dont l'utilisateur a disparu hors route — garde `Path.parent == racine` pour ne jamais toucher à un vault admin homonyme) ; `release_user_home()` à la suppression (index/watcher/registre refermés, **dossier conservé**). **Blocage levé** : registre persistant `data/vaults.json` fusionné par `load_vault_config()` — avant, tout vault ajouté à la volée (`/api/vaults/add`) disparaissait au premier reindex complet et au redémarrage. Correctif au passage : `reload_single_vault()` remet `vault_config[name]` après `remove_vault_from_index()`. Réponse du POST = vaults réactualisés (l'instantané de `create_user` était antérieur à l'octroi). Déploiement : montage unique `/NFS/OBSIDIAN_DOC/Home:/vaults/Home` + `OBSIGATE_HOME_ROOT` (absente = feature inactive, dev/tests/desktop inchangés). Vérifié : `test_user_home.py` 12/12 (création, idempotence, username invalide, persistance après `load_vault_config`, retrait, isolation `check_vault_access`, orphelins), `test_auth_api.py::TestUserHome` 2/2, suite **1703 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, `validate-imports` 42 modules / 372 exports, `unit.test.mjs` 13/13 | ✅ livré (en attente vérif utilisateur) |
|
||||
|
||||
| 2026-10-09 | #194 | Fonctionnalité (UI) | `frontend/js/sidebar.js`, `frontend/js/viewer.js`, `docker-compose.test-win.yml`, `.gitignore`, `tests/frontend/sidebar-filters.test.mjs`, `docs/ROADMAP.md`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 (incrément UI)** : les vaults `home-<user>` s'affichent **en premier** dans l'arborescence de la sidebar — tri stable appliqué à la source (`_homeFirst` dans `loadVaults()` et dans le soft-refresh, donc `state.allVaults`, l'arborescence et les sélecteurs restent cohérents) — et portent l'icône **maison** lucide (`data-lucide="home"` via `getVaultIcon`, même `safeCreateIcons`/`stroke: currentColor` que les autres icônes, thème-aware). Compose de test : `test_home:/vaults/Home` + `OBSIGATE_HOME_ROOT=/vaults/Home` (dossier `test_home/` ignoré par git). Vérifié : `sidebar-filters.test.mjs` 8→**10/10**, `validate-imports` 42 modules / 372 exports, `unit.test.mjs` 13/13, suites voisines (ai-sidebar, toolbar-order, search-facets, pane-manager, editor-inline) vertes, **live Playwright sur l'instance 2020** : ordre `home-admin, home-bruno, TestDir, TestVault` + `svg.lucide-home` rendu (placeholder `<i>` remplacé) | ✅ livré (en attente vérif utilisateur) |
|
||||
|
||||
| 2026-10-09 | #194 | Correction (isolation) | `backend/auth/middleware.py`, `backend/services/recent.py`, `backend/services/search.py`, `backend/routers/search.py`, `backend/routers/config.py`, `backend/routers/conflicts.py`, `backend/routers/files_read.py`, `backend/routers/history.py`, `backend/push.py`, `backend/user_home.py`, `tests/test_user_home.py`, `tests/test_auth_api.py`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 — l'admin voyait le home de chaque utilisateur** (retour utilisateur) : `vaults: ["*"]` ouvrait **tous** les vaults, donc `home-<user>` compris. Correctif à la racine : `check_vault_access` exige un octroi **explicite** pour tout vault `home-*` (nouveau `is_home_vault()`), et les filtres `« * »` écrits en dur dans les routes ont été remplacés par cet appel unique — dashboard (`routers/config.py`), conflits, liens retour, favoris, abonnements push (`/api/search/replace` utilisait déjà `is_vault_allowed`). `/api/search` filtre désormais les bruts **avant** pagination via `search_vaults(is_allowed=…)` (sinon notes personnelles d'un autre compte dans les résultats). `_grant` n'écarte plus les comptes `« * »` → l'admin reçoit son propre `home-admin` (auto-réparé au démarrage). Vérifié : `test_user_home.py` 12→**14/14** (dont « * » sans homes + filtrage recherche), `test_auth_api.py` 2/2 (admin ne voit **plus** `home-alice`, alice voit le sien), suite **1705 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, **live Playwright instance 2020** : arborescence admin = `home-admin, TestDir, TestVault` (plus de `home-bruno`), `svg.lucide-home` rendu. Reste ouvert (fuite pré-existante, hors périmètre) : `/api/search/advanced`, `/api/tags`, `/api/tags/suggest`, `/api/tree-search`, `/api/suggest` ne filtrent pas par vault accessible | ✅ livré (en attente vérif utilisateur) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+28
-1
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.61.0 | **Dernière mise à jour :** 2026-10-08
|
||||
> **Version :** 2.65.1 | **Dernière mise à jour :** 2026-10-10
|
||||
> **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)**
|
||||
@@ -30,6 +30,33 @@
|
||||
|
||||
## 🔵 En cours
|
||||
|
||||
### 194. Dossier personnel par utilisateur (`<racine>/Home/<user>`)
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟡 | **Ouvert :** 2026-10-09 | **Statut :** 🔵 en cours
|
||||
- **Cadrage :** chaque utilisateur créé reçoit un dossier personnel monté en vault propre
|
||||
(`home-<user>`), isolé par l'ACL par vault existante — pas d'ACL par chemin à inventer.
|
||||
- **Décisions validées :** option A (un vault par utilisateur), nom `home-<user>`,
|
||||
dossier **conservé** à la suppression du compte, racine `/NFS/OBSIDIAN_DOC/Home`
|
||||
(montée `/vaults/Home`, exposée par `OBSIGATE_HOME_ROOT`).
|
||||
- **Sous-tâches :**
|
||||
- [x] ID stable créé (avant codage, AGENTS.md)
|
||||
- [x] Registre persistant des vaults dynamiques `data/vaults.json` (les vaults ajoutés à la
|
||||
volée sont aujourd'hui perdus au redémarrage / reindex — `load_vault_config` ne lit
|
||||
que les env `VAULT_N_*`)
|
||||
- [x] Service `ensure_user_home()` idempotent : mkdir + vault + octroi, appelé à la création
|
||||
d'un user et au démarrage (réparation/migration)
|
||||
- [x] Retrait au suppression de compte (index/watcher/registre) sans supprimer le dossier
|
||||
- [x] Montage Docker + `OBSIGATE_HOME_ROOT` (prod **et** compose de test
|
||||
`docker-compose.test-win.yml` → `test_home:/vaults/Home`)
|
||||
- [x] UI : vaults `home-*` en **premier** dans l'arborescence + icône maison
|
||||
lucide (`getVaultIcon`), vérifié live sur l'instance 2020
|
||||
- [x] Isolation : « * » (admin) ne couvre **plus** les dossiers persos —
|
||||
`check_vault_access` + filtres des routes + `/api/search` (avant
|
||||
pagination) ; octroi du home aussi aux comptes « * »
|
||||
- [x] Tests (création, persistance après rebuild, isolation 403 entre users) + docs
|
||||
|
||||
---
|
||||
|
||||
### 77. Application Desktop native — Tauri (Windows / Linux / macOS)
|
||||
|
||||
- **Effort :** 8-12 jours | **Impact :** 🟡 | **Framework :** Tauri v2 (Rust + Webview)
|
||||
|
||||
@@ -211,6 +211,54 @@ carte reste (jamais de message perdu).
|
||||
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
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# #194 — Dossier personnel par utilisateur
|
||||
|
||||
> **Statut :** livré | **Ouvert :** 2026-10-09 | **Type :** feature (auth + vaults)
|
||||
|
||||
## Problème
|
||||
|
||||
Un utilisateur créé dans ObsiGate n'avait aucun espace propre : il ne voyait
|
||||
que les vaults qu'un admin lui attribuait à la main, et rien n'était préparé
|
||||
sur le disque à sa création.
|
||||
|
||||
## Décision d'architecture
|
||||
|
||||
Deux formes étaient possibles :
|
||||
|
||||
1. **Un vault par utilisateur** (retenu) — `<racine>/<user>` devient le vault
|
||||
`home-<user>`, octroyé dans `user.vaults`. L'ACL par vault existante
|
||||
(`backend/auth/middleware.py::check_vault_access`, appliquée partout :
|
||||
API, MCP, search, push, tools) fait l'isolation **sans rien inventer**.
|
||||
2. Un vault `Home` unique + ACL par sous-dossier (écartée) — il n'existe
|
||||
aucune ACL par chemin dans le code ; il aurait fallu la brancher dans
|
||||
`files_read`, `files_media`, `history`, `backups`, `mcp/tools`, `push`…
|
||||
Surface de sécurité trop grande pour un gain marginal.
|
||||
|
||||
Décisions produit validées : nom `home-<user>`, dossier **conservé** à la
|
||||
suppression du compte, racine `/NFS/OBSIDIAN_DOC/Home` montée `/vaults/Home`.
|
||||
|
||||
## Mise en place
|
||||
|
||||
| Élément | Où |
|
||||
|---|---|
|
||||
| Service idempotent `ensure_user_home()` / `release_user_home()` / `ensure_all_user_homes()` | `backend/user_home.py` |
|
||||
| Registre persistant des vaults dynamiques `data/vaults.json`, fusionné par `load_vault_config()` | `backend/indexer.py` (`persist_vault` / `unpersist_vault`) |
|
||||
| Appel à la création et à la suppression d'un compte | `backend/auth/router.py` (`POST/DELETE /api/auth/admin/users`) |
|
||||
| Passe de réparation/migration au démarrage | `backend/main.py` (`lifespan` → après le watcher) |
|
||||
| Persistance des vaults ajoutés par `/api/vaults/add` + retrait | `backend/routers/vaults.py` |
|
||||
| Montage + variable d'environnement | `docker-compose.yml` (`/NFS/OBSIDIAN_DOC/Home:/vaults/Home`, `OBSIGATE_HOME_ROOT=/vaults/Home`) |
|
||||
|
||||
## Garde-fous
|
||||
|
||||
- **Fonctionnalité inactive sans `OBSIGATE_HOME_ROOT`** : dev, tests et
|
||||
desktop n'ont aucun comportement modifié.
|
||||
- **Validation du nom** : `^[a-zA-Z0-9_-]{2,32}$` (même grammaire que
|
||||
`CreateUserRequest.username_valid`), re-vérifiée avant de construire un
|
||||
chemin — défense en profondeur contre le path traversal.
|
||||
- **Nom de vault sans `/` ni `::`** : le nom sert de segment d'URL
|
||||
(`/api/file/{vault}/…`) et de clé d'index `vault::path`.
|
||||
- **Auto-réparation** : `ensure_all_user_homes()` au démarrage crée les
|
||||
dossiers manquants, ré-enregistre les vaults perdus et octroie l'accès aux
|
||||
users créés hors API. Toute erreur disque (NFS indisponible) est
|
||||
journalisée et rattrapée au boot.
|
||||
- **Suppression de compte** : index/watcher/registre refermés, dossier sur
|
||||
disque conservé.
|
||||
|
||||
## Bug corrigé au passage
|
||||
|
||||
- `load_vault_config()` ne lisait que les env `VAULT_N_*`/`DIR_N_*` : tout
|
||||
vault ajouté à la volée (y compris via `/api/vaults/add`) disparaissait au
|
||||
premier reindex complet et au redémarrage. → `data/vaults.json`.
|
||||
- `reload_single_vault()` perdait l'entrée `vault_config[vault]` après
|
||||
`remove_vault_from_index()`.
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/test_user_home.py` (10) : création (dossier + vault + octroi +
|
||||
registre), idempotence, username invalide, désactivation sans variable,
|
||||
relecture du registre par `load_vault_config`, `release` (dossier
|
||||
conservé), isolation `check_vault_access` entre users, passe de réparation.
|
||||
- `tests/test_auth_api.py::TestUserHome` (2) : provisionnement via l'API
|
||||
(création → dossier, vault visible, alice ne voit que son home, suppression
|
||||
→ vault refermé / dossier conservé) + absence de dossier sans
|
||||
`OBSIGATE_HOME_ROOT`.
|
||||
|
||||
## UI — arborescence (incrément)
|
||||
|
||||
- **Ordre** : tri stable `home-*` en premier, appliqué à la source
|
||||
(`frontend/js/sidebar.js::_homeFirst`, sur les deux sites qui alimentent
|
||||
`state.allVaults`) → arborescence, sélecteurs et refreshs homogènes.
|
||||
- **Icône** : `getVaultIcon()` (`frontend/js/viewer.js`) renvoie
|
||||
`icon("home", size)` pour `home-*` → même pipeline lucide que le reste de
|
||||
l'app (`safeCreateIcons`, `stroke: currentColor`, suit le thème).
|
||||
- **Test local** : `docker-compose.test-win.yml` monte
|
||||
`test_home:/vaults/Home` + `OBSIGATE_HOME_ROOT=/vaults/Home`
|
||||
(`test_home/` est dans `.gitignore`).
|
||||
- **Vérifié live** (instance 2020, Playwright) : ordre `home-admin, TestDir,
|
||||
TestVault` (l'admin ne voit plus le home des autres), `svg.lucide-home`
|
||||
rendu, placeholder `<i>` remplacé.
|
||||
|
||||
## Isolation — « * » ne couvre pas les dossiers persos
|
||||
|
||||
- **Racine** : `check_vault_access` (`backend/auth/middleware.py`) renvoie
|
||||
``vault_name in vaults`` pour tout vault ``home-*``, quel que soit le ``*``.
|
||||
Toutes les routes (89 usages) passent par cette fonction.
|
||||
- **Filtres « * » en dur remplacés** par cet appel : dashboard
|
||||
(`routers/config.py`), conflits, liens retour, favoris, abonnements push.
|
||||
- **Recherche** : `services/search.search_vaults(is_allowed=…)` filtre les
|
||||
bruts **avant** pagination — ``total`` et page restent justes.
|
||||
- **Octroi** : `_grant` n'écarte plus les comptes ``*`` → l'admin reçoit son
|
||||
propre ``home-admin`` (réparé au démarrage pour les comptes existants).
|
||||
- **Reste ouvert** (fuite pré-existante, hors #194) : `/api/search/advanced`,
|
||||
`/api/tags`, `/api/tags/suggest`, `/api/tree-search` et `/api/suggest` ne
|
||||
filtrent pas par vault accessible — à traiter dans une tâche dédiée.
|
||||
|
||||
## Impact utilisateur
|
||||
|
||||
Aucune nouvelle chaîne i18n : les noms de vault sont des données, la liste
|
||||
provient déjà de `GET /api/vaults`. Le dossier apparaît tout seul dans le
|
||||
sélecteur de vaults de l'utilisateur concerné.
|
||||
+140
-15
@@ -11,8 +11,9 @@
|
||||
// depuis sync.js) : vault `__global__` = chat général, sinon chat fichier.
|
||||
import { api, AuthManager } from "./auth.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { showToast } from "./ui.js";
|
||||
import { escapeHtml, safeCreateIcons } from "./utils.js";
|
||||
import { showToast, TabManager } from "./ui.js";
|
||||
import { escapeHtml, safeCreateIcons, safeHighlight } from "./utils.js";
|
||||
import { icon } from "./viewer.js";
|
||||
|
||||
const PANEL_ID = "file-chat-panel";
|
||||
const GLOBAL_VAULT = "__global__";
|
||||
@@ -146,7 +147,32 @@ async function _markRead(vault, path) {
|
||||
}
|
||||
|
||||
// URL dans le texte → lien cliquable (partage d'URL #190), texte sinon.
|
||||
function _fillBody(body, text) {
|
||||
/**
|
||||
* Remplit le corps d'un message (#193).
|
||||
*
|
||||
* Quand le serveur a fourni le rendu markdown (`html`, même pipeline que les
|
||||
* documents : mistune + sanitizer BUG-021), on l'injecte dans un `.md-content`
|
||||
* — titres, listes, tableaux, blocs de code — et on colore les blocs avec
|
||||
* highlight.js (`safeHighlight`), exactement comme dans le viewer.
|
||||
*
|
||||
* Sans `html` (message optimiste d'un autre client, cache ancien), on retombe
|
||||
* sur le texte brut avec liens cliquables, comme avant.
|
||||
*/
|
||||
function _fillBody(body, m) {
|
||||
const text = typeof m === "string" ? m : (m && m.text) || "";
|
||||
const html = m && typeof m === "object" ? m.html || "" : "";
|
||||
if (html) {
|
||||
body.classList.add("file-chat-md");
|
||||
const div = document.createElement("div");
|
||||
div.className = "md-content";
|
||||
div.innerHTML = html; // rendu + assaini côté serveur
|
||||
body.appendChild(div);
|
||||
div.querySelectorAll("pre code").forEach((block) => {
|
||||
if (block.querySelector(".secret-mask")) return;
|
||||
safeHighlight(block);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const parts = String(text || "").split(/(https?:\/\/[^\s<>"']+)/g);
|
||||
parts.forEach((part) => {
|
||||
if (!part) return;
|
||||
@@ -164,15 +190,48 @@ function _fillBody(body, text) {
|
||||
});
|
||||
}
|
||||
|
||||
// #110 — image jointe : clic = même visualiseur que pour une image de vault
|
||||
// (ouvert dans un onglet de l'app), boutons discrets (icônes seules) sous
|
||||
// l'image, alignés à droite.
|
||||
function _attachEl(att) {
|
||||
if (!att || !att.url) return null;
|
||||
if (att.kind === "image") {
|
||||
const container = document.createElement("div");
|
||||
container.className = "file-chat-image-container";
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = att.url;
|
||||
img.alt = att.name || "image";
|
||||
img.loading = "lazy";
|
||||
img.className = "file-chat-image";
|
||||
return img;
|
||||
img.title = t("chat.view_image");
|
||||
img.addEventListener("click", () => TabManager.openChatImage(att));
|
||||
container.appendChild(img);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "file-chat-image-actions";
|
||||
const mkBtn = (iconName, label, cls, onClick) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = `file-chat-image-btn ${cls}`;
|
||||
b.title = label;
|
||||
b.setAttribute("aria-label", label);
|
||||
b.appendChild(icon(iconName, 14));
|
||||
b.addEventListener("click", (e) => { e.stopPropagation(); onClick(); });
|
||||
return b;
|
||||
};
|
||||
actions.appendChild(mkBtn("download", t("chat.download_image"), "file-chat-image-download", () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = att.url;
|
||||
a.download = att.name || "image";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}));
|
||||
actions.appendChild(mkBtn("maximize", t("chat.view_image"), "file-chat-open", () => TabManager.openChatImage(att)));
|
||||
container.appendChild(actions);
|
||||
|
||||
return container;
|
||||
}
|
||||
if (att.kind === "video") {
|
||||
const v = document.createElement("video");
|
||||
@@ -192,14 +251,46 @@ function _canDelete(m, currentUser) {
|
||||
return u.role === "admin" || m.user === u.username;
|
||||
}
|
||||
|
||||
async function _deleteMessage(delUrl, id, wrap) {
|
||||
if (!window.confirm(t("chat.delete_confirm"))) return;
|
||||
try {
|
||||
await api(`${delUrl}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
wrap.remove();
|
||||
} catch (_) {
|
||||
showToast(t("chat.delete_error"), "error");
|
||||
}
|
||||
// #110 — suppression différée : la ligne passe tout de suite à « supprimé »
|
||||
// avec un bouton Annuler ; le DELETE n'est envoyé qu'au bout de 10 s.
|
||||
// ponytail: un rechargement complet pendant la fenêtre annule la suppression
|
||||
// (le message reste côté serveur) — connu, acceptable.
|
||||
const _pendingDeletes = new Map(); // id -> {timer}
|
||||
|
||||
export function _deleteMessage(delUrl, id, wrap) {
|
||||
if (_pendingDeletes.has(id)) return;
|
||||
const parent = wrap.parentNode;
|
||||
if (!parent) return;
|
||||
|
||||
const holder = document.createElement("div");
|
||||
holder.className = "file-chat-msg file-chat-deleted";
|
||||
holder.innerHTML =
|
||||
`<div class="file-chat-body file-chat-deleted-text">${t("chat.deleted_message")}` +
|
||||
`<button class="file-chat-undo" type="button">${t("chat.undo")}</button></div>`;
|
||||
parent.replaceChild(holder, wrap);
|
||||
|
||||
const undo = () => {
|
||||
const entry = _pendingDeletes.get(id);
|
||||
if (!entry) return;
|
||||
clearTimeout(entry.timer);
|
||||
_pendingDeletes.delete(id);
|
||||
holder.replaceWith(wrap);
|
||||
};
|
||||
const timer = setTimeout(async () => {
|
||||
_pendingDeletes.delete(id);
|
||||
try {
|
||||
await api(`${delUrl}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
holder.remove();
|
||||
} catch (_) {
|
||||
showToast(t("chat.delete_error"), "error");
|
||||
holder.replaceWith(wrap); // suppression ratée : le message revient
|
||||
}
|
||||
}, 10000);
|
||||
_pendingDeletes.set(id, { timer });
|
||||
holder.querySelector(".file-chat-undo").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
undo();
|
||||
});
|
||||
}
|
||||
|
||||
// #191 — carte de lien : site, titre, description, vignette.
|
||||
@@ -275,7 +366,7 @@ export function _messageEl(m, currentUser, delUrl, readMap) {
|
||||
}
|
||||
const body = document.createElement("div");
|
||||
body.className = "file-chat-body";
|
||||
_fillBody(body, m.text);
|
||||
_fillBody(body, m);
|
||||
wrap.appendChild(meta);
|
||||
if (m.text) wrap.appendChild(body);
|
||||
const att = _attachEl(m.attachment);
|
||||
@@ -295,6 +386,7 @@ export function renderChatMessages(container, messages, currentUser, delUrl, rea
|
||||
}
|
||||
messages.forEach((m) => container.appendChild(_messageEl(m, currentUser, delUrl, readMap)));
|
||||
container.scrollTop = container.scrollHeight;
|
||||
safeCreateIcons(); // #110 — hydrate les icônes des boutons de pièce jointe
|
||||
}
|
||||
|
||||
function _appendMessage(list, m, delUrl) {
|
||||
@@ -304,6 +396,7 @@ function _appendMessage(list, m, delUrl) {
|
||||
if (id && list.querySelector(`[data-id="${CSS.escape(id)}"]`)) return false;
|
||||
list.appendChild(_messageEl(m, _currentUser(), delUrl));
|
||||
list.scrollTop = list.scrollHeight;
|
||||
safeCreateIcons();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -409,6 +502,7 @@ export async function openFileChat(vault, path, title) {
|
||||
_vault = vault;
|
||||
_path = path;
|
||||
_title = title || path || "";
|
||||
_ensureNotifyPermission(); // #110 — geste utilisateur → permission notifications
|
||||
const panel = _panel();
|
||||
_renderShell(panel);
|
||||
panel.classList.remove("hidden");
|
||||
@@ -524,6 +618,7 @@ async function _loadUsers() {
|
||||
export async function openSidebarChat() {
|
||||
const list = _sidebarList();
|
||||
if (!list) return;
|
||||
_ensureNotifyPermission(); // #110 — geste utilisateur → permission notifications
|
||||
const users = await _loadUsers();
|
||||
_renderChannels(users);
|
||||
try {
|
||||
@@ -678,6 +773,8 @@ function _totalUnread() {
|
||||
function _renderBadge(count) {
|
||||
const tab = document.getElementById("sidebar-tab-chat");
|
||||
if (!tab) return;
|
||||
// #110 — point de couleur sur l'icône chat tant qu'il reste des non-lus
|
||||
tab.classList.toggle("chat-has-unread", count > 0);
|
||||
let badge = tab.querySelector(".chat-unread-badge");
|
||||
if (!count) {
|
||||
if (badge) badge.remove();
|
||||
@@ -696,6 +793,29 @@ export function refreshUnreadBadge() {
|
||||
_renderBadge(_totalUnread());
|
||||
}
|
||||
|
||||
/** #110 — notification OS quand l'app n'est pas au premier plan. */
|
||||
function _notifyNewMessage(m) {
|
||||
const Notif = window.Notification;
|
||||
if (!Notif) return;
|
||||
if (!document.hidden && document.hasFocus()) return; // visible : le toast suffit
|
||||
if (Notif.permission !== "granted") return;
|
||||
try {
|
||||
const n = new Notif(t("chat.new_message", { user: m.user }), {
|
||||
body: (m.text || "").slice(0, 140),
|
||||
icon: "/static/icons/icon-192x192.svg",
|
||||
tag: "obsigate-chat",
|
||||
});
|
||||
n.onclick = () => { try { window.focus(); } catch (_) { /* ignore */ } n.close(); };
|
||||
} catch (_) { /* notifications indisponibles */ }
|
||||
}
|
||||
|
||||
/** Demande la permission au premier clic utilisateur (ouverture du chat). */
|
||||
function _ensureNotifyPermission() {
|
||||
const Notif = window.Notification;
|
||||
if (!Notif || Notif.permission !== "default") return;
|
||||
try { Notif.requestPermission(); } catch (_) { /* ignoré */ }
|
||||
}
|
||||
|
||||
/** Initialisation : formulaire + raccourcis clavier. */
|
||||
export function initSidebarChat() {
|
||||
const form = document.getElementById("sidebar-panel-chat-form");
|
||||
@@ -743,6 +863,7 @@ export function onChatMessage(data) {
|
||||
_bumpUnread(key);
|
||||
refreshUnreadBadge();
|
||||
showToast(t("chat.new_message", { user: data.message.user }), "info");
|
||||
_notifyNewMessage(data.message); // #110 — notification OS si app en arrière-plan
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -755,12 +876,16 @@ export function onChatMessage(data) {
|
||||
if (isOpen) {
|
||||
const list = panel.querySelector(".file-chat-list");
|
||||
if (list) _appendMessage(list, data.message);
|
||||
// #192 — panneau affiché : accusé de réception immédiat.
|
||||
if (data.message.user !== _currentUser()) _markRead(data.vault, data.path);
|
||||
if (data.message.user !== _currentUser()) {
|
||||
// #192 — panneau affiché : accusé de réception immédiat.
|
||||
_markRead(data.vault, data.path);
|
||||
_notifyNewMessage(data.message); // #110 — notification OS si app en arrière-plan
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data.message.user === _currentUser()) return; // son propre message
|
||||
showToast(t("chat.new_message", { user: data.message.user }), "info");
|
||||
_notifyNewMessage(data.message); // #110
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -220,8 +220,15 @@ async function loadVaultSettings() {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar — Vault tree
|
||||
// ---------------------------------------------------------------------------
|
||||
// #194 : le dossier personnel (vault « home-<user> ») s'affiche en premier
|
||||
// dans l'arborescence. Tri stable fait sur place, à la source de toutes les
|
||||
// listes (arborescence, sélecteurs, state.allVaults).
|
||||
const _homeFirst = (a, b) =>
|
||||
(String(b?.name).startsWith("home-") ? 1 : 0) - (String(a?.name).startsWith("home-") ? 1 : 0);
|
||||
|
||||
async function loadVaults() {
|
||||
const vaults = await api("/api/vaults");
|
||||
vaults.sort(_homeFirst);
|
||||
state.allVaults = vaults;
|
||||
const container = document.getElementById("vault-tree");
|
||||
container.innerHTML = "";
|
||||
@@ -426,6 +433,7 @@ export async function refreshSidebarTreePreservingState() {
|
||||
// 2. Soft update: vault names/counts without wiping the tree
|
||||
try {
|
||||
const vaults = await api("/api/vaults");
|
||||
vaults.sort(_homeFirst);
|
||||
state.allVaults = vaults;
|
||||
vaults.forEach((v) => {
|
||||
const vItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(v.name)}"]`);
|
||||
|
||||
+42
-1
@@ -1,7 +1,7 @@
|
||||
/* ObsiGate — UI module */
|
||||
import { api, AuthManager, initLoginForm } from './auth.js';
|
||||
import { state } from './state.js';
|
||||
import { openFile, showWelcome, renderFile, el } from './viewer.js';
|
||||
import { openFile, showWelcome, renderFile, renderImageViewer, el } from './viewer.js';
|
||||
import { safeCreateIcons, getFileIcon, escapeHtml, detachInlineEditor } from './utils.js';
|
||||
import { syncActiveFileTreeItem, refreshSidebarTreePreservingState } from './sidebar.js';
|
||||
import { GraphViewManager } from './graph.js';
|
||||
@@ -2122,6 +2122,28 @@ export const TabManager = {
|
||||
this.activate(tabId);
|
||||
},
|
||||
|
||||
/** #110 — ouvre une image jointe au chat dans un onglet, avec le même
|
||||
* visualiseur que pour une image de vault (zoom/pan/plein écran). La pièce
|
||||
* jointe est servie par ``/api/chat/attachment/`` : pas de couple
|
||||
* (vault, path) derrière, donc onglet dédié qui rend le viewer directement
|
||||
* depuis l'URL (aucun fetch de l'API fichiers).
|
||||
* ponytail: l'onglet reste sur le TabManager singleton (les PaneTabManager
|
||||
* n'ont pas cette branche) — suffisant pour un cas limite du mode split. */
|
||||
async openChatImage(att) {
|
||||
if (!att || !att.url) return;
|
||||
const tabId = `chatimg::${att.url}`;
|
||||
const name = att.name || "image";
|
||||
const existing = this._tabs.find(t => t.id === tabId);
|
||||
if (existing) {
|
||||
this.activate(tabId);
|
||||
return;
|
||||
}
|
||||
this._tabs.push({ id: tabId, vault: null, path: name, name, icon: "image", chatImage: true });
|
||||
this._tabCache[tabId] = { chatImage: true, url: att.url, mime: att.mime, name, title: name };
|
||||
this._renderTabs();
|
||||
this.activate(tabId);
|
||||
},
|
||||
|
||||
/** Open (or retarget) the navigation tab for a vault directory (#158).
|
||||
* One navigation tab per vault: clicking another directory reuses it and
|
||||
* updates its directory, so open file tabs are never displaced. */
|
||||
@@ -2229,6 +2251,25 @@ export const TabManager = {
|
||||
return;
|
||||
}
|
||||
|
||||
// #110 — onglet image de chat : rendu direct depuis l'URL de la pièce
|
||||
// jointe, sans passer par l'API fichiers (l'image n'est pas dans un vault).
|
||||
if (cache.chatImage) {
|
||||
state.currentVault = null;
|
||||
state.currentPath = null;
|
||||
const chatArea = document.getElementById("content-area");
|
||||
if (!chatArea) return;
|
||||
renderImageViewer(chatArea, {
|
||||
vault: null,
|
||||
path: cache.name,
|
||||
title: cache.name,
|
||||
url: cache.url,
|
||||
image_mime: cache.mime,
|
||||
});
|
||||
const dash = document.getElementById("dashboard-home");
|
||||
if (dash) dash.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
// Update global state
|
||||
state.currentVault = cache.vault;
|
||||
state.currentPath = cache.path;
|
||||
|
||||
@@ -727,7 +727,7 @@ export function renderImageViewer(area, data) {
|
||||
// of re-rendering the whole viewer (which used to refetch the file + listing).
|
||||
let currentPath = data.path;
|
||||
let currentTitle = data.title || (currentPath || "").split("/").pop();
|
||||
let imgUrl = buildImageUrl(vault, currentPath);
|
||||
let imgUrl = data.url || buildImageUrl(vault, currentPath);
|
||||
let currentMeta = {
|
||||
image_mime: data.image_mime || imageMimeFor(currentPath),
|
||||
size_bytes: data.size_bytes,
|
||||
@@ -959,6 +959,7 @@ export function renderImageViewer(area, data) {
|
||||
}, { passive: false });
|
||||
|
||||
(async () => {
|
||||
if (!vault) return; // #110 — pièce jointe de chat : pas de listing voisin
|
||||
try {
|
||||
const dir = currentPath.includes("/") ? currentPath.slice(0, currentPath.lastIndexOf("/")) : "";
|
||||
siblings = await listImageSiblings(vault, dir);
|
||||
@@ -1023,7 +1024,8 @@ export function renderImageViewer(area, data) {
|
||||
nextBtn.addEventListener("click", () => go(1));
|
||||
originalBtn.addEventListener("click", () => window.open(imgUrl, "_blank"));
|
||||
downloadBtn.addEventListener("click", () => {
|
||||
const dlUrl = `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(currentPath)}`;
|
||||
// #110 — une pièce jointe de chat n'est pas dans un vault : URL directe.
|
||||
const dlUrl = data.url || `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(currentPath)}`;
|
||||
window.open(dlUrl, "_blank");
|
||||
});
|
||||
metaBtn.setAttribute("aria-pressed", _imageViewerState.meta ? "true" : "false");
|
||||
@@ -7577,6 +7579,10 @@ export function attachTreeItemLongPress(itemEl, getMenuData) {
|
||||
}
|
||||
|
||||
export function getVaultIcon(vaultName, size = 16) {
|
||||
// Dossier personnel (#194) : icône maison, même style lucide que le reste.
|
||||
if (typeof vaultName === "string" && vaultName.startsWith("home-")) {
|
||||
return icon("home", size);
|
||||
}
|
||||
const v = state.allVaults.find((val) => val.name === vaultName);
|
||||
const type = v ? v.type : "VAULT";
|
||||
|
||||
|
||||
@@ -322,8 +322,11 @@
|
||||
"chat.attach_error": "Attachment upload failed",
|
||||
"chat.channel_general": "General",
|
||||
"chat.delete": "Delete this message",
|
||||
"chat.delete_confirm": "Delete this message?",
|
||||
"chat.delete_error": "Delete failed",
|
||||
"chat.deleted_message": "This message has been deleted",
|
||||
"chat.undo": "Undo",
|
||||
"chat.download_image": "Download image",
|
||||
"chat.view_image": "View full size",
|
||||
"chat.attach_remove": "Remove attachment",
|
||||
"chat.button": "Chat",
|
||||
"chat.button_title": "Discuss this file",
|
||||
|
||||
@@ -322,8 +322,11 @@
|
||||
"chat.attach_error": "Échec de l'envoi de la pièce jointe",
|
||||
"chat.channel_general": "Général",
|
||||
"chat.delete": "Supprimer ce message",
|
||||
"chat.delete_confirm": "Supprimer ce message ?",
|
||||
"chat.delete_error": "Échec de la suppression",
|
||||
"chat.deleted_message": "Ce message a été supprimé",
|
||||
"chat.undo": "Annuler",
|
||||
"chat.download_image": "Télécharger l'image",
|
||||
"chat.view_image": "Afficher en grand",
|
||||
"chat.attach_remove": "Retirer la pièce jointe",
|
||||
"chat.button": "Chat",
|
||||
"chat.button_title": "Discuter de ce fichier",
|
||||
|
||||
+175
-13
@@ -13828,8 +13828,8 @@ body.reading-mode .file-actions {
|
||||
}
|
||||
.file-chat-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted, #8b949e);
|
||||
margin-bottom: 4px;
|
||||
@@ -13852,6 +13852,70 @@ body.reading-mode .file-actions {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
/* #193 — un post peut être rendu comme un document markdown (mistune côté
|
||||
serveur + hljs côté client) : on annule le `pre-wrap` hérité (sinon les
|
||||
retours à la ligne du HTML source deviennent des lignes vides) et on
|
||||
compacte la typographie `.md-content` pour une bulle de chat. */
|
||||
.file-chat-body.file-chat-md {
|
||||
white-space: normal;
|
||||
word-break: normal;
|
||||
}
|
||||
.file-chat-body .md-content > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.file-chat-body .md-content > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.file-chat-body .md-content h1,
|
||||
.file-chat-body .md-content h2,
|
||||
.file-chat-body .md-content h3,
|
||||
.file-chat-body .md-content h4,
|
||||
.file-chat-body .md-content h5,
|
||||
.file-chat-body .md-content h6 {
|
||||
font-size: 1rem;
|
||||
margin: 0.7em 0 0.35em;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.file-chat-body .md-content p {
|
||||
margin: 0.4em 0;
|
||||
}
|
||||
.file-chat-body .md-content ul,
|
||||
.file-chat-body .md-content ol {
|
||||
padding-left: 1.3em;
|
||||
margin: 0.4em 0;
|
||||
}
|
||||
.file-chat-body .md-content pre {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.file-chat-body .md-content pre code {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.file-chat-body .md-content :not(pre) > code {
|
||||
font-size: 0.82rem;
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-hover, rgba(128, 128, 128, 0.15));
|
||||
}
|
||||
.file-chat-body .md-content table {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.file-chat-body .md-content img {
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.file-chat-body .md-content .wikilink,
|
||||
.file-chat-body .md-content .wikilink-anchor {
|
||||
color: var(--accent, #58a6ff);
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-chat-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -13947,18 +14011,23 @@ textarea.file-chat-input {
|
||||
/* Suppression d'un post */
|
||||
.file-chat-del {
|
||||
border: none;
|
||||
background: transparent;
|
||||
background: var(--bg-hover, #1f2430);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
padding: 0 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
font-size: 0.72rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
color: var(--danger, #ff7b72);
|
||||
line-height: 1;
|
||||
margin-left: auto; /* #110 — l'action se détache à droite de la ligne */
|
||||
}
|
||||
.file-chat-msg:hover .file-chat-del {
|
||||
opacity: 0.7;
|
||||
opacity: 1;
|
||||
}
|
||||
.file-chat-del:hover {
|
||||
opacity: 1 !important;
|
||||
background: var(--danger-bg, #3d1a18);
|
||||
}
|
||||
/* Link preview (#191) */
|
||||
.file-chat-preview {
|
||||
@@ -14035,6 +14104,25 @@ textarea.file-chat-input {
|
||||
.sidebar-tab {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* #110 — point de couleur sur l'icône chat tant qu'il reste des non-lus */
|
||||
.sidebar-tab#sidebar-tab-chat.chat-has-unread::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--danger, #e5534b);
|
||||
animation: chat-dot-pulse 1.8s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes chat-dot-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(229, 83, 75, 0.5); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(229, 83, 75, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(229, 83, 75, 0); }
|
||||
}
|
||||
.chat-unread-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
@@ -14107,12 +14195,46 @@ textarea.file-chat-input {
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-chat-image {
|
||||
.file-chat-image-container {
|
||||
max-width: 100%;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.file-chat-image-container .file-chat-image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 220px;
|
||||
border-radius: 8px;
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
/* Boutons discrets : icônes seules, sous l'image, alignés à droite */
|
||||
.file-chat-image-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.file-chat-image-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-muted, #8b949e);
|
||||
opacity: 0.55;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
.file-chat-image-btn:hover {
|
||||
opacity: 1;
|
||||
color: var(--accent, #58a6ff);
|
||||
background: var(--bg-hover, rgba(128, 128, 128, 0.12));
|
||||
}
|
||||
.file-chat-image-btn i {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-chat-video {
|
||||
max-width: 100%;
|
||||
@@ -14120,10 +14242,50 @@ textarea.file-chat-input {
|
||||
border-radius: 8px;
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
}
|
||||
.file-chat-link {
|
||||
}.file-chat-link {
|
||||
color: var(--accent, #58a6ff);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* #110 — message supprimé avec bouton Undo */
|
||||
.file-chat-deleted {
|
||||
opacity: 0.7;
|
||||
background: var(--bg-hover, #1f2430);
|
||||
border-left: 2px solid var(--text-muted, #484f58);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.file-chat-deleted-text {
|
||||
font-style: italic;
|
||||
color: var(--text-muted, #8b949e);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.file-chat-undo {
|
||||
background: var(--accent, #58a6ff);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
margin-left: 6px;
|
||||
transition: background 0.15s;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.file-chat-undo:hover {
|
||||
background: var(--accent-hover, #4494e6);
|
||||
}
|
||||
|
||||
.file-chat-undo i {
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.61.0",
|
||||
"version": "2.65.1",
|
||||
"description": "**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.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
@@ -46,8 +46,10 @@ const LOCALES_DIR = path.resolve(__dirname, "..", "..", "frontend", "locales");
|
||||
|
||||
// Route fetch: locales from disk, auth 401, API chat endpoints from a stub map.
|
||||
const apiStubs = new Map();
|
||||
globalThis.fetch = (url) => {
|
||||
const fetchCalls = []; // #110 — journal des appels {url, method}
|
||||
globalThis.fetch = (url, opts = {}) => {
|
||||
const u = String(url);
|
||||
fetchCalls.push({ url: u, method: (opts.method || "GET").toUpperCase() });
|
||||
if (u.includes("/static/locales/")) {
|
||||
const name = u.split("/").pop();
|
||||
try {
|
||||
@@ -77,7 +79,7 @@ const mod = await import("../../frontend/js/filechat.js");
|
||||
const {
|
||||
renderChatMessages, onChatMessage, openFileChat, closeFileChat, toggleFileChat,
|
||||
followFileChat, openSidebarChat, filterChatMessages, refreshUnreadBadge,
|
||||
onChatDeleted, onChatRead,
|
||||
onChatDeleted, onChatRead, _deleteMessage,
|
||||
} = mod;
|
||||
|
||||
let pass = 0;
|
||||
@@ -338,6 +340,123 @@ test("#192 — saisie en textarea auto-agrandie, Enter envoie", async () => {
|
||||
closeFileChat();
|
||||
});
|
||||
|
||||
// --- #110 : boutons discrets, image en onglet, point non-lu, delete différé -
|
||||
|
||||
test("#110 — bouton supprimer visible (pas opacity:0)", () => {
|
||||
const list = document.createElement("div");
|
||||
renderChatMessages(list, [{ id: "d1", user: "bruno", text: "x", ts: 50 }], "bruno", "/api/chat");
|
||||
const del = list.querySelector(".file-chat-del");
|
||||
assert.ok(del, "bouton suppression présent");
|
||||
assert.strictEqual(del.style.opacity, "", "pas de opacity:0 — bouton visible");
|
||||
});
|
||||
|
||||
test("#110 — image : boutons icônes discrets sous l'image, à droite", () => {
|
||||
const list = document.createElement("div");
|
||||
renderChatMessages(list, [{
|
||||
id: "img1", user: "alice", text: "", ts: 100,
|
||||
attachment: { kind: "image", url: "/api/chat/attachment/photo.png", name: "photo.png", mime: "image/png" },
|
||||
}], "bruno");
|
||||
const container = list.querySelector(".file-chat-image-container");
|
||||
assert.ok(container, "conteneur image présent");
|
||||
const actions = container.querySelector(".file-chat-image-actions");
|
||||
assert.ok(actions, "rangée de boutons sous l'image");
|
||||
assert.ok(actions.querySelector(".file-chat-image-download"), "bouton téléchargement présent");
|
||||
assert.ok(actions.querySelector(".file-chat-open"), "bouton visualisation présent");
|
||||
actions.querySelectorAll("button").forEach((b) => {
|
||||
assert.strictEqual(b.textContent.trim(), "", "bouton icône seule (sans libellé texte)");
|
||||
assert.ok(b.getAttribute("aria-label"), "aria-label présent");
|
||||
});
|
||||
});
|
||||
|
||||
test("#110 — clic sur l'image : ouverture dans un onglet de l'app", async () => {
|
||||
const ui = await import(pathToFileURL(path.join(JS_DIR, "ui.js")).href);
|
||||
const seen = [];
|
||||
const orig = ui.TabManager.openChatImage;
|
||||
ui.TabManager.openChatImage = (att) => seen.push(att);
|
||||
try {
|
||||
const list = document.createElement("div");
|
||||
const att = { kind: "image", url: "/api/chat/attachment/p2.png", name: "p2.png" };
|
||||
renderChatMessages(list, [{ id: "img2", user: "alice", text: "", ts: 101, attachment: att }], "bruno");
|
||||
list.querySelector(".file-chat-image").click();
|
||||
assert.strictEqual(seen.length, 1, "openChatImage appelé au clic sur l'image");
|
||||
assert.strictEqual(seen[0].url, att.url, "même pièce jointe transmise");
|
||||
list.querySelector(".file-chat-open").click();
|
||||
assert.strictEqual(seen.length, 2, "le bouton discret ouvre aussi l'onglet");
|
||||
} finally {
|
||||
ui.TabManager.openChatImage = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test("#110 — point de couleur sur l'icône chat tant qu'il reste des non-lus", () => {
|
||||
const tab = document.getElementById("sidebar-tab-chat");
|
||||
assert.ok(tab, "onglet chat présent");
|
||||
localStorage.setItem("obsigate-chat-unread", JSON.stringify({ general: 3 }));
|
||||
refreshUnreadBadge();
|
||||
assert.ok(tab.classList.contains("chat-has-unread"), "classe chat-has-unread posée");
|
||||
localStorage.setItem("obsigate-chat-unread", JSON.stringify({}));
|
||||
refreshUnreadBadge();
|
||||
assert.ok(!tab.classList.contains("chat-has-unread"), "classe retirée quand tout est lu");
|
||||
});
|
||||
|
||||
test("#110 — suppression différée : Annuler remet le message, aucun DELETE", () => {
|
||||
const list = document.createElement("div");
|
||||
renderChatMessages(list, [{ id: "del9", user: "bruno", text: "mon message", ts: 200 }], "bruno", "/api/chat");
|
||||
const before = fetchCalls.length;
|
||||
list.querySelector(".file-chat-del").click();
|
||||
assert.ok(list.querySelector(".file-chat-deleted"), "ligne remplacée par « supprimé »");
|
||||
assert.ok(list.querySelector(".file-chat-undo"), "bouton Annuler présent");
|
||||
assert.ok(!fetchCalls.slice(before).some((c) => c.method === "DELETE"), "aucun DELETE parti pendant la fenêtre");
|
||||
list.querySelector(".file-chat-undo").click();
|
||||
assert.ok(list.querySelector("[data-id='del9']"), "message d'origine restauré");
|
||||
assert.strictEqual(list.querySelector(".file-chat-deleted"), null, "ligne « supprimé » retirée");
|
||||
assert.ok(!fetchCalls.slice(before).some((c) => c.method === "DELETE"), "toujours aucun DELETE");
|
||||
});
|
||||
|
||||
test("#110 — notification OS quand l'app est en arrière-plan", () => {
|
||||
const fired = [];
|
||||
class FakeNotification {
|
||||
constructor(title, opts) { fired.push({ title, opts }); }
|
||||
close() {}
|
||||
}
|
||||
FakeNotification.permission = "granted";
|
||||
window.Notification = FakeNotification;
|
||||
Object.defineProperty(document, "hidden", { value: true, configurable: true });
|
||||
try {
|
||||
const panel = document.getElementById("sidebar-panel-chat");
|
||||
panel.classList.remove("active");
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "n-1", user: "bob", text: "coucou", ts: 300 } });
|
||||
assert.strictEqual(fired.length, 1, "notification affichée");
|
||||
assert.ok(String(fired[0].opts.body).includes("coucou"), "aperçu du message dans la notification");
|
||||
} finally {
|
||||
Object.defineProperty(document, "hidden", { value: false, configurable: true });
|
||||
window.Notification = { permission: "denied" };
|
||||
}
|
||||
});
|
||||
|
||||
// --- #193 : un post est rendu comme un document (markdown + code coloré) -----
|
||||
|
||||
test("#193 — post avec html rendu : markdown + bloc de code", () => {
|
||||
const list = document.createElement("div");
|
||||
renderChatMessages(list, [{
|
||||
id: "md1", user: "alice", text: "**gras**\n\n```python\nprint(1)\n```", ts: 400,
|
||||
html: '<p><strong>gras</strong></p><pre><code class="language-python">print(1)</code></pre>',
|
||||
}], "bruno");
|
||||
const body = list.querySelector("[data-id='md1'] .file-chat-body");
|
||||
assert.ok(body.classList.contains("file-chat-md"), "classe md posée sur le corps");
|
||||
assert.ok(body.querySelector(".md-content"), "conteneur md-content");
|
||||
assert.ok(body.querySelector(".md-content strong"), "markdown rendu (strong)");
|
||||
assert.ok(body.querySelector("pre code.language-python"), "bloc de code conservé");
|
||||
});
|
||||
|
||||
test("#193 — sans html, repli texte brut + URL cliquable", () => {
|
||||
const list = document.createElement("div");
|
||||
renderChatMessages(list, [{ id: "md2", user: "alice", text: "voir https://ex.com/a", ts: 401 }], "bruno");
|
||||
const body = list.querySelector("[data-id='md2'] .file-chat-body");
|
||||
assert.ok(!body.classList.contains("file-chat-md"), "pas de rendu markdown");
|
||||
assert.ok(body.querySelector("a.file-chat-link"), "URL toujours cliquable");
|
||||
assert.strictEqual(body.textContent, "voir https://ex.com/a", "texte intact");
|
||||
});
|
||||
|
||||
// Sequential execution: each case depends on panel state left by the previous.
|
||||
for (const [name, fn] of cases) {
|
||||
try {
|
||||
|
||||
@@ -176,6 +176,19 @@ await test("filterSavedSearches combines with the type pills", async () => {
|
||||
assert.equal(visibleSaved(), 3, "clearing both filters restores everything");
|
||||
});
|
||||
|
||||
// ── #194 : icône du dossier personnel ──
|
||||
await test("getVaultIcon returns the lucide house icon for home-* vaults", async () => {
|
||||
const homeIcon = viewerMod.getVaultIcon("home-bruno", 16);
|
||||
assert.equal(homeIcon.tagName.toLowerCase(), "i", "placeholder lucide (comme les autres icônes)");
|
||||
assert.equal(homeIcon.getAttribute("data-lucide"), "home", "icône maison lucide");
|
||||
assert.equal(homeIcon.style.width, "16px", "taille demandée respectée");
|
||||
});
|
||||
|
||||
await test("getVaultIcon is unchanged for regular vaults", async () => {
|
||||
const vaultIcon = viewerMod.getVaultIcon("Recettes", 16);
|
||||
assert.equal(vaultIcon.tagName.toLowerCase(), "svg", "gemme violette SVG d'origine");
|
||||
});
|
||||
|
||||
// ── Summary ──
|
||||
console.log(`\n${passCount}/${testCount} tests passed`);
|
||||
if (passCount !== testCount) {
|
||||
|
||||
+63
-1
@@ -481,4 +481,66 @@ class TestSecureCookies:
|
||||
assert "secure" in resp.headers.get("set-cookie", "").lower()
|
||||
finally:
|
||||
if hasattr(https_client, "close"):
|
||||
https_client.close()
|
||||
https_client.close()
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Dossier personnel par utilisateur (#194)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestUserHome:
|
||||
def _login(self, client, username, password):
|
||||
resp = client.post("/api/auth/login", json={
|
||||
"username": username, "password": password,
|
||||
})
|
||||
return resp.json()["access_token"]
|
||||
|
||||
def test_create_user_provisions_home(self, auth_client, monkeypatch):
|
||||
"""POST /admin/users → dossier + vault home-<user> + isolation + retrait."""
|
||||
from pathlib import Path
|
||||
|
||||
from backend import watcher_state
|
||||
|
||||
monkeypatch.setattr(watcher_state, "get_watcher", lambda: None)
|
||||
home = Path.cwd() / "Home"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("OBSIGATE_HOME_ROOT", str(home))
|
||||
|
||||
admin = {"Authorization": f"Bearer {self._login(auth_client, 'admin', 'chab30')}"}
|
||||
|
||||
resp = auth_client.post("/api/auth/admin/users", headers=admin, json={
|
||||
"username": "alice", "password": "testpass", "role": "user",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert (home / "alice").is_dir()
|
||||
|
||||
names = [v["name"] for v in auth_client.get("/api/vaults", headers=admin).json()]
|
||||
# #194 : « * » n'inclut pas les dossiers persos — l'admin ne voit pas
|
||||
# le home d'alice (il ne verrait sinon celui de chaque utilisateur).
|
||||
assert "home-alice" not in names
|
||||
assert "TestVault" in names
|
||||
# L'octroi atterrit bien dans le user
|
||||
assert "home-alice" in resp.json()["vaults"]
|
||||
|
||||
# Isolation : alice ne voit QUE son propre dossier
|
||||
alice = {"Authorization": f"Bearer {self._login(auth_client, 'alice', 'testpass')}"}
|
||||
alice_names = [v["name"] for v in auth_client.get("/api/vaults", headers=alice).json()]
|
||||
assert alice_names == ["home-alice"]
|
||||
|
||||
# Suppression du compte → vault refermé, dossier conservé
|
||||
resp = auth_client.delete("/api/auth/admin/users/alice", headers=admin)
|
||||
assert resp.status_code == 200
|
||||
assert (home / "alice").is_dir()
|
||||
names = [v["name"] for v in auth_client.get("/api/vaults", headers=admin).json()]
|
||||
assert "home-alice" not in names
|
||||
|
||||
def test_create_user_without_home_root_is_unchanged(self, auth_client):
|
||||
"""Sans OBSIGATE_HOME_ROOT : aucun dossier, comportement historique."""
|
||||
from pathlib import Path
|
||||
|
||||
assert Path.cwd().exists()
|
||||
admin = {"Authorization": f"Bearer {self._login(auth_client, 'admin', 'chab30')}"}
|
||||
resp = auth_client.post("/api/auth/admin/users", headers=admin, json={
|
||||
"username": "nohome", "password": "testpass", "role": "user",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert not (Path.cwd() / "Home").exists()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# tests/test_file_chat.py — Chat par fichier (#169)
|
||||
"""Store + REST routes for the per-file chat history."""
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
@@ -479,3 +480,73 @@ class TestReadReceipts:
|
||||
assert [e for e, _ in events] == ["chat_read"]
|
||||
assert events[0][1]["user"] == "anonymous"
|
||||
assert events[0][1]["vault"] == "__global__"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# #193 — un post est rendu comme un document markdown
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestMarkdownRendering:
|
||||
"""Le post porte son HTML (mistune + sanitizer), comme une prévisualisation
|
||||
de document : le client n'a plus à réimplémenter le markdown."""
|
||||
|
||||
def test_message_carries_rendered_html(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
msg = _store.add_message("V", "a.md", "u", "**gras** et `code`")
|
||||
assert "<strong>gras</strong>" in msg["html"]
|
||||
assert "<code>code</code>" in msg["html"]
|
||||
got = _store.get_messages("V", "a.md")
|
||||
assert "<strong>gras</strong>" in got[0]["html"]
|
||||
|
||||
def test_html_is_not_persisted(self, tmp_path, monkeypatch):
|
||||
"""`html` est recalculé à chaque lecture, jamais écrit dans le store."""
|
||||
chat_dir = tmp_path / "chats"
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", chat_dir)
|
||||
_store.add_message("V", "a.md", "u", "**gras**")
|
||||
raw = json.loads(next(chat_dir.glob("*.json")).read_text(encoding="utf-8"))
|
||||
assert "html" not in raw["messages"][0]
|
||||
|
||||
def test_fenced_code_block_keeps_its_language(self, tmp_path, monkeypatch):
|
||||
"""La classe `language-xxx` est ce sur quoi hljs se branche (comme dans
|
||||
le viewer)."""
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
msg = _store.add_message("V", "a.md", "u", "```python\nprint(1)\n```")
|
||||
assert 'class="language-python"' in msg["html"]
|
||||
|
||||
def test_tables_and_lists_are_rendered(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
msg = _store.add_message("V", "a.md", "u", "- un\n- deux")
|
||||
assert "<ul>" in msg["html"] and "<li>un</li>" in msg["html"]
|
||||
|
||||
def test_raw_html_is_sanitized(self, tmp_path, monkeypatch):
|
||||
"""Pas de XSS par le chat : le sanitizer des documents s'applique."""
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
msg = _store.add_message("V", "a.md", "u", "<script>alert(1)</script>ok")
|
||||
assert "<script" not in msg["html"]
|
||||
|
||||
def test_routes_return_html(self, client, test_vault_dir):
|
||||
r = client.post(
|
||||
"/api/file/TestVault/chat",
|
||||
json={"path": "note1.md", "text": "## Titre\n\n- item"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
html = r.json()["message"]["html"]
|
||||
assert "<h2" in html and "<li>item</li>" in html
|
||||
r2 = client.get("/api/file/TestVault/chat", params={"path": "note1.md"})
|
||||
assert "<h2" in r2.json()["messages"][0]["html"]
|
||||
|
||||
def test_sse_broadcast_carries_html(self, client, test_vault_dir, monkeypatch):
|
||||
"""L'écho SSE part avec le html : les autres clients n'attendent pas un
|
||||
refresh pour voir le rendu."""
|
||||
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", json={"text": "**fort**"})
|
||||
assert events and events[0][0] == "chat_message"
|
||||
assert "<strong>fort</strong>" in events[0][1]["message"]["html"]
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
# tests/test_user_home.py — #194 dossier personnel par utilisateur
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _run(coro):
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def home_env(tmp_path, monkeypatch):
|
||||
"""Racine Home + registre vaults + users.json isolés, globals sauvegardés."""
|
||||
root = tmp_path / "Home"
|
||||
root.mkdir()
|
||||
monkeypatch.setenv("OBSIGATE_HOME_ROOT", str(root))
|
||||
|
||||
from backend import indexer
|
||||
|
||||
monkeypatch.setattr(indexer, "DYNAMIC_VAULTS_FILE", tmp_path / "vaults.json")
|
||||
|
||||
from backend.auth import user_store
|
||||
|
||||
monkeypatch.setattr(user_store, "USERS_FILE", tmp_path / "users.json")
|
||||
|
||||
from backend import watcher_state
|
||||
|
||||
monkeypatch.setattr(watcher_state, "get_watcher", lambda: None)
|
||||
|
||||
saved_index = dict(indexer.index)
|
||||
saved_config = dict(indexer.vault_config)
|
||||
yield root
|
||||
indexer.index.clear()
|
||||
indexer.index.update(saved_index)
|
||||
indexer.vault_config.clear()
|
||||
indexer.vault_config.update(saved_config)
|
||||
|
||||
|
||||
def _create_user(name: str) -> dict:
|
||||
from backend.auth.user_store import create_user
|
||||
|
||||
return create_user(name, "Motdepasse1!", role="user")
|
||||
|
||||
|
||||
def _registry() -> dict:
|
||||
"""Contenu du registre dynamique (chemin monkeypatché par home_env)."""
|
||||
from backend import indexer
|
||||
|
||||
return json.loads(indexer.DYNAMIC_VAULTS_FILE.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class TestEnsureUserHome:
|
||||
def test_creates_folder_vault_and_grant(self, home_env):
|
||||
from backend.auth.user_store import get_user
|
||||
from backend.indexer import index
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
_create_user("alice")
|
||||
name = _run(ensure_user_home("alice"))
|
||||
|
||||
assert name == "home-alice"
|
||||
assert (home_env / "alice").is_dir()
|
||||
assert "home-alice" in index
|
||||
assert "home-alice" in get_user("alice")["vaults"]
|
||||
# Registre persistant rempli → survit au prochain rebuild/redémarrage
|
||||
assert "home-alice" in _registry()["vaults"]
|
||||
|
||||
def test_is_idempotent(self, home_env):
|
||||
from backend.auth.user_store import get_user
|
||||
from backend.indexer import index
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
_create_user("bob")
|
||||
_run(ensure_user_home("bob"))
|
||||
index_snapshot = dict(index)
|
||||
_run(ensure_user_home("bob"))
|
||||
|
||||
assert get_user("bob")["vaults"].count("home-bob") == 1
|
||||
assert index == index_snapshot
|
||||
|
||||
def test_invalid_username_never_touches_disk(self, home_env):
|
||||
from backend.indexer import index
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
assert _run(ensure_user_home("../evil")) is None
|
||||
assert _run(ensure_user_home("a/b")) is None
|
||||
assert not (home_env.parent / "evil").exists()
|
||||
assert not any(name.startswith("home-") for name in index)
|
||||
|
||||
def test_disabled_without_env(self, home_env, monkeypatch):
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
monkeypatch.delenv("OBSIGATE_HOME_ROOT")
|
||||
assert _run(ensure_user_home("alice")) is None
|
||||
assert not (home_env / "alice").exists()
|
||||
|
||||
|
||||
class TestPersistence:
|
||||
def test_registry_reloaded_by_load_vault_config(self, home_env):
|
||||
"""data/vaults.json doit être relu par load_vault_config (rebuild/restart)."""
|
||||
from backend import indexer
|
||||
|
||||
_create_user("carol")
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
_run(ensure_user_home("carol"))
|
||||
|
||||
# Simule un redémarrage : vault_config vidé, rechargé depuis la source
|
||||
indexer.vault_config.clear()
|
||||
config = indexer.load_vault_config()
|
||||
assert config["home-carol"]["path"] == str(home_env / "carol")
|
||||
|
||||
def test_unpersist_drops_registry_entry(self, home_env):
|
||||
from backend import indexer
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
_create_user("dan")
|
||||
_run(ensure_user_home("dan"))
|
||||
indexer.unpersist_vault("home-dan")
|
||||
assert "home-dan" not in _registry()["vaults"]
|
||||
|
||||
|
||||
class TestRelease:
|
||||
def test_release_closes_vault_but_keeps_folder(self, home_env):
|
||||
from backend.indexer import index
|
||||
from backend.user_home import ensure_user_home, release_user_home
|
||||
|
||||
_create_user("erin")
|
||||
_run(ensure_user_home("erin"))
|
||||
assert "home-erin" in index
|
||||
|
||||
_run(release_user_home("erin"))
|
||||
assert "home-erin" not in index
|
||||
assert (home_env / "erin").is_dir() # décision : dossier conservé
|
||||
assert "home-erin" not in _registry()["vaults"]
|
||||
|
||||
|
||||
class TestOrphanSweep:
|
||||
def test_orphan_home_released_when_user_deleted_outside_route(self, home_env):
|
||||
"""create_admin.py delete ne passe pas par la route → réparé au boot."""
|
||||
from backend.auth.user_store import delete_user
|
||||
from backend.indexer import index
|
||||
from backend.user_home import ensure_all_user_homes, ensure_user_home
|
||||
|
||||
_create_user("hank")
|
||||
_run(ensure_user_home("hank"))
|
||||
delete_user("hank")
|
||||
assert "home-hank" in index
|
||||
|
||||
_run(ensure_all_user_homes())
|
||||
assert "home-hank" not in index
|
||||
assert (home_env / "hank").is_dir()
|
||||
|
||||
def test_vault_named_home_outside_root_is_left_alone(self, home_env):
|
||||
"""Un vault admin nommé « home-… » ailleurs dans le FS n'est pas touché."""
|
||||
from backend.indexer import index
|
||||
from backend.user_home import ensure_all_user_homes
|
||||
|
||||
other = home_env.parent / "Elsewhere"
|
||||
other.mkdir()
|
||||
_run(add_vault("home-keep", str(other)))
|
||||
|
||||
_run(ensure_all_user_homes())
|
||||
assert "home-keep" in index
|
||||
|
||||
|
||||
def add_vault(name: str, path: str):
|
||||
from backend.indexer import add_vault_to_index
|
||||
|
||||
return add_vault_to_index(name, path)
|
||||
|
||||
|
||||
class TestIsolation:
|
||||
def test_user_cannot_access_another_home_vault(self, home_env):
|
||||
from backend.auth.middleware import check_vault_access
|
||||
from backend.auth.user_store import get_user
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
_create_user("alice")
|
||||
_create_user("bob")
|
||||
_run(ensure_user_home("alice"))
|
||||
_run(ensure_user_home("bob"))
|
||||
|
||||
assert check_vault_access("home-alice", get_user("alice")) is True
|
||||
assert check_vault_access("home-alice", get_user("bob")) is False
|
||||
assert check_vault_access("home-bob", get_user("alice")) is False
|
||||
|
||||
def test_wildcard_admin_does_not_cover_homes(self, home_env):
|
||||
"""#194 : « * » n'inclut JAMAIS un dossier perso, même pour l'admin."""
|
||||
from backend.auth.middleware import check_vault_access
|
||||
from backend.auth.user_store import create_user, get_user
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
create_user("root", "Motdepasse1!", role="admin", vaults=["*"])
|
||||
_create_user("alice")
|
||||
_run(ensure_user_home("alice"))
|
||||
|
||||
admin = get_user("root")
|
||||
assert check_vault_access("home-alice", admin) is False # pas le sien
|
||||
assert check_vault_access("TestVault", admin) is True # « * » intact
|
||||
|
||||
# L'octroi explicite suffit (y compris pour l'admin, cf. _grant)
|
||||
_run(ensure_user_home("root"))
|
||||
assert check_vault_access("home-root", get_user("root")) is True
|
||||
|
||||
|
||||
class TestSearchFiltering:
|
||||
def test_search_service_filters_before_pagination(self, monkeypatch):
|
||||
"""#194 : /api/search ne doit pas renvoyer les notes d'un autre user."""
|
||||
import backend.search as search_mod
|
||||
from backend.services import search as search_service
|
||||
|
||||
hits = [
|
||||
{"vault": "home-bruno", "path": "secret.md"},
|
||||
{"vault": "TestVault", "path": "note.md"},
|
||||
{"vault": "home-bruno", "path": "autre.md"},
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
search_mod, "search",
|
||||
lambda q, vault_filter="all", tag_filter=None: list(hits),
|
||||
)
|
||||
|
||||
page = search_service.search_vaults("x", limit=50, is_allowed=lambda v: v == "TestVault")
|
||||
assert page["total"] == 1, "total = résultats filtrés"
|
||||
assert [r["vault"] for r in page["results"]] == ["TestVault"]
|
||||
|
||||
# Sans filtre (tool layer, qui filtre lui-même) : inchangé.
|
||||
assert search_service.search_vaults("x", limit=50)["total"] == 3
|
||||
|
||||
|
||||
class TestEnsureAll:
|
||||
def test_repairs_every_existing_user(self, home_env):
|
||||
from backend.auth.user_store import get_user
|
||||
from backend.user_home import ensure_all_user_homes
|
||||
|
||||
_create_user("fab")
|
||||
_create_user("gab")
|
||||
# Dossier supprimé à la main + user créé hors API (pas de vault)
|
||||
(home_env / "fab").mkdir()
|
||||
(home_env / "fab").rmdir()
|
||||
|
||||
created = _run(ensure_all_user_homes())
|
||||
|
||||
assert created == 2
|
||||
assert (home_env / "fab").is_dir()
|
||||
assert (home_env / "gab").is_dir()
|
||||
assert "home-fab" in get_user("fab")["vaults"]
|
||||
assert "home-gab" in get_user("gab")["vaults"]
|
||||
|
||||
|
||||
def test_home_root_env_contract():
|
||||
"""OBSIGATE_HOME_ROOT absent → fonctionnalité inactive (dev/tests/desktop)."""
|
||||
from backend.user_home import home_root
|
||||
|
||||
saved = os.environ.pop("OBSIGATE_HOME_ROOT", None)
|
||||
try:
|
||||
assert home_root() is None
|
||||
finally:
|
||||
if saved is not None:
|
||||
os.environ["OBSIGATE_HOME_ROOT"] = saved
|
||||
Reference in New Issue
Block a user