feat: dossier personnel par utilisateur — vault home-<user> + registre de vaults persistant #194
- backend/user_home.py : ensure_user_home()/release_user_home() idempotents (mkdir, vault, watcher, octroi dans user.vaults), rappelés à la création d'un compte et au démarrage (migration + réparation + sweep des vaults home-* orphelins), dossier conservé à la suppression. - backend/indexer.py : data/vaults.json, registre persistant des vaults ajoutés à la volée, fusionné par load_vault_config() — ils disparaissaient au premier reindex complet et au redémarrage. reload_single_vault remet vault_config[name] après remove_vault_from_index. - backend/auth/router.py + routers/vaults.py : appels ensure/release + persistance à l'ajout/retrait d'un vault. - docker-compose.yml : montage unique /NFS/OBSIDIAN_DOC/Home:/vaults/Home + OBSIGATE_HOME_ROOT (absente = fonctionnalité inactive). - Tests : tests/test_user_home.py (12) + tests/test_auth_api.py::TestUserHome (2).
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
"""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`` 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-<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
|
||||
Reference in New Issue
Block a user