"""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`` 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- 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