From 3ba8b7b66799fdf649edb288ef09ffe34966f172 Mon Sep 17 00:00:00 2001 From: Spark Date: Tue, 3 Nov 2026 04:59:38 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20vault=20DIR=20=E2=80=94=20exclusion?= =?UTF-8?q?s=20par=20vault=20et=20lecture=20seule=20d=C3=A9clarable=20(vau?= =?UTF-8?q?lt=20Spark)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 10 ++ CHANGELOG.md | 2 + backend/indexer.py | 48 +++++- backend/routers/files_read.py | 8 +- backend/schemas.py | 1 + backend/services/files.py | 20 ++- backend/services/mutations.py | 48 +++--- backend/services/search.py | 3 +- backend/services/vaults.py | 72 ++++++++- docs/features/spark-vault-dir.md | 79 ++++++++++ frontend/js/sidebar.js | 47 +++--- tests/test_services.py | 2 +- tests/test_spark_vault_dir.py | 251 +++++++++++++++++++++++++++++++ 13 files changed, 545 insertions(+), 46 deletions(-) create mode 100644 docs/features/spark-vault-dir.md create mode 100644 tests/test_spark_vault_dir.py diff --git a/.env.example b/.env.example index 2087871..74a7436 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,16 @@ OBSIGATE_ADMIN_PASSWORD=chab30 # Ignored directories (séparés par des virgules) # OBSIGATE_IGNORED_DIRS=.obsidian,.trash,.git,__pycache__,node_modules,.obsigate-backup +# Vaults DIR (DIR_N_NAME / DIR_N_PATH dans le compose) — options par vault : +# DIR_N_READONLY=true : lecture seule déclarée (mutations refusées +# en 403, actions d'écriture masquées dans l'UI) +# DIR_N_IGNORED_DIRS=.home,.venv : noms de dossiers cachés pour CE vault +# (toute profondeur, en plus du global) +# DIR_N_IGNORED_PATHS=Executeurs/*/data,Executeurs/*/.env +# : motifs fnmatch sur le chemin relatif dans +# le vault (un dossier qui matche cache son +# sous-arbre) — navigation, lecture, index + # Audit # OBSIGATE_AUDIT_MAX_SIZE=10485760 diff --git a/CHANGELOG.md b/CHANGELOG.md index fc39ac5..01b2b2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- **Vaults DIR : exclusions par vault et lecture seule déclarable** (prépare le vault « Spark ») : trois nouvelles variables par vault DIR — `DIR_N_READONLY=true` (les mutations sont refusées en 403 « read_only » et l'arborescence n'affiche plus les actions d'écriture pour ce vault), `DIR_N_IGNORED_DIRS=.home,.venv,…` (noms de dossiers cachés à toute profondeur, en plus du global `IGNORED_DIRS`) et `DIR_N_IGNORED_PATHS=Executeurs/*/data,…` (motifs fnmatch sur le chemin relatif ; un dossier qui matche cache tout son sous-arbre). Les exclusions s'appliquent désormais **partout** — navigation (`/api/browse`), lecture (`/api/file`, `raw`, téléchargement : 404), liste des chemins et indexation — et plus seulement à l'indexeur. `/api/vaults` expose `readonly` pour chaque vault. Sans ces variables, comportement inchangé. Fiche : `docs/features/spark-vault-dir.md`. Tests : `tests/test_spark_vault_dir.py`. + --- ## [2.82.8] — 2026-10-30 diff --git a/backend/indexer.py b/backend/indexer.py index 5f8cf71..975aa47 100644 --- a/backend/indexer.py +++ b/backend/indexer.py @@ -97,6 +97,13 @@ def load_vault_config() -> dict[str, dict[str, Any]]: Also reads optional configuration: - VAULT_N_ATTACHMENTS_PATH: relative path to attachments folder - VAULT_N_SCAN_ATTACHMENTS: "true"/"false" to enable/disable scanning + - DIR_N_READONLY: "true"/"false" — vault DIR déclaré en lecture seule + (les mutations sont refusées en 403, l'UI masque les actions d'écriture) + - DIR_N_IGNORED_DIRS: noms de dossiers ignorés pour ce vault, séparés + par des virgules (toute profondeur, en plus du global IGNORED_DIRS) + - DIR_N_IGNORED_PATHS: motifs fnmatch sur le chemin relatif POSIX dans + le vault, séparés par des virgules (un dossier qui matche cache tout + son sous-arbre) — navigation, lecture et indexation les respectent Returns: Dict mapping vault names to configuration dicts with keys: @@ -104,6 +111,9 @@ def load_vault_config() -> dict[str, dict[str, Any]]: - attachmentsPath: relative attachments folder (optional) - scanAttachmentsOnStartup: boolean (default True) - type: "VAULT" or "DIR" + - readonly: boolean (DIR only, default False) + - ignoredDirs: set of directory names (DIR only, default empty) + - ignoredPaths: list of fnmatch patterns (DIR only, default empty) """ vaults: dict[str, dict[str, Any]] = {} n = 1 @@ -131,12 +141,23 @@ def load_vault_config() -> dict[str, dict[str, Any]]: path = os.environ.get(f"DIR_{n}_PATH") if not name or not path: break - + + readonly = os.environ.get(f"DIR_{n}_READONLY", "false").lower() == "true" + ignored_dirs = { + d.strip() for d in os.environ.get(f"DIR_{n}_IGNORED_DIRS", "").split(",") if d.strip() + } + ignored_paths = [ + p.strip() for p in os.environ.get(f"DIR_{n}_IGNORED_PATHS", "").split(",") if p.strip() + ] + vaults[name] = { "path": path, "attachmentsPath": None, "scanAttachmentsOnStartup": False, - "type": "DIR" + "type": "DIR", + "readonly": readonly, + "ignoredDirs": ignored_dirs, + "ignoredPaths": ignored_paths, } n += 1 @@ -509,7 +530,8 @@ def _scan_vault( Args: vault_name: Display name of the vault. vault_path: Absolute filesystem path to the vault root. - vault_cfg: Optional vault configuration dict (unused for indexing, kept for compatibility). + vault_cfg: Optional vault configuration dict — ses exclusions + (``ignoredDirs`` / ``ignoredPaths``) sont appliquées au scan. previous_files: Optional ``{relative_path: file_info}`` snapshot from a previous scan used for differential reuse. @@ -529,6 +551,16 @@ def _scan_vault( root_resolved = vault_root.resolve(strict=False) + # Exclusions propres au vault (DIR_N_IGNORED_DIRS / DIR_N_IGNORED_PATHS) : + # elles s'ajoutent au global IGNORED_DIRS, sans le modifier. Le cfg est + # celui passé par l'appelant — l'index global ne contient pas encore ce + # vault pendant son premier scan, chemin_exclu() (qui lit l'index) ne + # peut donc pas servir ici. + from backend.services.vaults import chemin_exclu_dans_config + + cfg = vault_cfg or {} + ignored_names = IGNORED_DIRS | set(cfg.get("ignoredDirs") or []) + # BUG-032: walk without following symlinks and refuse any symlink that # escapes the vault root, so external data can never be indexed/exposed. for dirpath, dirnames, filenames in os.walk(vault_root, followlinks=False): @@ -537,7 +569,11 @@ def _scan_vault( # Prune ignored and symlinked directories in place (no recursion). dirnames[:] = [ d for d in dirnames - if d not in IGNORED_DIRS and not (current_dir / d).is_symlink() + if d not in ignored_names + and not (current_dir / d).is_symlink() + and not chemin_exclu_dans_config( + cfg, str((current_dir / d).relative_to(vault_root)).replace("\\", "/") + ) ] for d in dirnames: @@ -565,6 +601,10 @@ def _scan_vault( rel_path_str = str(fpath.relative_to(vault_root)).replace("\\", "/") + # Exclusion propre au vault (motif de chemin, ex. un .env ciblé). + if chemin_exclu_dans_config(cfg, rel_path_str): + continue + ext = fpath.suffix.lower() # Also match extensionless files named like Dockerfile, Makefile basename_lower = fpath.name.lower() diff --git a/backend/routers/files_read.py b/backend/routers/files_read.py index 4192f5a..3f17b8f 100644 --- a/backend/routers/files_read.py +++ b/backend/routers/files_read.py @@ -44,7 +44,7 @@ from backend.schemas import ( from backend.services.files import read_raw_file from backend.services.mutations import file_revision from backend.services.paths import resolve_safe_path -from backend.services.vaults import browse_directory, get_vault_root +from backend.services.vaults import browse_directory, chemin_exclu, get_vault_root from backend.share import list_shares logger = logging.getLogger("obsigate") @@ -171,6 +171,9 @@ async def api_file_download(vault_name: str, path: str = Query(..., description= if not vault_data: raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") + if chemin_exclu(vault_name, path): + raise HTTPException(status_code=404, detail=f"File not found: {path}") + vault_root = Path(vault_data["path"]) file_path = resolve_safe_path(vault_root, path) @@ -366,6 +369,9 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative if not vault_data: raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") + if chemin_exclu(vault_name, path): + raise HTTPException(status_code=404, detail=f"File not found: {path}") + vault_root = Path(vault_data["path"]) file_path = resolve_safe_path(vault_root, path) diff --git a/backend/schemas.py b/backend/schemas.py index c09df7f..1ef6a43 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -609,6 +609,7 @@ class VaultInfo(BaseModel): file_count: int = Field(description="Number of indexed files") tag_count: int = Field(description="Number of unique tags") type: str = Field(default="VAULT", description="Type of the vault mapping (VAULT or DIR)") + readonly: bool = Field(default=False, description="True when the vault is declared read-only (DIR_N_READONLY) — mutations are refused and the UI hides write actions") class BookmarkToggleRequest(BaseModel): diff --git a/backend/services/files.py b/backend/services/files.py index 81023b8..28114b7 100644 --- a/backend/services/files.py +++ b/backend/services/files.py @@ -7,13 +7,30 @@ from typing import Any from backend.services.errors import ServiceError from backend.services.paths import resolve_safe_path -from backend.services.vaults import get_vault_root +from backend.services.vaults import chemin_exclu, get_vault_root logger = logging.getLogger("obsigate.services.files") +def _ensure_not_excluded(vault_name: str, path: str) -> None: + """Raise ``not_found`` (404) when *path* is excluded in this vault. + + Un chemin exclu (DIR_N_IGNORED_DIRS / DIR_N_IGNORED_PATHS) est + indiscernable d'un chemin inexistant : même erreur, aucun détail + sur la règle qui l'a caché. + """ + if chemin_exclu(vault_name, path): + raise ServiceError( + f"File not found: {path}", + code="not_found", + status=404, + details={"vault": vault_name, "path": path}, + ) + + def read_raw_file(vault_name: str, path: str) -> dict[str, Any]: """Return the raw text content of a vault file (no redaction).""" + _ensure_not_excluded(vault_name, path) root = get_vault_root(vault_name) file_path = resolve_safe_path(root, path) @@ -56,6 +73,7 @@ def read_file_text( ServiceError: ``not_found`` (404), ``file_too_large`` (413) or a read error (500). """ + _ensure_not_excluded(vault_name, path) root = get_vault_root(vault_name) target = resolve_safe_path(root, path) diff --git a/backend/services/mutations.py b/backend/services/mutations.py index 1e188e8..db2d5cf 100644 --- a/backend/services/mutations.py +++ b/backend/services/mutations.py @@ -69,8 +69,18 @@ def _rel(root: Path, path: Path) -> str: return str(path.relative_to(root)).replace("\\", "/") -def _ensure_writable(root: Path) -> None: - """Raise ``read_only`` (403) when the vault root is not writable.""" +def _ensure_writable(root: Path, vault_name: str | None = None) -> None: + """Raise ``read_only`` (403) when the vault must not be mutated. + + Two sources, same error: the vault is **declared** read-only + (``DIR_N_READONLY``, via :func:`vault_readonly`) or its root is + not writable on the filesystem (``:ro`` mount). + """ + if vault_name is not None: + from backend.services.vaults import vault_readonly + + if vault_readonly(vault_name): + raise ServiceError("Vault is read-only", code="read_only", status=403) if not os.access(root, os.W_OK): raise ServiceError("Vault is read-only", code="read_only", status=403) @@ -139,7 +149,7 @@ def create_file( ``unsupported_extension`` (400) or ``already_exists`` (409). """ root = get_vault_root(vault_name) - _ensure_writable(root) + _ensure_writable(root, vault_name) file_path = resolve_safe_path(root, path) _validate_extension(file_path) @@ -190,7 +200,7 @@ def create_directory(vault_name: str, path: str, *, exist_ok: bool = False) -> d ``already_exists`` (409) when *exist_ok* is False. """ root = get_vault_root(vault_name) - _ensure_writable(root) + _ensure_writable(root, vault_name) dir_path = resolve_safe_path(root, path) if dir_path.exists(): @@ -234,7 +244,7 @@ def edit_file( ServiceError: ``not_found`` (404) or ``read_only`` (403). """ root = get_vault_root(vault_name) - _ensure_writable(root) + _ensure_writable(root, vault_name) file_path = resolve_safe_path(root, path) if not file_path.exists() or not file_path.is_file(): @@ -470,7 +480,7 @@ def edit_xlsx_cells( reference or value. """ root = get_vault_root(vault_name) - _ensure_writable(root) + _ensure_writable(root, vault_name) file_path = resolve_safe_path(root, path) if not file_path.exists() or not file_path.is_file(): @@ -592,7 +602,7 @@ def mutate_xlsx_structure( — deletions are NOT recoverable from the viewer (only via the ``.bak``). """ root = get_vault_root(vault_name) - _ensure_writable(root) + _ensure_writable(root, vault_name) file_path = resolve_safe_path(root, path) if not file_path.exists() or not file_path.is_file(): @@ -1000,7 +1010,7 @@ def mutate_xlsx_style( from openpyxl.utils.cell import range_boundaries root = get_vault_root(vault_name) - _ensure_writable(root) + _ensure_writable(root, vault_name) file_path = resolve_safe_path(root, path) if not file_path.exists() or not file_path.is_file(): @@ -1255,7 +1265,7 @@ def save_csv_cells( from backend.xlsx_reader import sniff_csv_delimiter root = get_vault_root(vault_name) - _ensure_writable(root) + _ensure_writable(root, vault_name) file_path = resolve_safe_path(root, path) if not file_path.exists() or not file_path.is_file(): @@ -1341,7 +1351,7 @@ def append_to_file( ServiceError: ``not_found`` (404) or ``read_only`` (403). """ root = get_vault_root(vault_name) - _ensure_writable(root) + _ensure_writable(root, vault_name) file_path = resolve_safe_path(root, path) if not file_path.exists() or not file_path.is_file(): @@ -1382,7 +1392,7 @@ def rename_file(vault_name: str, path: str, new_name: str) -> dict[str, Any]: ``unsupported_extension`` (400) or ``already_exists`` (409). """ root = get_vault_root(vault_name) - _ensure_writable(root) + _ensure_writable(root, vault_name) old_path = resolve_safe_path(root, path) if not old_path.exists() or not old_path.is_file(): @@ -1424,7 +1434,7 @@ def rename_directory(vault_name: str, path: str, new_name: str) -> dict[str, Any ``already_exists`` (409). """ root = get_vault_root(vault_name) - _ensure_writable(root) + _ensure_writable(root, vault_name) old_path = resolve_safe_path(root, path) if not old_path.exists() or not old_path.is_dir(): @@ -1467,7 +1477,7 @@ def move_path(vault_name: str, source_path: str, destination_dir: str = "") -> d ``unsupported_extension`` (400) or ``already_exists`` (409). """ root = get_vault_root(vault_name) - _ensure_writable(root) + _ensure_writable(root, vault_name) source = resolve_safe_path(root, source_path) if not source.exists(): @@ -1546,7 +1556,7 @@ def duplicate_path(vault_name: str, source_path: str, destination_dir: str = "") destination is inside the source directory. """ root = get_vault_root(vault_name) - _ensure_writable(root) + _ensure_writable(root, vault_name) source = resolve_safe_path(root, source_path) if not source.exists(): @@ -1764,7 +1774,7 @@ def delete_file(vault_name: str, path: str, *, backup: bool = True) -> dict[str, ServiceError: ``not_found`` (404) or ``read_only`` (403). """ root = get_vault_root(vault_name) - _ensure_writable(root) + _ensure_writable(root, vault_name) file_path = resolve_safe_path(root, path) if not file_path.exists() or not file_path.is_file(): @@ -1797,7 +1807,7 @@ def delete_directory(vault_name: str, path: str, *, recursive: bool = True) -> d ``invalid_arguments`` (400) when targeting the vault root. """ root = get_vault_root(vault_name) - _ensure_writable(root) + _ensure_writable(root, vault_name) dir_path = resolve_safe_path(root, path) if not dir_path.exists() or not dir_path.is_dir(): @@ -1855,7 +1865,7 @@ def restore_backup( or ``read_only`` (403). """ root = get_vault_root(vault_name) - _ensure_writable(root) + _ensure_writable(root, vault_name) file_path = resolve_safe_path(root, path) backup_dir = get_backup_dir(vault_name, path) @@ -1931,7 +1941,7 @@ def save_raw_file( Dict with ``success``, ``vault``, ``path``, and ``size``. """ root = get_vault_root(vault_name) - _ensure_writable(root) + _ensure_writable(root, vault_name) file_path = resolve_safe_path(root, path) _validate_extension(file_path, allow_images=True, allow_docs=allow_docs) @@ -1980,7 +1990,7 @@ def batch_upload_files( and ``errors`` (list of error dicts). """ root = get_vault_root(vault_name) - _ensure_writable(root) + _ensure_writable(root, vault_name) clean_target = (target_dir or "").strip().strip("/\\") uploaded: list[str] = [] diff --git a/backend/services/search.py b/backend/services/search.py index d23af0b..6b6803e 100644 --- a/backend/services/search.py +++ b/backend/services/search.py @@ -215,8 +215,9 @@ def list_paths(vault: str, limit: int = 5000) -> dict[str, Any]: from datetime import datetime from backend.indexer import get_vault_data, path_index + from backend.services.vaults import chemin_exclu - entries = path_index.get(vault, []) + entries = [e for e in path_index.get(vault, []) if not chemin_exclu(vault, e["path"])] meta: dict[str, dict[str, Any]] = {} data = get_vault_data(vault) if data: diff --git a/backend/services/vaults.py b/backend/services/vaults.py index 1303367..5c3f34e 100644 --- a/backend/services/vaults.py +++ b/backend/services/vaults.py @@ -7,6 +7,7 @@ Single source of truth consumed by both the REST routes (``/api/vaults``, from __future__ import annotations +import fnmatch from datetime import UTC from pathlib import Path from typing import Any @@ -15,6 +16,68 @@ from backend.services.errors import ServiceError from backend.services.paths import resolve_safe_path +def chemin_exclu_dans_config(config: dict[str, Any] | None, rel_path: str) -> bool: + """True si *rel_path* est exclu par la config d'exclusions d'un vault. + + Deux mécanismes, lus dans le dict ``config`` du vault (alimenté par + ``DIR_N_IGNORED_DIRS`` / ``DIR_N_IGNORED_PATHS``, vaults DIR seulement) : + + - ``ignoredDirs`` : le nom d'un segment du chemin (à toute profondeur) + figure dans l'ensemble — ex. ``.home`` cache ``a/.home/x`` ; + - ``ignoredPaths`` : motifs fnmatch testés contre le chemin relatif + POSIX **et chacun de ses préfixes** — un dossier qui matche cache + donc tout son sous-arbre, ex. ``Executeurs/*/data`` cache aussi + ``Executeurs/Hermes/data/config.md``. + + Tolérant : config absente/vide ou chemin vide → False. + """ + if not config or not rel_path: + return False + rel = str(rel_path).replace("\\", "/").strip("/") + if not rel: + return False + segments = rel.split("/") + ignored_dirs = config.get("ignoredDirs") or [] + if any(seg in ignored_dirs for seg in segments): + return True + prefixes = ["/".join(segments[:i]) for i in range(1, len(segments) + 1)] + for pattern in config.get("ignoredPaths") or []: + pat = str(pattern).replace("\\", "/").strip("/") + if pat and any(fnmatch.fnmatchcase(prefix, pat) for prefix in prefixes): + return True + return False + + +def chemin_exclu(vault_name: str, rel_path: str) -> bool: + """True si *rel_path* est exclu dans le vault *vault_name*. + + Lit la config du vault via l'index (``get_vault_data``). Tolérant : + vault inconnu ou sans config d'exclusions → False. + """ + try: + from backend.indexer import get_vault_data + + data = get_vault_data(vault_name) + except Exception: + return False + if not data: + return False + return chemin_exclu_dans_config(data.get("config") or {}, rel_path) + + +def vault_readonly(vault_name: str) -> bool: + """True si le vault est déclaré en lecture seule (DIR_N_READONLY).""" + try: + from backend.indexer import get_vault_data + + data = get_vault_data(vault_name) + except Exception: + return False + if not data: + return False + return bool((data.get("config") or {}).get("readonly", False)) + + def list_accessible_vaults(user: dict[str, Any]) -> list[dict[str, Any]]: """Return the vaults *user* may access, with summary metadata.""" from backend.auth.middleware import check_vault_access @@ -29,6 +92,7 @@ def list_accessible_vaults(user: dict[str, Any]) -> list[dict[str, Any]]: "file_count": len(data.get("files", [])), "tag_count": len(data.get("tags", {})), "type": data.get("config", {}).get("type", "VAULT"), + "readonly": bool(data.get("config", {}).get("readonly", False)), }) return result @@ -58,7 +122,7 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]: root = get_vault_root(vault_name) target = resolve_safe_path(root, path) if path else root.resolve() - if not target.exists(): + if not target.exists() or (path and chemin_exclu(vault_name, path)): raise ServiceError( f"Path not found: {path}", code="not_found", @@ -74,12 +138,15 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]: if hide_hidden and entry.name.startswith("."): continue rel = str(entry.relative_to(root)).replace("\\", "/") + if chemin_exclu(vault_name, rel): + continue if entry.is_dir(): # Count only direct children (files and subdirs) for performance. try: file_count = sum( 1 for child in entry.iterdir() if (not hide_hidden or not child.name.startswith(".")) + and not chemin_exclu(vault_name, f"{rel}/{child.name}") and (child.is_file() and (child.suffix.lower() in SUPPORTED_EXTENSIONS or child.name.lower() in ("dockerfile", "makefile")) or child.is_dir()) ) @@ -200,6 +267,9 @@ def list_all_files( stat = entry.stat() rel_path = str(entry.relative_to(root)).replace("\\", "/") + if chemin_exclu(vault_name, rel_path): + continue + if recursive and dir_prefix: try: rel_to_dir = str(entry.parent.relative_to(dir_path)).replace("\\", "/") diff --git a/docs/features/spark-vault-dir.md b/docs/features/spark-vault-dir.md new file mode 100644 index 0000000..0775b7a --- /dev/null +++ b/docs/features/spark-vault-dir.md @@ -0,0 +1,79 @@ +# Vault DIR « Spark » — exclusions par vault et lecture seule déclarable + +## Objectif + +Permettre d'exposer dans ObsiGate des répertoires techniques (code du +service d'agents, workspaces du compagnon, coquilles d'exécuteurs) sous un +vault `DIR` monté en lecture seule — le vault « Spark » — sans jamais +rendre visibles leurs zones sensibles (profils et clés des exécuteurs, +`.home` des workspaces, `.venv`, etc.). + +Avant ce chantier, deux manques le bloquaient proprement : + +1. Les exclusions n'existaient qu'au niveau de l'indexeur (`IGNORED_DIRS`, + global) : la navigation et la lecture montraient le disque tel quel. +2. La lecture seule d'un vault n'était ni déclarable ni affichée : les + menus contextuels proposaient renommer / déplacer / supprimer, qui + échouaient ensuite côté serveur (montage `:ro`). + +## Contrat de configuration (vaults DIR seulement) + +Lues par `load_vault_config()` (`backend/indexer.py`, boucle `DIR_N`), +rangées dans le dict `config` du vault ; les vaults `VAULT` ne sont pas +touchés et les défauts (vide / faux) conservent le comportement actuel : + +| Variable | Forme | Effet | +|---|---|---| +| `DIR_N_READONLY` | `true` / `false` | Mutations refusées en 403 `read_only` ; `readonly: true` exposé par `/api/vaults` ; l'UI masque les actions d'écriture | +| `DIR_N_IGNORED_DIRS` | `.home,.venv` | Noms de dossiers cachés à toute profondeur, en plus du global | +| `DIR_N_IGNORED_PATHS` | `Executeurs/*/data,Executeurs/*/.env` | Motifs fnmatch sur le chemin relatif POSIX ; un dossier qui matche cache tout son sous-arbre | + +Exemple visé pour le vault Spark : + +``` +DIR_1_NAME=Spark +DIR_1_PATH=/vaults/Spark +DIR_1_READONLY=true +DIR_1_IGNORED_DIRS=.home,.venv,__pycache__,node_modules,.git +DIR_1_IGNORED_PATHS=Executeurs/*/data,Executeurs/*/.env,*/compagnon.json +``` + +(le montage lui-même — volume `:ro`, arborescence assemblée sur l'hôte — +relève de la configuration de déploiement, pas d'ObsiGate.) + +## Implémentation + +- **Helper central** : `chemin_exclu(vault_name, rel_path)` et + `chemin_exclu_dans_config(config, rel_path)` dans + `backend/services/vaults.py` — segment présent dans `ignoredDirs`, ou + fnmatch du chemin ou d'un de ses préfixes contre `ignoredPaths`. + Tolérant si vault / config absents. `vault_readonly(vault_name)` au + même endroit. +- **Navigation** : `browse_directory` filtre les items exclus (et leurs + compteurs d'enfants) ; naviguer directement dans un chemin exclu + répond 404. `list_all_files` et `list_paths` filtrent aussi. +- **Lecture** : un chemin exclu est indiscernable d'un chemin inexistant + (404, sans détail sur la règle) — `read_raw_file` / `read_file_text` + (`backend/services/files.py`), route `GET /api/file/{vault}` et route + de téléchargement (`backend/routers/files_read.py`). +- **Indexation** : le scan (`_scan_vault`, qui reçoit le `vault_cfg`) + élague les dossiers exclus et saute les fichiers exclus, en plus du + global `IGNORED_DIRS` inchangé. +- **Lecture seule** : `_ensure_writable(root, vault_name)` dans + `backend/services/mutations.py` refuse d'abord sur la déclaration + (`DIR_N_READONLY`), avant le garde filesystem existant — toutes les + mutations du service (créer, éditer, renommer, déplacer, supprimer, + upload, xlsx/csv) passent par ce point. +- **API / UI** : `VaultInfo.readonly` (`backend/schemas.py`) rempli par + `list_accessible_vaults` ; côté frontend, `vaultIsReadonly(nom)` dans + `frontend/js/sidebar.js` alimente `ContextMenuManager`, + `attachTreeItemActionButton` et `attachTreeItemLongPress` pour le + vault et ses dossiers / fichiers (aucun autre fichier JS touché). + +## Tests + +`tests/test_spark_vault_dir.py` : parsing des trois variables, helper +`chemin_exclu` (noms et motifs), navigation filtrée, lectures exclues en +404 (vue fichier, raw, téléchargement), fichier non exclu lisible, +indexation filtrée, mutation refusée en 403 sur vault readonly et +`/api/vaults` exposant `readonly=true`. diff --git a/frontend/js/sidebar.js b/frontend/js/sidebar.js index 1bad822..f30caa6 100644 --- a/frontend/js/sidebar.js +++ b/frontend/js/sidebar.js @@ -209,6 +209,17 @@ function _openSecretsMenu(x, y) { }, 0); } +/** + * Vault déclaré en lecture seule (DIR_N_READONLY, champ « readonly » de + * /api/vaults) : ses menus et boutons d'action ne doivent proposer aucune + * mutation (renommer, déplacer, supprimer…) — le backend les refuse en + * 403 de toute façon, l'UI le dit avant le clic. + */ +function vaultIsReadonly(vaultName) { + const v = (state.allVaults || []).find((x) => x.name === vaultName); + return !!(v && v.readonly); +} + /** Fichier partagé (créé ou reçu) → icône share-2 bleue, sinon icône usuelle. */ function sharedFileIconSync(sharedPaths, itemName, vaultName, itemPath) { if (sharedPaths && sharedPaths.has(`${vaultName}::${itemPath}`)) return _sharedIcon(); @@ -356,11 +367,11 @@ async function refreshSidebarForContext() { vaultItem.addEventListener("contextmenu", (e) => { e.preventDefault(); - const isReadonly = false; + const isReadonly = vaultIsReadonly(v.name); ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly); }); - attachTreeItemActionButton(vaultItem, v.name, "", "vault", false); - attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false })); + attachTreeItemActionButton(vaultItem, v.name, "", "vault", vaultIsReadonly(v.name)); + attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: vaultIsReadonly(v.name) })); container.appendChild(vaultItem); @@ -460,11 +471,11 @@ async function loadVaults() { vaultItem.addEventListener("contextmenu", (e) => { e.preventDefault(); - const isReadonly = false; + const isReadonly = vaultIsReadonly(v.name); ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly); }); - attachTreeItemActionButton(vaultItem, v.name, "", "vault", false); - attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false })); + attachTreeItemActionButton(vaultItem, v.name, "", "vault", vaultIsReadonly(v.name)); + attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: vaultIsReadonly(v.name) })); container.appendChild(vaultItem); @@ -596,8 +607,8 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) { // New item — create it if (item.type === "directory") { const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "directory" }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]); - attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false); - attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false })); + attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", vaultIsReadonly(vaultName)); + attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: vaultIsReadonly(vaultName) })); fragment.appendChild(dirItem); const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` }); @@ -624,14 +635,14 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) { dirItem.addEventListener("contextmenu", (e) => { e.preventDefault(); - ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "directory", false); + ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "directory", vaultIsReadonly(vaultName)); }); } else { const fileIconName = getFileIcon(item.name); const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name; const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [sharedFileIconSync(sharedPaths, item.name, vaultName, item.path), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]); - attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false); - attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false })); + attachTreeItemActionButton(fileItem, vaultName, item.path, "file", vaultIsReadonly(vaultName)); + attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: vaultIsReadonly(vaultName) })); fileItem.addEventListener("click", () => { scrollTreeItemIntoView(fileItem, false); if (window.innerWidth <= 768) { @@ -649,7 +660,7 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) { fileItem.addEventListener("contextmenu", (e) => { e.preventDefault(); - ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "file", false); + ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "file", vaultIsReadonly(vaultName)); }); fragment.appendChild(fileItem); @@ -944,8 +955,8 @@ async function loadDirectory(vaultName, dirPath, container) { if (item.type === "directory") { const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "directory" }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]); - attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false); - attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false })); + attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", vaultIsReadonly(vaultName)); + attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: vaultIsReadonly(vaultName) })); fragment.appendChild(dirItem); const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` }); @@ -972,7 +983,7 @@ async function loadDirectory(vaultName, dirPath, container) { dirItem.addEventListener("contextmenu", (e) => { e.preventDefault(); - const isReadonly = false; + const isReadonly = vaultIsReadonly(vaultName); ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'directory', isReadonly); }); } else { @@ -980,8 +991,8 @@ async function loadDirectory(vaultName, dirPath, container) { const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name; const isShared = sharedPaths.has(`${vaultName}::${item.path}`); const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [isShared ? _sharedIcon() : icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]); - attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false); - attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false })); + attachTreeItemActionButton(fileItem, vaultName, item.path, "file", vaultIsReadonly(vaultName)); + attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: vaultIsReadonly(vaultName) })); fileItem.addEventListener("click", () => { scrollTreeItemIntoView(fileItem, false); if (window.innerWidth <= 768) { @@ -999,7 +1010,7 @@ async function loadDirectory(vaultName, dirPath, container) { fileItem.addEventListener("contextmenu", (e) => { e.preventDefault(); - const isReadonly = false; + const isReadonly = vaultIsReadonly(vaultName); ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'file', isReadonly); }); diff --git a/tests/test_services.py b/tests/test_services.py index 0843aae..cfa1879 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -33,7 +33,7 @@ class TestListAccessibleVaults: def test_metadata_fields(self, client): vault = next(v for v in list_accessible_vaults(ADMIN) if v["name"] == "TestVault") - assert set(vault) == {"name", "file_count", "tag_count", "type"} + assert set(vault) == {"name", "file_count", "tag_count", "type", "readonly"} assert vault["file_count"] >= 1 diff --git a/tests/test_spark_vault_dir.py b/tests/test_spark_vault_dir.py new file mode 100644 index 0000000..3a0f75f --- /dev/null +++ b/tests/test_spark_vault_dir.py @@ -0,0 +1,251 @@ +"""Tests des vaults DIR « Spark » : exclusions par vault et lecture seule. + +Couvre le contrat DIR_N_READONLY / DIR_N_IGNORED_DIRS / DIR_N_IGNORED_PATHS : +- parsing des trois variables dans load_vault_config() ; +- helper chemin_exclu (noms de dossiers et motifs fnmatch à préfixes) ; +- navigation filtrée, lectures exclues en 404 (vue, raw, téléchargement), + fichier non exclu lisible, indexation et list_paths filtrés ; +- mutation refusée en 403 sur un vault déclaré readonly ; +- /api/vaults expose readonly=true pour un DIR readonly. + +Le vault de test est un répertoire temporaire monté en DIR (jamais un +vault de notes), avec une arborescence qui imite la vue Spark visée : +Console/, Workspaces/ (avec un .home), Executeurs/Hermes/{missions,data,.env}. +""" + +from __future__ import annotations + +import asyncio +import os +from pathlib import Path +from typing import ClassVar + +import pytest +from fastapi.testclient import TestClient + +VAULT_NAME = "SparkTest" + + +def _build_spark_tree(root: Path) -> None: + """Crée l'arborescence technique de test sous *root*.""" + (root / "Console").mkdir(parents=True) + (root / "Console" / "code.py").write_text('print("visible")\n', encoding="utf-8") + (root / "Console" / "notes.md").write_text("# Notes\n", encoding="utf-8") + + atelier = root / "Workspaces" / "atelier" + (atelier / ".home").mkdir(parents=True) + (atelier / "note.md").write_text("# Atelier\n", encoding="utf-8") + (atelier / ".home" / "cache.md").write_text("# Cache du sandbox\n", encoding="utf-8") + + venv = root / ".venv" + venv.mkdir() + (venv / "lib.py").write_text("# lib du venv\n", encoding="utf-8") + + hermes = root / "Executeurs" / "Hermes" + (hermes / "missions").mkdir(parents=True) + (hermes / "data").mkdir(parents=True) + (hermes / "missions" / "mission.md").write_text("# Mission\n", encoding="utf-8") + (hermes / "data" / "config.md").write_text("# Profil de l'exécuteur\n", encoding="utf-8") + (hermes / ".env").write_text("CLE_FICTIVE=ne-pas-exposer\n", encoding="utf-8") + + (root / "racine.md").write_text("# Racine\n", encoding="utf-8") + + +@pytest.fixture +def spark_dir(tmp_path: Path) -> Path: + root = tmp_path / "SparkDir" + root.mkdir() + _build_spark_tree(root) + return root + + +@pytest.fixture +def spark_client(spark_dir: Path): + """TestClient avec le répertoire de test monté en vault DIR readonly. + + Même patron que le fixture ``app_with_vault`` de conftest.py, mais en + DIR_N_* avec les trois nouvelles variables posées. + """ + os.environ["DIR_1_NAME"] = VAULT_NAME + os.environ["DIR_1_PATH"] = str(spark_dir) + os.environ["DIR_1_READONLY"] = "true" + os.environ["DIR_1_IGNORED_DIRS"] = ".home,.venv" + os.environ["DIR_1_IGNORED_PATHS"] = "Executeurs/*/data,Executeurs/*/.env" + os.environ["OBSIGATE_AUTH_ENABLED"] = "false" + + import backend.main + + backend.main._load_config = lambda: {"watcher_enabled": False} + + from backend.indexer import build_index + from backend.main import app + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(build_index()) + + return TestClient(app) + + +# ── Parsing de la configuration ───────────────────────────────────── + + +class TestParsingConfig: + def test_dir_variables_parsees(self, monkeypatch, tmp_path): + monkeypatch.setenv("DIR_1_NAME", "SparkParse") + monkeypatch.setenv("DIR_1_PATH", str(tmp_path)) + monkeypatch.setenv("DIR_1_READONLY", "true") + monkeypatch.setenv("DIR_1_IGNORED_DIRS", ".home, .venv ,__pycache__") + monkeypatch.setenv("DIR_1_IGNORED_PATHS", "Executeurs/*/data, Executeurs/*/.env") + + from backend.indexer import load_vault_config + + cfg = load_vault_config()["SparkParse"] + assert cfg["type"] == "DIR" + assert cfg["readonly"] is True + assert cfg["ignoredDirs"] == {".home", ".venv", "__pycache__"} + assert cfg["ignoredPaths"] == ["Executeurs/*/data", "Executeurs/*/.env"] + + def test_dir_sans_variables_defauts(self, monkeypatch, tmp_path): + monkeypatch.setenv("DIR_1_NAME", "DirNu") + monkeypatch.setenv("DIR_1_PATH", str(tmp_path)) + + from backend.indexer import load_vault_config + + cfg = load_vault_config()["DirNu"] + assert cfg["readonly"] is False + assert cfg["ignoredDirs"] == set() + assert cfg["ignoredPaths"] == [] + + +# ── Helper chemin_exclu ───────────────────────────────────────────── + + +class TestCheminExclu: + CFG: ClassVar[dict] = { + "ignoredDirs": {".home", ".venv"}, + "ignoredPaths": ["Executeurs/*/data", "Executeurs/*/.env"], + } + + def test_nom_de_dossier_a_toute_profondeur(self): + from backend.services.vaults import chemin_exclu_dans_config + + assert chemin_exclu_dans_config(self.CFG, "Workspaces/atelier/.home/cache.md") + assert chemin_exclu_dans_config(self.CFG, ".venv/lib.py") + assert chemin_exclu_dans_config(self.CFG, ".home") + + def test_motif_chemin_et_sous_arbre(self): + from backend.services.vaults import chemin_exclu_dans_config + + assert chemin_exclu_dans_config(self.CFG, "Executeurs/Hermes/data") + assert chemin_exclu_dans_config(self.CFG, "Executeurs/Hermes/data/config.md") + assert chemin_exclu_dans_config(self.CFG, "Executeurs/Pi/data/x/y.py") + assert chemin_exclu_dans_config(self.CFG, "Executeurs/Hermes/.env") + + def test_non_exclus_et_tolerance(self): + from backend.services.vaults import chemin_exclu_dans_config + + assert not chemin_exclu_dans_config(self.CFG, "Console/code.py") + assert not chemin_exclu_dans_config(self.CFG, "Executeurs/Hermes/missions/mission.md") + assert not chemin_exclu_dans_config({}, "Console/code.py") + assert not chemin_exclu_dans_config(None, "Console/code.py") + assert not chemin_exclu_dans_config(self.CFG, "") + + +# ── Navigation et lectures via l'API ──────────────────────────────── + + +class TestNavigationEtLecture: + def test_browse_racine_cache_les_dossiers_ignores(self, spark_client): + resp = spark_client.get(f"/api/browse/{VAULT_NAME}") + assert resp.status_code == 200 + names = {item["name"] for item in resp.json()["items"]} + assert {"Console", "Workspaces", "Executeurs", "racine.md"} <= names + assert ".venv" not in names + + def test_browse_sous_dossier_cache_data(self, spark_client): + resp = spark_client.get(f"/api/browse/{VAULT_NAME}", params={"path": "Executeurs/Hermes"}) + assert resp.status_code == 200 + names = {item["name"] for item in resp.json()["items"]} + assert "missions" in names + assert "data" not in names + + def test_browse_chemin_exclu_404(self, spark_client): + resp = spark_client.get(f"/api/browse/{VAULT_NAME}", params={"path": "Executeurs/Hermes/data"}) + assert resp.status_code == 404 + + def test_vue_fichier_exclu_404(self, spark_client): + resp = spark_client.get(f"/api/file/{VAULT_NAME}", params={"path": "Executeurs/Hermes/data/config.md"}) + assert resp.status_code == 404 + + def test_raw_exclu_404(self, spark_client): + resp = spark_client.get(f"/api/file/{VAULT_NAME}/raw", params={"path": "Executeurs/Hermes/data/config.md"}) + assert resp.status_code == 404 + + def test_raw_env_exclu_404(self, spark_client): + resp = spark_client.get(f"/api/file/{VAULT_NAME}/raw", params={"path": "Executeurs/Hermes/.env"}) + assert resp.status_code == 404 + assert "CLE_FICTIVE" not in resp.text + + def test_telechargement_env_exclu_404(self, spark_client): + resp = spark_client.get(f"/api/file/{VAULT_NAME}/download", params={"path": "Executeurs/Hermes/.env"}) + assert resp.status_code == 404 + assert "CLE_FICTIVE" not in resp.text + + def test_fichier_non_exclu_lisible(self, spark_client): + resp = spark_client.get(f"/api/file/{VAULT_NAME}/raw", params={"path": "Console/code.py"}) + assert resp.status_code == 200 + assert "visible" in resp.json()["raw"] + + +# ── Indexation et liste des chemins ───────────────────────────────── + + +class TestIndexation: + def test_index_ne_contient_aucun_exclu(self, spark_client): + from backend.indexer import get_vault_data + + data = get_vault_data(VAULT_NAME) + assert data is not None + file_paths = {f["path"] for f in data.get("files", [])} + assert "Console/code.py" in file_paths + for p in file_paths: + segments = p.split("/") + assert ".home" not in segments and ".venv" not in segments + assert "data" not in segments[:-1] or not p.startswith("Executeurs/") + path_entries = {e["path"] for e in data.get("paths", [])} + assert "Executeurs/Hermes/data" not in path_entries + assert "Workspaces/atelier/.home" not in path_entries + + def test_list_paths_filtre_les_exclus(self, spark_client): + from backend.services.search import list_paths + + result = list_paths(VAULT_NAME) + paths = {r["path"] for r in result["results"]} + assert "Console/code.py" in paths + assert not any("/data/" in f"/{p}/" and p.startswith("Executeurs/") for p in paths) + assert not any(".home" in p.split("/") or ".venv" in p.split("/") for p in paths) + + +# ── Lecture seule ─────────────────────────────────────────────────── + + +class TestLectureSeule: + def test_save_refuse_403(self, spark_client): + resp = spark_client.put( + f"/api/file/{VAULT_NAME}/save", + params={"path": "Console/code.py"}, + json={"content": "print('modifié')\n"}, + ) + assert resp.status_code == 403 + + def test_creation_dossier_refusee_403(self, spark_client): + resp = spark_client.post(f"/api/directory/{VAULT_NAME}", json={"path": "NouveauDossier"}) + assert resp.status_code == 403 + + def test_vaults_expose_readonly(self, spark_client): + resp = spark_client.get("/api/vaults") + assert resp.status_code == 200 + entry = next(v for v in resp.json() if v["name"] == VAULT_NAME) + assert entry["type"] == "DIR" + assert entry["readonly"] is True -- 2.54.0 From bd5507d6ef5316b5c76d0e48f47fe8e6854ab7c0 Mon Sep 17 00:00:00 2001 From: Spark Date: Tue, 3 Nov 2026 05:00:19 +0000 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20vault=20DIR=20=E2=80=94=20garde=20d?= =?UTF-8?q?'exclusion=20sur=20les=20lectures=20xlsx=20(vault=20Spark)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routers/files_read.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/routers/files_read.py b/backend/routers/files_read.py index 3f17b8f..8159aa9 100644 --- a/backend/routers/files_read.py +++ b/backend/routers/files_read.py @@ -246,6 +246,8 @@ def api_file_xlsx_dashboard( """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") + if chemin_exclu(vault_name, path): + raise HTTPException(status_code=404, detail=f"File not found: {path}") _vault_root = get_vault_root(vault_name) file_path = resolve_safe_path(_vault_root, path) if not file_path.is_file(): @@ -314,6 +316,9 @@ def api_file_xlsx_sheet( if not vault_data: raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") + if chemin_exclu(vault_name, path): + raise HTTPException(status_code=404, detail=f"File not found: {path}") + file_path = resolve_safe_path(Path(vault_data["path"]), path) if not file_path.is_file(): raise HTTPException(status_code=404, detail=f"File not found: {path}") -- 2.54.0 From 099ffe7e8f479625e13bee9005abf9cc913517db Mon Sep 17 00:00:00 2001 From: Spark Date: Tue, 3 Nov 2026 06:58:52 +0000 Subject: [PATCH 3/6] =?UTF-8?q?chore:=20relance=20CI=20(runner=20d=C3=A9gr?= =?UTF-8?q?ad=C3=A9,=20run=203161=20gel=C3=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -- 2.54.0 From 60666bf79dfdb1a598144a0e057f6443a8ba1271 Mon Sep 17 00:00:00 2001 From: Spark Date: Tue, 3 Nov 2026 13:14:23 +0000 Subject: [PATCH 4/6] =?UTF-8?q?chore:=20relance=20CI=20apr=C3=A8s=20nettoy?= =?UTF-8?q?age=20du=20runner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -- 2.54.0 From b5b5a0e8203ea482a12cb9861b1e63b5a6098556 Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 3 Nov 2026 14:09:13 -0500 Subject: [PATCH 5/6] chore: version 2.83.0 (vault DIR exclusions + fix CI e2e) --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index ba4f65c..c0c86fb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.82.8 +2.83.0 \ No newline at end of file -- 2.54.0 From ad1cea23ab9485286d60dd0e3b9d33d93b96fcae Mon Sep 17 00:00:00 2001 From: Spark Date: Tue, 3 Nov 2026 21:00:59 +0000 Subject: [PATCH 6/6] =?UTF-8?q?release:=20version=202.83.0=20(synchronisat?= =?UTF-8?q?ion=20compl=C3=A8te)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 +++++- README.fr.md | 6 +++--- README.md | 6 +++--- desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ROADMAP.md | 2 +- package.json | 2 +- 8 files changed, 16 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01b2b2a..aaf0193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,16 @@ 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.82.8**. +> [Unreleased](#unreleased). La dernière version livrée est **2.83.0**. --- ## [Unreleased] +--- + +## [2.83.0] — 2026-11-03 + - **Vaults DIR : exclusions par vault et lecture seule déclarable** (prépare le vault « Spark ») : trois nouvelles variables par vault DIR — `DIR_N_READONLY=true` (les mutations sont refusées en 403 « read_only » et l'arborescence n'affiche plus les actions d'écriture pour ce vault), `DIR_N_IGNORED_DIRS=.home,.venv,…` (noms de dossiers cachés à toute profondeur, en plus du global `IGNORED_DIRS`) et `DIR_N_IGNORED_PATHS=Executeurs/*/data,…` (motifs fnmatch sur le chemin relatif ; un dossier qui matche cache tout son sous-arbre). Les exclusions s'appliquent désormais **partout** — navigation (`/api/browse`), lecture (`/api/file`, `raw`, téléchargement : 404), liste des chemins et indexation — et plus seulement à l'indexeur. `/api/vaults` expose `readonly` pour chaque vault. Sans ces variables, comportement inchangé. Fiche : `docs/features/spark-vault-dir.md`. Tests : `tests/test_spark_vault_dir.py`. --- diff --git a/README.fr.md b/README.fr.md index cc2e60b..7143cf1 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.82.8-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.83.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/) @@ -977,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.82.8). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.83.0). --- -*Projet : ObsiGate | Version : 2.82.8 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.83.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index b784650..c448af9 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.82.8-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.83.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/) @@ -1152,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.82.8). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.83.0). --- -*Project: ObsiGate | Version: 2.82.8 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.83.0 | Last updated: September 2026* diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index d30dd88..70b24d4 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.82.8" +version = "2.83.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 4fbc81f..7405961 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.82.8" +version = "2.83.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 f81e8aa..f970521 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.82.8", + "version": "2.83.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 38154d9..e5c5866 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.82.8 | **Dernière mise à jour :** 2026-11-03 +> **Version :** 2.83.0 | **Dernière mise à jour :** 2026-11-03 > **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)** diff --git a/package.json b/package.json index cfef4e3..1170613 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.82.8", + "version": "2.83.0", "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": { -- 2.54.0