Merge pull request 'feat: vault DIR — exclusions par vault et lecture seule déclarable (vault Spark)' (#58) from spark-vault-dir into main
This commit was merged in pull request #58.
This commit is contained in:
@@ -47,6 +47,16 @@ OBSIGATE_ADMIN_PASSWORD=chab30
|
|||||||
# Ignored directories (séparés par des virgules)
|
# Ignored directories (séparés par des virgules)
|
||||||
# OBSIGATE_IGNORED_DIRS=.obsidian,.trash,.git,__pycache__,node_modules,.obsigate-backup
|
# 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
|
# Audit
|
||||||
# OBSIGATE_AUDIT_MAX_SIZE=10485760
|
# OBSIGATE_AUDIT_MAX_SIZE=10485760
|
||||||
|
|
||||||
|
|||||||
+7
-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).
|
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
|
> **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**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,6 +14,12 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [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`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.82.8] — 2026-10-30
|
## [2.82.8] — 2026-10-30
|
||||||
|
|
||||||
- **Panneau détail d'un secret : Afficher/Masquer + Copier la valeur** (PR #57) : la valeur reste masquée (••••••) par défaut ; Afficher la révèle en place, Masquer l'efface (effacée aussi dès que le détail change). « Copier la valeur » copie au presse-papiers sans afficher. Le bouton « Copier » existant ne copiait que la référence.
|
- **Panneau détail d'un secret : Afficher/Masquer + Copier la valeur** (PR #57) : la valeur reste masquée (••••••) par défaut ; Afficher la révèle en place, Masquer l'efface (effacée aussi dès que le détail change). « Copier la valeur » copie au presse-papiers sans afficher. Le bouton « Copier » existant ne copiait que la référence.
|
||||||
|
|||||||
+3
-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.
|
**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://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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*
|
||||||
|
|||||||
@@ -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.
|
**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://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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*
|
||||||
|
|||||||
+44
-4
@@ -97,6 +97,13 @@ def load_vault_config() -> dict[str, dict[str, Any]]:
|
|||||||
Also reads optional configuration:
|
Also reads optional configuration:
|
||||||
- VAULT_N_ATTACHMENTS_PATH: relative path to attachments folder
|
- VAULT_N_ATTACHMENTS_PATH: relative path to attachments folder
|
||||||
- VAULT_N_SCAN_ATTACHMENTS: "true"/"false" to enable/disable scanning
|
- 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:
|
Returns:
|
||||||
Dict mapping vault names to configuration dicts with keys:
|
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)
|
- attachmentsPath: relative attachments folder (optional)
|
||||||
- scanAttachmentsOnStartup: boolean (default True)
|
- scanAttachmentsOnStartup: boolean (default True)
|
||||||
- type: "VAULT" or "DIR"
|
- 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]] = {}
|
vaults: dict[str, dict[str, Any]] = {}
|
||||||
n = 1
|
n = 1
|
||||||
@@ -131,12 +141,23 @@ def load_vault_config() -> dict[str, dict[str, Any]]:
|
|||||||
path = os.environ.get(f"DIR_{n}_PATH")
|
path = os.environ.get(f"DIR_{n}_PATH")
|
||||||
if not name or not path:
|
if not name or not path:
|
||||||
break
|
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] = {
|
vaults[name] = {
|
||||||
"path": path,
|
"path": path,
|
||||||
"attachmentsPath": None,
|
"attachmentsPath": None,
|
||||||
"scanAttachmentsOnStartup": False,
|
"scanAttachmentsOnStartup": False,
|
||||||
"type": "DIR"
|
"type": "DIR",
|
||||||
|
"readonly": readonly,
|
||||||
|
"ignoredDirs": ignored_dirs,
|
||||||
|
"ignoredPaths": ignored_paths,
|
||||||
}
|
}
|
||||||
n += 1
|
n += 1
|
||||||
|
|
||||||
@@ -509,7 +530,8 @@ def _scan_vault(
|
|||||||
Args:
|
Args:
|
||||||
vault_name: Display name of the vault.
|
vault_name: Display name of the vault.
|
||||||
vault_path: Absolute filesystem path to the vault root.
|
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_files: Optional ``{relative_path: file_info}`` snapshot from a
|
||||||
previous scan used for differential reuse.
|
previous scan used for differential reuse.
|
||||||
|
|
||||||
@@ -529,6 +551,16 @@ def _scan_vault(
|
|||||||
|
|
||||||
root_resolved = vault_root.resolve(strict=False)
|
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
|
# BUG-032: walk without following symlinks and refuse any symlink that
|
||||||
# escapes the vault root, so external data can never be indexed/exposed.
|
# escapes the vault root, so external data can never be indexed/exposed.
|
||||||
for dirpath, dirnames, filenames in os.walk(vault_root, followlinks=False):
|
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).
|
# Prune ignored and symlinked directories in place (no recursion).
|
||||||
dirnames[:] = [
|
dirnames[:] = [
|
||||||
d for d in 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:
|
for d in dirnames:
|
||||||
@@ -565,6 +601,10 @@ def _scan_vault(
|
|||||||
|
|
||||||
rel_path_str = str(fpath.relative_to(vault_root)).replace("\\", "/")
|
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()
|
ext = fpath.suffix.lower()
|
||||||
# Also match extensionless files named like Dockerfile, Makefile
|
# Also match extensionless files named like Dockerfile, Makefile
|
||||||
basename_lower = fpath.name.lower()
|
basename_lower = fpath.name.lower()
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ from backend.schemas import (
|
|||||||
from backend.services.files import read_raw_file
|
from backend.services.files import read_raw_file
|
||||||
from backend.services.mutations import file_revision
|
from backend.services.mutations import file_revision
|
||||||
from backend.services.paths import resolve_safe_path
|
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
|
from backend.share import list_shares
|
||||||
|
|
||||||
logger = logging.getLogger("obsigate")
|
logger = logging.getLogger("obsigate")
|
||||||
@@ -171,6 +171,9 @@ async def api_file_download(vault_name: str, path: str = Query(..., description=
|
|||||||
if not vault_data:
|
if not vault_data:
|
||||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
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"])
|
vault_root = Path(vault_data["path"])
|
||||||
file_path = resolve_safe_path(vault_root, path)
|
file_path = resolve_safe_path(vault_root, path)
|
||||||
|
|
||||||
@@ -243,6 +246,8 @@ def api_file_xlsx_dashboard(
|
|||||||
"""
|
"""
|
||||||
if not check_vault_access(vault_name, current_user):
|
if not check_vault_access(vault_name, current_user):
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
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)
|
_vault_root = get_vault_root(vault_name)
|
||||||
file_path = resolve_safe_path(_vault_root, path)
|
file_path = resolve_safe_path(_vault_root, path)
|
||||||
if not file_path.is_file():
|
if not file_path.is_file():
|
||||||
@@ -311,6 +316,9 @@ def api_file_xlsx_sheet(
|
|||||||
if not vault_data:
|
if not vault_data:
|
||||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
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)
|
file_path = resolve_safe_path(Path(vault_data["path"]), path)
|
||||||
if not file_path.is_file():
|
if not file_path.is_file():
|
||||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||||
@@ -366,6 +374,9 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
|||||||
if not vault_data:
|
if not vault_data:
|
||||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
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"])
|
vault_root = Path(vault_data["path"])
|
||||||
file_path = resolve_safe_path(vault_root, path)
|
file_path = resolve_safe_path(vault_root, path)
|
||||||
|
|
||||||
|
|||||||
@@ -609,6 +609,7 @@ class VaultInfo(BaseModel):
|
|||||||
file_count: int = Field(description="Number of indexed files")
|
file_count: int = Field(description="Number of indexed files")
|
||||||
tag_count: int = Field(description="Number of unique tags")
|
tag_count: int = Field(description="Number of unique tags")
|
||||||
type: str = Field(default="VAULT", description="Type of the vault mapping (VAULT or DIR)")
|
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):
|
class BookmarkToggleRequest(BaseModel):
|
||||||
|
|||||||
@@ -7,13 +7,30 @@ from typing import Any
|
|||||||
|
|
||||||
from backend.services.errors import ServiceError
|
from backend.services.errors import ServiceError
|
||||||
from backend.services.paths import resolve_safe_path
|
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")
|
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]:
|
def read_raw_file(vault_name: str, path: str) -> dict[str, Any]:
|
||||||
"""Return the raw text content of a vault file (no redaction)."""
|
"""Return the raw text content of a vault file (no redaction)."""
|
||||||
|
_ensure_not_excluded(vault_name, path)
|
||||||
root = get_vault_root(vault_name)
|
root = get_vault_root(vault_name)
|
||||||
file_path = resolve_safe_path(root, path)
|
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
|
ServiceError: ``not_found`` (404), ``file_too_large`` (413) or a read
|
||||||
error (500).
|
error (500).
|
||||||
"""
|
"""
|
||||||
|
_ensure_not_excluded(vault_name, path)
|
||||||
root = get_vault_root(vault_name)
|
root = get_vault_root(vault_name)
|
||||||
target = resolve_safe_path(root, path)
|
target = resolve_safe_path(root, path)
|
||||||
|
|
||||||
|
|||||||
@@ -69,8 +69,18 @@ def _rel(root: Path, path: Path) -> str:
|
|||||||
return str(path.relative_to(root)).replace("\\", "/")
|
return str(path.relative_to(root)).replace("\\", "/")
|
||||||
|
|
||||||
|
|
||||||
def _ensure_writable(root: Path) -> None:
|
def _ensure_writable(root: Path, vault_name: str | None = None) -> None:
|
||||||
"""Raise ``read_only`` (403) when the vault root is not writable."""
|
"""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):
|
if not os.access(root, os.W_OK):
|
||||||
raise ServiceError("Vault is read-only", code="read_only", status=403)
|
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).
|
``unsupported_extension`` (400) or ``already_exists`` (409).
|
||||||
"""
|
"""
|
||||||
root = get_vault_root(vault_name)
|
root = get_vault_root(vault_name)
|
||||||
_ensure_writable(root)
|
_ensure_writable(root, vault_name)
|
||||||
file_path = resolve_safe_path(root, path)
|
file_path = resolve_safe_path(root, path)
|
||||||
_validate_extension(file_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.
|
``already_exists`` (409) when *exist_ok* is False.
|
||||||
"""
|
"""
|
||||||
root = get_vault_root(vault_name)
|
root = get_vault_root(vault_name)
|
||||||
_ensure_writable(root)
|
_ensure_writable(root, vault_name)
|
||||||
dir_path = resolve_safe_path(root, path)
|
dir_path = resolve_safe_path(root, path)
|
||||||
|
|
||||||
if dir_path.exists():
|
if dir_path.exists():
|
||||||
@@ -234,7 +244,7 @@ def edit_file(
|
|||||||
ServiceError: ``not_found`` (404) or ``read_only`` (403).
|
ServiceError: ``not_found`` (404) or ``read_only`` (403).
|
||||||
"""
|
"""
|
||||||
root = get_vault_root(vault_name)
|
root = get_vault_root(vault_name)
|
||||||
_ensure_writable(root)
|
_ensure_writable(root, vault_name)
|
||||||
file_path = resolve_safe_path(root, path)
|
file_path = resolve_safe_path(root, path)
|
||||||
|
|
||||||
if not file_path.exists() or not file_path.is_file():
|
if not file_path.exists() or not file_path.is_file():
|
||||||
@@ -470,7 +480,7 @@ def edit_xlsx_cells(
|
|||||||
reference or value.
|
reference or value.
|
||||||
"""
|
"""
|
||||||
root = get_vault_root(vault_name)
|
root = get_vault_root(vault_name)
|
||||||
_ensure_writable(root)
|
_ensure_writable(root, vault_name)
|
||||||
file_path = resolve_safe_path(root, path)
|
file_path = resolve_safe_path(root, path)
|
||||||
|
|
||||||
if not file_path.exists() or not file_path.is_file():
|
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``).
|
— deletions are NOT recoverable from the viewer (only via the ``.bak``).
|
||||||
"""
|
"""
|
||||||
root = get_vault_root(vault_name)
|
root = get_vault_root(vault_name)
|
||||||
_ensure_writable(root)
|
_ensure_writable(root, vault_name)
|
||||||
file_path = resolve_safe_path(root, path)
|
file_path = resolve_safe_path(root, path)
|
||||||
|
|
||||||
if not file_path.exists() or not file_path.is_file():
|
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
|
from openpyxl.utils.cell import range_boundaries
|
||||||
|
|
||||||
root = get_vault_root(vault_name)
|
root = get_vault_root(vault_name)
|
||||||
_ensure_writable(root)
|
_ensure_writable(root, vault_name)
|
||||||
file_path = resolve_safe_path(root, path)
|
file_path = resolve_safe_path(root, path)
|
||||||
|
|
||||||
if not file_path.exists() or not file_path.is_file():
|
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
|
from backend.xlsx_reader import sniff_csv_delimiter
|
||||||
|
|
||||||
root = get_vault_root(vault_name)
|
root = get_vault_root(vault_name)
|
||||||
_ensure_writable(root)
|
_ensure_writable(root, vault_name)
|
||||||
file_path = resolve_safe_path(root, path)
|
file_path = resolve_safe_path(root, path)
|
||||||
|
|
||||||
if not file_path.exists() or not file_path.is_file():
|
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).
|
ServiceError: ``not_found`` (404) or ``read_only`` (403).
|
||||||
"""
|
"""
|
||||||
root = get_vault_root(vault_name)
|
root = get_vault_root(vault_name)
|
||||||
_ensure_writable(root)
|
_ensure_writable(root, vault_name)
|
||||||
file_path = resolve_safe_path(root, path)
|
file_path = resolve_safe_path(root, path)
|
||||||
|
|
||||||
if not file_path.exists() or not file_path.is_file():
|
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).
|
``unsupported_extension`` (400) or ``already_exists`` (409).
|
||||||
"""
|
"""
|
||||||
root = get_vault_root(vault_name)
|
root = get_vault_root(vault_name)
|
||||||
_ensure_writable(root)
|
_ensure_writable(root, vault_name)
|
||||||
old_path = resolve_safe_path(root, path)
|
old_path = resolve_safe_path(root, path)
|
||||||
|
|
||||||
if not old_path.exists() or not old_path.is_file():
|
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).
|
``already_exists`` (409).
|
||||||
"""
|
"""
|
||||||
root = get_vault_root(vault_name)
|
root = get_vault_root(vault_name)
|
||||||
_ensure_writable(root)
|
_ensure_writable(root, vault_name)
|
||||||
old_path = resolve_safe_path(root, path)
|
old_path = resolve_safe_path(root, path)
|
||||||
|
|
||||||
if not old_path.exists() or not old_path.is_dir():
|
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).
|
``unsupported_extension`` (400) or ``already_exists`` (409).
|
||||||
"""
|
"""
|
||||||
root = get_vault_root(vault_name)
|
root = get_vault_root(vault_name)
|
||||||
_ensure_writable(root)
|
_ensure_writable(root, vault_name)
|
||||||
|
|
||||||
source = resolve_safe_path(root, source_path)
|
source = resolve_safe_path(root, source_path)
|
||||||
if not source.exists():
|
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.
|
destination is inside the source directory.
|
||||||
"""
|
"""
|
||||||
root = get_vault_root(vault_name)
|
root = get_vault_root(vault_name)
|
||||||
_ensure_writable(root)
|
_ensure_writable(root, vault_name)
|
||||||
|
|
||||||
source = resolve_safe_path(root, source_path)
|
source = resolve_safe_path(root, source_path)
|
||||||
if not source.exists():
|
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).
|
ServiceError: ``not_found`` (404) or ``read_only`` (403).
|
||||||
"""
|
"""
|
||||||
root = get_vault_root(vault_name)
|
root = get_vault_root(vault_name)
|
||||||
_ensure_writable(root)
|
_ensure_writable(root, vault_name)
|
||||||
file_path = resolve_safe_path(root, path)
|
file_path = resolve_safe_path(root, path)
|
||||||
|
|
||||||
if not file_path.exists() or not file_path.is_file():
|
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.
|
``invalid_arguments`` (400) when targeting the vault root.
|
||||||
"""
|
"""
|
||||||
root = get_vault_root(vault_name)
|
root = get_vault_root(vault_name)
|
||||||
_ensure_writable(root)
|
_ensure_writable(root, vault_name)
|
||||||
dir_path = resolve_safe_path(root, path)
|
dir_path = resolve_safe_path(root, path)
|
||||||
|
|
||||||
if not dir_path.exists() or not dir_path.is_dir():
|
if not dir_path.exists() or not dir_path.is_dir():
|
||||||
@@ -1855,7 +1865,7 @@ def restore_backup(
|
|||||||
or ``read_only`` (403).
|
or ``read_only`` (403).
|
||||||
"""
|
"""
|
||||||
root = get_vault_root(vault_name)
|
root = get_vault_root(vault_name)
|
||||||
_ensure_writable(root)
|
_ensure_writable(root, vault_name)
|
||||||
file_path = resolve_safe_path(root, path)
|
file_path = resolve_safe_path(root, path)
|
||||||
|
|
||||||
backup_dir = get_backup_dir(vault_name, path)
|
backup_dir = get_backup_dir(vault_name, path)
|
||||||
@@ -1931,7 +1941,7 @@ def save_raw_file(
|
|||||||
Dict with ``success``, ``vault``, ``path``, and ``size``.
|
Dict with ``success``, ``vault``, ``path``, and ``size``.
|
||||||
"""
|
"""
|
||||||
root = get_vault_root(vault_name)
|
root = get_vault_root(vault_name)
|
||||||
_ensure_writable(root)
|
_ensure_writable(root, vault_name)
|
||||||
file_path = resolve_safe_path(root, path)
|
file_path = resolve_safe_path(root, path)
|
||||||
_validate_extension(file_path, allow_images=True, allow_docs=allow_docs)
|
_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).
|
and ``errors`` (list of error dicts).
|
||||||
"""
|
"""
|
||||||
root = get_vault_root(vault_name)
|
root = get_vault_root(vault_name)
|
||||||
_ensure_writable(root)
|
_ensure_writable(root, vault_name)
|
||||||
|
|
||||||
clean_target = (target_dir or "").strip().strip("/\\")
|
clean_target = (target_dir or "").strip().strip("/\\")
|
||||||
uploaded: list[str] = []
|
uploaded: list[str] = []
|
||||||
|
|||||||
@@ -215,8 +215,9 @@ def list_paths(vault: str, limit: int = 5000) -> dict[str, Any]:
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from backend.indexer import get_vault_data, path_index
|
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]] = {}
|
meta: dict[str, dict[str, Any]] = {}
|
||||||
data = get_vault_data(vault)
|
data = get_vault_data(vault)
|
||||||
if data:
|
if data:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Single source of truth consumed by both the REST routes (``/api/vaults``,
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fnmatch
|
||||||
from datetime import UTC
|
from datetime import UTC
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -15,6 +16,68 @@ from backend.services.errors import ServiceError
|
|||||||
from backend.services.paths import resolve_safe_path
|
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]]:
|
def list_accessible_vaults(user: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
"""Return the vaults *user* may access, with summary metadata."""
|
"""Return the vaults *user* may access, with summary metadata."""
|
||||||
from backend.auth.middleware import check_vault_access
|
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", [])),
|
"file_count": len(data.get("files", [])),
|
||||||
"tag_count": len(data.get("tags", {})),
|
"tag_count": len(data.get("tags", {})),
|
||||||
"type": data.get("config", {}).get("type", "VAULT"),
|
"type": data.get("config", {}).get("type", "VAULT"),
|
||||||
|
"readonly": bool(data.get("config", {}).get("readonly", False)),
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -58,7 +122,7 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
|||||||
root = get_vault_root(vault_name)
|
root = get_vault_root(vault_name)
|
||||||
target = resolve_safe_path(root, path) if path else root.resolve()
|
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(
|
raise ServiceError(
|
||||||
f"Path not found: {path}",
|
f"Path not found: {path}",
|
||||||
code="not_found",
|
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("."):
|
if hide_hidden and entry.name.startswith("."):
|
||||||
continue
|
continue
|
||||||
rel = str(entry.relative_to(root)).replace("\\", "/")
|
rel = str(entry.relative_to(root)).replace("\\", "/")
|
||||||
|
if chemin_exclu(vault_name, rel):
|
||||||
|
continue
|
||||||
if entry.is_dir():
|
if entry.is_dir():
|
||||||
# Count only direct children (files and subdirs) for performance.
|
# Count only direct children (files and subdirs) for performance.
|
||||||
try:
|
try:
|
||||||
file_count = sum(
|
file_count = sum(
|
||||||
1 for child in entry.iterdir()
|
1 for child in entry.iterdir()
|
||||||
if (not hide_hidden or not child.name.startswith("."))
|
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"))
|
and (child.is_file() and (child.suffix.lower() in SUPPORTED_EXTENSIONS or child.name.lower() in ("dockerfile", "makefile"))
|
||||||
or child.is_dir())
|
or child.is_dir())
|
||||||
)
|
)
|
||||||
@@ -200,6 +267,9 @@ def list_all_files(
|
|||||||
stat = entry.stat()
|
stat = entry.stat()
|
||||||
rel_path = str(entry.relative_to(root)).replace("\\", "/")
|
rel_path = str(entry.relative_to(root)).replace("\\", "/")
|
||||||
|
|
||||||
|
if chemin_exclu(vault_name, rel_path):
|
||||||
|
continue
|
||||||
|
|
||||||
if recursive and dir_prefix:
|
if recursive and dir_prefix:
|
||||||
try:
|
try:
|
||||||
rel_to_dir = str(entry.parent.relative_to(dir_path)).replace("\\", "/")
|
rel_to_dir = str(entry.parent.relative_to(dir_path)).replace("\\", "/")
|
||||||
|
|||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.82.8"
|
version = "2.83.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.82.8"
|
version = "2.83.0"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.82.8",
|
"version": "2.83.0",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# 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
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.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`.
|
||||||
+29
-18
@@ -209,6 +209,17 @@ function _openSecretsMenu(x, y) {
|
|||||||
}, 0);
|
}, 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. */
|
/** Fichier partagé (créé ou reçu) → icône share-2 bleue, sinon icône usuelle. */
|
||||||
function sharedFileIconSync(sharedPaths, itemName, vaultName, itemPath) {
|
function sharedFileIconSync(sharedPaths, itemName, vaultName, itemPath) {
|
||||||
if (sharedPaths && sharedPaths.has(`${vaultName}::${itemPath}`)) return _sharedIcon();
|
if (sharedPaths && sharedPaths.has(`${vaultName}::${itemPath}`)) return _sharedIcon();
|
||||||
@@ -356,11 +367,11 @@ async function refreshSidebarForContext() {
|
|||||||
|
|
||||||
vaultItem.addEventListener("contextmenu", (e) => {
|
vaultItem.addEventListener("contextmenu", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const isReadonly = false;
|
const isReadonly = vaultIsReadonly(v.name);
|
||||||
ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly);
|
ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly);
|
||||||
});
|
});
|
||||||
attachTreeItemActionButton(vaultItem, v.name, "", "vault", false);
|
attachTreeItemActionButton(vaultItem, v.name, "", "vault", vaultIsReadonly(v.name));
|
||||||
attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false }));
|
attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: vaultIsReadonly(v.name) }));
|
||||||
|
|
||||||
container.appendChild(vaultItem);
|
container.appendChild(vaultItem);
|
||||||
|
|
||||||
@@ -460,11 +471,11 @@ async function loadVaults() {
|
|||||||
|
|
||||||
vaultItem.addEventListener("contextmenu", (e) => {
|
vaultItem.addEventListener("contextmenu", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const isReadonly = false;
|
const isReadonly = vaultIsReadonly(v.name);
|
||||||
ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly);
|
ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly);
|
||||||
});
|
});
|
||||||
attachTreeItemActionButton(vaultItem, v.name, "", "vault", false);
|
attachTreeItemActionButton(vaultItem, v.name, "", "vault", vaultIsReadonly(v.name));
|
||||||
attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false }));
|
attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: vaultIsReadonly(v.name) }));
|
||||||
|
|
||||||
container.appendChild(vaultItem);
|
container.appendChild(vaultItem);
|
||||||
|
|
||||||
@@ -596,8 +607,8 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
|
|||||||
// New item — create it
|
// New item — create it
|
||||||
if (item.type === "directory") {
|
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)]);
|
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);
|
attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", vaultIsReadonly(vaultName));
|
||||||
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false }));
|
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: vaultIsReadonly(vaultName) }));
|
||||||
fragment.appendChild(dirItem);
|
fragment.appendChild(dirItem);
|
||||||
|
|
||||||
const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` });
|
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) => {
|
dirItem.addEventListener("contextmenu", (e) => {
|
||||||
e.preventDefault();
|
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 {
|
} else {
|
||||||
const fileIconName = getFileIcon(item.name);
|
const fileIconName = getFileIcon(item.name);
|
||||||
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : 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)])]);
|
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);
|
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", vaultIsReadonly(vaultName));
|
||||||
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: vaultIsReadonly(vaultName) }));
|
||||||
fileItem.addEventListener("click", () => {
|
fileItem.addEventListener("click", () => {
|
||||||
scrollTreeItemIntoView(fileItem, false);
|
scrollTreeItemIntoView(fileItem, false);
|
||||||
if (window.innerWidth <= 768) {
|
if (window.innerWidth <= 768) {
|
||||||
@@ -649,7 +660,7 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
|
|||||||
|
|
||||||
fileItem.addEventListener("contextmenu", (e) => {
|
fileItem.addEventListener("contextmenu", (e) => {
|
||||||
e.preventDefault();
|
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);
|
fragment.appendChild(fileItem);
|
||||||
@@ -944,8 +955,8 @@ async function loadDirectory(vaultName, dirPath, container) {
|
|||||||
|
|
||||||
if (item.type === "directory") {
|
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)]);
|
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);
|
attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", vaultIsReadonly(vaultName));
|
||||||
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false }));
|
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: vaultIsReadonly(vaultName) }));
|
||||||
fragment.appendChild(dirItem);
|
fragment.appendChild(dirItem);
|
||||||
|
|
||||||
const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` });
|
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) => {
|
dirItem.addEventListener("contextmenu", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const isReadonly = false;
|
const isReadonly = vaultIsReadonly(vaultName);
|
||||||
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'directory', isReadonly);
|
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'directory', isReadonly);
|
||||||
});
|
});
|
||||||
} else {
|
} 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 displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
|
||||||
const isShared = sharedPaths.has(`${vaultName}::${item.path}`);
|
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)])]);
|
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);
|
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", vaultIsReadonly(vaultName));
|
||||||
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: vaultIsReadonly(vaultName) }));
|
||||||
fileItem.addEventListener("click", () => {
|
fileItem.addEventListener("click", () => {
|
||||||
scrollTreeItemIntoView(fileItem, false);
|
scrollTreeItemIntoView(fileItem, false);
|
||||||
if (window.innerWidth <= 768) {
|
if (window.innerWidth <= 768) {
|
||||||
@@ -999,7 +1010,7 @@ async function loadDirectory(vaultName, dirPath, container) {
|
|||||||
|
|
||||||
fileItem.addEventListener("contextmenu", (e) => {
|
fileItem.addEventListener("contextmenu", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const isReadonly = false;
|
const isReadonly = vaultIsReadonly(vaultName);
|
||||||
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'file', isReadonly);
|
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'file', isReadonly);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"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.",
|
"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",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class TestListAccessibleVaults:
|
|||||||
|
|
||||||
def test_metadata_fields(self, client):
|
def test_metadata_fields(self, client):
|
||||||
vault = next(v for v in list_accessible_vaults(ADMIN) if v["name"] == "TestVault")
|
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
|
assert vault["file_count"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user