diff --git a/CHANGELOG.md b/CHANGELOG.md index b32c69a..4ae7f98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.63.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.64.0**. --- @@ -14,6 +14,37 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.64.0] — 2026-10-10 + +### Ajouté + +- **#194 — Dossier personnel par utilisateur** + - Chaque utilisateur créé reçoit `/` (ex. + `/NFS/OBSIDIAN_DOC/Home/` côté hôte) monté comme un vault propre + `home-` : 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é diff --git a/README.fr.md b/README.fr.md index 0227ef5..f294869 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.63.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.64.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -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-` 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.63.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.64.0). --- -*Projet : ObsiGate | Version : 2.63.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.64.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index b014562..73089c6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.63.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.64.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -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-` 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.63.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.64.0). --- -*Project: ObsiGate | Version: 2.63.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.64.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index e604dbd..d1524d4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.63.0 +2.64.0 diff --git a/backend/auth/router.py b/backend/auth/router.py index 3ff96e3..fec4105 100644 --- a/backend/auth/router.py +++ b/backend/auth/router.py @@ -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) ────────────────────────────────── diff --git a/backend/indexer.py b/backend/indexer.py index 5c7e2be..cb1d692 100644 --- a/backend/indexer.py +++ b/backend/indexer.py @@ -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 diff --git a/backend/main.py b/backend/main.py index c5f7f6e..bfac717 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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()) diff --git a/backend/routers/vaults.py b/backend/routers/vaults.py index da0164d..93c2199 100644 --- a/backend/routers/vaults.py +++ b/backend/routers/vaults.py @@ -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} diff --git a/backend/user_home.py b/backend/user_home.py new file mode 100644 index 0000000..4af4729 --- /dev/null +++ b/backend/user_home.py @@ -0,0 +1,165 @@ +"""Dossier personnel par utilisateur (#194). + +Chaque utilisateur reçoit ``/`` monté comme un +vault propre ``home-`` : 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`` si l'utilisateur n'a pas déjà ``*``.""" + 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 "*" in vaults or 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- toujours indexé mais 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 diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 2e34d66..06102d1 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.63.0" +version = "2.64.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index e3d47b5..aabcdd6 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.63.0" +version = "2.64.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index f10c8ee..7f8b404 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.63.0", + "version": "2.64.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docker-compose.yml b/docker-compose.yml index 962e90a..2405559 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 = /home- + - OBSIGATE_HOME_ROOT=/vaults/Home - OBSIGATE_AUTH_ENABLED=true - OBSIGATE_ADMIN_USER=admin # OBSIGATE_ADMIN_PASSWORD → .env diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 6a62a24..9fbaa37 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -339,6 +339,8 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 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 `
` 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, `