Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f783394f09 | ||
|
|
2be0b15bcf | ||
|
|
de6bde1613 | ||
|
|
fdc9f47a0d | ||
|
|
49c715199d | ||
|
|
fd192df036 | ||
|
|
8662d23ec8 | ||
|
|
10453d8dfe | ||
|
|
8235d632b8 | ||
|
|
f4c8504c8d | ||
|
|
4fb7c43e06 | ||
|
|
54caa48d1f | ||
|
|
f00a8bea8f | ||
|
|
55a9fcc0f4 | ||
|
|
4cdea956d7 | ||
|
|
ae06436f91 | ||
|
|
d0e10d4cc6 | ||
|
|
c94f065f80 | ||
|
|
36b4962efb | ||
|
|
e01e837a2a | ||
|
|
634ba8a272 | ||
|
|
ce1944d437 | ||
|
|
39bad990c0 | ||
|
|
431d6e9338 | ||
|
|
d453969708 | ||
|
|
778fa65b4c | ||
|
|
69c3817579 | ||
|
|
b89af917b1 | ||
|
|
d79202e698 | ||
|
|
4ce677902a | ||
|
|
fe9f7b49f7 | ||
|
|
649f965c52 | ||
|
|
d6fa8c7bb1 | ||
|
|
d2734dc8ce | ||
|
|
bab272bb5c | ||
|
|
34428ea7b3 | ||
|
|
83e6a951dd | ||
|
|
3d618eb660 | ||
|
|
2add24a9c1 | ||
|
|
a50227849d | ||
|
|
5ffc9d851a | ||
|
|
1e0e419210 | ||
|
|
2813d546a7 | ||
|
|
19e94dfa51 | ||
|
|
49f97fc26e | ||
|
|
562290d922 | ||
|
|
66a5505965 | ||
|
|
c5c225a68e | ||
|
|
1bacfd69d9 | ||
|
|
383ffa6a65 | ||
|
|
69927176df | ||
|
|
5c2ae26a74 | ||
|
|
3f52b56251 | ||
|
|
72da123a51 | ||
|
|
e94af0369b | ||
|
|
8da65611cb | ||
|
|
605060c51d | ||
|
|
856e654306 | ||
|
|
4de9ee038c | ||
|
|
290d62da4e | ||
|
|
140e9a679d | ||
|
|
267a33d43b | ||
|
|
435a0687d7 | ||
|
|
dbf935bec0 | ||
|
|
d6d081c0e9 | ||
|
|
c72f852a55 | ||
|
|
99779ecc08 | ||
|
|
c4b8e66206 | ||
|
|
ca6407e0c0 | ||
|
|
dff32a97ee | ||
|
|
d5c528fead | ||
|
|
38f39a10ae | ||
|
|
48e023ba25 | ||
|
|
011ec84f23 | ||
|
|
472ea9d309 | ||
|
|
6ba04c4381 | ||
|
|
06f8e63d06 | ||
|
|
31d4616baf | ||
|
|
4c4b1222d5 | ||
|
|
a3973b981c | ||
|
|
b6e2029770 | ||
|
|
6b878caff3 | ||
|
|
e9b7a317c1 | ||
|
|
14b8032635 | ||
|
|
7dfe26c83d | ||
|
|
24229316c7 | ||
|
|
7d70e0fb75 | ||
|
|
d70ecd0968 | ||
|
|
36a4030c09 | ||
|
|
330462e7a5 | ||
|
|
922dfa2e79 | ||
|
|
34fce932cb | ||
|
|
18b1e13f34 | ||
|
|
7bee4a237d | ||
|
|
d6cca2b1af | ||
|
|
58312e64da | ||
|
|
3b0927a8c9 | ||
|
|
6e527c371d | ||
|
|
6cccdc1f34 | ||
|
|
0abc17e9f2 | ||
|
|
b83d8dacdf | ||
|
|
dadc055429 | ||
|
|
750114a923 | ||
|
|
83a81da319 | ||
|
|
3eb0256127 | ||
|
|
9d8b3cc854 |
+8
-2
@@ -12,8 +12,12 @@ OBSIGATE_ADMIN_PASSWORD=chab30
|
||||
# (ex. 0.0.0.0) sauf si l'on force l'opt-in ci-dessous. À réserver au local.
|
||||
# OBSIGATE_ALLOW_INSECURE=false
|
||||
|
||||
# Sécurité des cookies (activer si derrière HTTPS)
|
||||
# OBSIGATE_SECURE_COOKIES=false
|
||||
# Sécurité des cookies : true|false|auto (défaut : auto — Secure si la
|
||||
# requête arrive en https, sinon pas de flag ; les navigateurs ignorent les
|
||||
# cookies `Secure` en HTTP, ce qui casserait les logins en local).
|
||||
# Derrière un reverse proxy qui termine TLS, auto suffit avec
|
||||
# OBSIGATE_TRUST_PROXY=true (X-Forwarded-Proto honoré).
|
||||
# OBSIGATE_SECURE_COOKIES=auto
|
||||
|
||||
# Tokens TTL en secondes
|
||||
# OBSIGATE_ACCESS_TOKEN_TTL=31536000000 # 1000 ans
|
||||
@@ -23,6 +27,8 @@ OBSIGATE_ADMIN_PASSWORD=chab30
|
||||
# OBSIGATE_LOGIN_MAX_ATTEMPTS=10
|
||||
# OBSIGATE_ACCOUNT_MAX_ATTEMPTS=10
|
||||
# OBSIGATE_LOGIN_WINDOW_SECONDS=900
|
||||
# Compteurs partagés/persistants (SQLite WAL, multi-workers) — défaut : mémoire.
|
||||
# OBSIGATE_RATELIMIT_DB=data/ratelimit.db
|
||||
|
||||
# IP client derrière un reverse proxy (fait confiance à X-Forwarded-For)
|
||||
# OBSIGATE_TRUST_PROXY=false
|
||||
|
||||
+76
-5
@@ -38,14 +38,19 @@ jobs:
|
||||
- name: Frontend unit tests
|
||||
run: |
|
||||
node tests/frontend/unit.test.mjs
|
||||
node tests/frontend/navfacets.test.mjs
|
||||
node tests/frontend/desktop-roots.test.mjs
|
||||
node tests/frontend/image-viewer.test.mjs
|
||||
node tests/frontend/pdf-viewer.test.mjs
|
||||
node tests/frontend/forge-completion.test.mjs
|
||||
node tests/frontend/config-mobile.test.mjs
|
||||
node tests/frontend/settings-order-avatar.test.mjs
|
||||
node tests/frontend/mobile-toolbar.test.mjs
|
||||
node tests/frontend/pretty.test.mjs
|
||||
node tests/frontend/media-viewer.test.mjs
|
||||
node tests/frontend/mfa-settings.test.mjs
|
||||
|
||||
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition)
|
||||
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition + Upload + XLSX)
|
||||
run: |
|
||||
cd tests/frontend
|
||||
if [ -d node_modules ]; then
|
||||
@@ -55,6 +60,7 @@ jobs:
|
||||
node ai.test.mjs
|
||||
node ai-sidebar.test.mjs
|
||||
node sidebar-filters.test.mjs
|
||||
node search-facets.test.mjs
|
||||
node sw.test.mjs
|
||||
node collab.test.mjs
|
||||
node mobile-editor.test.mjs
|
||||
@@ -63,6 +69,10 @@ jobs:
|
||||
node toolbar-order.test.mjs
|
||||
node editor-inline.test.mjs
|
||||
node ai-quick-actions.test.mjs
|
||||
node upload.test.mjs
|
||||
node config-ai-keys.test.mjs
|
||||
node xlsx-viewer.test.mjs
|
||||
node filechat.test.mjs
|
||||
else
|
||||
echo "tests/frontend/node_modules missing - installing jsdom"
|
||||
npm install --no-audit --no-fund --silent
|
||||
@@ -72,6 +82,7 @@ jobs:
|
||||
node ai.test.mjs
|
||||
node ai-sidebar.test.mjs
|
||||
node sidebar-filters.test.mjs
|
||||
node search-facets.test.mjs
|
||||
node sw.test.mjs
|
||||
node collab.test.mjs
|
||||
node mobile-editor.test.mjs
|
||||
@@ -80,6 +91,10 @@ jobs:
|
||||
node toolbar-order.test.mjs
|
||||
node editor-inline.test.mjs
|
||||
node ai-quick-actions.test.mjs
|
||||
node upload.test.mjs
|
||||
node config-ai-keys.test.mjs
|
||||
node xlsx-viewer.test.mjs
|
||||
node filechat.test.mjs
|
||||
fi
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────
|
||||
@@ -122,15 +137,68 @@ jobs:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
# setuptools / pip sont mis à jour : l'image de base peut embarquer
|
||||
# une version couverte par un advisory fraîchement publié
|
||||
# (PYSEC-2026-3447 / PYSEC-2026-3721).
|
||||
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
|
||||
run: |
|
||||
pip install -U pip setuptools
|
||||
pip install bandit pip-audit
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Bandit (SAST)
|
||||
run: bandit -r backend/ --skip B101,B110,B310 || echo "bandit found issues (non-blocking)"
|
||||
- name: Bandit (SAST, bloquant — #87)
|
||||
# B105 est exclu (aligné avec [tool.bandit] de pyproject.toml :
|
||||
# faux positifs systématiques sur les noms de variables) ; les rares
|
||||
# vrais positifs restants portent un `# nosec` justifié inline.
|
||||
run: bandit -r backend/ --skip B101,B105,B110,B310
|
||||
|
||||
- name: Pip-audit (dependency vulnerabilities)
|
||||
run: pip-audit || echo "pip-audit found vulnerabilities (non-blocking)"
|
||||
- name: Semgrep (SAST local) — DÉSACTIVÉ (BUG-091)
|
||||
# Les règles locales (semgrep-rules/, 8 règles) ne sont plus exécutées
|
||||
# en CI : semgrep-core est un exécutable natif que le runner actuel ne
|
||||
# peut pas lancer (exit 127, sans message exploitable) — les releases
|
||||
# récentes exigent un CPU x86-64-v2, et la dernière version compatible
|
||||
# (1.157.0, core statique vérifié en baseline v1) échoue aussi. Les
|
||||
# règles restent applicables en local : `semgrep --config semgrep-rules/
|
||||
# backend/`. À réactiver dès que le runner dispose d'un CPU x86-64-v2
|
||||
# (ou d'une image de runner plus récente). Bandit et pip-audit, eux,
|
||||
# restent bloquants dans ce job.
|
||||
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "::warning::SAST semgrep non exécutée (runner incompatible — BUG-091). Bandit et pip-audit restent bloquants."
|
||||
|
||||
- name: Pip-audit (bloquant — #87)
|
||||
# Bloquant depuis T6 (#87) : dépendances qualifiées (mistune 3.3.3,
|
||||
# python-multipart 0.0.31, weasyprint 70, mcp 1.28.1, fastapi 0.141.1
|
||||
# + starlette 1.7.0, setuptools 84 — suite complète verte + 0 vuln).
|
||||
# Seule exception documentée : PYSEC-2026-1325 (ecdsa, Minerva) —
|
||||
# aucun correctif upstream ET ObsiGate ne signe/vérifie qu'en HS256
|
||||
# (backend/auth/jwt_handler.py), les chemins ECDSA P-256 ne
|
||||
# s'exécutent jamais. Les advisories pyjwt (PYSEC-2026-178 puis
|
||||
# CVE-2026-102274) sont corrigées par le plancher pyjwt>=2.14.0 de
|
||||
# backend/requirements.txt (BUG-091, BUG-095).
|
||||
# PYSEC-2026-3910 / PYSEC-2026-3911 (pypdf, DoS de ressources sur
|
||||
# l'extraction de texte et la lecture d'outlines — donc atteignables
|
||||
# via backend/pdf_reader.py) sont corrigés par le plancher
|
||||
# pypdf>=6.16.1 (BUG-093).
|
||||
# CVE-2026-97687 / CVE-2026-97688 / CVE-2026-97689 (urllib3 2.7.0)
|
||||
# corrigés par le plancher urllib3>=2.8.0.
|
||||
# CVE-2026-104874 (multidict 6.7.x) corrigé par le plancher
|
||||
# multidict>=6.9.1 (transitive aiohttp/yarl ; 6.7.x est dans la
|
||||
# toolcache de l'image du runner — même piège « already satisfied »).
|
||||
# CVE-2026-85394 (python-jose ≤3.5.0, forgery HS256 par clé publique
|
||||
# DER passée comme secret HMAC) : AUCUN correctif upstream (projet
|
||||
# sans release depuis 2025). Non atteignable dans ObsiGate :
|
||||
# jwt.decode passe toujours algorithms=["HS256"] et un secret
|
||||
# symétrique serveur (backend/auth/jwt_handler.py,
|
||||
# backend/mcp/confirmations.py) — jamais une clé publique comme clé.
|
||||
# Ces planchers doivent rester *au-dessus* des versions préinstallées
|
||||
# dans la toolcache de l'image du runner : en dessous, pip répond
|
||||
# « already satisfied » et n'aligne jamais (c'est exactement ce qui a
|
||||
# fait échouer ce job). Le garde-fou tests/test_ci_workflow.py::
|
||||
# TestDependencySecurityFloors verrouille ces planchers.
|
||||
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
|
||||
run: pip-audit --ignore-vuln PYSEC-2026-1325 --ignore-vuln CVE-2026-85394
|
||||
|
||||
# ── Docker build ──────────────────────────────────────────────────
|
||||
build:
|
||||
@@ -192,6 +260,9 @@ jobs:
|
||||
npm ci
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
- name: Npm audit (bloquant — #87, 0 dépendance prod hors Playwright)
|
||||
run: npm audit --omit=dev
|
||||
|
||||
- name: Start ObsiGate
|
||||
run: |
|
||||
docker rm -f obsigate-e2e 2>/dev/null || true
|
||||
|
||||
+15
@@ -31,6 +31,21 @@ desktop/backend/
|
||||
desktop/frontend/
|
||||
backend/VERSION
|
||||
|
||||
# Artefacts générés par les runs E2E (excalidraw crée ces diagrammes)
|
||||
test_vault/IT/e2e-diagram-*.excalidraw
|
||||
|
||||
# Fixtures de test locales non versionnées (~200 Mo, pas de fixture CI).
|
||||
# Aucun test/CI ne les référence : les tests unitaires génèrent leurs fixtures
|
||||
# dans tmp_path (tests/conftest.py), et l'E2E n'utilise que les fixtures
|
||||
# committées (test_vault/sample-*.{mp3,png,svg,webm,pdf}, test_dir/*.md).
|
||||
# → à committer volontairement : `git add -f <chemin>`.
|
||||
test_dir/music/
|
||||
test_dir/video/
|
||||
test_vault/images/
|
||||
test_vault/markdown/
|
||||
test_vault/budget.xlsx
|
||||
test_home/
|
||||
|
||||
# Tauri updater signing keys (private key — never commit)
|
||||
desktop/*.key
|
||||
desktop/*.key.pub
|
||||
|
||||
+1987
-1
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,7 @@ COPY VERSION ./VERSION
|
||||
# Using explicit UID/GID 1000 to match common host user and docker-compose settings
|
||||
RUN groupadd -g 1000 obsigate && useradd -u 1000 -g obsigate -d /app -s /sbin/nologin obsigate \
|
||||
&& mkdir -p /app/data \
|
||||
&& chmod 777 /app/data \
|
||||
&& chown -R obsigate:obsigate /app
|
||||
USER obsigate
|
||||
|
||||
|
||||
+9
-8
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -23,7 +23,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
|
||||
| Guide | Contenu |
|
||||
|---|---|
|
||||
| 🚀 [Prise en main](docs/GUIDES/PRISE_EN_MAIN.md) | Premier lancement, interface, navigation, vaults, raccourcis |
|
||||
| 🔍 [Recherche, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Syntaxe de requête, recherche sémantique, lecteur PDF, diagrammes |
|
||||
| 🔍 [Recherche, PDF, Excel & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Syntaxe de requête, recherche sémantique, lecteurs PDF/Excel, diagrammes |
|
||||
| 🤖 [Assistant IA & Forge](docs/GUIDES/ASSISTANT_IA_FORGE.md) | Fournisseurs, éditeur IA, BooksLM, Forge, commandes `@` / `/` |
|
||||
| 📝 [Édition & collaboration](docs/GUIDES/COLLABORATION.md) | Édition simultanée, curseurs distants, persistance |
|
||||
| 📱 [PWA & hors-ligne](docs/GUIDES/PWA_HORS_LIGNE.md) | Installation, cache hors-ligne, file de synchro, notifications |
|
||||
@@ -75,7 +75,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
|
||||
- **📱 Éditeur mobile natif** — Édition optimisée pour le tactile : barre d'outils Markdown flottante (gras/italique/code/liste/lien), bouton « Coller » persistant (contournement iOS), zoom par pincement et hauteur ajustable, raccourcis swipe (liens entrants / table des matières) et mode lecture plein écran avec navigation entre fichiers ([détail](docs/features/mobile-editor.md))
|
||||
- **🗺️ Vue graphe interactive** — Canvas force-directed avec Barnes-Hut O(n log n), filtres (tag, type), profondeur, mode focus, historique de navigation ←→↑, export PNG, aperçu au survol (Ctrl+click)
|
||||
- **🗂️ Multi-vault** : Visualisez plusieurs vaults Obsidian simultanément
|
||||
- **🌳 Navigation arborescente** : Parcourez vos dossiers et fichiers dans la sidebar
|
||||
- **🌳 Navigation arborescente** : Parcourez vos dossiers et fichiers dans la sidebar ; chaque clic sur un répertoire de l'arbre ouvre un **onglet de navigation** — chemin, récents, sous-répertoires cliquables, facettes Vaults · Tags · Extensions, tri Pertinence/Date et enregistrement du répertoire — qui coexiste avec vos fichiers ouverts
|
||||
- **🔍 Recherche avancée** : Moteur TF-IDF avec stemming français, normalisation des accents, snippets surlignés, facettes, pagination et tri — plus une **recherche sémantique** optionnelle (embeddings `all-MiniLM-L6-v2`, fusion hybride TF-IDF + RRF) activable via le toggle `~` ([détail](docs/features/semantic-search.md))
|
||||
- **💡 Autocomplétion intelligente** : Suggestions de fichiers, tags et historique avec navigation clavier
|
||||
- **🧩 Syntaxe de requête** : Opérateurs `tag:`, `#`, `vault:`, `title:`, `path:`, `ext:` avec chips visuels
|
||||
@@ -85,7 +85,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
|
||||
- **🖼️ Images Obsidian** : Support complet des syntaxes d'images Obsidian avec résolution intelligente
|
||||
- **🎬 Audio & vidéo** : Lecteurs HTML5 intégrés (`.mp3 .wav .flac .mp4 .webm`…) avec streaming HTTP Range (lecture, déplacement, plein écran) et **lecture persistante** (mini-lecteur flottant / mini-fenêtre vidéo, retour au média ou arrêt à tout moment, contrôles écran verrouillé via Media Session), repli téléchargement si le format n'est pas lisible par le navigateur
|
||||
- **🎨 Diagrammes Excalidraw** : Visualiseur/éditeur natif des fichiers `.excalidraw` et `.excalidraw.md` (iframe sandboxée, auto-save, thème clair/sombre, texte des diagrammes indexé pour la recherche)
|
||||
- **📊 Tableurs Excel** : les fichiers `.xlsx` s'ouvrent dans un visualiseur dédié — un tableau par feuille avec onglets, en-têtes A1 et édition directe des cellules (`PUT /api/file/{vault}/xlsx/save`, backup automatique), plus le téléchargement du fichier d'origine
|
||||
- **📊 Tableurs Excel** : les fichiers `.xlsx` et `.xlsm` s'ouvrent dans un visualiseur dédié — un tableau par feuille avec onglets, en-têtes A1 et édition directe des cellules (`PUT /api/file/{vault}/xlsx/save`, backup automatique, écriture atomique), plus le téléchargement du fichier d'origine. Le visualiseur rend polices, couleurs, cellules fusionnées et volets figés, et offre navigation et raccourcis clavier (`Ctrl+S`, `Suppr`, `F2`, `Ctrl+Origine/Fin`, `PgPréc/PgSuiv`, `Ctrl+flèches`), barre de formule avec noms de fonctions, zone Nom éditable (« Atteindre » `A1:B3`), presse-papiers de plage (copier/couper/coller un bloc, depuis ou vers Excel), un menu **Mise en forme** (gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur — `PUT /api/file/{vault}/xlsx/style`), tri/filtre/recherche sur toutes les feuilles, export CSV/Markdown/HTML et impression (sélection ou feuille), édition de la structure (feuilles, lignes, colonnes) et un tableau de bord du classeur (plages nommées, détection graphiques/TCD, stats par feuille) ; un `.csv` s'édite dans la même grille (RFC 4180) tandis que `.xls` et `.ods` s'ouvrent en lecture seule. Les classeurs contenant des éléments qu'ObsiGate ne peut pas conserver (valeurs calculées, segments, contrôles de formulaire, signature…) affichent un **avertissement** et demandent confirmation avant l'enregistrement ; une saisie commençant par `=` ou `@` est stockée comme texte sauf activation du bouton `f(x)`, et les écritures concurrentes d'un autre poste sont détectées (`If-Match` → « Réessayer »). L'assistant IA peut lister les feuilles, injecter un tableau borné dans son contexte, rechercher dans le classeur, analyser une plage, modifier des cellules et ajouter des lignes — sur `.xlsx`, `.xlsm` et `.csv`. Sur mobile (≤ 768 px), la barre de menus et le ruban sont **repliés par défaut** — un bouton ☰ les déplie — pour que la grille occupe toute la hauteur d'écran
|
||||
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
|
||||
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
|
||||
- **📡 Synchronisation temps réel** : Surveillance automatique des fichiers via watchdog avec mise à jour incrémentale de l'index
|
||||
@@ -222,6 +222,7 @@ Les vaults sont configurées par paires `VAULT_N_NAME` / `VAULT_N_PATH` (N = 1,
|
||||
| `VAULT_1_PATH` | Chemin dans le conteneur | `/vaults/Obsidian-RECETTES` |
|
||||
| `VAULT_1_ATTACHMENTS_PATH` | Dossier d'attachements (optionnel) | `06_Boite_a_Outils/6.2_Attachments` |
|
||||
| `VAULT_1_SCAN_ATTACHMENTS` | Scan d'images au démarrage (défaut : true) | `true` |
|
||||
| `OBSIGATE_HOME_ROOT` | Racine des dossiers personnels (un vault `home-<user>` par compte). Absente = fonctionnalité désactivée. | `/vaults/Home` |
|
||||
|
||||
**Règles de nommage :** lettres, chiffres et tirets uniquement ; pas d'espaces ; le nom doit correspondre au chemin dans le conteneur.
|
||||
|
||||
@@ -673,7 +674,7 @@ curl "http://localhost:2020/api/file/Recettes?path=pizza.md"
|
||||
|
||||
## 🔍 Recherche avancée
|
||||
|
||||
> 📖 Guide complet : [Recherche, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
|
||||
> 📖 Guide complet : [Recherche, PDF, Excel & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
|
||||
|
||||
### Syntaxe de requête
|
||||
|
||||
@@ -802,7 +803,7 @@ Configurables via l'interface (Settings) ou l'API `/api/config`.
|
||||
- **Rate limiting** : 10 tentatives de login max par IP sur 15 minutes + lockout par compte (5 tentatives)
|
||||
- **Audit log** : écritures/suppressions/config journalisées dans `data/audit.log` (JSON lines, rotation 10 MB)
|
||||
- **Backup automatique** : chaque modification/suppression sauvegardée dans `.obsigate-backup/` avec timestamp
|
||||
- **Secret redaction** : masquage automatique des JWT, clés API, tokens dans les aperçus
|
||||
- **Secret redaction** : masquage automatique des JWT, mots de passe, clés API (OpenAI, GitHub, Google, AWS, Slack, Stripe…), tokens dans les aperçus — cliquez sur un masque pour copier la valeur
|
||||
- **Utilisateur non-root** : conteneur Docker sous `obsigate` (UID 1000)
|
||||
- **Volumes read-only** : vaults montées en `:ro` par défaut
|
||||
- **Secrets dans `.env`** : jamais dans `docker-compose.yml`
|
||||
@@ -976,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.1).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.68.0).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.27.1 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.68.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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -21,7 +21,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
|
||||
| Guide | What it covers |
|
||||
|---|---|
|
||||
| 🚀 [Getting Started](docs/GUIDES/PRISE_EN_MAIN.md) | First run, interface, navigation, vaults, shortcuts |
|
||||
| 🔍 [Search, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Query syntax, semantic search, PDF viewer, diagrams |
|
||||
| 🔍 [Search, PDF, Excel & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Query syntax, semantic search, PDF/Excel viewers, diagrams |
|
||||
| 🤖 [AI Assistant & Forge](docs/GUIDES/ASSISTANT_IA_FORGE.md) | Providers, AI editor, BooksLM, Forge, `@` / `/` commands |
|
||||
| 📝 [Editing & Collaboration](docs/GUIDES/COLLABORATION.md) | Simultaneous editing, remote cursors, persistence |
|
||||
| 📱 [PWA & Offline](docs/GUIDES/PWA_HORS_LIGNE.md) | Install as an app, offline cache, sync queue, push |
|
||||
@@ -74,7 +74,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
|
||||
- **📱 Native Mobile Editor** — Touch-optimised editing: floating Markdown toolbar (bold/italic/code/list/link), persistent Paste button (iOS workaround), pinch-zoom font & adjustable height, swipe shortcuts (backlinks / table of contents) and a full-screen reading mode with page navigation ([details](docs/features/mobile-editor.md))
|
||||
- **🗺️ Interactive Graph View** — Canvas force-directed with Barnes-Hut O(n log n), filters (tag, type), depth, focus mode, navigation history ←→↑, export PNG, preview on hover (Ctrl+click)
|
||||
- **🗂️ Multi-vault** : View multiple Obsidian vaults simultaneously
|
||||
- **🌳 Tree Navigation** : Browse your folders and files in the sidebar
|
||||
- **🌳 Tree Navigation** : Browse your folders and files in the sidebar; clicking a folder in the tree opens a dedicated **navigation tab** — path, recents, clickable subfolders, Vaults · Tags · Extensions facets, Pertinence/Date sorting and save-as-search — coexisting with your open files
|
||||
- **🔍 Advanced Search** : TF-IDF search engine with French stemming, accent normalization, highlighted snippets, facets, pagination, and sorting — plus an optional **semantic search** (embeddings via `all-MiniLM-L6-v2`, hybrid TF-IDF + RRF fusion) toggled with `~` ([details](docs/features/semantic-search.md))
|
||||
- **💡 Smart Autocomplete** : Suggestions for files, tags, and history with keyboard navigation
|
||||
- **🧩 Query Syntax** : Operators `tag:`, `#`, `vault:`, `title:`, `path:`, `ext:` with visual chips
|
||||
@@ -84,7 +84,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
|
||||
- **🖼️ Obsidian Images** : Full support for all Obsidian image syntaxes with intelligent resolution
|
||||
- **🎬 Audio & video** : Built-in HTML5 players (`.mp3 .wav .flac .mp4 .webm`…) with HTTP Range streaming (play, seek, fullscreen) and **persistent playback** (floating mini-player / mini video window, return to media or stop anytime, lock-screen controls via Media Session), falling back to download when the format is not playable in the browser
|
||||
- **🎨 Excalidraw Diagrams** : Native viewer/editor for `.excalidraw` and `.excalidraw.md` files (sandboxed iframe, autosave, dark/light theme, diagram text indexed for search)
|
||||
- **📊 Excel Spreadsheets** : `.xlsx` files open in a dedicated viewer — one table per sheet with tabs, A1 headers and inline cell editing (`PUT /api/file/{vault}/xlsx/save`, automatic backup), plus download of the original file
|
||||
- **📊 Excel Spreadsheets** : `.xlsx` and `.xlsm` files open in a dedicated viewer — one table per sheet with tabs, A1 headers and inline cell editing (`PUT /api/file/{vault}/xlsx/save`, automatic backup, atomic write), plus download of the original file. The viewer renders fonts, colors, merged cells and frozen panes, offers keyboard navigation and shortcuts (`Ctrl+S`, `Delete`, `F2`, `Ctrl+Home/End`, `PgUp/PgDn`, `Ctrl+arrows`), a formula bar with function suggestions, an editable Name Box ("go to" `A1:B3`), a range clipboard (copy/cut/paste a block, from or to Excel), a **Format** menu (bold/italic/underline, alignments, font & fill colours, number formats, merges, frozen panes, column width/row height — `PUT /api/file/{vault}/xlsx/style`), sort/filter/find across every sheet, CSV/Markdown/HTML export and printing (selection or sheet), sheet & row/column structure editing and a workbook dashboard (named ranges, charts/pivot detection, per-sheet stats); `.csv` is edited in the same grid (RFC 4180) while `.xls` and `.ods` open read-only. Workbooks holding elements ObsiGate cannot preserve (cached values, slicers, form controls, signature…) show a **warning** and ask for confirmation before saving; a value starting with `=` or `@` is stored as text unless the `f(x)` toggle is enabled, and concurrent writes from another process are detected (`If-Match` → "Retry"). The AI assistant can list sheets, dump a bounded table to its context, search the workbook, analyze a range, update cells and append rows — on `.xlsx`, `.xlsm` and `.csv`. On mobile (≤ 768 px), the menu bar and ribbon are **collapsed by default** — a single ☰ button expands them — so the grid gets the full screen height
|
||||
- **🎨 Syntax Highlight** : Syntax highlighting for code blocks
|
||||
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
|
||||
- **📡 Real-time Sync** : Automatic file monitoring via watchdog with incremental index updates
|
||||
@@ -238,6 +238,7 @@ Vaults are configured using pairs of `VAULT_N_NAME` / `VAULT_N_PATH` variables (
|
||||
| `VAULT_1_SCAN_ATTACHMENTS` | Enable image scanning on startup (optional, default: true) | `true` |
|
||||
| `VAULT_2_NAME` | Display name of the vault | `IT` |
|
||||
| `VAULT_2_PATH` | Path inside the container | `/vaults/Obsidian_IT` |
|
||||
| `OBSIGATE_HOME_ROOT` | Root folder for per-user home directories (one `home-<user>` vault per account). Unset = feature disabled. | `/vaults/Home` |
|
||||
|
||||
**Naming rules:**
|
||||
- Use only letters, numbers, and hyphens
|
||||
@@ -804,7 +805,7 @@ curl "http://localhost:2020/api/file/Recipes?path=pizza.md"
|
||||
|
||||
## 🔍 Advanced Search
|
||||
|
||||
> 📖 Full guide: [Search, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
|
||||
> 📖 Full guide: [Search, PDF, Excel & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
|
||||
|
||||
### Query Syntax
|
||||
|
||||
@@ -964,7 +965,7 @@ These parameters are configurable via the interface (Settings) or the `/api/conf
|
||||
- **Rate limiting** : 10 login attempts max per IP over 15 minutes + per-account lockout (5 attempts)
|
||||
- **Audit log** : All writes, deletions, and config changes are logged in `data/audit.log` (JSON lines, 10 MB rotation)
|
||||
- **Automatic backup** : Every file modification or deletion is saved in `.obsigate-backup/` with timestamp
|
||||
- **Secret redaction** : Automatic masking of JWTs, API keys, tokens, and connection strings in previews
|
||||
- **Secret redaction** : Automatic masking of JWTs, passwords, API keys (OpenAI, GitHub, Google, AWS, Slack, Stripe…), tokens and connection strings in previews — click a mask to copy the value
|
||||
- **Non-root user** : The Docker container runs under user `obsigate` (UID 1000)
|
||||
- **Read-only volumes** : Vaults are mounted as `:ro` by default in docker-compose
|
||||
- **Secrets in `.env`** : Passwords and tokens are never in `docker-compose.yml`
|
||||
@@ -1151,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.1).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.68.0).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.27.1 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.68.0 | Last updated: September 2026*
|
||||
|
||||
+70
-33
@@ -119,25 +119,30 @@ def decode_token(token: str) -> dict | None:
|
||||
_revoked_map: dict[str, int] = {}
|
||||
_revoked_loaded = False
|
||||
|
||||
# ROADMAP #85 T10a — verrou autour du read-modify-write du store de
|
||||
# révocation (perte de révocations en cas de logouts concurrents).
|
||||
_revoked_lock = threading.RLock()
|
||||
|
||||
|
||||
def _load_revoked():
|
||||
"""Load revoked token JTIs from disk into memory (once)."""
|
||||
global _revoked_loaded, _revoked_map
|
||||
if _revoked_loaded:
|
||||
return
|
||||
if REVOKED_TOKENS_FILE.exists():
|
||||
try:
|
||||
data = json.loads(REVOKED_TOKENS_FILE.read_text())
|
||||
# Drop entries whose underlying token has itself expired.
|
||||
now = int(time.time())
|
||||
_revoked_map = {
|
||||
jti: int(exp) for jti, exp in data.items()
|
||||
if int(exp) > now
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load revoked tokens: {e}")
|
||||
_revoked_map = {}
|
||||
_revoked_loaded = True
|
||||
with _revoked_lock:
|
||||
if _revoked_loaded:
|
||||
return
|
||||
if REVOKED_TOKENS_FILE.exists():
|
||||
try:
|
||||
data = json.loads(REVOKED_TOKENS_FILE.read_text())
|
||||
# Drop entries whose underlying token has itself expired.
|
||||
now = int(time.time())
|
||||
_revoked_map = {
|
||||
jti: int(exp) for jti, exp in data.items()
|
||||
if int(exp) > now
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load revoked tokens: {e}")
|
||||
_revoked_map = {}
|
||||
_revoked_loaded = True
|
||||
|
||||
|
||||
def _save_revoked():
|
||||
@@ -154,24 +159,26 @@ def revoke_token(jti: str, expires_at: int | None = None):
|
||||
``expires_at`` is the revoked token's own ``exp`` (unix seconds) — the
|
||||
record is kept at least that long so a long-lived API token cannot
|
||||
outlive its revocation. ``None`` means the token never expires (API/MCP
|
||||
"sans fin") → the record is kept forever (capped at ~100 years, the JWT
|
||||
"sans fin") → the record is kept forever (capped at ~100 years, the JWT
|
||||
store's practical infinity). Default keeps 7 days (session tokens).
|
||||
"""
|
||||
_load_revoked()
|
||||
now = int(time.time())
|
||||
if expires_at is None:
|
||||
until = now + 100 * 365 * 24 * 3600
|
||||
else:
|
||||
until = max(int(expires_at), now + REFRESH_TOKEN_EXPIRE_SECONDS)
|
||||
_revoked_map[jti] = until
|
||||
_save_revoked()
|
||||
with _revoked_lock:
|
||||
_load_revoked()
|
||||
now = int(time.time())
|
||||
if expires_at is None:
|
||||
until = now + 100 * 365 * 24 * 3600
|
||||
else:
|
||||
until = max(int(expires_at), now + REFRESH_TOKEN_EXPIRE_SECONDS)
|
||||
_revoked_map[jti] = until
|
||||
_save_revoked()
|
||||
logger.debug(f"Revoked token JTI: {jti[:8]}...")
|
||||
|
||||
|
||||
def is_token_revoked(jti: str) -> bool:
|
||||
"""Check if a token JTI has been revoked."""
|
||||
_load_revoked()
|
||||
return jti in _revoked_map
|
||||
with _revoked_lock:
|
||||
_load_revoked()
|
||||
return jti in _revoked_map
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -208,15 +215,37 @@ def _save_api_tokens(data: dict):
|
||||
tmp.replace(API_TOKENS_FILE)
|
||||
|
||||
|
||||
def create_api_token(user: dict, name: str, expiry_key: str) -> tuple[dict, str]:
|
||||
#: Endpoint perimeters accepted for scoped API tokens (#199). ``"full"``
|
||||
#: keeps today's behaviour ; ``"files"`` restricts the token to the file
|
||||
#: read/write surface (enforced by ``TokenScopeMiddleware`` on REST and by
|
||||
#: the tool registry on MCP/agent calls).
|
||||
API_TOKEN_SCOPES = ("full", "files")
|
||||
|
||||
|
||||
def create_api_token(
|
||||
user: dict,
|
||||
name: str,
|
||||
expiry_key: str,
|
||||
vaults: list[str] | None = None,
|
||||
scope: str = "full",
|
||||
) -> tuple[dict, str]:
|
||||
"""Create a persistent API/MCP token. Returns (record, jwt_string).
|
||||
|
||||
``expiry_key`` must be one of API_TOKEN_EXPIRY_CHOICES; "never" omits the
|
||||
``exp`` claim (valid until explicitly revoked).
|
||||
|
||||
#199 (synchro TikTak) — optional least-privilege restriction:
|
||||
``vaults`` limits the token to the given vaults (``None`` = snapshot of
|
||||
the creator's vaults, i.e. today's behaviour) and ``scope`` limits the
|
||||
endpoint perimeter (``"full"`` or ``"files"``). Both are embedded in the
|
||||
JWT so enforcement stays stateless; the record keeps them for display.
|
||||
"""
|
||||
if expiry_key not in API_TOKEN_EXPIRY_CHOICES:
|
||||
raise ValueError("Expiration invalide")
|
||||
if scope not in API_TOKEN_SCOPES:
|
||||
raise ValueError("Portée invalide (full | files)")
|
||||
seconds = API_TOKEN_EXPIRY_CHOICES[expiry_key]
|
||||
resolved_vaults = list(vaults) if vaults is not None else list(user.get("vaults", []))
|
||||
with _api_tokens_lock:
|
||||
data = _load_api_tokens()
|
||||
tokens = data["tokens"]
|
||||
@@ -228,11 +257,12 @@ def create_api_token(user: dict, name: str, expiry_key: str) -> tuple[dict, str]
|
||||
payload = {
|
||||
"sub": user["username"],
|
||||
"role": user.get("role", "user"),
|
||||
"vaults": user.get("vaults", []),
|
||||
"vaults": resolved_vaults,
|
||||
"jti": jti,
|
||||
"iat": now,
|
||||
"type": "access",
|
||||
"api": True,
|
||||
"scope": scope,
|
||||
}
|
||||
if seconds is not None:
|
||||
payload["exp"] = now + seconds
|
||||
@@ -245,6 +275,8 @@ def create_api_token(user: dict, name: str, expiry_key: str) -> tuple[dict, str]
|
||||
"expires_at": payload.get("exp"),
|
||||
"expiry_key": expiry_key,
|
||||
"last_used_at": None,
|
||||
"vaults": resolved_vaults,
|
||||
"scope": scope,
|
||||
}
|
||||
tokens[jti] = record
|
||||
_save_api_tokens(data)
|
||||
@@ -255,11 +287,16 @@ def list_api_tokens(username: str) -> list[dict]:
|
||||
"""Token metadata for one user, newest first."""
|
||||
data = _load_api_tokens()
|
||||
now = int(time.time())
|
||||
items = [
|
||||
{**t, "expired": t.get("expires_at") is not None and t["expires_at"] < now}
|
||||
for t in data["tokens"].values()
|
||||
if t["username"] == username
|
||||
]
|
||||
items = []
|
||||
for t in data["tokens"].values():
|
||||
if t["username"] != username:
|
||||
continue
|
||||
# #199 : les clés créées avant la portée limitée n'ont ni scope ni
|
||||
# vaults stockés → portée pleine, comportement inchangé.
|
||||
entry = {**t, "expired": t.get("expires_at") is not None and t["expires_at"] < now}
|
||||
entry.setdefault("scope", "full")
|
||||
entry.setdefault("vaults", None)
|
||||
items.append(entry)
|
||||
return sorted(items, key=lambda t: t["created_at"], reverse=True)
|
||||
|
||||
|
||||
|
||||
@@ -115,6 +115,9 @@ def get_current_user(
|
||||
user["_token_vaults"] = payload.get("vaults", [])
|
||||
# Attach the token id for per-token rate limiting (AI tool layer).
|
||||
user["_token_jti"] = payload.get("jti")
|
||||
# #199 : périmètre d'endpoints du token (défaut "full" = comportement
|
||||
# actuel, y compris pour les clés créées avant la portée limitée).
|
||||
user["_token_scope"] = payload.get("scope", "full")
|
||||
# Feature #107: track last usage of user-managed API/MCP tokens
|
||||
# (throttled write — this dependency runs on both REST and /mcp paths).
|
||||
if payload.get("api"):
|
||||
@@ -142,6 +145,11 @@ def require_admin(current_user=Depends(require_auth)):
|
||||
return current_user
|
||||
|
||||
|
||||
def is_home_vault(vault_name: str) -> bool:
|
||||
"""Un dossier personnel (#194) : vault « home-<user> »."""
|
||||
return vault_name.startswith("home-")
|
||||
|
||||
|
||||
def check_vault_access(vault_name: str, user: dict) -> bool:
|
||||
"""Check if a user has access to a specific vault.
|
||||
|
||||
@@ -149,8 +157,13 @@ def check_vault_access(vault_name: str, user: dict) -> bool:
|
||||
- vaults == ["*"] → full access (admin default)
|
||||
- vault_name in vaults → access granted
|
||||
- otherwise → denied
|
||||
|
||||
#194 : un dossier personnel n'est **jamais** couvert par ``*`` — sinon
|
||||
un admin (``vaults: ["*"]``) verrait le dossier de chaque utilisateur.
|
||||
"""
|
||||
vaults = user.get("_token_vaults") or user.get("vaults", [])
|
||||
if is_home_vault(vault_name):
|
||||
return vault_name in vaults
|
||||
if "*" in vaults:
|
||||
return True
|
||||
return vault_name in vaults
|
||||
|
||||
+99
-18
@@ -5,6 +5,7 @@
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
|
||||
@@ -15,7 +16,7 @@ from backend.ratelimit import record_account_failure as rl_record_account_failur
|
||||
from backend.ratelimit import record_account_success as rl_record_account_success
|
||||
from backend.ratelimit import record_failure as rl_record_failure
|
||||
from backend.ratelimit import record_success as rl_record_success
|
||||
from backend.services.net import get_client_ip
|
||||
from backend.services.net import get_client_ip, is_trusted_proxy
|
||||
|
||||
from .jwt_handler import (
|
||||
ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||
@@ -56,6 +57,34 @@ logger = logging.getLogger("obsigate.auth.router")
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
def is_secure_cookies(request: Request | None = None) -> bool:
|
||||
"""True when auth cookies must carry the ``Secure`` flag (#87 T3/T8).
|
||||
|
||||
``OBSIGATE_SECURE_COOKIES=true|false|auto`` (défaut : ``auto``) :
|
||||
``true``/``false`` forcent le comportement ; ``auto`` met ``Secure``
|
||||
si la requête arrive en https (production derrière TLS) et l'omet
|
||||
sinon (dev local en http — les navigateurs jettent les cookies
|
||||
``Secure`` sur http, ce qui casserait silencieusement les logins
|
||||
localhost). Derrière un reverse proxy qui termine TLS, le schéma perçu
|
||||
est http : avec ``OBSIGATE_TRUST_PROXY=true``, ``X-Forwarded-Proto``
|
||||
est honoré (même garde que ``get_client_ip``, BUG-030).
|
||||
"""
|
||||
forced = os.environ.get("OBSIGATE_SECURE_COOKIES", "auto").lower()
|
||||
if forced in ("1", "true", "yes", "on"):
|
||||
return True
|
||||
if forced in ("0", "false", "no", "off"):
|
||||
return False
|
||||
if request is None:
|
||||
return False
|
||||
if request.url.scheme == "https":
|
||||
return True
|
||||
if is_trusted_proxy():
|
||||
proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip().lower()
|
||||
if proto == "https":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ── Pydantic request models ──────────────────────────────────────────
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@@ -218,10 +247,11 @@ async def login(body: LoginRequest, response: Response, request: Request):
|
||||
"remember_me": body.remember_me,
|
||||
}
|
||||
|
||||
return _issue_tokens(user, body.username, body.remember_me, response)
|
||||
return _issue_tokens(user, body.username, body.remember_me, response, request)
|
||||
|
||||
|
||||
def _issue_tokens(user: dict, username: str, remember_me: bool, response: Response) -> dict:
|
||||
def _issue_tokens(user: dict, username: str, remember_me: bool, response: Response,
|
||||
request: Request | None = None) -> dict:
|
||||
"""Issue JWT tokens after successful authentication (password or MFA verified)."""
|
||||
record_login_success(username)
|
||||
rl_record_account_success(username)
|
||||
@@ -229,9 +259,8 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
|
||||
access_token = create_access_token(user)
|
||||
refresh_token, refresh_jti = create_refresh_token(username, remember=remember_me)
|
||||
|
||||
import os
|
||||
max_age = 2592000 if remember_me else 604800 # 30d or 7d
|
||||
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
|
||||
secure = is_secure_cookies(request)
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=refresh_token,
|
||||
@@ -253,7 +282,8 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
|
||||
)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer", # nosec B105 — OAuth2 token_type, pas un mot de passe
|
||||
# OAuth2 token_type, pas un mot de passe (B105) :
|
||||
"token_type": "bearer", # nosec B105
|
||||
"expires_in": ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||
"user": {
|
||||
"username": user["username"],
|
||||
@@ -299,9 +329,7 @@ async def refresh_token_endpoint(request: Request, response: Response):
|
||||
if stale:
|
||||
raise HTTPException(401, "Session expirée, veuillez vous reconnecter")
|
||||
|
||||
import os
|
||||
|
||||
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
|
||||
secure = is_secure_cookies(request)
|
||||
remember_me = bool(payload.get("remember", False))
|
||||
|
||||
# BUG-027: rotate the refresh token — the old one is now single-use.
|
||||
@@ -332,7 +360,8 @@ async def refresh_token_endpoint(request: Request, response: Response):
|
||||
|
||||
return {
|
||||
"access_token": new_access_token,
|
||||
"token_type": "bearer", # nosec B105 — OAuth2 token_type, pas un mot de passe
|
||||
# OAuth2 token_type, pas un mot de passe (B105) :
|
||||
"token_type": "bearer", # nosec B105
|
||||
"expires_in": ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||
}
|
||||
|
||||
@@ -426,6 +455,7 @@ async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)):
|
||||
async def change_password(
|
||||
req: ChangePasswordRequest,
|
||||
response: Response,
|
||||
request: Request,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Change own password.
|
||||
@@ -441,7 +471,7 @@ async def change_password(
|
||||
updated = get_user(current_user["username"])
|
||||
result: dict = {"message": "Mot de passe mis à jour"}
|
||||
if updated is not None:
|
||||
result.update(_issue_tokens(updated, updated["username"], False, response))
|
||||
result.update(_issue_tokens(updated, updated["username"], False, response, request))
|
||||
return result
|
||||
|
||||
|
||||
@@ -804,7 +834,7 @@ async def mfa_webauthn_verify(
|
||||
|
||||
rl_record_success(client_ip)
|
||||
logger.info(f"User '{body.username}' logged in via WebAuthn")
|
||||
return _issue_tokens(user, body.username, body.remember_me, response)
|
||||
return _issue_tokens(user, body.username, body.remember_me, response, request)
|
||||
|
||||
|
||||
@router.get("/mfa/status")
|
||||
@@ -812,6 +842,16 @@ async def mfa_status(current_user=Depends(require_auth)):
|
||||
"""Return current user's MFA status."""
|
||||
from .user_store import get_user
|
||||
user = get_user(current_user["username"])
|
||||
if user is None:
|
||||
# BUG-081 : auth désactivée (OBSIGATE_AUTH_ENABLED=false) → le
|
||||
# pseudo-user "anonymous" n'a aucune entrée en store : pas de MFA,
|
||||
# et surtout pas de 500 (`AttributeError` sur `user.get`).
|
||||
return {
|
||||
"mfa_enabled": False,
|
||||
"mfa_method": None,
|
||||
"totp_enabled": False,
|
||||
"webauthn_credentials": 0,
|
||||
}
|
||||
return {
|
||||
"mfa_enabled": user.get("mfa_enabled", False),
|
||||
"mfa_method": user.get("mfa_method"),
|
||||
@@ -847,7 +887,7 @@ async def mfa_totp_verify(body: MfaVerifyRequest, response: Response, request: R
|
||||
# Clear IP rate limit on success
|
||||
rl_record_success(client_ip)
|
||||
|
||||
return _issue_tokens(user, body.username, body.remember_me, response)
|
||||
return _issue_tokens(user, body.username, body.remember_me, response, request)
|
||||
|
||||
|
||||
@router.post("/mfa/recovery")
|
||||
@@ -885,7 +925,7 @@ async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, reque
|
||||
rl_record_success(client_ip)
|
||||
|
||||
logger.info(f"User '{body.username}' logged in via recovery code")
|
||||
return _issue_tokens(user, body.username, False, response)
|
||||
return _issue_tokens(user, body.username, False, response, request)
|
||||
|
||||
|
||||
# ── Admin endpoints ───────────────────────────────────────────────────
|
||||
@@ -906,9 +946,20 @@ async def create_user_endpoint(
|
||||
user = create_user(
|
||||
req.username, req.password, req.role, req.vaults, req.display_name
|
||||
)
|
||||
return user
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
# #194 : dossier perso — best effort, réparé au démarrage si le disque
|
||||
# (NFS) est indisponible (ensure_user_home journalise l'erreur).
|
||||
from backend.auth.user_store import get_user
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
await ensure_user_home(req.username)
|
||||
# La réponse doit refléter l'octroi du vault perso (#194) : create_user a
|
||||
# renvoyé un instantané construit avant l'octroi.
|
||||
fresh = get_user(req.username)
|
||||
if fresh:
|
||||
user["vaults"] = fresh.get("vaults", [])
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/admin/users/{username}")
|
||||
@@ -935,9 +986,13 @@ async def delete_user_endpoint(
|
||||
raise HTTPException(400, "Impossible de supprimer son propre compte")
|
||||
try:
|
||||
delete_user(username)
|
||||
return {"message": f"Utilisateur '{username}' supprimé"}
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
# #194 : fermer le vault du dossier perso (le dossier est conservé).
|
||||
from backend.user_home import release_user_home
|
||||
|
||||
await release_user_home(username)
|
||||
return {"message": f"Utilisateur '{username}' supprimé"}
|
||||
|
||||
|
||||
# ── API / MCP tokens (feature #107) ──────────────────────────────────
|
||||
@@ -948,6 +1003,8 @@ async def delete_user_endpoint(
|
||||
class CreateApiTokenRequest(BaseModel):
|
||||
name: str
|
||||
expiry: str # 1d | 30d | 180d | 365d | never
|
||||
vaults: list[str] | None = None # #199 : None = vaults du créateur (comportement actuel)
|
||||
scope: str = "full" # #199 : "full" | "files"
|
||||
|
||||
|
||||
@router.get("/tokens")
|
||||
@@ -965,9 +1022,33 @@ async def create_user_token(
|
||||
request: Request,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Create a long-lived API/MCP token. The raw JWT is returned ONCE."""
|
||||
"""Create a long-lived API/MCP token. The raw JWT is returned ONCE.
|
||||
|
||||
#199 : portée optionnelle — ``vaults`` (sous-ensemble des vaults du
|
||||
créateur, défaut = tous) et ``scope`` (``"full"`` ou ``"files"``).
|
||||
"""
|
||||
from backend.auth.jwt_handler import API_TOKEN_SCOPES
|
||||
from backend.auth.middleware import check_vault_access
|
||||
from backend.indexer import index
|
||||
|
||||
scope = (req.scope or "full").strip().lower()
|
||||
if scope not in API_TOKEN_SCOPES:
|
||||
raise HTTPException(400, "Portée invalide (full | files)")
|
||||
vaults: list[str] | None = None
|
||||
if req.vaults is not None:
|
||||
cleaned = sorted({v.strip() for v in req.vaults if isinstance(v, str) and v.strip()})
|
||||
if not cleaned:
|
||||
raise HTTPException(400, "Liste de vaults vide")
|
||||
unknown = [v for v in cleaned if v not in index]
|
||||
if unknown:
|
||||
raise HTTPException(400, f"Vaults inconnus : {', '.join(unknown)}")
|
||||
denied = [v for v in cleaned if not check_vault_access(v, current_user)]
|
||||
if denied:
|
||||
raise HTTPException(403, f"Accès refusé aux vaults : {', '.join(denied)}")
|
||||
vaults = cleaned
|
||||
try:
|
||||
record, token = create_api_token(current_user, req.name.strip(), req.expiry)
|
||||
record, token = create_api_token(current_user, req.name.strip(), req.expiry,
|
||||
vaults=vaults, scope=scope)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
from backend.audit import log_config_change
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# backend/auth/scope.py
|
||||
# Scoped API/MCP tokens (#199, synchro TikTak — Gitea #2).
|
||||
#
|
||||
# A token may carry an endpoint perimeter in its ``scope`` JWT claim:
|
||||
# - ``"full"`` (default, and the case of every token issued before #199):
|
||||
# no restriction — today's behaviour, unchanged ;
|
||||
# - ``"files"``: file read/write surface only (vault file CRUD, listings,
|
||||
# search, backups read/restore). Everything else (admin, config, AI,
|
||||
# webhooks, shares, auth management…) answers **403**.
|
||||
#
|
||||
# Vault restriction is orthogonal and needs no new machinery: a scoped token
|
||||
# embeds the allowed vault list in its ``vaults`` JWT claim, so the existing
|
||||
# ``check_vault_access`` (REST routes) and ``ToolContext.require_vault_access``
|
||||
# (MCP/agent tools) refuse other vaults with 403 — for REST *and* MCP alike.
|
||||
"""Endpoint perimeter for scoped API tokens."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
SCOPE_FULL = "full"
|
||||
SCOPE_FILES = "files"
|
||||
KNOWN_SCOPES = (SCOPE_FULL, SCOPE_FILES)
|
||||
|
||||
# (methods, path prefix or exact path). ``methods`` None = any method.
|
||||
# Keep this list tight: a sync client (TikTak) needs file CRUD, listings,
|
||||
# search and backup read/restore — nothing else.
|
||||
_RULES_FOR_FILES: tuple[tuple[frozenset[str] | None, str, bool], ...] = (
|
||||
# (methods, path, exact_only)
|
||||
(frozenset({"GET"}), "/api/health", False),
|
||||
(frozenset({"GET"}), "/api/auth/me", True),
|
||||
(frozenset({"GET"}), "/api/vaults", True),
|
||||
(None, "/api/browse/", False),
|
||||
(None, "/api/file/", False),
|
||||
(None, "/api/media/", False),
|
||||
(None, "/api/directory/", False),
|
||||
(None, "/api/move/", False),
|
||||
(None, "/api/vault/", False), # {vault}/files, {vault}/paths, batch-upload…
|
||||
(frozenset({"GET"}), "/api/search", False),
|
||||
(frozenset({"GET"}), "/api/tree-search", True),
|
||||
(frozenset({"GET"}), "/api/suggest", False),
|
||||
(frozenset({"GET"}), "/api/tags", False),
|
||||
(frozenset({"GET"}), "/api/graph/", False),
|
||||
(frozenset({"GET"}), "/api/backups", False),
|
||||
(frozenset({"GET"}), "/docs", False),
|
||||
(frozenset({"GET"}), "/redoc", True),
|
||||
(frozenset({"GET"}), "/openapi.json", True),
|
||||
(None, "/mcp", False),
|
||||
)
|
||||
|
||||
|
||||
def is_path_allowed_for_scope(scope: str | None, method: str, path: str) -> bool:
|
||||
"""True when a token with *scope* may call (*method*, *path*).
|
||||
|
||||
- ``None``/``"full"`` → everything allowed (backward compatible).
|
||||
- ``"files"`` → allow-list above (API surface only ; static assets and
|
||||
public pages carry no data and are handled by the caller — the
|
||||
middleware only gates ``/api/*`` and ``/mcp*``).
|
||||
- unknown scope → fail-closed (deny).
|
||||
"""
|
||||
if scope in (None, SCOPE_FULL):
|
||||
return True
|
||||
if scope != SCOPE_FILES:
|
||||
return False
|
||||
if not (path.startswith("/api/") or path.startswith("/mcp")):
|
||||
return True
|
||||
method = (method or "GET").upper()
|
||||
for methods, rule_path, exact in _RULES_FOR_FILES:
|
||||
if methods is not None and method not in methods:
|
||||
continue
|
||||
if exact:
|
||||
if path == rule_path:
|
||||
return True
|
||||
elif path == rule_path or path.startswith(rule_path):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def scope_of_user(user: dict[str, Any] | None) -> str:
|
||||
"""Endpoint scope attached to an authenticated user (default ``"full"``)."""
|
||||
if not user:
|
||||
return SCOPE_FULL
|
||||
return user.get("_token_scope") or SCOPE_FULL
|
||||
|
||||
|
||||
class TokenScopeMiddleware:
|
||||
"""Pure-ASGI middleware enforcing the endpoint perimeter of scoped tokens.
|
||||
|
||||
Denies out-of-perimeter calls made with a scoped token (**403**) before
|
||||
they reach the routes. Only *adds* restrictions: the ``scope`` claim is
|
||||
read unverified here, and the signature is still verified downstream by
|
||||
``get_current_user`` (an invalid token is rejected with 401 a step later
|
||||
anyway). Full-scope and scopeless tokens pass through untouched.
|
||||
"""
|
||||
|
||||
def __init__(self, app: Any):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
|
||||
if scope.get("type") == "http":
|
||||
method = scope.get("method", "GET")
|
||||
path = scope.get("path", "")
|
||||
if path.startswith("/api/") or path.startswith("/mcp"):
|
||||
token_scope = _scope_from_asgi(scope)
|
||||
if token_scope not in (None, SCOPE_FULL) and not is_path_allowed_for_scope(
|
||||
token_scope, method, path
|
||||
):
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
response = JSONResponse(
|
||||
{"detail": "Token à portée limitée : endpoint hors périmètre 'files'"},
|
||||
status_code=403,
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
def _scope_from_asgi(asgi_scope: Any) -> str | None:
|
||||
"""Extract the unverified ``scope`` claim from an ASGI scope's auth."""
|
||||
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in asgi_scope.get("headers", [])}
|
||||
token: str | None = None
|
||||
auth = headers.get("authorization", "")
|
||||
if auth.lower().startswith("bearer "):
|
||||
token = auth[7:].strip()
|
||||
else:
|
||||
for part in headers.get("cookie", "").split(";"):
|
||||
name, _, value = part.strip().partition("=")
|
||||
if name.strip() == "access_token" and value.strip():
|
||||
token = value.strip()
|
||||
break
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
from jose import jwt as _jwt
|
||||
|
||||
claims = _jwt.get_unverified_claims(token)
|
||||
except Exception:
|
||||
return None
|
||||
token_scope = (claims or {}).get("scope")
|
||||
return token_scope if isinstance(token_scope, str) and token_scope else None
|
||||
+17
-12
@@ -536,18 +536,6 @@ async def api_bookslm_agent(
|
||||
# run no longer pauses on every subsequent mutating call.
|
||||
ctx.confirmed = True
|
||||
|
||||
async def _llm(msgs, tool_schemas):
|
||||
return await chat_completion(
|
||||
msgs,
|
||||
tools=tool_schemas,
|
||||
provider=req.provider,
|
||||
model=req.model,
|
||||
temperature=0.3,
|
||||
# Tool-call arguments can carry a whole file body (e.g. a generated
|
||||
# table): leave more room than the plain-chat default.
|
||||
max_tokens=8192,
|
||||
)
|
||||
|
||||
async def generate_sse():
|
||||
import asyncio
|
||||
|
||||
@@ -561,6 +549,23 @@ async def api_bookslm_agent(
|
||||
yield f"event: error\ndata: {error_data}\n\n"
|
||||
return
|
||||
|
||||
# #187: resolve the provider like /chat does — the agent must use
|
||||
# the same engine the SSE "provider" tag reports (the raw
|
||||
# req.provider could name an unavailable provider and silently
|
||||
# fall back to another one via _get_provider_config).
|
||||
async def _llm(msgs, tool_schemas):
|
||||
return await chat_completion(
|
||||
msgs,
|
||||
tools=tool_schemas,
|
||||
provider=cfg_name,
|
||||
model=req.model,
|
||||
temperature=0.3,
|
||||
# Tool-call arguments can carry a whole file body (e.g. a
|
||||
# generated table): leave more room than the plain-chat
|
||||
# default.
|
||||
max_tokens=8192,
|
||||
)
|
||||
|
||||
# Stream tool events live: each executed step is pushed on the
|
||||
# queue by the loop callback and emitted as soon as it happens,
|
||||
# so the UI can grow its « N steps » block while thinking.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Content-Security-Policy nonces (ROADMAP #87, tranche 5b).
|
||||
|
||||
Chaque réponse HTTP reçoit un nonce frais (``request.state.csp_nonce``)
|
||||
injecté dans ``script-src``. Les routes servant du HTML avec des scripts
|
||||
inline (index, popout, admin, editor-poc, excalidraw, page de partage)
|
||||
l'injectent dans le balisage via :func:`inject_csp_nonce` — mêmes
|
||||
emplacements, aucun script déplacé.
|
||||
|
||||
Tant que ``'unsafe-inline'`` reste dans la politique (retrait en T5c),
|
||||
l'injection est inerte : elle prépare la bascule sans changer le
|
||||
comportement.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
|
||||
# Balises <script> exécutables sans `src` et sans nonce existant :
|
||||
# `<script>`, `<script type="module">`, `<script type="importmap">`.
|
||||
# Les blocs non-JS (ex. `type="text/plain"`) et les scripts externes
|
||||
# (`src=…`, couverts par 'self'/hôtes CDN) sont laissés intacts.
|
||||
_SCRIPT_TAG_RE = re.compile(
|
||||
r"<script(?=>|\s+type=\"(?:module|importmap)\"\s*>)",
|
||||
)
|
||||
|
||||
|
||||
def new_nonce() -> str:
|
||||
"""Generate a fresh per-response CSP nonce."""
|
||||
return secrets.token_urlsafe(16)
|
||||
|
||||
|
||||
def inject_csp_nonce(html: str, nonce: str) -> str:
|
||||
"""Add ``nonce="…"`` to bare executable inline ``<script>`` tags."""
|
||||
return _SCRIPT_TAG_RE.sub(f'<script nonce="{nonce}"', html)
|
||||
+4
-1
@@ -23,6 +23,7 @@ import re
|
||||
import unicodedata
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import frontmatter
|
||||
import mistune
|
||||
@@ -246,7 +247,9 @@ def _render_body(md: str, file_dir: Path, vault_path: Path, current: Path) -> st
|
||||
"""Render raw markdown to an HTML fragment (images inlined, wikilinks resolved)."""
|
||||
md = _inline_images(md, file_dir, vault_path)
|
||||
md = _convert_wikilinks(md, vault_path, current)
|
||||
return _markdown(md)
|
||||
# mistune 3.3 types `Markdown.__call__` as `str | list[...]` (le renderer
|
||||
# HTML renvoie toujours `str` à l'exécution).
|
||||
return cast(str, _markdown(md))
|
||||
|
||||
|
||||
def _build_nav(vault_path: Path, current: Path) -> str:
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
# backend/file_chat.py — historique de discussion (#169, #190)
|
||||
"""Chat history persisted under ``data/chats/``.
|
||||
|
||||
One JSON document per ``(vault, path)`` pair, keyed by a SHA-256 of both so
|
||||
the filename never carries user-controlled path separators. Writes are
|
||||
atomic (tmp + move) and the message list is capped at
|
||||
:data:`MAX_MESSAGES` to bound growth.
|
||||
|
||||
#190 adds the **general chat**: the same store addressed with the reserved
|
||||
sentinels (:data:`GLOBAL_VAULT` / :data:`GLOBAL_PATH`), so no second
|
||||
implementation. Messages may carry an ``attachment`` (image/video/url)
|
||||
uploaded under ``data/chat_uploads/``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.render import _render_markdown
|
||||
|
||||
logger = logging.getLogger("obsigate.file_chat")
|
||||
|
||||
CHAT_DIR = Path("data/chats")
|
||||
MAX_MESSAGES = 500 # retention ceiling per file (oldest dropped first)
|
||||
MAX_TEXT = 4000 # characters per message
|
||||
|
||||
# ponytail: global lock over read-modify-write — chat writes are HTTP-only and
|
||||
# serialized anyway, this just makes losing a message to a future thread (or a
|
||||
# watcher hook) impossible; per-file locks if it ever becomes contended.
|
||||
_LOCK = threading.RLock()
|
||||
|
||||
# #190 — general (non file-bound) conversation, stored like any other one.
|
||||
GLOBAL_VAULT = "__global__"
|
||||
GLOBAL_PATH = "general"
|
||||
|
||||
# #191 — private (2 users) conversations reuse the same store: the vault is
|
||||
# the reserved sentinel and the path is the sorted username pair, so the
|
||||
# storage key never depends on who asks.
|
||||
DM_VAULT = "__dm__"
|
||||
|
||||
# #190 — attachments (image/video) live outside the vaults.
|
||||
UPLOAD_DIR = Path("data/chat_uploads")
|
||||
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB per attachment
|
||||
ALLOWED_ATTACH_EXT = {
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg",
|
||||
".mp4", ".webm", ".ogg", ".mov", ".m4v",
|
||||
}
|
||||
|
||||
# #191 — link preview fetch budget
|
||||
PREVIEW_TIMEOUT = 5.0 # seconds
|
||||
PREVIEW_MAX_BYTES = 512 * 1024 # only the head of the page is parsed
|
||||
PREVIEW_IMAGE_MAX = 2 * 1024 * 1024 # ponytail: 2 MB ceiling on a thumbnail
|
||||
|
||||
|
||||
def _chat_file(vault: str, path: str) -> Path:
|
||||
"""Return the chat file for *(vault, path)* (hashed, traversal-proof)."""
|
||||
CHAT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
key = hashlib.sha256(f"{vault}\0{path}".encode()).hexdigest()[:32]
|
||||
return CHAT_DIR / f"{key}.json"
|
||||
|
||||
|
||||
def _read(vault: str, path: str) -> dict[str, Any]:
|
||||
"""Load the raw chat document (empty structure when missing/corrupt)."""
|
||||
file = _chat_file(vault, path)
|
||||
if not file.exists():
|
||||
return {"vault": vault, "path": path, "messages": []}
|
||||
try:
|
||||
doc = json.loads(file.read_text(encoding="utf-8"))
|
||||
if not isinstance(doc.get("messages"), list):
|
||||
raise TypeError("messages is not a list") # caught by the handler below
|
||||
return doc
|
||||
except Exception as e:
|
||||
logger.error("Failed to read chat for %s/%s: %s", vault, path, e)
|
||||
return {"vault": vault, "path": path, "messages": []}
|
||||
|
||||
|
||||
def _write(file: Path, doc: dict[str, Any]) -> None:
|
||||
"""Atomically persist *doc* (tmp file + rename)."""
|
||||
try:
|
||||
tmp = file.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
shutil.move(str(tmp), str(file))
|
||||
except Exception as e:
|
||||
logger.error("Failed to write chat %s: %s", file.name, e)
|
||||
|
||||
|
||||
def get_messages(vault: str, path: str) -> list[dict[str, Any]]:
|
||||
"""Return the chat history for *(vault, path)* (chronological).
|
||||
|
||||
Every message carries its rendered ``html`` (#193): same markdown pipeline
|
||||
as a document (mistune + sanitizer), computed on read so a template change
|
||||
applies to the whole history without rewriting the JSON store.
|
||||
"""
|
||||
return [_decorate(m, vault) for m in _read(vault, path).get("messages", [])]
|
||||
|
||||
|
||||
def _decorate(msg: dict[str, Any], vault: str) -> dict[str, Any]:
|
||||
"""Return a copy of *msg* with its sanitized markdown ``html`` (#193).
|
||||
|
||||
The stored message is left untouched (``html`` is never persisted). A
|
||||
rendering failure must never break the chat: the message goes out with an
|
||||
empty ``html`` and the client falls back to plain text.
|
||||
"""
|
||||
try:
|
||||
html = _render_markdown(msg.get("text", ""), vault)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
logger.warning("chat markdown rendering failed: %s", e)
|
||||
html = ""
|
||||
return {**msg, "html": html}
|
||||
|
||||
|
||||
def add_message(
|
||||
vault: str,
|
||||
path: str,
|
||||
user: str,
|
||||
text: str,
|
||||
attachment: dict[str, Any] | None = None,
|
||||
preview: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Append a message and persist it. Returns the stored message.
|
||||
|
||||
The list is capped at :data:`MAX_MESSAGES` (oldest dropped first).
|
||||
*attachment* (#190) is ``{name, url, mime, kind}`` for image/video/url;
|
||||
*preview* (#191) is the OpenGraph card of the first URL in *text*.
|
||||
"""
|
||||
text = (text or "").strip()[:MAX_TEXT]
|
||||
msg: dict[str, Any] = {
|
||||
"id": uuid.uuid4().hex[:12],
|
||||
"user": user or "anonyme",
|
||||
"text": text,
|
||||
"ts": time.time(),
|
||||
}
|
||||
if attachment:
|
||||
msg["attachment"] = attachment
|
||||
if preview:
|
||||
msg["preview"] = preview
|
||||
return _append(vault, path, msg)
|
||||
|
||||
|
||||
def _append(
|
||||
vault: str,
|
||||
path: str,
|
||||
msg: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Cap, persist and return *msg* (shared by file, global and DM chats).
|
||||
|
||||
The read-modify-write of the whole document happens under ``_LOCK`` so a
|
||||
concurrent writer can never drop a message (same class of bug as
|
||||
BUG-029 on ``users.json``).
|
||||
"""
|
||||
with _LOCK:
|
||||
doc = _read(vault, path)
|
||||
messages = list(doc.get("messages", []))
|
||||
messages.append(msg)
|
||||
if len(messages) > MAX_MESSAGES:
|
||||
messages = messages[-MAX_MESSAGES:]
|
||||
doc["messages"] = messages
|
||||
_write(_chat_file(vault, path), doc)
|
||||
return _decorate(msg, vault) # #193 — le html part avec l'écho SSE
|
||||
|
||||
|
||||
# --- #192 : accusé de réception ---------------------------------------------
|
||||
|
||||
def get_read(vault: str, path: str) -> dict[str, float]:
|
||||
"""Return ``{username: last_read_ts}`` for a conversation (#192)."""
|
||||
return {str(u): float(ts) for u, ts in (_read(vault, path).get("read") or {}).items()}
|
||||
|
||||
|
||||
def mark_read(vault: str, path: str, user: str) -> dict[str, float]:
|
||||
"""Record that *user* has seen the conversation (#192).
|
||||
|
||||
Returns the whole read map so the caller can broadcast it on SSE: a
|
||||
sender learns their messages were received as soon as the recipient
|
||||
displays the conversation.
|
||||
"""
|
||||
if not user:
|
||||
return get_read(vault, path)
|
||||
with _LOCK:
|
||||
doc = _read(vault, path)
|
||||
read = {str(u): float(ts) for u, ts in (doc.get("read") or {}).items()}
|
||||
read[user] = time.time()
|
||||
doc["read"] = read
|
||||
_write(_chat_file(vault, path), doc)
|
||||
return read
|
||||
|
||||
|
||||
# --- #191 : messages privés (2 utilisateurs) -------------------------------
|
||||
|
||||
def dm_path(user_a: str, user_b: str) -> str:
|
||||
"""Storage path for the private conversation between two users.
|
||||
|
||||
The pair is sorted so both participants address the same document.
|
||||
"""
|
||||
return "|".join(sorted([user_a, user_b]))
|
||||
|
||||
|
||||
def get_dm_messages(user_a: str, user_b: str) -> list[dict[str, Any]]:
|
||||
"""Return the private history between two users (chronological)."""
|
||||
return get_messages(DM_VAULT, dm_path(user_a, user_b))
|
||||
|
||||
|
||||
def add_dm_message(
|
||||
user_a: str,
|
||||
user_b: str,
|
||||
author: str,
|
||||
text: str,
|
||||
attachment: dict[str, Any] | None = None,
|
||||
preview: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Append a private message. Returns the stored message."""
|
||||
return add_message(DM_VAULT, dm_path(user_a, user_b), author, text, attachment, preview)
|
||||
|
||||
|
||||
# --- #191 : suppression -----------------------------------------------------
|
||||
|
||||
def delete_message(vault: str, path: str, message_id: str) -> bool:
|
||||
"""Remove one message from a conversation. True when it existed."""
|
||||
with _LOCK:
|
||||
doc = _read(vault, path)
|
||||
messages = list(doc.get("messages", []))
|
||||
kept = [m for m in messages if m.get("id") != message_id]
|
||||
if len(kept) == len(messages):
|
||||
return False
|
||||
doc["messages"] = kept
|
||||
_write(_chat_file(vault, path), doc)
|
||||
return True
|
||||
|
||||
|
||||
# --- #190 : chat général (conversation centrale, hors fichier) -------------
|
||||
|
||||
def get_global_messages() -> list[dict[str, Any]]:
|
||||
"""Return the general-chat history (chronological)."""
|
||||
return get_messages(GLOBAL_VAULT, GLOBAL_PATH)
|
||||
|
||||
|
||||
def add_global_message(
|
||||
user: str,
|
||||
text: str,
|
||||
attachment: dict[str, Any] | None = None,
|
||||
preview: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Append a message to the general chat. Returns the stored message."""
|
||||
return add_message(GLOBAL_VAULT, GLOBAL_PATH, user, text, attachment, preview)
|
||||
|
||||
|
||||
def save_attachment(filename: str, data: bytes) -> dict[str, Any]:
|
||||
"""Persist an uploaded attachment under :data:`UPLOAD_DIR`.
|
||||
|
||||
Returns ``{name, url, mime, kind}``. The stored name is a fresh UUID
|
||||
(never the client name), the extension must be in
|
||||
:data:`ALLOWED_ATTACH_EXT` and the size is capped at
|
||||
:data:`MAX_UPLOAD_BYTES`.
|
||||
|
||||
Raises:
|
||||
ValueError: extension refused, empty file or size exceeded.
|
||||
"""
|
||||
ext = Path(filename or "").suffix.lower()
|
||||
if ext not in ALLOWED_ATTACH_EXT:
|
||||
raise ValueError(f"extension refusée : {ext or '(aucune)'}")
|
||||
if not data:
|
||||
raise ValueError("fichier vide")
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise ValueError(f"fichier trop lourd (max {MAX_UPLOAD_BYTES // (1024 * 1024)} MB)")
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
name = f"{uuid.uuid4().hex}{ext}"
|
||||
(UPLOAD_DIR / name).write_bytes(data)
|
||||
kind = "video" if ext in {".mp4", ".webm", ".ogg", ".mov", ".m4v"} else "image"
|
||||
return {
|
||||
"name": name,
|
||||
"url": f"/api/chat/attachment/{name}",
|
||||
"mime": _MIME_BY_EXT.get(ext, "application/octet-stream"),
|
||||
"kind": kind,
|
||||
}
|
||||
|
||||
|
||||
# --- #191 : link preview ----------------------------------------------------
|
||||
|
||||
_URL_RE = re.compile(r"https?://[^\s<>\"']+")
|
||||
_PREVIEW_CACHE: dict[str, dict[str, Any] | None] = {}
|
||||
PREVIEW_CACHE_MAX = 200
|
||||
|
||||
|
||||
def _og(content: str, prop: str) -> str:
|
||||
"""Extract one OpenGraph/``<title>`` value from an HTML head (regex)."""
|
||||
for pattern in (
|
||||
rf'<meta[^>]+(?:property|name)="{prop}"[^>]+content="([^"]*)"',
|
||||
rf'<meta[^>]+content="([^"]*)"[^>]+(?:property|name)="{prop}"',
|
||||
):
|
||||
m = re.search(pattern, content, re.IGNORECASE)
|
||||
if m:
|
||||
return html.unescape(m.group(1)).strip()[:300]
|
||||
if prop == "og:title":
|
||||
m = re.search(r"<title[^>]*>([^<]*)</title>", content, re.IGNORECASE)
|
||||
if m:
|
||||
return html.unescape(m.group(1)).strip()[:300]
|
||||
return ""
|
||||
|
||||
|
||||
# BUG-109 — content-type → extension (the attachment allow-list decides).
|
||||
_IMG_EXT_BY_MIME = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
}
|
||||
|
||||
|
||||
def _proxy_image(img_url: str, page_url: str) -> str:
|
||||
"""Download *img_url* into ``chat_uploads`` and return a same-origin URL.
|
||||
|
||||
The response CSP is ``img-src 'self' data: blob:``: a remote ``og:image``
|
||||
would be blocked by the browser (BUG-109). Relative and protocol-relative
|
||||
values are resolved against *page_url* first. Raises ``ValueError`` /
|
||||
``SSRFError`` on any failure — the caller keeps the card and drops only
|
||||
the thumbnail.
|
||||
"""
|
||||
from backend.tools.web import USER_AGENT, _assert_public_http_url
|
||||
|
||||
full = urljoin(page_url, img_url)
|
||||
_assert_public_http_url(full)
|
||||
resp = httpx.get(
|
||||
full,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
timeout=PREVIEW_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise ValueError(f"HTTP {resp.status_code}")
|
||||
data = resp.content
|
||||
if not data or len(data) > PREVIEW_IMAGE_MAX:
|
||||
raise ValueError("image vide ou trop lourde")
|
||||
mime = (resp.headers.get("content-type") or "").split(";")[0].strip().lower()
|
||||
ext = _IMG_EXT_BY_MIME.get(mime) or Path(urlparse(full).path).suffix.lower()
|
||||
# save_attachment(): allow-list d'extensions + nom UUID (jamais le nom distant)
|
||||
return str(save_attachment(f"preview{ext}", data)["url"])
|
||||
|
||||
|
||||
def build_preview(text: str) -> dict[str, Any] | None:
|
||||
"""Fetch OpenGraph metadata for the first URL in *text* (#191).
|
||||
|
||||
SSRF-guarded (reuses the web-tool guard), size/time capped, cached in a
|
||||
bounded dict. Returns ``{url, title, description, image, site}`` or
|
||||
``None`` when there is no URL / the fetch fails (never raises: a dead
|
||||
link must not block the message). ``image`` is a **same-origin**
|
||||
``/api/chat/attachment/...`` URL (BUG-109), empty when the thumbnail
|
||||
could not be fetched.
|
||||
"""
|
||||
m = _URL_RE.search(text or "")
|
||||
if not m:
|
||||
return None
|
||||
url = m.group(0).rstrip(".,;:!?)")
|
||||
if url in _PREVIEW_CACHE:
|
||||
cached = _PREVIEW_CACHE[url]
|
||||
return dict(cached) if cached else None
|
||||
try:
|
||||
from backend.tools.web import USER_AGENT, _assert_public_http_url
|
||||
|
||||
_assert_public_http_url(url)
|
||||
resp = httpx.get(
|
||||
url,
|
||||
headers={"User-Agent": USER_AGENT, "Accept": "text/html,*/*"},
|
||||
timeout=PREVIEW_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise ValueError(f"HTTP {resp.status_code}")
|
||||
body = resp.text[:PREVIEW_MAX_BYTES]
|
||||
# BUG-109 : vignette téléchargée côté serveur — une image distante
|
||||
# échouerait à la CSP. Échec isolé = carte sans vignette.
|
||||
image = _og(body, "og:image")
|
||||
try:
|
||||
image = _proxy_image(image, url) if image else ""
|
||||
except Exception as ie:
|
||||
logger.debug("preview image failed for %s: %s", url, ie)
|
||||
image = ""
|
||||
preview = {
|
||||
"url": url,
|
||||
"title": _og(body, "og:title") or _og(body, "og:site_name"),
|
||||
"description": _og(body, "og:description"),
|
||||
"image": image,
|
||||
"site": _og(body, "og:site_name") or (url.split("/")[2] if "/" in url[8:] else url),
|
||||
}
|
||||
if not preview["title"]:
|
||||
raise ValueError("pas de titre")
|
||||
except Exception as e:
|
||||
logger.debug("link preview failed for %s: %s", url, e)
|
||||
preview = None
|
||||
if len(_PREVIEW_CACHE) >= PREVIEW_CACHE_MAX:
|
||||
_PREVIEW_CACHE.pop(next(iter(_PREVIEW_CACHE))) # oldest first (dict order)
|
||||
_PREVIEW_CACHE[url] = preview
|
||||
return dict(preview) if preview else None
|
||||
|
||||
|
||||
def attachment_path(name: str) -> Path | None:
|
||||
"""Resolve an attachment by its stored name (UUID+ext only, no traversal)."""
|
||||
p = Path(name)
|
||||
if p.name != name or p.suffix.lower() not in ALLOWED_ATTACH_EXT:
|
||||
return None
|
||||
file = UPLOAD_DIR / p.name
|
||||
return file if file.exists() else None
|
||||
|
||||
|
||||
# Extension → MIME (literals only; ``mimetypes`` guesses poorly for a few).
|
||||
_MIME_BY_EXT = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".ogg": "video/ogg",
|
||||
".mov": "video/quicktime",
|
||||
".m4v": "video/x-m4v",
|
||||
}
|
||||
@@ -35,7 +35,8 @@ def diagram_png_for(code: str) -> Path | None:
|
||||
Mermaid, ou None. Le hash doit rester synchrone avec le script de build :
|
||||
sha1(unescape(code).strip())[:16]."""
|
||||
normalized = html.unescape(code).strip()
|
||||
sha = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:16]
|
||||
# Identifiant de cache déterministe (pas un usage sécurité).
|
||||
sha = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:16] # nosec B324
|
||||
png = DIAGRAMS_DIR / (sha + ".png")
|
||||
return png if png.exists() else None
|
||||
|
||||
|
||||
+124
-8
@@ -37,6 +37,12 @@ _last_full_index_ts: str = ""
|
||||
# Hook for incremental inverted index updates: called as (action, vault, path, file_info)
|
||||
_on_index_change: Callable[..., None] | None = None
|
||||
|
||||
# Registre des vaults ajoutés à la volée (#194) : les env VAULT_N_*/DIR_N_*
|
||||
# ne couvrent que le déploiement, tout ce qui est créé à runtime
|
||||
# (/api/vaults/add, dossiers perso) vivrait uniquement en mémoire sinon et
|
||||
# disparaîtrait au prochain rebuild ou redémarrage.
|
||||
DYNAMIC_VAULTS_FILE = Path("data/vaults.json")
|
||||
|
||||
|
||||
def set_index_change_hook(hook):
|
||||
"""Register a callback for incremental inverted index updates.
|
||||
@@ -134,7 +140,64 @@ def load_vault_config() -> dict[str, dict[str, Any]]:
|
||||
}
|
||||
n += 1
|
||||
|
||||
return vaults
|
||||
# Registre dynamique (#194) : les vaults créés à runtime (dossiers
|
||||
# persos, /api/vaults/add) sont chargés en premier, les env gagnent en
|
||||
# cas de collision de nom (vérité du déploiement).
|
||||
merged = _load_dynamic_vaults()
|
||||
merged.update(vaults)
|
||||
return merged
|
||||
|
||||
|
||||
def _load_dynamic_vaults() -> dict[str, dict[str, Any]]:
|
||||
"""Read vaults registered at runtime from ``data/vaults.json`` (#194)."""
|
||||
if not DYNAMIC_VAULTS_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(DYNAMIC_VAULTS_FILE.read_text(encoding="utf-8"))
|
||||
vaults = data.get("vaults", {})
|
||||
return vaults if isinstance(vaults, dict) else {}
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.error(f"Failed to read {DYNAMIC_VAULTS_FILE}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def _save_dynamic_vaults(vaults: dict[str, dict[str, Any]]) -> None:
|
||||
"""Atomic write of the dynamic vault registry (tmp + rename, as users.json)."""
|
||||
try:
|
||||
DYNAMIC_VAULTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = DYNAMIC_VAULTS_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(
|
||||
json.dumps({"version": 1, "vaults": vaults}, indent=2, default=str),
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(tmp, DYNAMIC_VAULTS_FILE)
|
||||
except OSError as e:
|
||||
# Le registre est un filet : vault_config mémoire reste valable
|
||||
# jusqu'au prochain rebuild, qui se reparera du dossier manquant.
|
||||
logger.error(f"Failed to write {DYNAMIC_VAULTS_FILE}: {e}")
|
||||
|
||||
|
||||
def persist_vault(vault_name: str) -> None:
|
||||
"""Persist *vault_name* so it survives rebuild/restart (#194).
|
||||
|
||||
Idempotent. Env-declared vaults are re-added by :func:`load_vault_config`
|
||||
anyway; persisting them too is harmless (single source after merge).
|
||||
"""
|
||||
cfg = vault_config.get(vault_name)
|
||||
if not cfg:
|
||||
return
|
||||
vaults = _load_dynamic_vaults()
|
||||
vaults[vault_name] = cfg
|
||||
_save_dynamic_vaults(vaults)
|
||||
|
||||
|
||||
def unpersist_vault(vault_name: str) -> None:
|
||||
"""Drop *vault_name* from the dynamic registry (#194). No-op if absent."""
|
||||
vaults = _load_dynamic_vaults()
|
||||
if vault_name not in vaults:
|
||||
return
|
||||
vaults.pop(vault_name, None)
|
||||
_save_dynamic_vaults(vaults)
|
||||
|
||||
|
||||
|
||||
@@ -351,6 +414,23 @@ def _decompress_excalidraw(compressed: str) -> dict[str, Any] | None:
|
||||
return data
|
||||
|
||||
|
||||
def extract_xlsx_indexable(file_path: Path) -> str:
|
||||
"""Return searchable text for a workbook (#153 A5).
|
||||
|
||||
Lazy wrapper: ``openpyxl`` is only imported when a spreadsheet is actually
|
||||
indexed, so a vault without workbooks never pays the import. Errors are
|
||||
swallowed — a corrupt or encrypted file still gets indexed by name.
|
||||
"""
|
||||
try:
|
||||
from backend.xlsx_reader import extract_indexable_text
|
||||
except Exception: # pragma: no cover - openpyxl missing
|
||||
return ""
|
||||
try:
|
||||
return extract_indexable_text(file_path)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return ""
|
||||
|
||||
|
||||
def extract_excalidraw_indexable(raw: str) -> str:
|
||||
"""Return indexable text content for a raw .excalidraw / .excalidraw.md file.
|
||||
|
||||
@@ -561,11 +641,12 @@ def _scan_vault(
|
||||
title = fpath.stem.replace("-", " ").replace("_", " ")
|
||||
content_preview = ""
|
||||
elif ext == ".xlsx":
|
||||
# #152 — binary workbook: metadata only, the viewer renders
|
||||
# it (parity with _index_single_file_sync).
|
||||
raw = ""
|
||||
# #153 A5 — a workbook stays rendered by the viewer, but its
|
||||
# cell values are now indexed as text so a spreadsheet is
|
||||
# findable by its content (parity with _index_single_file_sync).
|
||||
raw = extract_xlsx_indexable(fpath)
|
||||
title = fpath.stem.replace("-", " ").replace("_", " ")
|
||||
content_preview = ""
|
||||
content_preview = raw[:200].strip()
|
||||
else:
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
title = fpath.stem.replace("-", " ").replace("_", " ")
|
||||
@@ -807,6 +888,13 @@ async def reload_index() -> dict[str, Any]:
|
||||
await build_index()
|
||||
# BUG-040/#86: complete the deferred PDF + excalidraw extraction.
|
||||
await enrich_pdf_texts()
|
||||
# The inverted index is NOT updated by the hooks here: the rebuild above
|
||||
# replaces whole vault entries, so the incremental notifications are not
|
||||
# emitted for the files that only changed content. Without this, a manual
|
||||
# reindex left TF-IDF search serving a stale index (BUG-089).
|
||||
from backend.search import init_inverted_index
|
||||
|
||||
init_inverted_index()
|
||||
stats = {}
|
||||
for name, data in index.items():
|
||||
stats[name] = {"file_count": len(data["files"]), "tag_count": len(data["tags"])}
|
||||
@@ -842,6 +930,10 @@ async def reload_single_vault(vault_name: str) -> dict[str, Any]:
|
||||
|
||||
# Remove old vault data from index structures
|
||||
await remove_vault_from_index(vault_name)
|
||||
# remove_vault_from_index a poppé la config : la remettre, sinon le vault
|
||||
# disparaît de vault_config jusqu'au prochain reload complet (#194 — les
|
||||
# vaults dynamiques n'y reviennent que par data/vaults.json).
|
||||
vault_config[vault_name] = config
|
||||
|
||||
# Re-add the vault with updated configuration
|
||||
import functools
|
||||
@@ -882,6 +974,13 @@ async def reload_single_vault(vault_name: str) -> dict[str, Any]:
|
||||
# BUG-040/#86: complete the deferred PDF + excalidraw extraction.
|
||||
await enrich_pdf_texts(vault_name)
|
||||
|
||||
# Same as reload_index: the vault entry was replaced wholesale, so rebuild
|
||||
# the inverted index or TF-IDF search keeps serving stale postings
|
||||
# (BUG-089).
|
||||
from backend.search import init_inverted_index
|
||||
|
||||
init_inverted_index()
|
||||
|
||||
stats = {"file_count": len(vault_data["files"]), "tag_count": len(vault_data["tags"])}
|
||||
logger.info(f"Vault '{vault_name}' reindexed: {stats['file_count']} files, {stats['tag_count']} tags")
|
||||
return stats
|
||||
@@ -955,9 +1054,9 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
|
||||
raw = ""
|
||||
content_preview = ""
|
||||
elif ext == ".xlsx":
|
||||
# #152 — binary workbook: metadata only (parity with _scan_vault).
|
||||
raw = ""
|
||||
content_preview = ""
|
||||
# #153 A5 — index sheet names + header rows as text (see _scan_vault).
|
||||
raw = extract_xlsx_indexable(fpath)
|
||||
content_preview = raw[:200].strip()
|
||||
else:
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
content_preview = raw[:200].strip()
|
||||
@@ -1130,6 +1229,13 @@ async def update_single_file(vault_name: str, abs_file_path: str) -> dict[str, A
|
||||
if not vault_path:
|
||||
return None
|
||||
|
||||
# #197 : les routes passent historiquement un chemin relatif au vault
|
||||
# (``result["path"]``) alors que la docstring exigeait un absolu — le
|
||||
# rafraîchissement était un no-op silencieux (couvert en prod par le
|
||||
# watcher, mais pas dans les tests ni en polling lent). On accepte les deux.
|
||||
if not Path(abs_file_path).is_absolute():
|
||||
abs_file_path = str(Path(vault_path) / abs_file_path)
|
||||
|
||||
# Get vault configuration for hidden files handling
|
||||
vault_cfg = vault_data.get("config") or vault_config.get(vault_name, {})
|
||||
|
||||
@@ -1173,6 +1279,10 @@ async def remove_single_file(vault_name: str, abs_file_path: str) -> dict[str, A
|
||||
if not vault_path:
|
||||
return None
|
||||
|
||||
# #197 : même tolérance relatif/absolu que ``update_single_file``.
|
||||
if not Path(abs_file_path).is_absolute():
|
||||
abs_file_path = str(Path(vault_path) / abs_file_path)
|
||||
|
||||
try:
|
||||
rel_path = str(Path(abs_file_path).relative_to(vault_path)).replace("\\", "/")
|
||||
except ValueError:
|
||||
@@ -1226,6 +1336,12 @@ async def remove_vault_from_index(vault_name: str):
|
||||
if not _file_lookup[key]:
|
||||
_file_lookup.pop(key, None)
|
||||
|
||||
# Notify the inverted index, otherwise every document of the vault
|
||||
# stays in it as a ghost (postings, doc_info, doc_vault, vault_docs)
|
||||
# and keeps matching searches for a vault that no longer exists.
|
||||
if _on_index_change:
|
||||
_on_index_change('remove', vault_name, rel_path, f) # type: ignore[misc]
|
||||
|
||||
# Clean path_index
|
||||
path_index.pop(vault_name, None)
|
||||
|
||||
|
||||
+268
-4194
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,7 @@ from backend.auth.middleware import get_current_user, is_auth_enabled
|
||||
from backend.services.files import read_file_text
|
||||
from backend.services.vaults import list_accessible_vaults
|
||||
from backend.tools.api import (
|
||||
FILES_SCOPE_TOOLS,
|
||||
ToolContext,
|
||||
ToolError,
|
||||
ToolMode,
|
||||
@@ -191,6 +192,14 @@ def _tool_definitions() -> list[types.Tool]:
|
||||
return definitions
|
||||
|
||||
|
||||
def _mcp_base_name(mcp_name: str) -> str:
|
||||
"""Strip the ``propose_``/``apply_`` prefix back to the registry tool name."""
|
||||
for prefix in ("propose_", "apply_"):
|
||||
if mcp_name.startswith(prefix):
|
||||
return mcp_name[len(prefix):]
|
||||
return mcp_name
|
||||
|
||||
|
||||
def _parse_vault_uri(uri: str) -> tuple[str, str]:
|
||||
"""Split ``vault://name/path`` into ``(vault, path)``."""
|
||||
parsed = urlparse(uri)
|
||||
@@ -208,6 +217,10 @@ def build_server() -> Server:
|
||||
# ── Tools ──────────────────────────────────────────────────────────
|
||||
@server.list_tools()
|
||||
async def _list_tools() -> list[types.Tool]:
|
||||
# #199 : un token à portée "files" ne voit que les outils fichiers.
|
||||
user = _user_from_context(server)
|
||||
if (user.get("_token_scope") or "full") == "files":
|
||||
return [t for t in _tool_definitions() if _mcp_base_name(t.name) in FILES_SCOPE_TOOLS]
|
||||
return _tool_definitions()
|
||||
|
||||
@server.call_tool()
|
||||
@@ -262,6 +275,7 @@ def build_server() -> Server:
|
||||
user = _user_from_context(server)
|
||||
vault, path = _parse_vault_uri(str(uri))
|
||||
ctx = ToolContext(user=user, mode=ToolMode.MCP)
|
||||
ctx.require_tool_allowed("read_file") # #199 : portée inconnue → refus
|
||||
ctx.require_vault_access(vault)
|
||||
data = read_file_text(vault, path, redact=True, max_bytes=MAX_RESOURCE_BYTES)
|
||||
mime = "text/markdown" if Path(path).suffix.lower() == ".md" else "text/plain"
|
||||
@@ -352,6 +366,12 @@ def _propose(user: dict[str, Any], tool: str, arguments: dict[str, Any]) -> list
|
||||
params = spec.input_model.model_validate(arguments)
|
||||
vault = getattr(params, "vault", None)
|
||||
ctx = ToolContext(user=user, mode=ToolMode.MCP)
|
||||
# #199 : les tokens à portée limitée ne peuvent proposer que les outils
|
||||
# fichiers (même garde que call_tool, qui couvre apply_).
|
||||
try:
|
||||
ctx.require_tool_allowed(tool)
|
||||
except ToolError as e:
|
||||
return _text(e.to_dict())
|
||||
if vault and vault != "all":
|
||||
ctx.require_vault_access(vault)
|
||||
if spec.risk == ToolRisk.DANGEROUS:
|
||||
|
||||
@@ -32,7 +32,8 @@ def thumb_cache_path(file_path: Path, size: int) -> Path:
|
||||
stamp = f"{st.st_mtime_ns}:{st.st_size}"
|
||||
except OSError:
|
||||
stamp = "0:0"
|
||||
key = hashlib.sha1(f"{file_path}:{stamp}:{size}".encode()).hexdigest()
|
||||
# Clé de cache miniature (pas un usage sécurité).
|
||||
key = hashlib.sha1(f"{file_path}:{stamp}:{size}".encode()).hexdigest() # nosec B324
|
||||
return thumbs_cache_dir() / f"{key}.webp"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
"""External notifications — Discord, Telegram, SMTP, generic webhook (#168).
|
||||
|
||||
Configuration is persisted in ``data/notify_channels.json``; secrets live in
|
||||
``data/notify_secrets.json`` (0600) or in ``OBSIGATE_NOTIFY_SECRET_<ID>``
|
||||
environment variables — never in the public config file (same pattern as
|
||||
``backend/webhooks.py``, BUG-026).
|
||||
|
||||
Supported channel types:
|
||||
|
||||
* ``discord`` — Discord webhook URL (``POST {"content": ...}``).
|
||||
* ``telegram`` — Bot API (``POST https://api.telegram.org/bot<token>/sendMessage``).
|
||||
* ``smtp`` — Email via stdlib ``smtplib`` (STARTTLS, auth login).
|
||||
* ``webhook`` — generic JSON ``POST`` (SSRF-safe, same policy as #9).
|
||||
|
||||
Each channel declares ``triggers`` chosen among :data:`VALID_TRIGGERS`.
|
||||
The scheduler (#170) broadcasts on ``schedule_failure``; file-event fan-out
|
||||
stays on the historical ``backend/webhooks.py`` path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import smtplib
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("obsigate.notify")
|
||||
|
||||
DATA_DIR = Path(os.environ.get("OBSIGATE_DATA_DIR", "data"))
|
||||
CHANNELS_FILE = DATA_DIR / "notify_channels.json"
|
||||
SECRETS_FILE = DATA_DIR / "notify_secrets.json"
|
||||
|
||||
CHANNEL_TYPES = ("discord", "telegram", "smtp", "webhook")
|
||||
VALID_TRIGGERS = ("manual", "schedule_failure", "schedule_success", "duplicate_found")
|
||||
|
||||
_lock = threading.RLock()
|
||||
|
||||
|
||||
# ── Store helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _read_channels() -> list[dict[str, Any]]:
|
||||
if not CHANNELS_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(CHANNELS_FILE.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, list) else []
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return []
|
||||
|
||||
|
||||
def _write_channels(channels: list[dict[str, Any]]) -> None:
|
||||
CHANNELS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = CHANNELS_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(channels, indent=2, default=str), encoding="utf-8")
|
||||
tmp.replace(CHANNELS_FILE)
|
||||
|
||||
|
||||
def _read_secrets() -> dict[str, str]:
|
||||
if not SECRETS_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(SECRETS_FILE.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
def _write_secrets(secrets: dict[str, str]) -> None:
|
||||
SECRETS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = SECRETS_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(secrets, indent=2), encoding="utf-8")
|
||||
tmp.replace(SECRETS_FILE)
|
||||
try:
|
||||
SECRETS_FILE.chmod(0o600)
|
||||
except OSError:
|
||||
pass # Windows: pas de permissions Unix
|
||||
|
||||
|
||||
def _secret_key(channel_id: str) -> str:
|
||||
return "OBSIGATE_NOTIFY_SECRET_" + channel_id.replace("-", "_").upper()
|
||||
|
||||
|
||||
def _get_secret(channel_id: str) -> str | None:
|
||||
"""Resolve a channel secret: env > dedicated store > legacy inline config."""
|
||||
env_val = os.environ.get(_secret_key(channel_id))
|
||||
if env_val:
|
||||
return env_val
|
||||
stored = _read_secrets().get(channel_id)
|
||||
if stored:
|
||||
return stored
|
||||
for ch in _read_channels():
|
||||
if ch.get("id") == channel_id:
|
||||
cfg = ch.get("config", {})
|
||||
for key in ("webhook_url", "bot_token", "password"):
|
||||
if cfg.get(key):
|
||||
return str(cfg[key])
|
||||
return None
|
||||
|
||||
|
||||
def _public_view(channel: dict[str, Any]) -> dict[str, Any]:
|
||||
clean = {k: v for k, v in channel.items() if k != "config"}
|
||||
cfg = dict(channel.get("config", {}))
|
||||
for secret_field in ("webhook_url", "bot_token", "password"):
|
||||
if cfg.get(secret_field):
|
||||
cfg[secret_field] = "***"
|
||||
clean["config"] = cfg
|
||||
clean["has_secret"] = bool(_get_secret(channel["id"]))
|
||||
return clean
|
||||
|
||||
|
||||
# ── CRUD ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_config(channel_type: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate (sans secret) and normalize a channel config. Raises ValueError."""
|
||||
config = dict(config or {})
|
||||
if channel_type == "discord":
|
||||
url = str(config.get("webhook_url") or config.get("url") or "").strip()
|
||||
if not url.startswith(("https://discord.com/api/webhooks/", "https://discordapp.com/api/webhooks/")):
|
||||
# Laisse passer les URLs de test locales quand le mode privé est ouvert.
|
||||
from backend.webhooks import validate_webhook_url
|
||||
|
||||
validate_webhook_url(url)
|
||||
if "discord" not in url and not os.environ.get("OBSIGATE_WEBHOOK_ALLOW_PRIVATE"):
|
||||
raise ValueError("URL Discord invalide (webhook discord.com attendu)")
|
||||
config["webhook_url"] = url
|
||||
elif channel_type == "telegram":
|
||||
if not str(config.get("chat_id") or "").strip():
|
||||
raise ValueError("chat_id Telegram requis")
|
||||
config["chat_id"] = str(config["chat_id"]).strip()
|
||||
if config.get("bot_token"):
|
||||
config["bot_token"] = str(config["bot_token"]).strip()
|
||||
elif channel_type == "smtp":
|
||||
for field in ("host", "from_addr", "to_addr"):
|
||||
if not str(config.get(field) or "").strip():
|
||||
raise ValueError(f"Champ SMTP requis : {field}")
|
||||
config["port"] = int(config.get("port") or 587)
|
||||
config["use_tls"] = bool(config.get("use_tls", True))
|
||||
config["username"] = str(config.get("username") or "").strip()
|
||||
elif channel_type == "webhook":
|
||||
from backend.webhooks import validate_webhook_url
|
||||
|
||||
url = str(config.get("url") or "").strip()
|
||||
validate_webhook_url(url)
|
||||
config["url"] = url
|
||||
else:
|
||||
raise ValueError(f"Type de canal inconnu : {channel_type}")
|
||||
triggers = [t for t in (config.get("triggers") or ["manual"]) if t in VALID_TRIGGERS]
|
||||
config["triggers"] = triggers or ["manual"]
|
||||
return config
|
||||
|
||||
|
||||
def list_channels() -> list[dict[str, Any]]:
|
||||
"""Return public views of all notification channels."""
|
||||
return [_public_view(ch) for ch in _read_channels()]
|
||||
|
||||
|
||||
def create_channel(name: str, channel_type: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create a notification channel. Secrets are split into the secret store."""
|
||||
if channel_type not in CHANNEL_TYPES:
|
||||
raise ValueError(f"Type de canal inconnu : {channel_type}")
|
||||
with _lock:
|
||||
channels = _read_channels()
|
||||
channel_id = str(uuid.uuid4())
|
||||
normalized = _validate_config(channel_type, config)
|
||||
secrets = _read_secrets()
|
||||
for field in ("webhook_url", "bot_token", "password"):
|
||||
if normalized.get(field) and len(str(normalized[field])) > 8:
|
||||
secrets[channel_id] = str(normalized[field])
|
||||
normalized[field] = "***" # placeholder : le secret vit dans le store dédié
|
||||
_write_secrets(secrets)
|
||||
channel = {
|
||||
"id": channel_id,
|
||||
"name": (name or channel_type).strip() or channel_type,
|
||||
"type": channel_type,
|
||||
"enabled": True,
|
||||
"config": normalized,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"last_sent_at": None,
|
||||
"last_error": None,
|
||||
}
|
||||
channels.append(channel)
|
||||
_write_channels(channels)
|
||||
logger.info(f"Created notify channel '{name}' ({channel_type})")
|
||||
return _public_view(channel)
|
||||
|
||||
|
||||
def update_channel(channel_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Update a channel (name/enabled/config). Returns None when unknown."""
|
||||
with _lock:
|
||||
channels = _read_channels()
|
||||
for channel in channels:
|
||||
if channel.get("id") != channel_id:
|
||||
continue
|
||||
if updates.get("name"):
|
||||
channel["name"] = str(updates["name"])
|
||||
if "enabled" in updates:
|
||||
channel["enabled"] = bool(updates["enabled"])
|
||||
if "config" in updates and isinstance(updates["config"], dict):
|
||||
merged = {**channel.get("config", {}), **updates["config"]}
|
||||
normalized = _validate_config(channel["type"], merged)
|
||||
secrets = _read_secrets()
|
||||
for field in ("webhook_url", "bot_token", "password"):
|
||||
if updates["config"].get(field):
|
||||
secrets[channel_id] = str(updates["config"][field])
|
||||
normalized[field] = "***"
|
||||
_write_secrets(secrets)
|
||||
channel["config"] = normalized
|
||||
_write_channels(channels)
|
||||
return _public_view(channel)
|
||||
return None
|
||||
|
||||
|
||||
def delete_channel(channel_id: str) -> bool:
|
||||
"""Delete a channel and its secret. Returns False when unknown."""
|
||||
with _lock:
|
||||
channels = _read_channels()
|
||||
remaining = [c for c in channels if c.get("id") != channel_id]
|
||||
if len(remaining) == len(channels):
|
||||
return False
|
||||
_write_channels(remaining)
|
||||
secrets = _read_secrets()
|
||||
if secrets.pop(channel_id, None) is not None:
|
||||
_write_secrets(secrets)
|
||||
return True
|
||||
|
||||
|
||||
# ── Dispatch ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _send_discord(webhook_url: str, title: str, message: str) -> None:
|
||||
import httpx
|
||||
|
||||
content = f"**{title}**\n{message}"[:2000]
|
||||
resp = httpx.post(webhook_url, json={"content": content}, timeout=10.0)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def _send_telegram(bot_token: str, chat_id: str, title: str, message: str) -> None:
|
||||
import httpx
|
||||
|
||||
from backend.webhooks import is_safe_target
|
||||
|
||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||
if not is_safe_target(url):
|
||||
raise RuntimeError("Cible Telegram bloquée par la politique SSRF")
|
||||
text = f"*{title}*\n{message}"[:4000]
|
||||
resp = httpx.post(
|
||||
url,
|
||||
json={"chat_id": chat_id, "text": text, "parse_mode": "Markdown"},
|
||||
timeout=10.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def _send_smtp(config: dict[str, Any], password: str | None, title: str, message: str) -> None:
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = f"[ObsiGate] {title}"
|
||||
msg["From"] = config["from_addr"]
|
||||
msg["To"] = config["to_addr"]
|
||||
msg.set_content(message)
|
||||
with smtplib.SMTP(str(config["host"]), int(config.get("port", 587)), timeout=10) as client:
|
||||
if config.get("use_tls", True):
|
||||
client.starttls()
|
||||
if config.get("username") and password:
|
||||
client.login(str(config["username"]), password)
|
||||
client.send_message(msg)
|
||||
|
||||
|
||||
def _send_webhook(url: str, title: str, message: str, trigger: str) -> None:
|
||||
import httpx
|
||||
|
||||
from backend.webhooks import is_safe_target
|
||||
|
||||
if not is_safe_target(url):
|
||||
raise RuntimeError("Cible webhook bloquée par la politique SSRF")
|
||||
resp = httpx.post(
|
||||
url,
|
||||
json={
|
||||
"event": trigger,
|
||||
"title": title,
|
||||
"message": message,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"source": "obsigate-notify",
|
||||
},
|
||||
timeout=10.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def send_via_channel(channel: dict[str, Any], title: str, message: str, trigger: str = "manual") -> None:
|
||||
"""Send a notification through one raw channel record. Raises on failure."""
|
||||
channel_type = channel.get("type")
|
||||
cfg = dict(channel.get("config", {}))
|
||||
secret = _get_secret(channel["id"])
|
||||
if channel_type == "discord":
|
||||
url = secret or cfg.get("webhook_url") or ""
|
||||
if not url or url == "***":
|
||||
raise RuntimeError("URL webhook Discord manquante")
|
||||
_send_discord(url, title, message)
|
||||
elif channel_type == "telegram":
|
||||
token = secret or cfg.get("bot_token") or os.environ.get("OBSIGATE_TELEGRAM_BOT_TOKEN") or ""
|
||||
if not token or token == "***":
|
||||
raise RuntimeError("Token bot Telegram manquant")
|
||||
_send_telegram(token, str(cfg.get("chat_id", "")), title, message)
|
||||
elif channel_type == "smtp":
|
||||
_send_smtp(cfg, secret, title, message)
|
||||
elif channel_type == "webhook":
|
||||
url = str(cfg.get("url") or "").strip()
|
||||
if not url:
|
||||
raise RuntimeError("URL webhook manquante")
|
||||
_send_webhook(url, title, message, trigger)
|
||||
else:
|
||||
raise RuntimeError(f"Type de canal inconnu : {channel_type}")
|
||||
|
||||
|
||||
def broadcast(trigger: str, title: str, message: str) -> list[dict[str, Any]]:
|
||||
"""Send to every enabled channel subscribed to *trigger*. Never raises."""
|
||||
results: list[dict[str, Any]] = []
|
||||
for channel in _read_channels():
|
||||
if not channel.get("enabled", True):
|
||||
continue
|
||||
if trigger not in channel.get("config", {}).get("triggers", ["manual"]):
|
||||
continue
|
||||
try:
|
||||
send_via_channel(channel, title, message, trigger)
|
||||
results.append({"channel_id": channel["id"], "ok": True})
|
||||
_mark_sent(channel["id"], None)
|
||||
except Exception as e:
|
||||
logger.warning(f"Notify channel '{channel.get('name')}' failed: {e}")
|
||||
results.append({"channel_id": channel["id"], "ok": False, "error": str(e)})
|
||||
_mark_sent(channel["id"], str(e))
|
||||
return results
|
||||
|
||||
|
||||
def _mark_sent(channel_id: str, error: str | None) -> None:
|
||||
with _lock:
|
||||
channels = _read_channels()
|
||||
for channel in channels:
|
||||
if channel.get("id") == channel_id:
|
||||
channel["last_sent_at"] = datetime.now(timezone.utc).isoformat()
|
||||
channel["last_error"] = error
|
||||
_write_channels(channels)
|
||||
+43
-3
@@ -41,6 +41,9 @@ TAGS_METADATA: list[dict[str, str]] = [
|
||||
{"name": "Admin", "description": "Admin-only system monitoring: stats, audit log, backup stats and live stream."},
|
||||
{"name": "Plugins", "description": "Install, enable and manage user plugins."},
|
||||
{"name": "Push", "description": "Web Push (VAPID) subscription management and test notifications."},
|
||||
{"name": "Duplicates", "description": "Duplicate-note detection and confirmed merge (#166)."},
|
||||
{"name": "Notify", "description": "External notifications: Discord, Telegram, SMTP and generic webhooks (#168)."},
|
||||
{"name": "Scheduler", "description": "Scheduled automatic tasks reusing the vault mutation services (#170)."},
|
||||
{"name": "Frontend", "description": "Static assets and SPA fallback routes."},
|
||||
]
|
||||
|
||||
@@ -95,6 +98,9 @@ _TAG_RULES: list[tuple[re.Pattern[str], str]] = [
|
||||
(re.compile(r"^/api/shares"), "Sharing"),
|
||||
(re.compile(r"^/s/"), "Sharing"),
|
||||
(re.compile(r"^/api/webhooks"), "Webhooks"),
|
||||
(re.compile(r"^/api/duplicates"), "Duplicates"),
|
||||
(re.compile(r"^/api/notify"), "Notify"),
|
||||
(re.compile(r"^/api/scheduler"), "Scheduler"),
|
||||
(re.compile(r"^/api/conflicts"), "Conflicts"),
|
||||
(re.compile(r"^/api/backups"), "Backups"),
|
||||
(re.compile(r"^/api/file/[^/]+/(backups|diff|restore)"), "Backups"),
|
||||
@@ -148,6 +154,9 @@ _TAG_ALIASES: dict[str, str] = {
|
||||
"export": "Export",
|
||||
"sharing": "Sharing",
|
||||
"webhooks": "Webhooks",
|
||||
"duplicates": "Duplicates",
|
||||
"notify": "Notify",
|
||||
"scheduler": "Scheduler",
|
||||
"conflicts": "Conflicts",
|
||||
"system": "System",
|
||||
"frontend": "Frontend",
|
||||
@@ -182,9 +191,40 @@ _ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = {
|
||||
"response": {"status": "ok", "vault": "TestVault", "path": "notes/Accueil.md", "size": 26},
|
||||
},
|
||||
("put", "/api/file/{vault_name}/xlsx/save"): {
|
||||
"request": {"sheet": "Budget", "cells": {"B1": "250"}},
|
||||
"request": {"sheet": "Budget", "cells": {"B1": "250"}, "allow_formula": False, "force": False},
|
||||
"response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 1},
|
||||
},
|
||||
("put", "/api/file/{vault_name}/xlsx/style"): {
|
||||
"request": {
|
||||
"ops": [
|
||||
{"op": "cell", "sheet": "Budget", "range": "A1:B1", "style": {"bold": True, "fill_color": "#ffe08a"}},
|
||||
{"op": "col_width", "sheet": "Budget", "col": "A", "width": 24},
|
||||
],
|
||||
"force": False,
|
||||
"if_match": "18f2c0ab-1f4",
|
||||
},
|
||||
"response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 2, "revision": "18f2c0ab-1f6"},
|
||||
},
|
||||
# GET : pas d'exemple de requête (un requestBody sur un GET serait un OpenAPI
|
||||
# invalide) — les paramètres sont documentés par leurs Query().
|
||||
("get", "/api/file/{vault_name}/xlsx/sheet"): {
|
||||
"response": {
|
||||
"vault": "TestVault",
|
||||
"path": "data/budget.xlsx",
|
||||
"sheet": "Budget",
|
||||
"offset": 0,
|
||||
"limit": 200,
|
||||
"rows": 2,
|
||||
"cols": 2,
|
||||
"total_rows": 640,
|
||||
"total_cols": 12,
|
||||
"max_rows": 500,
|
||||
"max_cols": 40,
|
||||
"truncated": True,
|
||||
"has_more": True,
|
||||
"html": "<table>…</table>",
|
||||
},
|
||||
},
|
||||
("post", "/api/search/replace"): {
|
||||
"request": {"query": "Python", "replacement": "Python 3", "vault": "all", "dry_run": True},
|
||||
"response": {"matches": [{"vault": "TestVault", "path": "note1.md", "title": "Python", "match_count": 3}], "total_matches": 3, "dry_run": True},
|
||||
@@ -202,8 +242,8 @@ _ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = {
|
||||
"response": {"id": "abc…", "token": "abc…", "vault": "TestVault", "path": "notes/Accueil.md", "url": "/s/abc…"},
|
||||
},
|
||||
("post", "/api/webhooks"): {
|
||||
"request": {"name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "secret": "s3cr3t"},
|
||||
"response": {"id": "3f2c…", "name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "enabled": True},
|
||||
"request": {"name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "secret": "s3cr3t", "vault": "TikTak", "path_prefix": "Inbox"},
|
||||
"response": {"id": "3f2c…", "name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "enabled": True, "vault": "TikTak", "path_prefix": "Inbox"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -13,7 +13,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.auth.middleware import require_auth
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
|
||||
logger = logging.getLogger("obsigate.push")
|
||||
|
||||
@@ -167,9 +167,9 @@ async def subscribe_push(
|
||||
"""Subscribe to push notifications for a vault."""
|
||||
username = current_user.get("username", "unknown")
|
||||
|
||||
# Check if user has access to this vault
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
if "*" not in user_vaults and request.vault not in user_vaults:
|
||||
# Check if user has access to this vault (#194 : "*" n'inclut pas les
|
||||
# dossiers persos, check_vault_access est la source unique).
|
||||
if not check_vault_access(request.vault, current_user):
|
||||
raise HTTPException(status_code=403, detail="No access to this vault")
|
||||
|
||||
# Check if subscription already exists
|
||||
|
||||
+172
-1
@@ -12,14 +12,24 @@ the per-account lockout in ``user_store.py``.
|
||||
deployment, front this service with a shared store (Redis) or a single
|
||||
worker. This limitation is intentional and documented (BUG-031).
|
||||
|
||||
Opt-in persistence (ROADMAP #85 T10b) : if ``OBSIGATE_RATELIMIT_DB`` points
|
||||
to a SQLite file, counters are stored there instead (WAL mode, one short
|
||||
connection per call — safe across threads, processes and restarts sharing
|
||||
the same file). Semantics (windows, budgets, success reset) are identical
|
||||
to the in-memory store, which remains the default when the variable is
|
||||
unset.
|
||||
|
||||
Configuration via environment variables:
|
||||
OBSIGATE_LOGIN_MAX_ATTEMPTS Max failures per IP (default: 10)
|
||||
OBSIGATE_ACCOUNT_MAX_ATTEMPTS Max failures per account (default: 10)
|
||||
OBSIGATE_LOGIN_WINDOW_SECONDS Lockout window in seconds (default: 900)
|
||||
OBSIGATE_RATELIMIT_DB SQLite file for shared/persistent counters (default: unset = memory)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
@@ -37,6 +47,127 @@ _last_cleanup = time.time()
|
||||
CLEANUP_INTERVAL = 60 # seconds
|
||||
|
||||
|
||||
def _db_path() -> str | None:
|
||||
"""SQLite file for shared counters, or ``None`` for the in-memory store."""
|
||||
path = os.environ.get("OBSIGATE_RATELIMIT_DB", "").strip()
|
||||
return path or None
|
||||
|
||||
|
||||
def _db_connect(path: str) -> sqlite3.Connection:
|
||||
"""Open a short-lived connection (WAL + busy timeout for concurrent workers)."""
|
||||
_db_ensure_schema(path)
|
||||
conn = sqlite3.connect(path, timeout=10.0)
|
||||
conn.execute("PRAGMA busy_timeout=10000")
|
||||
return conn
|
||||
|
||||
|
||||
_schema_ready: set[str] = set()
|
||||
_schema_lock = threading.Lock()
|
||||
|
||||
|
||||
def _db_ensure_schema(path: str) -> None:
|
||||
"""Create the store schema once per file (DDL under a process-wide lock)."""
|
||||
with _schema_lock:
|
||||
if path in _schema_ready:
|
||||
return
|
||||
conn = sqlite3.connect(path, timeout=10.0)
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS attempts"
|
||||
" (kind TEXT NOT NULL, key TEXT NOT NULL, ts REAL NOT NULL, success INTEGER NOT NULL)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_attempts_kind_key_ts"
|
||||
" ON attempts (kind, key, ts)"
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
_schema_ready.add(path)
|
||||
|
||||
|
||||
def _db_write(fn, *args):
|
||||
"""Run a write op, retrying once on lock contention (concurrent workers)."""
|
||||
try:
|
||||
return fn(*args)
|
||||
except sqlite3.OperationalError as e:
|
||||
if "locked" not in str(e).lower():
|
||||
raise
|
||||
time.sleep(0.05)
|
||||
return fn(*args)
|
||||
|
||||
|
||||
def _db_prune(conn: sqlite3.Connection, cutoff: float) -> None:
|
||||
"""Drop expired entries (best-effort cap on disk growth)."""
|
||||
conn.execute("DELETE FROM attempts WHERE ts <= ?", (cutoff,))
|
||||
|
||||
|
||||
def _db_record(kind: str, key: str, success: bool) -> int:
|
||||
"""Record one attempt in SQLite; return the live failure count."""
|
||||
path = _db_path()
|
||||
assert path is not None
|
||||
now = time.time()
|
||||
cutoff = now - WINDOW_SECONDS
|
||||
|
||||
def _write() -> int:
|
||||
with _db_connect(path) as conn:
|
||||
_db_prune(conn, cutoff)
|
||||
if success:
|
||||
# Mirror the in-memory reset: replace history with one success.
|
||||
conn.execute("DELETE FROM attempts WHERE kind = ? AND key = ?", (kind, key))
|
||||
conn.execute(
|
||||
"INSERT INTO attempts (kind, key, ts, success) VALUES (?, ?, ?, ?)",
|
||||
(kind, key, now, int(success)),
|
||||
)
|
||||
conn.commit()
|
||||
(failures,) = conn.execute(
|
||||
"SELECT COUNT(*) FROM attempts WHERE kind = ? AND key = ? AND ts > ? AND success = 0",
|
||||
(kind, key, cutoff),
|
||||
).fetchone()
|
||||
return failures
|
||||
|
||||
return _db_write(_write)
|
||||
|
||||
|
||||
def _db_failures(kind: str, key: str) -> int:
|
||||
"""Live failure count in SQLite (expired entries never count)."""
|
||||
path = _db_path()
|
||||
assert path is not None
|
||||
cutoff = time.time() - WINDOW_SECONDS
|
||||
with _db_connect(path) as conn:
|
||||
(failures,) = conn.execute(
|
||||
"SELECT COUNT(*) FROM attempts WHERE kind = ? AND key = ? AND ts > ? AND success = 0",
|
||||
(kind, key, cutoff),
|
||||
).fetchone()
|
||||
return failures
|
||||
|
||||
|
||||
def _db_tracked(kind: str) -> int:
|
||||
"""Number of distinct keys ever seen for one budget (SQLite)."""
|
||||
path = _db_path()
|
||||
assert path is not None
|
||||
with _db_connect(path) as conn:
|
||||
(n,) = conn.execute(
|
||||
"SELECT COUNT(DISTINCT key) FROM attempts WHERE kind = ?", (kind,)
|
||||
).fetchone()
|
||||
return n
|
||||
|
||||
|
||||
def _db_limited_count(kind: str, max_attempts: int) -> int:
|
||||
"""Number of keys currently over budget (SQLite)."""
|
||||
path = _db_path()
|
||||
assert path is not None
|
||||
cutoff = time.time() - WINDOW_SECONDS
|
||||
with _db_connect(path) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT key, COUNT(*) FROM attempts"
|
||||
" WHERE kind = ? AND ts > ? AND success = 0 GROUP BY key",
|
||||
(kind, cutoff),
|
||||
).fetchall()
|
||||
return sum(1 for _, n in rows if n >= max_attempts)
|
||||
|
||||
|
||||
def _prune(store: dict[str, list], cutoff: float) -> None:
|
||||
"""Drop expired entries from one store in place."""
|
||||
expired = []
|
||||
@@ -66,6 +197,12 @@ def record_failure(ip: str) -> tuple[int, int]:
|
||||
Returns:
|
||||
(current_failure_count, remaining_attempts)
|
||||
"""
|
||||
if _db_path() is not None:
|
||||
failures = _db_record("ip", ip, False)
|
||||
remaining = max(0, MAX_ATTEMPTS - failures)
|
||||
if failures >= MAX_ATTEMPTS:
|
||||
logger.warning(f"IP {ip} rate-limited after {failures} failed logins")
|
||||
return failures, remaining
|
||||
_cleanup_expired()
|
||||
_ip_attempts[ip].append((time.time(), False))
|
||||
failures = sum(1 for _, success in _ip_attempts[ip] if not success)
|
||||
@@ -77,12 +214,17 @@ def record_failure(ip: str) -> tuple[int, int]:
|
||||
|
||||
def record_success(ip: str):
|
||||
"""Clear rate limit state for an IP after successful login."""
|
||||
if _db_path() is not None:
|
||||
_db_record("ip", ip, True)
|
||||
return
|
||||
_cleanup_expired()
|
||||
_ip_attempts[ip] = [(time.time(), True)]
|
||||
|
||||
|
||||
def is_rate_limited(ip: str) -> bool:
|
||||
"""Check if an IP has exceeded the rate limit."""
|
||||
if _db_path() is not None:
|
||||
return _db_failures("ip", ip) >= MAX_ATTEMPTS
|
||||
_cleanup_expired()
|
||||
failures = sum(1 for _, success in _ip_attempts.get(ip, []) if not success)
|
||||
return failures >= MAX_ATTEMPTS
|
||||
@@ -94,8 +236,14 @@ def record_account_failure(account: str) -> tuple[int, int]:
|
||||
Returns:
|
||||
(current_failure_count, remaining_attempts)
|
||||
"""
|
||||
_cleanup_expired()
|
||||
key = account.lower()
|
||||
if _db_path() is not None:
|
||||
failures = _db_record("account", key, False)
|
||||
remaining = max(0, ACCOUNT_MAX_ATTEMPTS - failures)
|
||||
if failures >= ACCOUNT_MAX_ATTEMPTS:
|
||||
logger.warning(f"Account {account} rate-limited after {failures} failed attempts")
|
||||
return failures, remaining
|
||||
_cleanup_expired()
|
||||
_account_attempts[key].append((time.time(), False))
|
||||
failures = sum(1 for _, success in _account_attempts[key] if not success)
|
||||
remaining = max(0, ACCOUNT_MAX_ATTEMPTS - failures)
|
||||
@@ -106,12 +254,17 @@ def record_account_failure(account: str) -> tuple[int, int]:
|
||||
|
||||
def record_account_success(account: str):
|
||||
"""Clear the per-account rate limit state after a successful login."""
|
||||
if _db_path() is not None:
|
||||
_db_record("account", account.lower(), True)
|
||||
return
|
||||
_cleanup_expired()
|
||||
_account_attempts[account.lower()] = [(time.time(), True)]
|
||||
|
||||
|
||||
def is_account_rate_limited(account: str) -> bool:
|
||||
"""Check if an account has exceeded the per-account rate limit."""
|
||||
if _db_path() is not None:
|
||||
return _db_failures("account", account.lower()) >= ACCOUNT_MAX_ATTEMPTS
|
||||
_cleanup_expired()
|
||||
failures = sum(
|
||||
1 for _, success in _account_attempts.get(account.lower(), []) if not success
|
||||
@@ -121,6 +274,24 @@ def is_account_rate_limited(account: str) -> bool:
|
||||
|
||||
def get_status(ip: str | None = None) -> dict:
|
||||
"""Get rate limit status for an IP (for diagnostics)."""
|
||||
if _db_path() is not None:
|
||||
if ip:
|
||||
failures = _db_failures("ip", ip)
|
||||
return {
|
||||
"ip": ip,
|
||||
"failures": failures,
|
||||
"max": MAX_ATTEMPTS,
|
||||
"limited": failures >= MAX_ATTEMPTS,
|
||||
"window_seconds": WINDOW_SECONDS,
|
||||
}
|
||||
return {
|
||||
"tracked_ips": _db_tracked("ip"),
|
||||
"tracked_accounts": _db_tracked("account"),
|
||||
"max_attempts": MAX_ATTEMPTS,
|
||||
"account_max_attempts": ACCOUNT_MAX_ATTEMPTS,
|
||||
"window_seconds": WINDOW_SECONDS,
|
||||
"limited_ips": _db_limited_count("ip", MAX_ATTEMPTS),
|
||||
}
|
||||
_cleanup_expired()
|
||||
if ip:
|
||||
attempts = _ip_attempts.get(ip, [])
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Markdown rendering pipeline (ROADMAP #85, tranche 9).
|
||||
|
||||
Helpers extraits de :mod:`backend.main` sans changement de comportement :
|
||||
slugification des headings, IDs d'ancrage, rendu mistune singleton,
|
||||
wikilinks, normalisation des sauts de ligne et pipeline complet
|
||||
:func:`_render_markdown` (rendu + sanitizer XSS BUG-021).
|
||||
|
||||
Les noms gardent leur préfixe ``_`` d'origine pour un déplacement
|
||||
strictement verbatim (tests et routers pointent ici désormais).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html as html_mod
|
||||
import re
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import mistune
|
||||
|
||||
from backend.image_processor import preprocess_images
|
||||
from backend.indexer import find_file_in_index, get_vault_data
|
||||
from backend.secret_redactor import redact_with_placeholders, restore_masks
|
||||
from backend.services.sanitizer import sanitize_html
|
||||
|
||||
|
||||
def _heading_slugify(text: str) -> str:
|
||||
"""Generate a URL-safe slug from heading text.
|
||||
|
||||
Matches the JavaScript slugify algorithm exactly using
|
||||
Unicode-aware character classification:
|
||||
1. Strip HTML tags (e.g. wikilink spans rendered inside headings)
|
||||
2. Decode HTML entities (e.g. ``&`` → ``&``)
|
||||
3. Lowercase
|
||||
4. NFD normalize + strip combining marks
|
||||
5. Keep only Unicode letters, numbers, spaces, hyphens
|
||||
6. Replace spaces with hyphens, collapse multiple hyphens
|
||||
|
||||
Args:
|
||||
text: The heading text content (may contain inline HTML).
|
||||
|
||||
Returns:
|
||||
A URL-safe slug string.
|
||||
"""
|
||||
# Strip any inline HTML so it does not pollute the slug
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
# Decode HTML entities so & becomes & before slugification
|
||||
text = html_mod.unescape(text)
|
||||
text = text.lower()
|
||||
text = unicodedata.normalize("NFD", text)
|
||||
text = "".join(ch for ch in text if not unicodedata.combining(ch))
|
||||
# Unicode-aware: keep letters (L*), numbers (N*), spaces, and hyphens
|
||||
cleaned = []
|
||||
for ch in text:
|
||||
cat = unicodedata.category(ch)
|
||||
if cat.startswith('L') or cat.startswith('N') or ch in (' ', '-'):
|
||||
cleaned.append(ch)
|
||||
text = "".join(cleaned)
|
||||
text = re.sub(r"\s+", "-", text)
|
||||
text = re.sub(r"-+", "-", text)
|
||||
result = text.strip("-")
|
||||
return result if result else "heading"
|
||||
|
||||
|
||||
def _add_heading_ids(html: str) -> str:
|
||||
"""Post-process rendered HTML to add IDs to heading tags.
|
||||
|
||||
Adds an ``id`` attribute to every ``<h1>`` through ``<h6>`` tag
|
||||
using a slug generated from the heading's text content.
|
||||
Duplicate slugs get a ``-2``, ``-3``, etc. suffix.
|
||||
|
||||
Args:
|
||||
html: Rendered HTML string.
|
||||
|
||||
Returns:
|
||||
HTML with heading IDs injected.
|
||||
"""
|
||||
used_ids: dict[str, int] = {}
|
||||
|
||||
def _replace_heading(match):
|
||||
tag = match.group(1)
|
||||
content = match.group(2)
|
||||
slug = _heading_slugify(content)
|
||||
count = used_ids.get(slug, 0)
|
||||
used_ids[slug] = count + 1
|
||||
if count > 0:
|
||||
slug = f"{slug}-{count + 1}"
|
||||
return f'<{tag} id="{slug}">{content}</{tag}>'
|
||||
|
||||
# Match h1-h6 tags with text content (no existing id attribute)
|
||||
return re.sub(
|
||||
r'<(h[1-6])>([^<]*(?:<(?!/?h[1-6])[^<]*)*)</h[1-6]>',
|
||||
_replace_heading,
|
||||
html,
|
||||
)
|
||||
|
||||
|
||||
# Cached mistune renderer — avoids re-creating on every request
|
||||
_markdown_renderer = mistune.create_markdown(
|
||||
escape=False,
|
||||
plugins=["table", "strikethrough", "footnotes", "task_lists"],
|
||||
)
|
||||
|
||||
|
||||
def _convert_wikilinks(content: str, current_vault: str) -> str:
|
||||
"""Convert ``[[wikilinks]]`` and ``[[target|display]]`` to clickable HTML.
|
||||
|
||||
Supports:
|
||||
- Internal file links: ``[[My Note]]`` / ``[[My Note|display]]``
|
||||
- Same-document anchors: ``[[#Heading]]`` / ``[[#Heading|display]]``
|
||||
|
||||
Resolved file links get a ``data-vault`` / ``data-path`` attribute pair.
|
||||
Anchor links target the slugified heading ID in the current document.
|
||||
Unresolved links are rendered as ``<span class="wikilink-missing">``.
|
||||
|
||||
Args:
|
||||
content: Markdown string potentially containing wikilinks.
|
||||
current_vault: Active vault name for resolution priority.
|
||||
|
||||
Returns:
|
||||
Markdown string with wikilinks replaced by HTML anchors.
|
||||
"""
|
||||
def _replace(match):
|
||||
target = match.group(1).strip()
|
||||
display = match.group(2).strip() if match.group(2) else target
|
||||
|
||||
# Same-document anchor link: [[#Heading|display]]
|
||||
if target.startswith("#"):
|
||||
anchor_text = target[1:].strip()
|
||||
anchor_slug = _heading_slugify(anchor_text)
|
||||
link_display = display if display != target else anchor_text
|
||||
return f'<a class="wikilink-anchor" href="#{anchor_slug}">{link_display}</a>'
|
||||
|
||||
found = find_file_in_index(target, current_vault)
|
||||
if found:
|
||||
return (
|
||||
f'<a class="wikilink" href="#" '
|
||||
f'data-vault="{found["vault"]}" '
|
||||
f'data-path="{found["path"]}">{display}</a>'
|
||||
)
|
||||
return f'<span class="wikilink-missing">{display}</span>'
|
||||
|
||||
pattern = r'\[\[([^\]|]+)(?:\|([^\]]+))?\]\]'
|
||||
return re.sub(pattern, _replace, content)
|
||||
|
||||
|
||||
def _normalize_line_breaks(text: str) -> str:
|
||||
"""Convert single newlines to hard breaks (matching Obsidian default behavior).
|
||||
|
||||
In standard Markdown, a single ``\\n`` is a "soft break" — it renders as a space,
|
||||
not a visible line break. Obsidian defaults to treating single newlines as hard
|
||||
breaks (equivalent to ``<br>``). This function pre-processes the Markdown source
|
||||
so that mistune renders standalone lines on separate rows, while still honouring
|
||||
blank lines as paragraph separators.
|
||||
|
||||
Fenced code blocks (`` ``` ``) are left untouched so their internal newlines are
|
||||
preserved verbatim.
|
||||
"""
|
||||
parts = re.split(r"(```[\s\S]*?```)", text)
|
||||
for i, part in enumerate(parts):
|
||||
if part.startswith("```"):
|
||||
continue # Protect fenced code blocks
|
||||
# Single \n (not preceded or followed by another \n) → two spaces + \n
|
||||
parts[i] = re.sub(r"(?<!\n)\n(?!\n)", " \n", part)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _render_markdown(
|
||||
raw_md: str,
|
||||
vault_name: str,
|
||||
current_file_path: Path | None = None,
|
||||
*,
|
||||
click_to_copy: bool = False,
|
||||
) -> str:
|
||||
"""Render a markdown string to HTML with wikilink and image support.
|
||||
|
||||
Uses the cached singleton mistune renderer for performance.
|
||||
|
||||
Args:
|
||||
raw_md: Raw markdown text (frontmatter already stripped).
|
||||
vault_name: Current vault for wikilink resolution context.
|
||||
current_file_path: Absolute path to the current markdown file.
|
||||
click_to_copy: Restore masked secrets as clickable badges carrying
|
||||
the real value (authenticated app preview, feature #188).
|
||||
Public shares and PDF exports keep plain labels: the secret
|
||||
never reaches their HTML.
|
||||
|
||||
Returns:
|
||||
HTML string.
|
||||
"""
|
||||
# Get vault data for image resolution
|
||||
vault_data = get_vault_data(vault_name)
|
||||
vault_root = Path(vault_data["path"]) if vault_data else None
|
||||
attachments_path = vault_data.get("config", {}).get("attachmentsPath") if vault_data else None
|
||||
|
||||
# Redact secrets before rendering (P0 security). Placeholders survive
|
||||
# the markdown conversion (fenced code blocks included) and are turned
|
||||
# back into visible masks — clickable badges when click_to_copy — right
|
||||
# after the HTML is produced (feature #188).
|
||||
raw_md, secret_entries = redact_with_placeholders(
|
||||
raw_md, str(current_file_path) if current_file_path else ""
|
||||
)
|
||||
|
||||
# Preprocess images first
|
||||
if vault_root:
|
||||
raw_md = preprocess_images(raw_md, vault_name, vault_root, current_file_path, attachments_path)
|
||||
|
||||
# Convert wikilinks
|
||||
converted = _convert_wikilinks(raw_md, vault_name)
|
||||
|
||||
# Normalize line breaks to match Obsidian behavior (single \n → hard break)
|
||||
converted = _normalize_line_breaks(converted)
|
||||
|
||||
# mistune 3.3 types `Markdown.__call__` as `str | list[...]` (les
|
||||
# renderers HTML renvoient toujours `str` à l'exécution).
|
||||
rendered = cast(str, _markdown_renderer(converted))
|
||||
|
||||
# Restore secret masks (plain labels, or clickable badges carrying the
|
||||
# real value on the authenticated app preview — feature #188).
|
||||
rendered = restore_masks(rendered, secret_entries, click_to_copy=click_to_copy)
|
||||
|
||||
# Add heading IDs for TOC navigation
|
||||
rendered = _add_heading_ids(rendered)
|
||||
|
||||
# Sanitize: raw HTML in vault content must never reach the DOM (BUG-021).
|
||||
rendered = sanitize_html(rendered)
|
||||
|
||||
return rendered
|
||||
@@ -1,9 +1,9 @@
|
||||
fastapi==0.110.3
|
||||
uvicorn==0.30.0
|
||||
fastapi==0.141.1
|
||||
uvicorn==0.54.0
|
||||
websockets>=12.0
|
||||
python-frontmatter==1.1.0
|
||||
mistune==3.0.2
|
||||
python-multipart==0.0.9
|
||||
mistune==3.3.3
|
||||
python-multipart==0.0.31
|
||||
aiofiles==23.2.1
|
||||
aiohttp>=3.9.0
|
||||
watchdog>=4.0.0
|
||||
@@ -11,17 +11,36 @@ argon2-cffi>=23.1.0
|
||||
python-jose>=3.3.0
|
||||
sortedcontainers>=2.4.0
|
||||
snowballstemmer>=2.2.0
|
||||
weasyprint>=60.0
|
||||
weasyprint>=70.0
|
||||
httpx>=0.27.0
|
||||
pypdf>=4.0
|
||||
# Plancher de sécurité (BUG-093) : 6.16.0 est vulnérable à deux DoS de
|
||||
# ressources (PYSEC-2026-3910 outlines, PYSEC-2026-3911 XForm, fix 6.16.1),
|
||||
# atteignables via backend/pdf_reader.py (PDF fournis par l'utilisateur).
|
||||
# Le plancher doit être >= 6.16.1 : l'image Act du runner embarque 6.16.0
|
||||
# dans sa toolcache Python, donc un plancher trop bas est « already satisfied »
|
||||
# et n'est jamais mis à niveau.
|
||||
pypdf>=6.16.1
|
||||
pyotp>=2.10.0
|
||||
segno>=1.5.0
|
||||
webauthn==2.6.0
|
||||
psutil>=5.9
|
||||
pywebpush>=2.3.0
|
||||
mcp==1.9.4
|
||||
mcp==1.28.1
|
||||
# Plancher de sécurité (BUG-091, BUG-095) : pyjwt est une dépendance transitive
|
||||
# (mcp). 2.12.x → PYSEC-2026-178 (fix 2.13.0) ; 2.13.0 → CVE-2026-102274
|
||||
# (fix 2.14.0). pip-audit étant bloquant, on reste au-dessus du dernier correctif.
|
||||
pyjwt[crypto]>=2.14.0
|
||||
sse-starlette==2.1.3
|
||||
openpyxl>=3.1
|
||||
xlrd==2.0.2
|
||||
odfpy==1.4.1
|
||||
python-docx>=1.1
|
||||
reportlab>=4.0
|
||||
pillow>=10.0
|
||||
# Plancher urllib3 >= 2.8.0 (CVE-2026-97687, CVE-2026-97688, CVE-2026-97689)
|
||||
urllib3>=2.8.0
|
||||
# Plancher de sécurité (CVE-2026-104874, fix 6.9.1) : multidict est transitive
|
||||
# (aiohttp/yarl). 6.7.x est la version pré-installée dans la toolcache de
|
||||
# l'image du runner — sans plancher, pip répond « already satisfied » et
|
||||
# n'aligne jamais (même piège que pypdf, BUG-093).
|
||||
multidict>=6.9.1
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""ObsiGate — routers FastAPI par domaine (ROADMAP #85).
|
||||
|
||||
Découpage progressif du monolithe ``backend/main.py`` : chaque module de ce
|
||||
paquet expose un ``APIRouter`` monté par ``main.py``. Les handlers sont
|
||||
déplacés sans changement de comportement (mêmes chemins, mêmes modèles de
|
||||
réponse, mêmes dépendances d'authentification).
|
||||
"""
|
||||
@@ -0,0 +1,412 @@
|
||||
"""Backup endpoints (ROADMAP #85, tranche 4).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/file/{vault}/backups|diff|restore``,
|
||||
``/api/backups*``), mêmes modèles de réponse, mêmes dépendances
|
||||
d'authentification. La logique métier vit déjà dans
|
||||
:mod:`backend.services.backups`.
|
||||
|
||||
Adaptations strictement équivalentes :
|
||||
- ``_resolve_safe_path`` / ``_backup_file`` / ``_list_backup_files`` de
|
||||
``main`` n'étaient que des wrappers directs : appelés ici via
|
||||
:mod:`backend.services.paths` et :mod:`backend.services.backups`.
|
||||
- ``RestoreRequest`` / ``RestoreResponse`` / ``DiffResponse`` ont déménagé
|
||||
dans :mod:`backend.schemas`.
|
||||
- Le singleton SSE vit désormais dans :mod:`backend.sse` (partagé avec
|
||||
``main`` : les clients ``/api/events`` reçoivent les mêmes broadcasts).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.indexer import get_vault_data, index, update_single_file
|
||||
from backend.schemas import (
|
||||
BackupContentResponse,
|
||||
BackupsAutoResponse,
|
||||
BackupsCompressResponse,
|
||||
BackupsDeletedResponse,
|
||||
BackupsListResponse,
|
||||
BackupsResponse,
|
||||
DiffResponse,
|
||||
RestoreRequest,
|
||||
RestoreResponse,
|
||||
)
|
||||
from backend.services.backups import (
|
||||
create_backup,
|
||||
)
|
||||
from backend.services.backups import (
|
||||
diff_backup as service_diff_backup,
|
||||
)
|
||||
from backend.services.backups import (
|
||||
list_backup_files as service_list_backup_files,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
restore_backup as service_restore_backup,
|
||||
)
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.sse import sse_manager
|
||||
from backend.webhooks import dispatch_webhooks
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
router = APIRouter(tags=["backups"])
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/backups", response_model=BackupsResponse)
|
||||
async def api_file_backups(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""List all available backups for a file.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative path of the file within the vault.
|
||||
|
||||
Returns:
|
||||
BackupListResponse with backups sorted newest first.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
|
||||
try:
|
||||
backups = service_list_backup_files(vault_name, path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing backups for {vault_name}/{path}: {type(e).__name__}: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Erreur lors de la lecture des backups: {e!s}")
|
||||
|
||||
return {"vault": vault_name, "path": path, "backups": backups}
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/diff", response_model=DiffResponse)
|
||||
async def api_file_diff(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
version: int = Query(..., description="Timestamp of the backup version (left/old side)"),
|
||||
compare_with: int | None = Query(default=None, description="Timestamp of another backup (right/new side). If omitted, compares with the current file."),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Generate a unified diff between a backup version and another version or the current file.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative path of the file within the vault.
|
||||
version: Timestamp of the backup to use as the old/left side.
|
||||
compare_with: Optional timestamp of another backup as the new/right side.
|
||||
If omitted, the current file on disk is used.
|
||||
|
||||
Returns:
|
||||
DiffResponse containing the unified diff string.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
return service_diff_backup(vault_name, path, version, compare_with)
|
||||
|
||||
|
||||
@router.post("/api/file/{vault_name}/restore", response_model=RestoreResponse)
|
||||
async def api_file_restore(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
body: RestoreRequest = ..., # type: ignore
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Restore a file from a backup version.
|
||||
|
||||
The current file is backed up before being overwritten (so the operation is reversible).
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative path of the file within the vault.
|
||||
body: RestoreRequest with the backup version timestamp.
|
||||
|
||||
Returns:
|
||||
RestoreResponse confirming the restore.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_restore_backup(vault_name, path, body.version)
|
||||
current_backed_up = result["current_backed_up"]
|
||||
|
||||
# Update index
|
||||
await update_single_file(vault_name, path)
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("file_restored", {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"restored_from": body.version,
|
||||
"current_backed_up": current_backed_up,
|
||||
})
|
||||
await dispatch_webhooks("file_restored", {"vault": vault_name, "path": path, "restored_from": body.version})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"restored_from": body.version,
|
||||
"current_backed_up": current_backed_up,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/backups", response_model=BackupsListResponse)
|
||||
async def api_backups_list(
|
||||
vault: str | None = Query(None, description="Filter by vault name"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""List all backups across vaults, grouped by file."""
|
||||
result: list[dict[str, Any]] = []
|
||||
try:
|
||||
for vault_name in index:
|
||||
if vault and vault_name != vault:
|
||||
continue
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
continue
|
||||
vd = get_vault_data(vault_name)
|
||||
if not vd:
|
||||
continue
|
||||
vault_root = Path(vd["path"])
|
||||
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
|
||||
if not backup_root.is_absolute():
|
||||
backup_root = vault_root / backup_root
|
||||
vault_backup_dir = backup_root / vault_name
|
||||
if not vault_backup_dir.exists():
|
||||
continue
|
||||
for fpath in vault_backup_dir.rglob("*.bak"):
|
||||
if not fpath.is_file():
|
||||
continue
|
||||
st = fpath.stat()
|
||||
fsize = st.st_size
|
||||
ts_part = fpath.name.rsplit(".", 2)
|
||||
if len(ts_part) < 3 or not ts_part[-2].isdigit():
|
||||
continue
|
||||
ts = int(ts_part[-2])
|
||||
rel_dir = str(fpath.parent.relative_to(vault_backup_dir)).replace("\\", "/")
|
||||
rel_file = rel_dir + "/" + ts_part[0] if rel_dir != "." else ts_part[0]
|
||||
result.append({
|
||||
"vault": vault_name,
|
||||
"file": rel_file,
|
||||
"backup_file": fpath.name,
|
||||
"timestamp": ts,
|
||||
"datetime": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(),
|
||||
"size": fsize,
|
||||
"full_path": str(fpath),
|
||||
})
|
||||
|
||||
result.sort(key=lambda x: x["timestamp"], reverse=True)
|
||||
total_size = sum(r["size"] for r in result)
|
||||
return {"backups": result, "total": len(result), "total_size_bytes": total_size}
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing backups: {type(e).__name__}: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Erreur listing backups: {e!s}")
|
||||
|
||||
|
||||
@router.post("/api/backups/delete", response_model=BackupsDeletedResponse)
|
||||
async def api_backups_delete(
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Delete one or more backup files."""
|
||||
paths = body.get("paths", [])
|
||||
if not paths:
|
||||
raise HTTPException(status_code=400, detail="No backup paths provided")
|
||||
|
||||
deleted = 0
|
||||
for p in paths:
|
||||
try:
|
||||
fpath = Path(p)
|
||||
# Security: ensure path is within a backup directory
|
||||
if ".obsigate-backup" not in str(fpath):
|
||||
continue
|
||||
if fpath.exists() and fpath.is_file():
|
||||
fpath.unlink()
|
||||
deleted += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete backup {p}: {e}")
|
||||
|
||||
return {"deleted": deleted}
|
||||
|
||||
|
||||
@router.post("/api/backups/purge", response_model=BackupsDeletedResponse)
|
||||
async def api_backups_purge(
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Purge all backups for a specific file or entire vault."""
|
||||
vault_name = body.get("vault")
|
||||
file_path = body.get("file") # optional
|
||||
|
||||
if not vault_name:
|
||||
raise HTTPException(status_code=400, detail="Vault name required")
|
||||
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
vd = get_vault_data(vault_name)
|
||||
if not vd:
|
||||
raise HTTPException(status_code=404, detail="Vault not found")
|
||||
|
||||
vault_root = Path(vd["path"])
|
||||
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
|
||||
if not backup_root.is_absolute():
|
||||
backup_root = vault_root / backup_root
|
||||
|
||||
if file_path:
|
||||
# Delete backups for specific file
|
||||
backup_dir = backup_root / vault_name / Path(file_path).parent
|
||||
if backup_dir.exists():
|
||||
fname = Path(file_path).name
|
||||
deleted = 0
|
||||
for f in backup_dir.iterdir():
|
||||
if f.is_file() and f.name.startswith(fname + ".") and f.name.endswith(".bak"):
|
||||
f.unlink()
|
||||
deleted += 1
|
||||
return {"deleted": deleted}
|
||||
return {"deleted": 0}
|
||||
else:
|
||||
# Delete all backups for vault
|
||||
vault_backup_dir = backup_root / vault_name
|
||||
if vault_backup_dir.exists():
|
||||
deleted = 0
|
||||
for f in vault_backup_dir.rglob("*.bak"):
|
||||
if f.is_file():
|
||||
f.unlink()
|
||||
deleted += 1
|
||||
return {"deleted": deleted}
|
||||
return {"deleted": 0}
|
||||
|
||||
|
||||
|
||||
@router.get("/api/backups/content", response_model=BackupContentResponse)
|
||||
async def api_backups_content(
|
||||
path: str = Query(..., description="Full path to backup file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return the content of a specific backup file."""
|
||||
try:
|
||||
fpath = Path(path)
|
||||
if ".obsigate-backup" not in str(fpath):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
if not fpath.exists() or not fpath.is_file():
|
||||
raise HTTPException(status_code=404, detail="Backup not found")
|
||||
content = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
# Truncate large files to 100KB
|
||||
if len(content) > 102400:
|
||||
content = content[:102400] + "\n\n... (tronque a 100 Ko)"
|
||||
return {"content": content, "name": fpath.name, "size": len(content)}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/backups/compress", response_model=BackupsCompressResponse)
|
||||
async def api_backups_compress(
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Compress backups older than N days. Body: {older_than_days: 30, dry_run: false}"""
|
||||
import gzip as gz_mod
|
||||
older_than = body.get("older_than_days", 30)
|
||||
dry_run = body.get("dry_run", False)
|
||||
cutoff = time.time() - (older_than * 86400)
|
||||
compressed = 0
|
||||
saved_bytes = 0
|
||||
|
||||
for vault_name in index:
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
continue
|
||||
vd = get_vault_data(vault_name)
|
||||
if not vd:
|
||||
continue
|
||||
vault_root = Path(vd["path"])
|
||||
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
|
||||
if not backup_root.is_absolute():
|
||||
backup_root = vault_root / backup_root
|
||||
vault_dir = backup_root / vault_name
|
||||
if not vault_dir.exists():
|
||||
continue
|
||||
for fpath in vault_dir.rglob("*.bak"):
|
||||
if not fpath.is_file():
|
||||
continue
|
||||
if fpath.name.endswith(".bak.gz"):
|
||||
continue
|
||||
mtime = fpath.stat().st_mtime
|
||||
if mtime > cutoff:
|
||||
continue
|
||||
if not dry_run:
|
||||
try:
|
||||
gz_path = fpath.with_suffix(fpath.suffix + ".gz")
|
||||
data = fpath.read_bytes()
|
||||
with gz_mod.open(str(gz_path), "wb", compresslevel=6) as gzf:
|
||||
gzf.write(data)
|
||||
orig_size = len(data)
|
||||
gz_size = gz_path.stat().st_size
|
||||
if gz_size < orig_size:
|
||||
fpath.unlink()
|
||||
saved_bytes += (orig_size - gz_size)
|
||||
else:
|
||||
gz_path.unlink() # compression didn't help
|
||||
compressed += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to compress {fpath}: {e}")
|
||||
else:
|
||||
compressed += 1
|
||||
|
||||
return {"compressed": compressed, "saved_bytes": saved_bytes, "dry_run": dry_run}
|
||||
|
||||
|
||||
@router.post("/api/backups/auto", response_model=BackupsAutoResponse)
|
||||
async def api_backups_auto(
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Create backups for files modified since a given time. Body: {since_hours: 24}"""
|
||||
since_hours = body.get("since_hours", 24)
|
||||
cutoff = time.time() - (since_hours * 3600)
|
||||
backed_up = 0
|
||||
|
||||
for vault_name in index:
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
continue
|
||||
vd = get_vault_data(vault_name)
|
||||
if not vd:
|
||||
continue
|
||||
vault_root = Path(vd["path"])
|
||||
for fpath in vault_root.rglob("*"):
|
||||
if not fpath.is_file():
|
||||
continue
|
||||
if fpath.name.startswith('.'):
|
||||
continue
|
||||
if any(p.startswith('.') or p in {'.obsidian', '.trash', '.git', '.obsigate-backup', '__pycache__', 'node_modules'} for p in fpath.relative_to(vault_root).parts):
|
||||
continue
|
||||
mtime = fpath.stat().st_mtime
|
||||
if mtime < cutoff:
|
||||
continue
|
||||
try:
|
||||
rel = str(fpath.relative_to(vault_root)).replace("\\", "/")
|
||||
create_backup(fpath, vault_name, rel)
|
||||
backed_up += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Auto-backup failed for {rel}: {e}")
|
||||
|
||||
return {"backed_up": backed_up, "since_hours": since_hours}
|
||||
@@ -0,0 +1,530 @@
|
||||
"""Configuration, AI keys, diagnostics & dashboard endpoints (ROADMAP #85, tranche 7).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/config*``, ``/api/diagnostics``,
|
||||
``/api/dashboard``), mêmes modèles de réponse, mêmes dépendances
|
||||
d'authentification.
|
||||
|
||||
Adaptations strictement équivalentes :
|
||||
- ``_load_config`` / ``_save_config`` / ``_DEFAULT_CONFIG`` /
|
||||
``_CONFIG_PATH`` / ``_BASE_DIR`` ont déménagé ici : ``main`` les
|
||||
réimporte pour son lifespan (pas de cycle : ce module ne dépend pas de
|
||||
``main``).
|
||||
- ``AI_KEYS_FILE`` / ``_write_ai_keys`` / ``_FALLBACK_MODELS`` ont déménagé
|
||||
ici (``AI_KEYS_FILE`` garde son chemin relatif ``data/api_keys.json``,
|
||||
résolu depuis le même CWD au runtime).
|
||||
"""
|
||||
|
||||
import json as _json
|
||||
import logging
|
||||
import os
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
|
||||
from backend.ai import PROVIDERS, _read_ai_keys, get_ai_key
|
||||
from backend.auth.middleware import check_vault_access, require_admin, require_auth
|
||||
from backend.indexer import index
|
||||
from backend.media_types import IMAGE_EXTENSIONS
|
||||
from backend.schemas import (
|
||||
AIKeyDeleteResponse,
|
||||
AIKeysResponse,
|
||||
AIModelsResponse,
|
||||
AITestResponse,
|
||||
AppConfigResponse,
|
||||
DashboardResponse,
|
||||
DiagnosticsResponse,
|
||||
StatusResponse,
|
||||
)
|
||||
from backend.search_executor import get_search_executor
|
||||
from backend.tools.secrets import (
|
||||
TOOL_KEY_NAMES as _TOOL_KEY_NAMES,
|
||||
)
|
||||
from backend.tools.secrets import (
|
||||
delete_tool_key as _delete_tool_key,
|
||||
)
|
||||
from backend.tools.secrets import (
|
||||
get_tool_key as _get_tool_key,
|
||||
)
|
||||
from backend.tools.secrets import (
|
||||
mask_value as _mask_tool_value,
|
||||
)
|
||||
from backend.tools.secrets import (
|
||||
set_tool_key as _set_tool_key,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
router = APIRouter(tags=["System"])
|
||||
|
||||
_BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
_CONFIG_PATH = _BASE_DIR / "data" / "config.json"
|
||||
|
||||
_DEFAULT_CONFIG = {
|
||||
"search_workers": 2,
|
||||
"debounce_ms": 300,
|
||||
"results_per_page": 50,
|
||||
"min_query_length": 2,
|
||||
"search_timeout_ms": 30000,
|
||||
"max_content_size": 100000,
|
||||
"snippet_context_chars": 120,
|
||||
"max_snippet_highlights": 5,
|
||||
"title_boost": 3.0,
|
||||
"path_boost": 1.5,
|
||||
"watcher_enabled": True,
|
||||
"watcher_use_polling": False,
|
||||
"watcher_polling_interval": 5.0,
|
||||
"watcher_debounce": 2.0,
|
||||
"tag_boost": 2.0,
|
||||
"prefix_max_expansions": 50,
|
||||
"recent_files_limit": 20,
|
||||
"max_backups_per_file": 10,
|
||||
"ai_default_provider": "deepseek",
|
||||
"ai_default_models": {},
|
||||
}
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
"""Load config from disk, merging with defaults."""
|
||||
config = dict(_DEFAULT_CONFIG)
|
||||
if _CONFIG_PATH.exists():
|
||||
try:
|
||||
stored = _json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
config.update(stored)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read config.json: {e}")
|
||||
return config
|
||||
|
||||
|
||||
def _save_config(config: dict) -> None:
|
||||
"""Persist config to disk."""
|
||||
try:
|
||||
_CONFIG_PATH.write_text(
|
||||
_json.dumps(config, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to write config.json: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to save config: {e}")
|
||||
|
||||
|
||||
AI_KEYS_FILE = Path("data/api_keys.json")
|
||||
|
||||
def _write_ai_keys(data: dict):
|
||||
AI_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = AI_KEYS_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(_json.dumps(data, indent=2), encoding="utf-8")
|
||||
tmp.replace(AI_KEYS_FILE)
|
||||
|
||||
@router.get("/api/config", response_model=AppConfigResponse)
|
||||
async def api_get_config(current_user=Depends(require_auth)):
|
||||
"""Return current configuration with defaults for missing keys."""
|
||||
return _load_config()
|
||||
|
||||
|
||||
@router.post("/api/config", response_model=AppConfigResponse)
|
||||
async def api_set_config(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Update configuration. Only known keys are accepted.
|
||||
|
||||
Keys matching ``_DEFAULT_CONFIG`` are validated and persisted.
|
||||
Unknown keys are silently ignored.
|
||||
Returns the full merged config after update.
|
||||
"""
|
||||
current = _load_config()
|
||||
updated_keys = []
|
||||
for key, value in body.items():
|
||||
if key in _DEFAULT_CONFIG:
|
||||
expected_type = type(_DEFAULT_CONFIG[key])
|
||||
if isinstance(value, expected_type) or (expected_type is float and isinstance(value, (int, float))):
|
||||
current[key] = value
|
||||
updated_keys.append(key)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid type for '{key}': expected {expected_type.__name__}, got {type(value).__name__}",
|
||||
)
|
||||
_save_config(current)
|
||||
if any(k.startswith("ai_") for k in updated_keys):
|
||||
try:
|
||||
from backend.ai import reload_ai_config
|
||||
reload_ai_config()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to reload AI config: {e}")
|
||||
logger.info(f"Config updated: {updated_keys}")
|
||||
return current
|
||||
|
||||
|
||||
@router.get("/api/config/ai-keys", response_model=AIKeysResponse)
|
||||
async def api_get_ai_keys(current_user=Depends(require_admin)):
|
||||
"""Return stored AI keys (values masked)."""
|
||||
keys = _read_ai_keys()
|
||||
masked = {}
|
||||
for k in ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "NVIDIA_API_KEY", "QWENCLOUD_API_KEY", "XIAOMI_API_KEY", "MISTRAL_API_KEY"]:
|
||||
val = keys.get(k, "") or os.environ.get(k, "")
|
||||
if val:
|
||||
masked[k] = val[:4] + "..." + val[-4:] if len(val) > 8 else "***"
|
||||
else:
|
||||
masked[k] = ""
|
||||
return masked
|
||||
|
||||
@router.post("/api/config/ai-keys", response_model=StatusResponse)
|
||||
async def api_set_ai_keys(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Save AI keys. Pass {"DEEPSEEK_API_KEY":"sk-...","OPENROUTER_API_KEY":"...","GEMINI_API_KEY":"..."}"""
|
||||
keys = _read_ai_keys()
|
||||
for k in ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "NVIDIA_API_KEY", "QWENCLOUD_API_KEY", "XIAOMI_API_KEY", "MISTRAL_API_KEY"]:
|
||||
if body.get(k):
|
||||
keys[k] = body[k]
|
||||
_write_ai_keys(keys)
|
||||
logger.info("AI keys updated")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/api/config/ai-keys/{provider_env}", response_model=AIKeyDeleteResponse)
|
||||
async def api_delete_ai_key(provider_env: str, current_user=Depends(require_admin)):
|
||||
"""Delete a specific AI provider key from storage."""
|
||||
allowed = {"DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY",
|
||||
"NVIDIA_API_KEY", "QWENCLOUD_API_KEY", "XIAOMI_API_KEY", "MISTRAL_API_KEY"}
|
||||
key_name = provider_env.upper()
|
||||
if key_name not in allowed:
|
||||
raise HTTPException(status_code=400, detail=f"Clé inconnue: {provider_env}")
|
||||
keys = _read_ai_keys()
|
||||
if key_name in keys:
|
||||
del keys[key_name]
|
||||
_write_ai_keys(keys)
|
||||
# Also clear from env at runtime so get_ai_key() no longer finds it
|
||||
os.environ.pop(key_name, None)
|
||||
logger.info(f"AI key deleted: {key_name}")
|
||||
return {"status": "deleted", "key": key_name}
|
||||
|
||||
|
||||
@router.get("/api/config/tool-keys", response_model=AIKeysResponse)
|
||||
async def api_get_tool_keys(current_user=Depends(require_admin)):
|
||||
"""Return tool/connected-source configuration (tokens masked, URLs clear)."""
|
||||
masked = {}
|
||||
for name in _TOOL_KEY_NAMES:
|
||||
masked[name] = _mask_tool_value(name, _get_tool_key(name))
|
||||
return masked
|
||||
|
||||
|
||||
@router.post("/api/config/tool-keys", response_model=StatusResponse)
|
||||
async def api_set_tool_keys(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Save tool/connected-source keys.
|
||||
|
||||
Only whitelisted names (``backend.tools.secrets.TOOL_KEY_NAMES``) are
|
||||
accepted: Tavily/Brave/SerpAPI/Exa API keys, Gitea URL + token, GitHub
|
||||
token. Empty values delete the stored entry.
|
||||
"""
|
||||
updated = []
|
||||
for name, value in body.items():
|
||||
if name not in _TOOL_KEY_NAMES:
|
||||
raise HTTPException(status_code=400, detail=f"Clé inconnue: {name}")
|
||||
if value is not None and not isinstance(value, str):
|
||||
raise HTTPException(status_code=400, detail=f"Type invalide pour {name}")
|
||||
_set_tool_key(name, value or "")
|
||||
updated.append(name)
|
||||
logger.info(f"Tool keys updated: {updated}")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/api/config/tool-keys/{name}", response_model=AIKeyDeleteResponse)
|
||||
async def api_delete_tool_key(name: str, current_user=Depends(require_admin)):
|
||||
"""Delete a stored tool key (the environment fallback still applies)."""
|
||||
key_name = name.upper()
|
||||
try:
|
||||
existed = _delete_tool_key(key_name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
logger.info(f"Tool key deleted: {key_name} (existed={existed})")
|
||||
return {"status": "deleted", "key": key_name}
|
||||
|
||||
|
||||
@router.post("/api/config/ai-keys/test", response_model=AITestResponse)
|
||||
async def api_test_ai_keys(current_user=Depends(require_admin)):
|
||||
"""Test which AI providers are configured.
|
||||
|
||||
Each provider has a dedicated (URL, header-name) test pair.
|
||||
- Most OpenAI-compatible APIs use `Authorization: Bearer KEY`
|
||||
- Xiaomi MiMo uses `api-key: KEY`
|
||||
- Gemini uses a query-string key
|
||||
"""
|
||||
results = {}
|
||||
for key_name, label, test_url_tmpl, header_name in [
|
||||
# OpenAI-compatible — Authorization: Bearer
|
||||
("DEEPSEEK_API_KEY", "deepseek", "https://api.deepseek.com/v1/models", "Authorization"),
|
||||
("OPENROUTER_API_KEY","openrouter", "https://openrouter.ai/api/v1/models", "Authorization"),
|
||||
("NVIDIA_API_KEY", "nvidia", "https://integrate.api.nvidia.com/v1/models", "Authorization"),
|
||||
("QWENCLOUD_API_KEY", "qwencloud", "https://dashscope.aliyuncs.com/compatible-mode/v1/models", "Authorization"),
|
||||
("MISTRAL_API_KEY", "mistral", "https://api.mistral.ai/v1/models", "Authorization"),
|
||||
# Xiaomi MiMo — dedicated api-key header (NOT Authorization: Bearer)
|
||||
("XIAOMI_API_KEY", "xiaomi", "https://api.xiaomimimo.com/v1/models", "api-key"),
|
||||
# Gemini — key in query string
|
||||
("GEMINI_API_KEY", "gemini", "https://generativelanguage.googleapis.com/v1beta/models?key={key}", None),
|
||||
]:
|
||||
key = get_ai_key(key_name)
|
||||
if not key:
|
||||
results[label] = "non configuré"
|
||||
continue
|
||||
try:
|
||||
url = test_url_tmpl.replace("{key}", key) if "{key}" in test_url_tmpl else test_url_tmpl
|
||||
if header_name:
|
||||
req = urllib.request.Request(url, headers={header_name: key})
|
||||
else:
|
||||
req = urllib.request.Request(url)
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
results[label] = "ok"
|
||||
except Exception as e:
|
||||
# Truncate the error to keep the response small.
|
||||
results[label] = "erreur: " + str(e)[:80]
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/api/config/ai-models", response_model=AIModelsResponse)
|
||||
async def api_list_ai_models(provider: str = Query(...), current_user=Depends(require_admin)):
|
||||
"""List available models for a given AI provider.
|
||||
|
||||
Strategy:
|
||||
1. Try the provider's public models endpoint (OpenAI-compatible /v1/models or Gemini).
|
||||
2. If the network call fails (timeout, 4xx, 5xx, DNS, etc.), fall back to a
|
||||
curated static list of known-good models for that provider.
|
||||
3. Always return a non-empty list when the provider is known, so the UI
|
||||
dropdown is never empty.
|
||||
"""
|
||||
provider = provider.lower()
|
||||
|
||||
from backend.model_capabilities import get_capabilities_for_models
|
||||
from backend.provider_capabilities import remember_declared_capabilities
|
||||
|
||||
all_providers = ("deepseek", "openrouter", "gemini", "nvidia", "qwencloud", "xiaomi", "mistral")
|
||||
if provider not in all_providers:
|
||||
return {"models": [], "error": f"Unknown provider: {provider}", "source": "validation"}
|
||||
|
||||
key_name = f"{provider.upper()}_API_KEY"
|
||||
key = get_ai_key(key_name)
|
||||
if not key:
|
||||
# No key configured — return curated fallback list so the UI can
|
||||
# still show what WOULD be available once a key is set.
|
||||
fallback = _FALLBACK_MODELS.get(provider, [])
|
||||
return {"models": fallback, "source": "fallback",
|
||||
"capabilities": get_capabilities_for_models(provider, fallback),
|
||||
"note": "API key not configured — showing default model list"}
|
||||
|
||||
# Build URL
|
||||
if provider == "gemini":
|
||||
url = f"https://generativelanguage.googleapis.com/v1beta/models?key={key}"
|
||||
elif provider == "deepseek":
|
||||
url = "https://api.deepseek.com/v1/models"
|
||||
elif provider == "openrouter":
|
||||
url = "https://openrouter.ai/api/v1/models"
|
||||
elif provider == "nvidia":
|
||||
url = "https://integrate.api.nvidia.com/v1/models"
|
||||
elif provider == "qwencloud":
|
||||
url = "https://dashscope.aliyuncs.com/compatible-mode/v1/models"
|
||||
elif provider == "xiaomi":
|
||||
# Xiaomi MiMo — dedicated api-key header (NOT Authorization: Bearer).
|
||||
# Endpoint: https://api.xiaomimimo.com/v1/models
|
||||
url = "https://api.xiaomimimo.com/v1/models"
|
||||
models = [] # parsed below with the custom header
|
||||
elif provider == "mistral":
|
||||
url = "https://api.mistral.ai/v1/models"
|
||||
|
||||
try:
|
||||
if provider == "gemini":
|
||||
req = urllib.request.Request(url)
|
||||
elif provider == "xiaomi":
|
||||
# Xiaomi MiMo uses a dedicated api-key header.
|
||||
req = urllib.request.Request(url, headers={"api-key": key})
|
||||
else:
|
||||
req = urllib.request.Request(url, headers={"Authorization": "Bearer " + key})
|
||||
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = _json.loads(resp.read().decode())
|
||||
|
||||
if provider == "gemini":
|
||||
models = [m.get("name", "") for m in data.get("models", []) if m.get("name")]
|
||||
# Gemini returns names like "models/gemini-1.5-flash" — strip prefix
|
||||
models = [m.replace("models/", "") for m in models]
|
||||
else:
|
||||
models = [m.get("id", "") for m in data.get("data", []) if m.get("id")]
|
||||
|
||||
# Cache the capabilities the provider declares for these models
|
||||
# (BUG-044) — get_capabilities_for_models() below then returns the
|
||||
# provider's own truth for the flags it declares, the curated table
|
||||
# for the rest. Providers that declare nothing are left untouched.
|
||||
remember_declared_capabilities(provider, data)
|
||||
|
||||
if models:
|
||||
# Prepend the configured default if not already present
|
||||
default = PROVIDERS.get(provider, {}).get("model")
|
||||
if default and default not in models:
|
||||
models = [default] + models
|
||||
return {"models": models, "source": "live", "count": len(models),
|
||||
"capabilities": get_capabilities_for_models(provider, models)}
|
||||
# Empty list from API — fall through to fallback
|
||||
raise ValueError("empty model list from provider API")
|
||||
except Exception as e:
|
||||
# Network error, auth error, parsing error — use curated fallback
|
||||
fallback = _FALLBACK_MODELS.get(provider, [])
|
||||
return {"models": fallback, "source": "fallback", "error": str(e)[:200],
|
||||
"capabilities": get_capabilities_for_models(provider, fallback),
|
||||
"note": "Could not reach provider API — showing default model list"}
|
||||
|
||||
|
||||
# ── Curated fallback model lists ──────────────────────────────────────────
|
||||
# Used when the provider API is unreachable or returns empty.
|
||||
# Keep these short and focused on models known to work with the
|
||||
# OpenAI-compatible chat completions interface (or Gemini's generateContent).
|
||||
_FALLBACK_MODELS: dict[str, list[str]] = {
|
||||
"deepseek": [
|
||||
"deepseek-chat",
|
||||
"deepseek-reasoner",
|
||||
],
|
||||
"openrouter": [
|
||||
"openai/gpt-4o-mini",
|
||||
"openai/gpt-4o",
|
||||
"anthropic/claude-3.5-sonnet",
|
||||
"anthropic/claude-3-haiku",
|
||||
"google/gemini-2.0-flash-exp:free",
|
||||
"meta-llama/llama-3.1-70b-instruct",
|
||||
"meta-llama/llama-3.1-8b-instruct:free",
|
||||
"mistralai/mistral-large-latest",
|
||||
],
|
||||
"gemini": [
|
||||
"gemini-2.0-flash",
|
||||
"gemini-2.0-flash-exp",
|
||||
"gemini-1.5-pro",
|
||||
"gemini-1.5-flash",
|
||||
"gemini-1.5-flash-8b",
|
||||
],
|
||||
"nvidia": [
|
||||
"meta/llama-3.1-405b-instruct",
|
||||
"meta/llama-3.1-70b-instruct",
|
||||
"meta/llama-3.1-8b-instruct",
|
||||
"mistralai/mistral-large",
|
||||
"google/gemma-2-27b-it",
|
||||
"nvidia/llama-3.1-nemotron-70b-instruct",
|
||||
],
|
||||
"qwencloud": [
|
||||
"qwen-max",
|
||||
"qwen-plus",
|
||||
"qwen-turbo",
|
||||
"qwen-long",
|
||||
"qwen-vl-max",
|
||||
"qwen-vl-plus",
|
||||
],
|
||||
"xiaomi": [
|
||||
# Xiaomi MiMo models — the public /v1/models endpoint requires the
|
||||
# `api-key` custom header (NOT Authorization: Bearer), so the live
|
||||
# call often fails with 401 even with the right key. We ship a
|
||||
# known-good list as fallback. See https://mimo.mi.com/docs/
|
||||
"mimo-v2.5-pro",
|
||||
"mimo-v2.5",
|
||||
"mimo-v2.5-asr",
|
||||
"mimo-v2.5-tts",
|
||||
"mimo-v2.5-tts-voiceclone",
|
||||
"mimo-v2.5-tts-voicedesign",
|
||||
],
|
||||
"mistral": [
|
||||
"mistral-large-latest",
|
||||
"mistral-medium-latest",
|
||||
"mistral-small-latest",
|
||||
"open-mistral-7b",
|
||||
"open-mixtral-8x7b",
|
||||
"codestral-latest",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/diagnostics", response_model=DiagnosticsResponse)
|
||||
async def api_diagnostics(current_user=Depends(require_admin)):
|
||||
"""Return index statistics and system diagnostics.
|
||||
|
||||
Includes document counts, token counts, memory estimates,
|
||||
and inverted index status.
|
||||
"""
|
||||
import sys
|
||||
|
||||
from backend.search import get_inverted_index
|
||||
|
||||
inv = get_inverted_index()
|
||||
|
||||
# Per-vault stats
|
||||
vault_stats = {}
|
||||
total_files = 0
|
||||
total_tags = 0
|
||||
# Snapshot both dicts first: the indexer mutates them from background
|
||||
# threads, and iterating a live dict raises "dictionary changed size".
|
||||
for vname, vdata in list(index.items()):
|
||||
file_count = len(vdata.get("files", []))
|
||||
tag_count = len(vdata.get("tags", {}))
|
||||
vault_stats[vname] = {"file_count": file_count, "tag_count": tag_count}
|
||||
total_files += file_count
|
||||
total_tags += tag_count
|
||||
|
||||
# Memory estimate for inverted index
|
||||
word_index = inv.word_index.copy()
|
||||
word_index_entries = sum(len(docs) for docs in word_index.values())
|
||||
mem_estimate_mb = round(
|
||||
(sys.getsizeof(inv.word_index) + word_index_entries * 80
|
||||
+ len(inv.doc_info) * 200
|
||||
+ len(inv._sorted_tokens) * 60) / (1024 * 1024), 2
|
||||
)
|
||||
|
||||
return {
|
||||
"index": {
|
||||
"total_files": total_files,
|
||||
"total_tags": total_tags,
|
||||
"vaults": vault_stats,
|
||||
},
|
||||
"inverted_index": {
|
||||
"unique_tokens": len(word_index),
|
||||
"total_postings": word_index_entries,
|
||||
"documents": inv.doc_count,
|
||||
"sorted_tokens": len(inv._sorted_tokens),
|
||||
"is_ready": inv.is_ready(),
|
||||
"memory_estimate_mb": mem_estimate_mb,
|
||||
},
|
||||
"config": _load_config(),
|
||||
"search_executor": {
|
||||
"active": get_search_executor() is not None,
|
||||
"max_workers": get_search_executor()._max_workers if get_search_executor() else 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/dashboard", response_model=DashboardResponse)
|
||||
async def api_dashboard(current_user=Depends(require_auth)):
|
||||
"""Aggregated dashboard statistics across all accessible vaults."""
|
||||
vault_stats = []
|
||||
total_files = 0
|
||||
total_tags = set()
|
||||
total_size = 0
|
||||
total_images = 0
|
||||
for vname, vdata in index.items():
|
||||
if not check_vault_access(vname, current_user):
|
||||
continue
|
||||
files = vdata.get("files", [])
|
||||
fc = len(files)
|
||||
total_files += fc
|
||||
vtags = set()
|
||||
vsize = 0
|
||||
vimages = 0
|
||||
for f in files:
|
||||
vtags.update(f.get("tags", []))
|
||||
vsize += f.get("size", 0)
|
||||
if (f.get("extension") or "").lower() in IMAGE_EXTENSIONS:
|
||||
vimages += 1
|
||||
total_tags.update(vtags)
|
||||
total_size += vsize
|
||||
total_images += vimages
|
||||
vault_stats.append({
|
||||
"name": vname, "file_count": fc, "tag_count": len(vtags),
|
||||
"total_size_bytes": vsize, "image_count": vimages,
|
||||
})
|
||||
return {
|
||||
"vaults": vault_stats,
|
||||
"total_files": total_files,
|
||||
"total_tags": len(total_tags),
|
||||
"total_size_bytes": total_size,
|
||||
"total_images": total_images,
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Syncthing conflict endpoints (ROADMAP #85, tranche 8).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/conflicts*``), mêmes modèles de
|
||||
réponse, mêmes dépendances d'authentification.
|
||||
|
||||
Adaptations strictement équivalentes :
|
||||
- ``_resolve_safe_path`` / ``_backup_file`` → :mod:`backend.services.paths`
|
||||
et :mod:`backend.services.backups` (pass-through).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
|
||||
from backend.audit import log_file_delete
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.indexer import get_conflicts, get_vault_data, remove_single_file
|
||||
from backend.schemas import ConflictResolveResponse, ConflictsResponse
|
||||
from backend.services.backups import create_backup
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.sse import sse_manager
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
router = APIRouter(tags=["conflicts"])
|
||||
|
||||
|
||||
@router.get("/api/conflicts", response_model=ConflictsResponse)
|
||||
async def api_conflicts(current_user=Depends(require_auth)):
|
||||
"""List sync-conflict files across accessible vaults."""
|
||||
all_conflicts = get_conflicts()
|
||||
# #194 : filtrage via check_vault_access ("*" n'inclut pas les homes).
|
||||
all_conflicts = [c for c in all_conflicts if check_vault_access(c["vault"], current_user)]
|
||||
return {"conflicts": all_conflicts, "total": len(all_conflicts)}
|
||||
|
||||
|
||||
@router.post("/api/conflicts/resolve", response_model=ConflictResolveResponse)
|
||||
async def api_conflict_resolve(body: dict = Body(...), current_user=Depends(require_auth)):
|
||||
"""Resolve a conflict: keep_local (delete conflict file) or keep_conflict (replace original)."""
|
||||
vault_name = body.get("vault")
|
||||
conflict_path = body.get("conflict_path")
|
||||
original_path = body.get("original_path")
|
||||
action = body.get("action") # "keep_local" or "keep_conflict"
|
||||
# mypy: narrow down from dict values
|
||||
assert isinstance(vault_name, str), "'vault' is required and must be a string"
|
||||
assert isinstance(conflict_path, str), "'conflict_path' is required and must be a string"
|
||||
assert isinstance(original_path, str), "'original_path' is required and must be a string"
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(404, "Vault not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
conf_file = resolve_safe_path(vault_root, conflict_path)
|
||||
orig_file = resolve_safe_path(vault_root, original_path)
|
||||
if not conf_file.exists():
|
||||
raise HTTPException(404, "Conflict file not found")
|
||||
try:
|
||||
if action == "keep_conflict":
|
||||
create_backup(orig_file, vault_name, original_path)
|
||||
shutil.copy2(conf_file, orig_file)
|
||||
logger.info(f"Conflict resolved (keep_conflict): {conflict_path} → {original_path}")
|
||||
conf_file.unlink()
|
||||
await remove_single_file(vault_name, conflict_path)
|
||||
log_file_delete(current_user["username"], vault_name, conflict_path)
|
||||
await sse_manager.broadcast("file_deleted", {"vault": vault_name, "path": conflict_path})
|
||||
return {"status": "resolved", "action": action}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Error resolving conflict: {e!s}")
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Duplicate detection & merge endpoints (#166).
|
||||
|
||||
Read endpoints require vault access; the merge endpoint is destructive
|
||||
(backup first in the service layer) and additionally requires the
|
||||
confirmation token pattern used by mutating routes — here enforced by an
|
||||
explicit ``confirm=true`` body flag, mirroring the agent two-step flow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.services import duplicates as _duplicates
|
||||
from backend.services.errors import ServiceError
|
||||
|
||||
router = APIRouter(prefix="/api/duplicates", tags=["duplicates"])
|
||||
|
||||
|
||||
class DuplicatePair(BaseModel):
|
||||
"""One candidate duplicate pair."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
file_a: str = Field(description="First file (vault-relative)")
|
||||
file_b: str = Field(description="Second file (vault-relative)")
|
||||
score: float = Field(description="Blended similarity in [0, 1]")
|
||||
|
||||
|
||||
class DuplicatesResponse(BaseModel):
|
||||
"""Response for GET /api/duplicates."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
vault: str = Field(description="Vault name")
|
||||
threshold: float = Field(description="Applied threshold")
|
||||
files_scanned: int = Field(description="Markdown files compared")
|
||||
truncated: bool = Field(description="True when the scan hit the file cap")
|
||||
pairs: list[DuplicatePair] = Field(description="Candidate pairs, best score first")
|
||||
|
||||
|
||||
class MergeResponse(BaseModel):
|
||||
"""Response for POST /api/duplicates/merge."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
strategy: str = Field(description="Applied merge strategy")
|
||||
target: str = Field(description="Surviving note")
|
||||
deleted: str = Field(description="Absorbed note (deleted after merge)")
|
||||
|
||||
|
||||
@router.get("", response_model=DuplicatesResponse)
|
||||
async def api_duplicates_list(
|
||||
vault: str = Query(..., description="Vault name"),
|
||||
threshold: float = Query(0.75, ge=0.3, le=1.0, description="Minimum similarity"),
|
||||
limit: int = Query(20, ge=1, le=200, description="Max pairs"),
|
||||
subdir: str = Query("", description="Directory scope"),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""List candidate duplicate notes ordered by descending score."""
|
||||
if not check_vault_access(vault, current_user):
|
||||
raise HTTPException(403, f"No access to vault '{vault}'")
|
||||
try:
|
||||
return _duplicates.find_duplicate_pairs(vault, threshold=threshold, limit=limit, subdir=subdir)
|
||||
except ServiceError as e:
|
||||
raise HTTPException(e.status or 400, e.message) from e
|
||||
|
||||
|
||||
@router.post("/merge", response_model=MergeResponse)
|
||||
async def api_duplicates_merge(
|
||||
body: dict[str, Any] = Body(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Merge *source_path* into *target_path* (``confirm: true`` required)."""
|
||||
vault = str(body.get("vault") or "")
|
||||
if not check_vault_access(vault, current_user):
|
||||
raise HTTPException(403, f"No access to vault '{vault}'")
|
||||
if body.get("confirm") is not True:
|
||||
raise HTTPException(400, "Fusion destructive : confirmez avec {confirm: true}")
|
||||
try:
|
||||
return _duplicates.merge_duplicates(
|
||||
vault,
|
||||
str(body.get("source_path") or ""),
|
||||
str(body.get("target_path") or ""),
|
||||
strategy=str(body.get("strategy") or "append"),
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise HTTPException(e.status or 400, e.message) from e
|
||||
@@ -0,0 +1,290 @@
|
||||
# backend/routers/file_chat.py — chat (#169, #190)
|
||||
"""Chat endpoints: history read + message post with SSE fan-out.
|
||||
|
||||
- ``GET/POST /api/file/{vault_name}/chat`` — per-file chat: auth + vault
|
||||
access + path traversal check (``resolve_safe_path`` raises
|
||||
``ServiceError`` mapped by the app-level handler).
|
||||
- ``GET/POST /api/chat`` — the **general chat** (#190), not bound to a file.
|
||||
- ``POST /api/chat/upload`` / ``GET /api/chat/attachment/{name}`` (#190):
|
||||
image/video attachments (extension allow-list, size cap, UUID name).
|
||||
|
||||
Every post is broadcast on the existing SSE channel (``chat_message``) so
|
||||
all connected clients update live without a second WebSocket.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from backend import file_chat as _store
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.auth.user_store import get_all_users, get_user
|
||||
from backend.indexer import get_vault_data
|
||||
from backend.schemas import ChatHistoryResponse, ChatMessageResponse, ChatReadResponse, StatusResponse
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.sse import sse_manager
|
||||
|
||||
router = APIRouter() # tags dérivés de `tag_for_path` → « Files »
|
||||
|
||||
|
||||
def _check(vault_name: str, path: str, current_user: dict[str, Any]) -> None:
|
||||
"""Authz + traversal guard shared by both verbs."""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(404, "Vault not found")
|
||||
resolve_safe_path(Path(vault_data["path"]), path) # ServiceError → 403/500
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/chat", response_model=ChatHistoryResponse)
|
||||
async def api_file_chat_history(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Return the chat history for a file (chronological)."""
|
||||
_check(vault_name, path, current_user)
|
||||
return {"messages": _store.get_messages(vault_name, path), "read": _store.get_read(vault_name, path)}
|
||||
|
||||
|
||||
@router.post("/api/file/{vault_name}/chat", response_model=ChatMessageResponse)
|
||||
async def api_file_chat_post(
|
||||
vault_name: str,
|
||||
body: dict[str, Any] = Body(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Post a chat message and broadcast it on SSE (``chat_message``)."""
|
||||
path = str(body.get("path") or "")
|
||||
text = str(body.get("text") or "")
|
||||
if not path:
|
||||
raise HTTPException(400, "path is required")
|
||||
if not text.strip():
|
||||
raise HTTPException(400, "text is required")
|
||||
_check(vault_name, path, current_user)
|
||||
msg = _store.add_message(vault_name, path, current_user.get("username", ""), text)
|
||||
await sse_manager.broadcast("chat_message", {"vault": vault_name, "path": path, "message": msg})
|
||||
return {"message": msg, "status": "ok"}
|
||||
|
||||
|
||||
# --- #190 : chat général ----------------------------------------------------
|
||||
|
||||
def _attachment(body: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Validate the optional ``attachment`` object sent by the client."""
|
||||
raw = body.get("attachment")
|
||||
if not raw or not isinstance(raw, dict):
|
||||
return None
|
||||
name = str(raw.get("name") or "")
|
||||
# Only an already-uploaded file (or an http(s) URL) may travel along.
|
||||
if not _store.attachment_path(name) and not str(raw.get("url", "")).startswith(("http://", "https://", "/api/")):
|
||||
raise HTTPException(400, "attachment inconnu")
|
||||
return {
|
||||
"name": name,
|
||||
"url": str(raw.get("url") or ""),
|
||||
"mime": str(raw.get("mime") or ""),
|
||||
"kind": str(raw.get("kind") or "file"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/chat", response_model=ChatHistoryResponse)
|
||||
async def api_chat_history(current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Return the general chat history (#190, chronological)."""
|
||||
return {
|
||||
"messages": _store.get_global_messages(),
|
||||
"read": _store.get_read(_store.GLOBAL_VAULT, _store.GLOBAL_PATH),
|
||||
}
|
||||
|
||||
|
||||
# --- #192 : accusé de réception ---------------------------------------------
|
||||
|
||||
def _check_read(vault: str, path: str, current_user: dict[str, Any]) -> None:
|
||||
"""Authorization for marking a conversation read (#192).
|
||||
|
||||
General chat is open to any member, a DM only to the two participants,
|
||||
a file chat follows the vault ACL.
|
||||
"""
|
||||
if vault == _store.GLOBAL_VAULT:
|
||||
return
|
||||
if vault == _store.DM_VAULT:
|
||||
if current_user.get("username") not in str(path).split("|"):
|
||||
raise HTTPException(403, "Accès refusé à cette conversation privée")
|
||||
return
|
||||
_check(vault, path, current_user)
|
||||
|
||||
|
||||
@router.post("/api/chat/read", response_model=ChatReadResponse)
|
||||
async def api_chat_read(
|
||||
body: dict[str, Any] = Body(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Record that the caller has seen a conversation (#192).
|
||||
|
||||
Broadcast on SSE (``chat_read``) so the sender's own messages flip to
|
||||
✓✓ live on every connected client.
|
||||
"""
|
||||
vault = str(body.get("vault") or "")
|
||||
path = str(body.get("path") or "")
|
||||
if not vault or not path:
|
||||
raise HTTPException(400, "vault and path are required")
|
||||
_check_read(vault, path, current_user)
|
||||
username = current_user.get("username", "")
|
||||
read = _store.mark_read(vault, path, username)
|
||||
await sse_manager.broadcast(
|
||||
"chat_read",
|
||||
{"vault": vault, "path": path, "user": username, "ts": read.get(username, 0.0), "read": read},
|
||||
)
|
||||
return {"read": read, "status": "ok"}
|
||||
|
||||
|
||||
@router.post("/api/chat", response_model=ChatMessageResponse)
|
||||
async def api_chat_post(
|
||||
body: dict[str, Any] = Body(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Post to the general chat and broadcast it on SSE (``chat_message``)."""
|
||||
text = str(body.get("text") or "")
|
||||
if not text.strip():
|
||||
raise HTTPException(400, "text is required")
|
||||
# #191 — best-effort link preview: a dead/slow URL never blocks the post.
|
||||
msg = _store.add_global_message(
|
||||
current_user.get("username", ""), text, _attachment(body),
|
||||
_store.build_preview(text),
|
||||
)
|
||||
await sse_manager.broadcast(
|
||||
"chat_message",
|
||||
{"vault": _store.GLOBAL_VAULT, "path": _store.GLOBAL_PATH, "message": msg},
|
||||
)
|
||||
return {"message": msg, "status": "ok"}
|
||||
|
||||
|
||||
@router.post("/api/chat/upload")
|
||||
async def api_chat_upload(
|
||||
file: UploadFile = File(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Store an image/video attachment (#190). Returns ``{attachment}``."""
|
||||
data = await file.read()
|
||||
try:
|
||||
info = _store.save_attachment(file.filename or "", data)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return {"attachment": info}
|
||||
|
||||
|
||||
@router.get("/api/chat/attachment/{name}")
|
||||
async def api_chat_attachment(name: str, current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Serve an uploaded attachment (name validated against the allow-list)."""
|
||||
path = _store.attachment_path(name)
|
||||
if not path:
|
||||
raise HTTPException(404, "Attachment not found")
|
||||
info = _store._MIME_BY_EXT.get(path.suffix.lower(), "application/octet-stream")
|
||||
return FileResponse(str(path), media_type=info)
|
||||
|
||||
|
||||
# --- #191 : suppression + messages privés -----------------------------------
|
||||
|
||||
def _owner_or_admin(msg_user: str, current_user: dict[str, Any]) -> None:
|
||||
"""A post may be deleted by its author or by an admin."""
|
||||
if current_user.get("role") != "admin" and current_user.get("username") != msg_user:
|
||||
raise HTTPException(403, "Seul l'auteur ou un administrateur peut supprimer ce message")
|
||||
|
||||
|
||||
def _find_and_authorize(vault: str, path: str, message_id: str, current_user: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Locate *message_id* in the conversation and check the delete right."""
|
||||
for m in _store.get_messages(vault, path):
|
||||
if m.get("id") == message_id:
|
||||
_owner_or_admin(m.get("user", ""), current_user)
|
||||
return m
|
||||
raise HTTPException(404, "Message not found")
|
||||
|
||||
|
||||
@router.delete("/api/chat/{message_id}", response_model=StatusResponse)
|
||||
async def api_chat_delete(message_id: str, current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Delete a message from the general chat (author or admin, #191)."""
|
||||
_find_and_authorize(_store.GLOBAL_VAULT, _store.GLOBAL_PATH, message_id, current_user)
|
||||
if not _store.delete_message(_store.GLOBAL_VAULT, _store.GLOBAL_PATH, message_id):
|
||||
raise HTTPException(404, "Message not found")
|
||||
await sse_manager.broadcast(
|
||||
"chat_deleted",
|
||||
{"vault": _store.GLOBAL_VAULT, "path": _store.GLOBAL_PATH, "id": message_id},
|
||||
)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/api/chat/users", response_model=list[dict[str, Any]])
|
||||
async def api_chat_users(current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Usernames available for a private conversation (chat DM picker, #191).
|
||||
|
||||
Every authenticated member may see who else is around — this is a
|
||||
self-hosted portal, not a directory that needs hiding.
|
||||
"""
|
||||
me = current_user.get("username", "")
|
||||
return [
|
||||
{"username": u.get("username", ""), "display_name": u.get("display_name") or u.get("username", "")}
|
||||
for u in get_all_users()
|
||||
if u.get("username") and u.get("username") != me
|
||||
]
|
||||
|
||||
|
||||
def _dm_peer(username: str, current_user: dict[str, Any]) -> str:
|
||||
"""Validate the DM peer exists and is not ourselves."""
|
||||
if not username or username == current_user.get("username"):
|
||||
raise HTTPException(400, "Destinataire invalide")
|
||||
if not get_user(username):
|
||||
raise HTTPException(404, "Utilisateur inconnu")
|
||||
return username
|
||||
|
||||
|
||||
@router.get("/api/chat/dm/{username}", response_model=ChatHistoryResponse)
|
||||
async def api_chat_dm_history(username: str, current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Private history with *username* (#191)."""
|
||||
peer = _dm_peer(username, current_user)
|
||||
return {
|
||||
"messages": _store.get_dm_messages(current_user["username"], peer),
|
||||
"read": _store.get_read(_store.DM_VAULT, _store.dm_path(current_user["username"], peer)),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/chat/dm/{username}", response_model=ChatMessageResponse)
|
||||
async def api_chat_dm_post(
|
||||
username: str,
|
||||
body: dict[str, Any] = Body(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Post a private message and broadcast it to both participants (#191)."""
|
||||
peer = _dm_peer(username, current_user)
|
||||
text = str(body.get("text") or "")
|
||||
if not text.strip():
|
||||
raise HTTPException(400, "text is required")
|
||||
msg = _store.add_dm_message(
|
||||
current_user["username"], peer, current_user.get("username", ""), text,
|
||||
_attachment(body), _store.build_preview(text),
|
||||
)
|
||||
# Same shape as the general chat so the client routes on vault/path.
|
||||
await sse_manager.broadcast(
|
||||
"chat_message",
|
||||
{"vault": _store.DM_VAULT, "path": _store.dm_path(current_user["username"], peer), "message": msg},
|
||||
)
|
||||
return {"message": msg, "status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/api/chat/dm/{username}/{message_id}", response_model=StatusResponse)
|
||||
async def api_chat_dm_delete(
|
||||
username: str,
|
||||
message_id: str,
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Delete one private message (author or admin, #191)."""
|
||||
peer = _dm_peer(username, current_user)
|
||||
vault, path = _store.DM_VAULT, _store.dm_path(current_user["username"], peer)
|
||||
_find_and_authorize(vault, path, message_id, current_user)
|
||||
if not _store.delete_message(vault, path, message_id):
|
||||
raise HTTPException(404, "Message not found")
|
||||
await sse_manager.broadcast(
|
||||
"chat_deleted", {"vault": vault, "path": path, "id": message_id}
|
||||
)
|
||||
return {"status": "deleted"}
|
||||
@@ -0,0 +1,569 @@
|
||||
"""Media, PDF, export & vault-settings endpoints (ROADMAP #85, tranche 6c).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/file/*/pdf*``, ``/api/export/*``,
|
||||
``/api/guide/download``, ``/api/image/*``, ``/api/media*``,
|
||||
``/api/attachments/*``, ``/api/vaults/*/settings``, ``/api/vault/*/files``,
|
||||
``/api/vaults/settings/all``), mêmes modèles de réponse, mêmes dépendances
|
||||
d'authentification.
|
||||
|
||||
Adaptations strictement équivalentes :
|
||||
- ``_resolve_safe_path`` → :mod:`backend.services.paths` (pass-through).
|
||||
- ``_render_markdown`` vient de :mod:`backend.render` (#85 T9, sans cycle
|
||||
d'import).
|
||||
- ``_resolve_export_target`` / ``_safe_export_name`` (export uniquement)
|
||||
sont définis ici ; ``stream_file_with_range`` vit dans
|
||||
:mod:`backend.routers.helpers` (partagé).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import FileResponse, Response
|
||||
|
||||
from backend.attachment_indexer import get_attachment_stats, rescan_vault_attachments
|
||||
from backend.auth.middleware import check_vault_access, require_admin, require_auth
|
||||
from backend.export import ExportError, export_epub, export_html, export_md_bundle
|
||||
from backend.history import record_open
|
||||
from backend.indexer import get_vault_data, index, parse_markdown_file
|
||||
from backend.media_thumbs import generate_thumbnail, is_decodable
|
||||
from backend.media_types import is_audio, is_image, is_video, media_mime_type
|
||||
from backend.render import _render_markdown
|
||||
from backend.routers.helpers import media_max_inline_bytes, stream_file_with_range
|
||||
from backend.schemas import (
|
||||
AllVaultSettingsResponse,
|
||||
AttachmentRescanResponse,
|
||||
AttachmentStatsResponse,
|
||||
PdfInfoResponse,
|
||||
VaultFilesResponse,
|
||||
VaultSettingsResponse,
|
||||
)
|
||||
from backend.secret_redactor import redact_file_content
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import list_all_files
|
||||
from backend.vault_settings import get_vault_setting, update_vault_setting
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
# Lazy import: WeasyPrint PDF export (requires GTK, may not be available everywhere)
|
||||
try:
|
||||
from backend.pdf_export import build_pdf_html, generate_pdf
|
||||
except Exception: # pragma: no cover - WeasyPrint/GTK missing
|
||||
generate_pdf = None # type: ignore[assignment]
|
||||
build_pdf_html = None # type: ignore[assignment]
|
||||
|
||||
logging.getLogger("obsigate").warning("PDF export unavailable (WeasyPrint/GTK not found)")
|
||||
|
||||
router = APIRouter() # pas de tags : assignation par chemin via openapi_docs.tag_for_path (comme avant)
|
||||
|
||||
|
||||
def _resolve_export_target(vault_name: str, path: str, current_user: dict) -> tuple[Path, Path]:
|
||||
"""Resolve a vault + relative path into (vault_root, absolute file path).
|
||||
|
||||
Enforces auth (vault access) and path traversal protection.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
target = resolve_safe_path(vault_root, path)
|
||||
return vault_root, target
|
||||
|
||||
|
||||
def _safe_export_name(name: str) -> str:
|
||||
"""ASCII-safe, filename-safe download name (falls back to 'document')."""
|
||||
cleaned = "".join(c for c in name if c.isascii() and (c.isalnum() or c in " _-.")).strip()
|
||||
return cleaned or "document"
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/file/{vault_name}/pdf",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/pdf": {}}, "description": "PDF document"}},
|
||||
)
|
||||
async def api_file_pdf(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||
"""Download a markdown file as PDF."""
|
||||
if generate_pdf is None:
|
||||
raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)")
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(404, f"Vault '{vault_name}' not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, f"File not found: {path}")
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
raise HTTPException(500, "Cannot read file")
|
||||
record_open(current_user.get("username"), vault_name, path)
|
||||
raw = redact_file_content(raw, str(file_path))
|
||||
post = parse_markdown_file(raw)
|
||||
html = _render_markdown(post.content, vault_name, file_path)
|
||||
title = post.metadata.get("title", file_path.stem)
|
||||
pdf_html = build_pdf_html(html, str(title))
|
||||
pdf_bytes = generate_pdf(pdf_html, str(title))
|
||||
safe_name = "".join(c for c in str(title) if c.isascii() and (c.isalnum() or c in " _-.")).strip() or "document"
|
||||
return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{safe_name}.pdf"'})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/export/html",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"text/html": {}}, "description": "Standalone HTML file"}},
|
||||
)
|
||||
async def api_export_html(
|
||||
vault: str = Query(..., description="Vault name"),
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Export a markdown note as a standalone HTML file."""
|
||||
try:
|
||||
vault_root, target = _resolve_export_target(vault, path, current_user)
|
||||
html_bytes = export_html(vault_root, target)
|
||||
except ExportError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
record_open(current_user.get("username"), vault, path)
|
||||
safe_name = _safe_export_name(target.stem)
|
||||
return Response(
|
||||
content=html_bytes,
|
||||
media_type="text/html; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}.html"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/export/md-bundle",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/zip": {}}, "description": "Markdown ZIP bundle"}},
|
||||
)
|
||||
async def api_export_md_bundle(
|
||||
vault: str = Query(..., description="Vault name"),
|
||||
path: str = Query(..., description="Relative path to directory or file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Export a directory (or single file) of markdown as a ZIP bundle."""
|
||||
try:
|
||||
vault_root, target = _resolve_export_target(vault, path, current_user)
|
||||
zip_bytes = export_md_bundle(vault_root, target)
|
||||
except ExportError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
safe_name = _safe_export_name(target.name)
|
||||
return Response(
|
||||
content=zip_bytes,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}.zip"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/export/epub",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/epub+zip": {}}, "description": "ePub document"}},
|
||||
)
|
||||
async def api_export_epub(
|
||||
vault: str = Query(..., description="Vault name"),
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Export a markdown note as an ePub document."""
|
||||
try:
|
||||
vault_root, target = _resolve_export_target(vault, path, current_user)
|
||||
epub_bytes = export_epub(vault_root, target)
|
||||
except ExportError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
record_open(current_user.get("username"), vault, path)
|
||||
safe_name = _safe_export_name(target.stem)
|
||||
return Response(
|
||||
content=epub_bytes,
|
||||
media_type="application/epub+zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}.epub"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/guide/download",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/pdf": {}, "text/markdown": {}}}},
|
||||
)
|
||||
async def api_guide_download(
|
||||
format: str = Query("md", description="Download format: 'md' or 'pdf'"),
|
||||
lang: str = Query("fr", description="Guide language: 'fr' or 'en'"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Download the in-app user guide as Markdown or PDF (#105).
|
||||
|
||||
The document is generated from the live help modal in index.html resolved
|
||||
through the locale files, so it always mirrors exactly what the user sees.
|
||||
"""
|
||||
from backend.guide_export import get_guide_document
|
||||
|
||||
if format not in ("md", "pdf"):
|
||||
raise HTTPException(status_code=400, detail="format doit être 'md' ou 'pdf'")
|
||||
try:
|
||||
payload, media, fname = get_guide_document(format, lang)
|
||||
except Exception as e: # weasyprint/reportlab unavailable
|
||||
logger.exception("guide export failed")
|
||||
raise HTTPException(status_code=500, detail=f"Export impossible: {e}") from e
|
||||
return Response(
|
||||
content=payload,
|
||||
media_type=media,
|
||||
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/pdf/stream", response_class=FileResponse)
|
||||
async def api_pdf_stream(
|
||||
request: Request,
|
||||
vault_name: str,
|
||||
path: str = Query(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Stream a PDF file with Content-Type: application/pdf for inline browser viewing.
|
||||
|
||||
Supports HTTP Range requests (206 Partial Content) so browsers can
|
||||
progressively render large PDFs in the native viewer.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
if file_path.suffix.lower() != ".pdf":
|
||||
raise HTTPException(status_code=400, detail="Not a PDF file")
|
||||
|
||||
return stream_file_with_range(file_path, request, "application/pdf")
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/pdf/info", response_model=PdfInfoResponse)
|
||||
async def api_pdf_info(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to PDF file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return PDF metadata (pages, title, author, size) without the document content.
|
||||
|
||||
Lets the UI display file info before loading a heavy PDF into the viewer.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
if file_path.suffix.lower() != ".pdf":
|
||||
raise HTTPException(status_code=400, detail="Not a PDF file")
|
||||
|
||||
from backend.pdf_reader import extract_pdf_metadata
|
||||
meta = extract_pdf_metadata(file_path)
|
||||
stat = file_path.stat()
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"pages": meta.get("pages", 0),
|
||||
"title": meta.get("title") or file_path.name,
|
||||
"author": meta.get("author", ""),
|
||||
"size_bytes": stat.st_size,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/image/{vault_name}",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/octet-stream": {}}, "description": "Image bytes"}},
|
||||
)
|
||||
async def api_image(vault_name: str, path: str = Query(..., description="Relative path to image"), current_user=Depends(require_auth)):
|
||||
"""Serve an image file with proper MIME type.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
|
||||
Returns:
|
||||
Image file with appropriate content-type header.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"Image not found: {path}")
|
||||
|
||||
mime_type = media_mime_type(str(file_path))
|
||||
|
||||
# #108-B3 — a standalone SVG opened in a tab executes its embedded JS
|
||||
# (same-origin XSS). ``sandbox`` forces a unique opaque origin with no
|
||||
# script execution; inside an <img> tag the header is irrelevant.
|
||||
headers = {"X-Content-Type-Options": "nosniff"}
|
||||
if file_path.suffix.lower() == ".svg":
|
||||
headers["Content-Security-Policy"] = "sandbox"
|
||||
|
||||
try:
|
||||
# Read and return the image file
|
||||
content = file_path.read_bytes()
|
||||
return Response(content=content, media_type=mime_type, headers=headers)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="Permission denied")
|
||||
except Exception as e:
|
||||
logger.error(f"Error serving image {vault_name}/{path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error serving image: {e!s}")
|
||||
|
||||
|
||||
@router.get("/api/media/{vault_name}", response_class=FileResponse)
|
||||
async def api_media_stream(
|
||||
request: Request,
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to audio/video file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Stream an audio/video file with HTTP Range support (roadmap #109-A2).
|
||||
|
||||
Serves the bytes with the correct MIME type and honours ``Range`` requests
|
||||
(``206 Partial Content`` + ``Content-Range``/``Accept-Ranges``), which is
|
||||
what enables scrubbing in ``<audio>``/``<video>`` and is required by Safari
|
||||
for MP4. Files above ``OBSIGATE_MEDIA_MAX_INLINE_MB`` (default 500 MB) are
|
||||
refused with ``413`` — the viewer falls back to the download button.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"Media not found: {path}")
|
||||
|
||||
ext = file_path.suffix.lower()
|
||||
if not (is_audio(ext) or is_video(ext)):
|
||||
raise HTTPException(status_code=400, detail="Not an audio/video file")
|
||||
|
||||
if file_path.stat().st_size > media_max_inline_bytes():
|
||||
raise HTTPException(status_code=413, detail="Media too large for inline streaming")
|
||||
|
||||
return stream_file_with_range(file_path, request, media_mime_type(str(file_path)))
|
||||
|
||||
|
||||
@router.get("/api/media/{vault_name}/thumb", response_class=FileResponse)
|
||||
async def api_media_thumb(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to image"),
|
||||
size: int = Query(256, ge=32, le=1024, description="Max thumbnail edge in pixels"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Serve a cached WebP thumbnail of an image (roadmap #108-C).
|
||||
|
||||
SVG (and any format Pillow cannot decode) falls back to the original
|
||||
bytes. Generation runs in a thread and is capped at 2 s; on timeout or
|
||||
failure the original is served so the UI never breaks.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"Image not found: {path}")
|
||||
if not is_image(file_path.suffix.lower()):
|
||||
raise HTTPException(status_code=400, detail="Not an image file")
|
||||
|
||||
mime_type = media_mime_type(str(file_path))
|
||||
if not is_decodable(file_path):
|
||||
# SVG: never let a standalone navigation execute embedded JS (#108-B3).
|
||||
svg_headers = {"X-Content-Type-Options": "nosniff"}
|
||||
if file_path.suffix.lower() == ".svg":
|
||||
svg_headers["Content-Security-Policy"] = "sandbox"
|
||||
return FileResponse(str(file_path), media_type=mime_type, headers=svg_headers)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
thumb: Path | None = None
|
||||
try:
|
||||
thumb = await asyncio.wait_for(
|
||||
loop.run_in_executor(None, generate_thumbnail, file_path, size),
|
||||
timeout=2.0,
|
||||
)
|
||||
except Exception:
|
||||
thumb = None
|
||||
|
||||
if thumb is not None and thumb.exists():
|
||||
return FileResponse(str(thumb), media_type="image/webp")
|
||||
return FileResponse(str(file_path), media_type=mime_type)
|
||||
|
||||
|
||||
@router.post("/api/attachments/rescan/{vault_name}", response_model=AttachmentRescanResponse)
|
||||
async def api_rescan_attachments(vault_name: str, current_user=Depends(require_admin)):
|
||||
"""Rescan attachments for a specific vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault to rescan.
|
||||
|
||||
Returns:
|
||||
Dict with status and attachment count.
|
||||
"""
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_path = vault_data["path"]
|
||||
count = await rescan_vault_attachments(vault_name, vault_path)
|
||||
|
||||
logger.info(f"Rescanned attachments for vault '{vault_name}': {count} attachments")
|
||||
return {"status": "ok", "vault": vault_name, "attachment_count": count}
|
||||
|
||||
|
||||
@router.get("/api/attachments/stats", response_model=AttachmentStatsResponse)
|
||||
async def api_attachment_stats(vault: str | None = Query(None, description="Vault filter"), current_user=Depends(require_auth)):
|
||||
"""Get attachment statistics for vaults.
|
||||
|
||||
Args:
|
||||
vault: Optional vault name to filter stats.
|
||||
|
||||
Returns:
|
||||
Dict with vault names as keys and attachment counts as values.
|
||||
"""
|
||||
stats = get_attachment_stats(vault)
|
||||
return {"vaults": stats}
|
||||
|
||||
|
||||
@router.get("/api/vaults/{vault_name}/settings", response_model=VaultSettingsResponse)
|
||||
async def api_get_vault_settings(vault_name: str, current_user=Depends(require_auth)):
|
||||
"""Get UI display settings for a specific vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
|
||||
Returns:
|
||||
Dict with vault settings including hideHiddenFiles.
|
||||
"""
|
||||
if vault_name not in index:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
# Get persisted settings
|
||||
persisted = get_vault_setting(vault_name) or {}
|
||||
|
||||
# Default settings
|
||||
settings = {
|
||||
"hideHiddenFiles": False,
|
||||
}
|
||||
settings.update(persisted)
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
@router.post("/api/vaults/{vault_name}/settings", response_model=VaultSettingsResponse)
|
||||
async def api_update_vault_settings(vault_name: str, body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Update UI display settings for a specific vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
body: Dict with settings to update (hideHiddenFiles).
|
||||
|
||||
Returns:
|
||||
Updated settings dict.
|
||||
"""
|
||||
if vault_name not in index:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
# Validate settings
|
||||
settings_to_update = {}
|
||||
|
||||
if "hideHiddenFiles" in body:
|
||||
if not isinstance(body["hideHiddenFiles"], bool):
|
||||
raise HTTPException(status_code=400, detail="hideHiddenFiles must be a boolean")
|
||||
settings_to_update["hideHiddenFiles"] = body["hideHiddenFiles"]
|
||||
|
||||
# Update persisted settings
|
||||
try:
|
||||
updated = update_vault_setting(vault_name, settings_to_update)
|
||||
except PermissionError as e:
|
||||
logger.error(f"Permission error saving settings for vault '{vault_name}': {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Permission denied: Cannot write to settings file. Check /app/data permissions."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving settings for vault '{vault_name}': {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to save settings: {e!s}"
|
||||
)
|
||||
|
||||
logger.info(f"Updated settings for vault '{vault_name}': {settings_to_update}")
|
||||
|
||||
return updated
|
||||
|
||||
|
||||
@router.get("/api/vault/{vault_name}/files", response_model=VaultFilesResponse)
|
||||
async def api_vault_recent_files(
|
||||
vault_name: str,
|
||||
dir: str = Query("", description="Directory path within the vault (empty = root)"),
|
||||
limit: int = Query(200, description="Maximum number of files to return"),
|
||||
recursive: bool = Query(True, description="If true, list files recursively from directory and all subdirectories"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""List files in a vault directory sorted by modification time (newest first).
|
||||
|
||||
Returns file metadata suitable for a vault home page display.
|
||||
Unlike /api/browse, this endpoint sorts by mtime and returns
|
||||
additional metadata (size, modified time, extension).
|
||||
|
||||
When recursive=True (default), lists files from the directory
|
||||
AND all its subdirectories, with a ``rel_dir`` field indicating
|
||||
the subdirectory path relative to the requested directory.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
dir: Relative directory path within the vault (empty for root).
|
||||
limit: Maximum files to return (default 200).
|
||||
recursive: If true, recursively list files in subdirectories (default true).
|
||||
|
||||
Returns:
|
||||
JSON with vault, directory, count, recursive flag, and list of file entries.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
return list_all_files(vault_name, dir=dir, limit=limit, recursive=recursive)
|
||||
|
||||
|
||||
@router.get("/api/vaults/settings/all", response_model=AllVaultSettingsResponse)
|
||||
async def api_get_all_vault_settings(current_user=Depends(require_auth)):
|
||||
"""Get UI display settings for all vaults.
|
||||
|
||||
Returns:
|
||||
Dict mapping vault names to their settings.
|
||||
"""
|
||||
all_settings = {}
|
||||
|
||||
for vault_name in index:
|
||||
persisted = get_vault_setting(vault_name) or {}
|
||||
|
||||
settings = {
|
||||
"hideHiddenFiles": False,
|
||||
}
|
||||
settings.update(persisted)
|
||||
all_settings[vault_name] = settings
|
||||
|
||||
return all_settings
|
||||
@@ -0,0 +1,744 @@
|
||||
"""File browsing & reading endpoints (ROADMAP #85, tranche 6a).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/browse/*``, ``/api/file/*`` en
|
||||
lecture), mêmes modèles de réponse (déménagés dans
|
||||
:mod:`backend.schemas`), mêmes dépendances d'authentification.
|
||||
|
||||
Adaptations strictement équivalentes :
|
||||
- ``_resolve_safe_path`` → :mod:`backend.services.paths` (pass-through).
|
||||
- ``_render_markdown`` vient de :mod:`backend.render` (#85 T9, sans cycle
|
||||
d'import).
|
||||
- ``_content_disposition`` / ``_media_max_inline_bytes`` / ``EXT_TO_LANG``
|
||||
ont déménagé : helpers partagés dans :mod:`backend.routers.helpers`
|
||||
(``EXT_TO_LANG`` n'était utilisé que par la vue fichier).
|
||||
"""
|
||||
|
||||
import html as html_mod
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.history import record_open
|
||||
from backend.indexer import (
|
||||
_extract_tags,
|
||||
get_backlinks,
|
||||
get_vault_data,
|
||||
parse_markdown_file,
|
||||
)
|
||||
from backend.media_types import is_audio, is_image, is_video, media_mime_type
|
||||
from backend.render import _render_markdown
|
||||
from backend.routers.helpers import media_max_inline_bytes
|
||||
from backend.schemas import (
|
||||
BacklinksResponse,
|
||||
BrowseResponse,
|
||||
FileContentResponse,
|
||||
FileRawResponse,
|
||||
XlsxDashboardResponse,
|
||||
XlsxSheetWindowResponse,
|
||||
)
|
||||
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.share import list_shares
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
|
||||
def _resolve_shared_file(vault_name: str, path: str, username: str | None):
|
||||
"""#196 — resolve ``home-<user>/Partage/<file>`` to the real source file.
|
||||
|
||||
Returns ``(real_vault, real_path)`` when *path* is a received share
|
||||
mounted in the user's personal folder, else ``None``. Read-only: only a
|
||||
recipient (or the share creator, whose own file is already accessible)
|
||||
gets a mapping — a share directed to someone else never resolves here.
|
||||
|
||||
Chemin canonique : ``Partage/<token>/<nom>`` (token = lève l'ambiguïté
|
||||
de deux partages au même nom). Fallback par nom seul pour les liens
|
||||
ne transportant pas le token.
|
||||
"""
|
||||
if not username or not vault_name.startswith("home-") or not path.startswith("Partage/"):
|
||||
return None
|
||||
owner = vault_name[len("home-"):]
|
||||
if owner != username:
|
||||
return None
|
||||
# Chemin virtuel canonique : Partage/<token>/<nom> — le token lève
|
||||
# l'ambiguïté (deux partages, même nom de base). Fallback : match par
|
||||
# nom pour les liens ne portant pas le token.
|
||||
rest = path.split("/", 1)[1]
|
||||
parts = rest.split("/", 1)
|
||||
if len(parts) == 2 and len(parts[0]) >= 20: # token (64 hex) vs nom de fichier
|
||||
token, _name = parts
|
||||
for s in list_shares(user=username):
|
||||
if s.get("token") == token and s.get("created_by") != username:
|
||||
return s["vault"], s["path"]
|
||||
return None
|
||||
for s in list_shares(user=username):
|
||||
if s.get("created_by") == username:
|
||||
continue
|
||||
if (s.get("path") or "").split("/")[-1] == rest:
|
||||
return s["vault"], s["path"]
|
||||
return None
|
||||
|
||||
# Map file extensions to highlight.js language hints
|
||||
EXT_TO_LANG = {
|
||||
".py": "python", ".js": "javascript", ".ts": "typescript",
|
||||
".jsx": "jsx", ".tsx": "tsx", ".sh": "bash", ".bash": "bash",
|
||||
".zsh": "bash", ".fish": "fish", ".bat": "batch", ".cmd": "batch",
|
||||
".ps1": "powershell", ".json": "json", ".yaml": "yaml", ".yml": "yaml",
|
||||
".toml": "toml", ".xml": "xml", ".csv": "plaintext",
|
||||
".cfg": "ini", ".ini": "ini", ".conf": "ini", ".env": "bash",
|
||||
".html": "html", ".css": "css", ".scss": "scss", ".less": "less",
|
||||
".java": "java", ".c": "c", ".cpp": "cpp", ".h": "c", ".hpp": "cpp",
|
||||
".cs": "csharp", ".go": "go", ".rs": "rust", ".rb": "ruby",
|
||||
".php": "php", ".sql": "sql", ".r": "r", ".swift": "swift",
|
||||
".kt": "kotlin", ".txt": "plaintext", ".log": "plaintext",
|
||||
".lua": "lua", ".pl": "perl", ".pm": "perl", ".ex": "elixir", ".exs": "elixir",
|
||||
".dart": "dart", ".tf": "haskell", ".gradle": "groovy", ".groovy": "groovy",
|
||||
".graphql": "graphql", ".gql": "graphql", ".prisma": "sql", ".proto": "c",
|
||||
".vb": "basic", ".asm": "x86asm", ".s": "armasm",
|
||||
".vue": "xml", ".svelte": "xml", ".astro": "xml",
|
||||
".properties": "ini", ".service": "ini", ".hosts": "ini",
|
||||
".ksh": "bash", ".dockerfile": "dockerfile",
|
||||
".makefile": "makefile", ".cmake": "cmake",
|
||||
}
|
||||
|
||||
router = APIRouter(tags=["files"])
|
||||
|
||||
|
||||
@router.get("/api/browse/{vault_name}", response_model=BrowseResponse)
|
||||
async def api_browse(vault_name: str, path: str = "", current_user=Depends(require_auth)):
|
||||
"""Browse directories and files in a vault at a given path level.
|
||||
|
||||
Returns sorted entries (directories first, then files) with metadata.
|
||||
Hidden files/directories (starting with ``"."`` ) are excluded.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault to browse.
|
||||
path: Relative directory path within the vault (empty = root).
|
||||
|
||||
Returns:
|
||||
``BrowseResponse`` with vault name, path, and item list.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
return browse_directory(vault_name, path)
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/raw", response_model=FileRawResponse)
|
||||
async def api_file_raw(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||
"""Return raw file content as plain text.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
|
||||
Returns:
|
||||
``FileRawResponse`` with vault, path, and raw text content.
|
||||
"""
|
||||
shared = _resolve_shared_file(vault_name, path, current_user.get("username"))
|
||||
if shared:
|
||||
# Partage dirigé = autorisation (cf. api_file).
|
||||
vault_name, path = shared
|
||||
elif not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
return read_raw_file(vault_name, path)
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/download", response_class=FileResponse)
|
||||
async def api_file_download(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||
"""Download a file as an attachment.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
|
||||
Returns:
|
||||
``FileResponse`` with ``application/octet-stream`` content-type.
|
||||
"""
|
||||
shared = _resolve_shared_file(vault_name, path, current_user.get("username"))
|
||||
if shared:
|
||||
# Partage dirigé = autorisation (cf. api_file).
|
||||
vault_name, path = shared
|
||||
elif not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
|
||||
# Record history
|
||||
record_open(current_user.get("username"), vault_name, path)
|
||||
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
filename=file_path.name,
|
||||
media_type="application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/backlinks", response_model=BacklinksResponse)
|
||||
async def api_file_backlinks(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Get backlinks (files linking to this file via wikilinks).
|
||||
|
||||
Returns a list of files that contain `[[wikilinks]]` pointing
|
||||
to the requested file, across all accessible vaults.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault containing the target file.
|
||||
path: Relative path of the target file within the vault.
|
||||
|
||||
Returns:
|
||||
``{"vault": str, "path": str, "backlinks": [...]}``
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
backlinks = get_backlinks(vault_name, path)
|
||||
|
||||
# Filter by user-accessible vaults (#194 : check_vault_access, "*" sans
|
||||
# les dossiers persos).
|
||||
backlinks = [b for b in backlinks if check_vault_access(b["vault"], current_user)]
|
||||
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"backlinks": backlinks,
|
||||
"total": len(backlinks),
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/file/{vault_name}/xlsx/dashboard", response_model=XlsxDashboardResponse
|
||||
)
|
||||
def api_file_xlsx_dashboard(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx workbook"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return the dashboard metadata of an .xlsx workbook (#153 A17).
|
||||
|
||||
Named ranges (workbook- or sheet-scoped), chart/pivot object counts and
|
||||
per-sheet KPI stats (non-empty cells, rows/cols coverage, formulas,
|
||||
numeric cells, first numeric values as KPI cards). Read-only, bounded by
|
||||
the 500x40 render caps; never raises for an unreadable workbook — an
|
||||
empty payload comes back and the viewer hides the panel.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
_vault_root = get_vault_root(vault_name)
|
||||
file_path = resolve_safe_path(_vault_root, path)
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
|
||||
raise HTTPException(
|
||||
status_code=415, detail="Le fichier n'est pas un classeur .xlsx/.xlsm"
|
||||
)
|
||||
|
||||
from backend.xlsx_reader import read_workbook_dashboard
|
||||
|
||||
try:
|
||||
dashboard = read_workbook_dashboard(file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"XLSX dashboard read error for {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
**dashboard,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/file/{vault_name}/xlsx/sheet", response_model=XlsxSheetWindowResponse
|
||||
)
|
||||
def api_file_xlsx_sheet(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx/.xlsm file"),
|
||||
sheet: str = Query(..., description="Sheet name (as shown in the viewer tab)"),
|
||||
offset: int = Query(0, ge=0, description="0-based index of the first row to return"),
|
||||
limit: int = Query(
|
||||
200, ge=1, le=1000, description="Rows to return (server-capped)"
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return a window of rows of one sheet of an .xlsx/.xlsm workbook (#153 A9).
|
||||
|
||||
Backs the viewer's lazy loading: instead of every sheet in a single JSON
|
||||
payload, the client asks for the block it is about to display. The row
|
||||
numbers and the ``data-cell`` references are the real A1 coordinates of the
|
||||
sheet, so a window behaves like the full render (editing a cell in it
|
||||
targets the right cell).
|
||||
|
||||
The response also carries ``total_rows``/``total_cols`` and the ``truncated``
|
||||
flag, so the client can say what is hidden behind the 500x40 render caps
|
||||
instead of silently hiding it.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative path of the .xlsx file within the vault.
|
||||
sheet: Sheet name; **404** if the workbook has no such sheet.
|
||||
offset: 0-based index of the first row to return.
|
||||
limit: Rows to return, capped server-side at 1000.
|
||||
|
||||
Returns:
|
||||
``XlsxSheetWindowResponse`` with the rendered ``html`` of the window.
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 (vault access), 404 (vault, file or sheet unknown),
|
||||
415 (not an .xlsx/.xlsm file), 500 (unreadable workbook).
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
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}")
|
||||
# BUG-097 — a .xlsm rides the same editable viewer (and its lazy loading),
|
||||
# so its row windows must be servable too; other formats stay refused.
|
||||
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
|
||||
raise HTTPException(
|
||||
status_code=415, detail="Le fichier n'est pas un classeur .xlsx/.xlsm"
|
||||
)
|
||||
|
||||
# Import tardif : openpyxl n'est chargé que si un .xlsx est réellement demandé.
|
||||
from backend.xlsx_reader import read_sheet_window
|
||||
|
||||
try:
|
||||
window = read_sheet_window(file_path, sheet, offset=offset, limit=limit)
|
||||
except Exception as e:
|
||||
logger.error(f"XLSX sheet read error for {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
|
||||
if window is None:
|
||||
raise HTTPException(status_code=404, detail=f"Feuille introuvable: {sheet}")
|
||||
|
||||
return {"vault": vault_name, "path": path, **window}
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}", response_model=FileContentResponse)
|
||||
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||
"""Return rendered HTML and metadata for a file.
|
||||
|
||||
Markdown files are parsed for frontmatter, rendered with wikilink
|
||||
support, and returned with extracted tags. Other supported file
|
||||
types are syntax-highlighted as code blocks.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
|
||||
Returns:
|
||||
``FileContentResponse`` with HTML, metadata, and tags.
|
||||
"""
|
||||
# #196 — fichier reçu par partage dirigé : home-<user>/Partage/<fichier>
|
||||
# est résolu vers le fichier source (lecture seule, viewer standard).
|
||||
# Résolu AVANT l'ACL vault : le dossier est virtuel, l'autorisation réelle
|
||||
# est l'appartenance au partage (vérifiée dans le resolver).
|
||||
shared = _resolve_shared_file(vault_name, path, current_user.get("username"))
|
||||
if shared:
|
||||
# Le partage dirigé EST l'autorisation : le destinataire n'a par
|
||||
# définition pas accès au vault source — on ne passe PAS par l'ACL.
|
||||
vault_name, path = shared
|
||||
elif not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
|
||||
# Record history
|
||||
record_open(current_user.get("username"), vault_name, path, title=file_path.name)
|
||||
|
||||
ext = file_path.suffix.lower()
|
||||
|
||||
# === PDF: special handling before read_text (binary file) ===
|
||||
if ext == ".pdf":
|
||||
try:
|
||||
from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text, extract_pdf_toc
|
||||
pdf_text = extract_pdf_text(file_path, max_chars=100000)
|
||||
pdf_meta = extract_pdf_metadata(file_path)
|
||||
pdf_toc = extract_pdf_toc(file_path)
|
||||
size = file_path.stat().st_size
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": pdf_meta.get("title") or file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": f"<div class='pdf-viewer'><p>PDF — {pdf_meta.get('pages', '?')} pages</p><pre>{pdf_text[:5000]}</pre></div>",
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"is_pdf": True,
|
||||
"unsupported": False,
|
||||
"pdf_metadata": pdf_meta,
|
||||
"pdf_toc": pdf_toc,
|
||||
"size_bytes": size,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"PDF read error for {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading PDF: {e!s}")
|
||||
|
||||
# === Excel .xlsx: render sheets as HTML tables (binary, before read_text) ===
|
||||
if ext == ".xlsx":
|
||||
try:
|
||||
from backend.xlsx_reader import inspect_workbook, render_sheets
|
||||
|
||||
# #153 A15 — every sheet dict already carries its styles, aligns,
|
||||
# merges and freeze anchor (read_workbook_meta, one normal-mode
|
||||
# load inside render_sheets).
|
||||
sheets = render_sheets(file_path)
|
||||
size = file_path.stat().st_size
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": sheets[0]["html"] if sheets else "",
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"is_xlsx": True,
|
||||
"xlsx_sheets": sheets,
|
||||
# #156-A12 — optimistic-concurrency token: the viewer sends it
|
||||
# back as `if_match` so another writer cannot be overwritten in
|
||||
# silence (409 `conflict` instead).
|
||||
"xlsx_revision": file_revision(file_path),
|
||||
# #153 A1 — parts a save would drop; the viewer warns and asks
|
||||
# for an explicit confirmation before forcing the write.
|
||||
"xlsx_lossy_features": inspect_workbook(file_path),
|
||||
"unsupported": False,
|
||||
"size_bytes": size,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"XLSX read error for {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
|
||||
|
||||
# === Images: return as viewable image ===
|
||||
if is_image(ext):
|
||||
size = file_path.stat().st_size
|
||||
mime = media_mime_type(str(file_path))
|
||||
# #108-B1 — the raw endpoint returns JSON (FileRawResponse), so the
|
||||
# standalone <img> must point to /api/image, which serves the bytes
|
||||
# with the right MIME type. Paths are URL-encoded (accents, spaces).
|
||||
img_url = f"/api/image/{quote(vault_name, safe='')}?path={quote(path, safe='')}"
|
||||
html = (
|
||||
f'<div class="image-viewer">'
|
||||
f'<img src="{img_url}" '
|
||||
f'alt="{html_mod.escape(file_path.name, quote=True)}" '
|
||||
f'style="max-width:100%;max-height:80vh;object-fit:contain" />'
|
||||
f'</div>'
|
||||
)
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": html,
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"is_image": True,
|
||||
"image_mime": mime,
|
||||
"size_bytes": size,
|
||||
}
|
||||
|
||||
# === Audio / Video: HTML5 players streamed from /api/media (roadmap #109) ===
|
||||
if is_audio(ext) or is_video(ext):
|
||||
size = file_path.stat().st_size
|
||||
mime = media_mime_type(str(file_path))
|
||||
media_kind = "audio" if is_audio(ext) else "video"
|
||||
|
||||
# #109-A3 — beyond the inline limit the viewer falls back to download
|
||||
# (a single uvicorn worker must not be pinned by multi-GB media).
|
||||
if size > media_max_inline_bytes():
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": "",
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"unsupported": True,
|
||||
"media_too_large": True,
|
||||
"size_bytes": size,
|
||||
}
|
||||
|
||||
# #109-A2 — byte-range endpoint: enables scrub and is required by Safari.
|
||||
stream_url = f"/api/media/{quote(vault_name, safe='')}?path={quote(path, safe='')}"
|
||||
if media_kind == "audio":
|
||||
html = (
|
||||
f'<div class="audio-viewer">'
|
||||
f'<audio controls preload="metadata" src="{stream_url}"></audio>'
|
||||
f'</div>'
|
||||
)
|
||||
else:
|
||||
html = (
|
||||
f'<div class="video-viewer">'
|
||||
f'<video controls playsinline preload="metadata" src="{stream_url}"></video>'
|
||||
f'</div>'
|
||||
)
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": html,
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"is_audio": media_kind == "audio",
|
||||
"is_video": media_kind == "video",
|
||||
"media_mime": mime,
|
||||
"stream_url": stream_url,
|
||||
"size_bytes": size,
|
||||
}
|
||||
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except PermissionError as e:
|
||||
logger.error(f"Permission denied reading file {path}: {e}")
|
||||
raise HTTPException(status_code=403, detail=f"Permission denied: cannot read file {path}")
|
||||
except UnicodeDecodeError:
|
||||
# Binary / unsupported file — return structured info with download option
|
||||
size = file_path.stat().st_size
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": "",
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"unsupported": True,
|
||||
"size_bytes": size,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error reading file {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}")
|
||||
|
||||
# === Excel .xlsm: same editable viewer as .xlsx, macros preserved on save ===
|
||||
if ext == ".xlsm":
|
||||
try:
|
||||
from backend.xlsx_reader import inspect_workbook, render_sheets
|
||||
|
||||
sheets = render_sheets(file_path)
|
||||
size = file_path.stat().st_size
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": sheets[0]["html"] if sheets else "",
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"is_xlsx": True,
|
||||
"xlsx_sheets": sheets,
|
||||
"xlsx_revision": file_revision(file_path),
|
||||
# Macros are NOT lossy for .xlsm: keep_vba re-serializes them
|
||||
# (an empty LOSSY probe is what makes the save gate pass).
|
||||
"xlsx_lossy_features": [],
|
||||
"unsupported": False,
|
||||
"size_bytes": size,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"XLSX read error for {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
|
||||
|
||||
# === Legacy/ODF spreadsheets (.xls, .ods): read-only table view ===
|
||||
if ext in (".xls", ".ods"):
|
||||
try:
|
||||
from backend.xlsx_reader import render_legacy_workbook
|
||||
|
||||
sheets = render_legacy_workbook(file_path, ext)
|
||||
size = file_path.stat().st_size
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": sheets[0]["html"] if sheets else "",
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"is_xlsx": True,
|
||||
"xlsx_readonly": True,
|
||||
"xlsx_sheets": sheets,
|
||||
"unsupported": False,
|
||||
"size_bytes": size,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Spreadsheet read error for {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading spreadsheet: {e!s}")
|
||||
|
||||
# === CSV: spreadsheet-style table (same shape as the xlsx viewer) ===
|
||||
if ext == ".csv":
|
||||
from backend.xlsx_reader import render_csv_table
|
||||
|
||||
html = render_csv_table(raw)
|
||||
return {
|
||||
"vault": vault_name, "path": path,
|
||||
"title": file_path.name, "tags": [], "frontmatter": {},
|
||||
"html": html, "raw_length": len(raw), "extension": ext,
|
||||
"is_markdown": False, "is_csv": True,
|
||||
# #156-A12 — same stale-write guard as the workbooks.
|
||||
"xlsx_revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
# === JSON: syntax-highlighted display ===
|
||||
if ext == ".json":
|
||||
import json as json_mod
|
||||
try:
|
||||
parsed = json_mod.loads(raw)
|
||||
formatted = json_mod.dumps(parsed, indent=2, ensure_ascii=False)
|
||||
except json_mod.JSONDecodeError:
|
||||
formatted = raw
|
||||
html = f"<pre class='json-viewer'><code>{html_mod.escape(formatted)}</code></pre>"
|
||||
return {
|
||||
"vault": vault_name, "path": path,
|
||||
"title": file_path.name, "tags": [], "frontmatter": {},
|
||||
"html": html, "raw_length": len(raw), "extension": ext,
|
||||
"is_markdown": False, "is_json": True,
|
||||
}
|
||||
|
||||
# === Excalidraw .excalidraw.md (Obsidian plugin format) ===
|
||||
if path.lower().endswith(".excalidraw.md"):
|
||||
import re as re_mod
|
||||
raw_lower = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
# Check for excalidraw-plugin in frontmatter or body
|
||||
if "excalidraw-plugin:" in raw_lower:
|
||||
# Extract compressed JSON block
|
||||
match = re_mod.search(r'```compressed-json\n(.*?)\n```', raw_lower, re_mod.DOTALL)
|
||||
if match:
|
||||
compressed = match.group(1).strip()
|
||||
return {
|
||||
"vault": vault_name, "path": path,
|
||||
"title": file_path.name.replace(".excalidraw.md", ""),
|
||||
"tags": [], "frontmatter": {},
|
||||
"html": "", "raw_length": len(raw_lower),
|
||||
"extension": ".excalidraw.md",
|
||||
"is_markdown": False,
|
||||
"is_excalidraw": True,
|
||||
"excalidraw_data_compressed": compressed,
|
||||
}
|
||||
# Fallback: treat as regular markdown
|
||||
raw = raw_lower
|
||||
if ext == ".excalidraw":
|
||||
import json as json_mod
|
||||
try:
|
||||
parsed = json_mod.loads(raw)
|
||||
except json_mod.JSONDecodeError:
|
||||
parsed = None
|
||||
if parsed and parsed.get("type") == "excalidraw":
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": parsed.get("appState", {}).get("name") or file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": "",
|
||||
"raw_length": len(raw),
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"is_excalidraw": True,
|
||||
"excalidraw_data": {
|
||||
"elements": parsed.get("elements", []),
|
||||
"appState": parsed.get("appState", {}),
|
||||
"files": parsed.get("files", {}),
|
||||
},
|
||||
}
|
||||
else:
|
||||
# Not a valid Excalidraw file — fall through to text viewer
|
||||
pass
|
||||
|
||||
# === Plain text / other readable files ===
|
||||
TEXT_EXTENSIONS = {".txt", ".log", ".yml", ".yaml", ".toml", ".ini", ".cfg",
|
||||
".sh", ".bash", ".py", ".js", ".ts", ".html", ".css",
|
||||
".xml", ".rst", ".tex", ".sql", ".conf", ".env"}
|
||||
if ext in TEXT_EXTENSIONS or ext == ".md":
|
||||
pass # handled below or by markdown section
|
||||
|
||||
if ext == ".md":
|
||||
post = parse_markdown_file(raw)
|
||||
|
||||
# Extract metadata using shared indexer logic
|
||||
tags = _extract_tags(post)
|
||||
|
||||
title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " "))
|
||||
html_content = _render_markdown(post.content, vault_name, file_path, click_to_copy=True)
|
||||
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": str(title),
|
||||
"tags": tags,
|
||||
"frontmatter": dict(post.metadata) if post.metadata else {},
|
||||
"html": html_content,
|
||||
"raw_length": len(raw),
|
||||
"extension": ext,
|
||||
"is_markdown": True,
|
||||
}
|
||||
else:
|
||||
# Non-markdown: wrap in syntax-highlighted code block
|
||||
lang = EXT_TO_LANG.get(ext, "")
|
||||
if not lang:
|
||||
# Fichiers sans extension usuels (Dockerfile, Makefile, etc.)
|
||||
NAME_TO_LANG = {
|
||||
"dockerfile": "dockerfile", "makefile": "makefile",
|
||||
"cmakelists.txt": "cmake", "jenkinsfile": "groovy",
|
||||
"vagrantfile": "ruby", "rakefile": "ruby", "gemfile": "ruby",
|
||||
"procfile": "plaintext", "bashrc": "bash", "bash_profile": "bash",
|
||||
"zshrc": "bash", "profile": "bash", "gitignore": "plaintext",
|
||||
}
|
||||
lang = NAME_TO_LANG.get(file_path.name.lower(), "plaintext")
|
||||
escaped = html_mod.escape(raw)
|
||||
html_content = f'<pre><code class="language-{lang}">{escaped}</code></pre>'
|
||||
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": html_content,
|
||||
"raw_length": len(raw),
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
"""File & directory mutation endpoints (ROADMAP #85, tranche 6b).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``PUT/DELETE/PATCH/POST /api/file/*``,
|
||||
``/api/directory/*``, ``/api/move/*``, ``/api/vault/*/batch-upload``),
|
||||
mêmes modèles de requête/réponse (déménagés dans :mod:`backend.schemas`),
|
||||
mêmes dépendances d'authentification et mêmes effets de bord (audit, index
|
||||
incrémental, SSE, webhooks, plugins, historique).
|
||||
|
||||
La logique métier vit déjà dans :mod:`backend.services.mutations`.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
|
||||
from backend.audit import log_file_delete, log_file_save
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.history import (
|
||||
remove_recent,
|
||||
update_bookmarks_after_rename,
|
||||
update_history_after_rename,
|
||||
)
|
||||
from backend.indexer import handle_file_move, remove_single_file, update_single_file
|
||||
from backend.schemas import (
|
||||
BatchUploadRequest,
|
||||
BatchUploadResponse,
|
||||
DirectoryCreateRequest,
|
||||
DirectoryCreateResponse,
|
||||
DirectoryDeleteResponse,
|
||||
DirectoryRenameRequest,
|
||||
DirectoryRenameResponse,
|
||||
FileCreateRequest,
|
||||
FileCreateResponse,
|
||||
FileDeleteResponse,
|
||||
FileMoveRequest,
|
||||
FileMoveResponse,
|
||||
FileRenameRequest,
|
||||
FileRenameResponse,
|
||||
FileSaveResponse,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
batch_upload_files as service_batch_upload_files,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
create_directory as service_create_directory,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
create_file as service_create_file,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
delete_directory as service_delete_directory,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
delete_file as service_delete_file,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
edit_file as service_edit_file,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
edit_xlsx_cells as service_edit_xlsx_cells,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
move_path as service_move_path,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
mutate_xlsx_structure as service_mutate_xlsx_structure,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
mutate_xlsx_style as service_mutate_xlsx_style,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
rename_directory as service_rename_directory,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
rename_file as service_rename_file,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
save_csv_cells as service_save_csv_cells,
|
||||
)
|
||||
from backend.share import update_shares_after_rename
|
||||
from backend.sse import sse_manager
|
||||
from backend.webhooks import dispatch_webhooks
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
router = APIRouter(tags=["files"])
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/save", response_model=FileSaveResponse)
|
||||
async def api_file_save(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
body: dict = Body(...),
|
||||
backup: bool = Query(True, description="Create a backup before saving (default true, set false for auto-save)"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Save (overwrite) a file's content.
|
||||
|
||||
Expects a JSON body with a ``content`` key containing the new text.
|
||||
The path is validated against traversal attacks before writing.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
body: JSON body with ``content`` string.
|
||||
|
||||
Returns:
|
||||
``FileSaveResponse`` confirming the write.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
content = body.get("content", "")
|
||||
result = service_edit_file(vault_name, path, content, backup=backup)
|
||||
|
||||
# #197 (synchro TikTak) : rafraîchir l'index de façon synchrone pour une
|
||||
# cohérence read-after-write — un client qui liste juste après le save
|
||||
# doit voir le nouveau mtime/size sans attendre le watcher.
|
||||
await update_single_file(vault_name, result["path"])
|
||||
|
||||
# #198 : la sauvegarde texte émet file_modified (événement clé pour tirer
|
||||
# les retours de synchro côté TikTak en phase 3).
|
||||
await dispatch_webhooks("file_modified", {"vault": vault_name, "path": result["path"]})
|
||||
|
||||
# Audit log
|
||||
client_ip = current_user.get("_request_ip", "unknown")
|
||||
log_file_save(current_user["username"], vault_name, path, len(content), client_ip)
|
||||
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/xlsx/save", response_model=FileSaveResponse)
|
||||
def api_file_xlsx_save(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx file"),
|
||||
body: dict = Body(
|
||||
...,
|
||||
description=(
|
||||
'{"sheet": str, "cells": {"A1": value}, '
|
||||
'"allow_formula": false, "force": false, "if_match": str}'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Apply cell edits to an .xlsx workbook.
|
||||
|
||||
Expects a JSON body with ``sheet`` and ``cells`` (A1 references to new
|
||||
scalar values, max 500 per request) plus two optional boolean flags:
|
||||
|
||||
* ``allow_formula`` — keep values starting with ``=``/``@`` as real
|
||||
formulas. Off by default (#153 A4): such a value is stored as text so a
|
||||
later Excel session cannot execute it (DDE).
|
||||
* ``force`` — write a workbook carrying features openpyxl cannot re-serialize
|
||||
(slicers, form controls, connections, custom XML, signature, cached formula
|
||||
results). Without it the call fails **409** ``xlsx_lossy_content`` and the
|
||||
client asks the user to confirm (#153 A1).
|
||||
* ``if_match`` — revision token returned by the read (#156-A12). When it no
|
||||
longer matches the file on disk the write is refused with **409**
|
||||
``conflict`` (``reason=stale_revision``) instead of overwriting a change
|
||||
made by another writer. Omitted: last writer wins (curl, AI tools).
|
||||
|
||||
A backup is created before the workbook is rewritten, and the new archive
|
||||
swaps in atomically. Declared as a sync endpoint on purpose: the openpyxl
|
||||
round-trip and the per-file lock wait (#153 A3) then run in the threadpool
|
||||
instead of blocking the event loop.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
sheet = body.get("sheet")
|
||||
cells = body.get("cells")
|
||||
if not isinstance(sheet, str) or not sheet:
|
||||
raise HTTPException(status_code=400, detail="Feuille manquante")
|
||||
if not isinstance(cells, dict) or not cells or len(cells) > 500:
|
||||
raise HTTPException(status_code=400, detail="Cellules invalides (1 à 500 par requête)")
|
||||
for ref, value in cells.items():
|
||||
if not isinstance(ref, str) or not isinstance(value, (str, int, float, bool, type(None))):
|
||||
raise HTTPException(status_code=400, detail=f"Cellule invalide: {ref!r}")
|
||||
flags: dict[str, bool] = {}
|
||||
for name in ("allow_formula", "force"):
|
||||
raw = body.get(name, False)
|
||||
if not isinstance(raw, bool):
|
||||
raise HTTPException(status_code=400, detail=f"Flag invalide: {name}")
|
||||
flags[name] = raw
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_edit_xlsx_cells(
|
||||
vault_name, path, sheet, cells, expected_revision=if_match, **flags
|
||||
)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
sum(len(str(v)) for v in cells.values()),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": result["size"], "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/csv/save", response_model=FileSaveResponse)
|
||||
def api_file_csv_save(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .csv file"),
|
||||
body: dict = Body(
|
||||
...,
|
||||
description=(
|
||||
'{"cells": {"A1": value}, "if_match": str} — A1-addressed text '
|
||||
'edits (#153 A16). `if_match` is the revision token of the read '
|
||||
'(#156-A12): a stale token fails with 409 `conflict`.'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Apply A1-addressed cell edits to a ``.csv`` file (#153 A16).
|
||||
|
||||
The grid is re-parsed with :mod:`csv`, patched and re-serialized
|
||||
(RFC 4180 quoting). References beyond the extent grow the grid. Values
|
||||
are stored verbatim as text — a CSV has no formula engine.
|
||||
|
||||
``if_match`` (optional, #156-A12) is the revision the client read: when the
|
||||
file changed in the meantime the write is refused with **409** ``conflict``.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
cells = body.get("cells")
|
||||
if not isinstance(cells, dict) or not cells or len(cells) > 500:
|
||||
raise HTTPException(status_code=400, detail="Cellules invalides (1 à 500 par requête)")
|
||||
for ref, value in cells.items():
|
||||
if not isinstance(ref, str) or not isinstance(value, (str, int, float, bool, type(None))):
|
||||
raise HTTPException(status_code=400, detail=f"Cellule invalide: {ref!r}")
|
||||
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_save_csv_cells(vault_name, path, cells, expected_revision=if_match)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
sum(len(str(v)) for v in cells.values()),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": result["size"], "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/xlsx/structure", response_model=FileSaveResponse)
|
||||
def api_file_xlsx_structure(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx file"),
|
||||
body: dict = Body(
|
||||
...,
|
||||
description=(
|
||||
'{"actions": [{"op": "sheet_add", "name": "X"}, '
|
||||
'{"op": "row_insert", "sheet": "X", "at": 2, "count": 1}], '
|
||||
'"force": false, "if_match": str}'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Apply structural changes to an .xlsx workbook (#153 A14).
|
||||
|
||||
``actions`` is an ordered list applied in one locked, atomic rewrite:
|
||||
``sheet_add`` (``name``, optional ``at`` 0-based), ``sheet_rename``
|
||||
(``from``/``to``), ``sheet_delete`` (refused on the last sheet),
|
||||
``sheet_duplicate`` (``name``/``as``) and ``row_insert``/``row_delete``/
|
||||
``col_insert``/``col_delete`` (``sheet``, 1-based ``at``, ``count``).
|
||||
|
||||
Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the
|
||||
workbook carries features openpyxl cannot rewrite (same gate as the cell
|
||||
edits). A backup is created before the archive is replaced. The optional
|
||||
``if_match`` revision (#156-A12) refuses a structural rewrite on a file that
|
||||
changed since it was read (**409** ``conflict``).
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative path to the ``.xlsx`` file.
|
||||
body: JSON body with ``actions`` (1 to 50) and optional ``force``.
|
||||
|
||||
Returns:
|
||||
``FileSaveResponse`` confirming the write.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
actions = body.get("actions")
|
||||
if not isinstance(actions, list) or not actions or len(actions) > 50:
|
||||
raise HTTPException(status_code=400, detail="Actions invalides (1 à 50 par requête)")
|
||||
raw_force = body.get("force", False)
|
||||
if not isinstance(raw_force, bool):
|
||||
raise HTTPException(status_code=400, detail="Flag invalide: force")
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_mutate_xlsx_structure(
|
||||
vault_name, path, actions, force=raw_force, expected_revision=if_match
|
||||
)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
len(actions),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": len(result["applied"]), "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/xlsx/style", response_model=FileSaveResponse)
|
||||
def api_file_xlsx_style(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx/.xlsm file"),
|
||||
body: dict = Body(
|
||||
...,
|
||||
description=(
|
||||
'{"ops": [{"op": "cell", "sheet": "X", "range": "A1:B2", '
|
||||
'"style": {"bold": true, "fill_color": "#ffe08a"}}], '
|
||||
'"force": false, "if_match": str}'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Write formatting on an .xlsx/.xlsm workbook (#156-A8).
|
||||
|
||||
``ops`` is an ordered list applied in one locked, atomic rewrite:
|
||||
``cell`` (``sheet``, ``range``/``cell``, ``style`` with ``bold``,
|
||||
``italic``, ``underline``, ``strike``, ``font_size`` (6-72),
|
||||
``font_color``/``fill_color`` as ``#rrggbb``, ``align``
|
||||
(left/center/right/justify), ``valign`` (top/middle/bottom), ``wrap``,
|
||||
``rotation`` (-90..90), ``border`` (all/outer/none) + ``border_style`` /
|
||||
``border_color`` and ``number_format``), ``merge``/``unmerge`` (``range``),
|
||||
``col_width`` (``col``, ``width``), ``row_height`` (``row``, ``height``),
|
||||
``comment_set`` (``ref``, ``text``) / ``comment_clear`` (``ref``)
|
||||
and ``freeze`` (``cell``, empty to release).
|
||||
|
||||
Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the
|
||||
workbook carries features openpyxl cannot rewrite (same gate as the cell
|
||||
edits). A backup is created before the archive is replaced; the optional
|
||||
``if_match`` revision (#156-A12) refuses a rewrite on a file that changed
|
||||
since it was read (**409** ``conflict``).
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative path to the ``.xlsx``/``.xlsm`` file.
|
||||
body: JSON body with ``ops`` (1 to 50) and optional ``force``.
|
||||
|
||||
Returns:
|
||||
``FileSaveResponse`` confirming the write.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
ops = body.get("ops")
|
||||
if not isinstance(ops, list) or not ops or len(ops) > 50:
|
||||
raise HTTPException(status_code=400, detail="Ops invalides (1 à 50 par requête)")
|
||||
raw_force = body.get("force", False)
|
||||
if not isinstance(raw_force, bool):
|
||||
raise HTTPException(status_code=400, detail="Flag invalide: force")
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_mutate_xlsx_style(
|
||||
vault_name, path, ops, force=raw_force, expected_revision=if_match
|
||||
)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
len(ops),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": len(result["applied"]), "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
|
||||
async def api_file_delete(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||
"""Delete a file from the vault.
|
||||
|
||||
The path is validated against traversal attacks before deletion.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
|
||||
Returns:
|
||||
``FileDeleteResponse`` confirming the deletion.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_delete_file(vault_name, path)
|
||||
|
||||
# Audit log
|
||||
client_ip = current_user.get("_request_ip", "unknown")
|
||||
log_file_delete(current_user["username"], vault_name, path, client_ip)
|
||||
|
||||
# Update index
|
||||
await remove_single_file(vault_name, path)
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("file_deleted", {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
})
|
||||
|
||||
from backend.plugins import emit_file_deleted
|
||||
emit_file_deleted(vault_name, path)
|
||||
|
||||
# Remove from recent files
|
||||
remove_recent(current_user["username"], vault_name, path)
|
||||
|
||||
# Dispatch webhooks
|
||||
await dispatch_webhooks("file_deleted", {"vault": vault_name, "path": path})
|
||||
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"]}
|
||||
|
||||
|
||||
@router.post("/api/directory/{vault_name}", response_model=DirectoryCreateResponse)
|
||||
async def api_directory_create(
|
||||
vault_name: str,
|
||||
body: DirectoryCreateRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Create a new directory in a vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
body: Request body with directory path.
|
||||
|
||||
Returns:
|
||||
DirectoryCreateResponse confirming creation.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_create_directory(vault_name, body.path)
|
||||
|
||||
# Update path_index with the new directory
|
||||
from backend.indexer import _index_lock
|
||||
from backend.indexer import path_index as _path_idx
|
||||
with _index_lock:
|
||||
if vault_name not in _path_idx:
|
||||
_path_idx[vault_name] = []
|
||||
existing = {p["path"] for p in _path_idx[vault_name]}
|
||||
# Build all parent segments
|
||||
parts = body.path.split("/")
|
||||
for i in range(1, len(parts) + 1):
|
||||
seg_path = "/".join(parts[:i])
|
||||
if seg_path and seg_path not in existing:
|
||||
existing.add(seg_path)
|
||||
_path_idx[vault_name].append({
|
||||
"path": seg_path,
|
||||
"name": parts[i - 1],
|
||||
"type": "directory",
|
||||
})
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("directory_created", {
|
||||
"vault": vault_name,
|
||||
"path": result["path"],
|
||||
})
|
||||
await dispatch_webhooks("directory_created", {"vault": vault_name, "path": result["path"]})
|
||||
|
||||
return {"success": True, "path": result["path"]}
|
||||
|
||||
|
||||
@router.patch("/api/directory/{vault_name}", response_model=DirectoryRenameResponse)
|
||||
async def api_directory_rename(
|
||||
vault_name: str,
|
||||
body: DirectoryRenameRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Rename a directory in a vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
body: Request body with current path and new name.
|
||||
|
||||
Returns:
|
||||
DirectoryRenameResponse with old and new paths.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_rename_directory(vault_name, body.path, body.new_name)
|
||||
old_path_str = result["old_path"]
|
||||
new_path_str = result["new_path"]
|
||||
|
||||
# Update index for all files in the directory
|
||||
from backend.indexer import reload_single_vault
|
||||
await reload_single_vault(vault_name)
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("directory_renamed", {
|
||||
"vault": vault_name,
|
||||
"old_path": old_path_str,
|
||||
"new_path": new_path_str,
|
||||
})
|
||||
await dispatch_webhooks("directory_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
|
||||
|
||||
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
|
||||
|
||||
|
||||
@router.delete("/api/directory/{vault_name}", response_model=DirectoryDeleteResponse)
|
||||
async def api_directory_delete(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to directory"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Delete a directory and all its contents from a vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative directory path within the vault.
|
||||
|
||||
Returns:
|
||||
DirectoryDeleteResponse with count of deleted files.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_delete_directory(vault_name, path, recursive=True)
|
||||
file_count = result["deleted_count"]
|
||||
|
||||
# Update index
|
||||
from backend.indexer import reload_single_vault
|
||||
await reload_single_vault(vault_name)
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("directory_deleted", {
|
||||
"vault": vault_name,
|
||||
"path": result["path"],
|
||||
"deleted_count": file_count,
|
||||
})
|
||||
await dispatch_webhooks("directory_deleted", {"vault": vault_name, "path": result["path"]})
|
||||
|
||||
return {"success": True, "deleted_count": file_count}
|
||||
|
||||
|
||||
@router.post("/api/file/{vault_name}", response_model=FileCreateResponse)
|
||||
async def api_file_create(
|
||||
vault_name: str,
|
||||
body: FileCreateRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Create a new file in a vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
body: Request body with file path and initial content.
|
||||
|
||||
Returns:
|
||||
FileCreateResponse confirming creation.
|
||||
|
||||
Note:
|
||||
A ``.xlsx`` path creates an empty workbook (one ``Feuille1`` sheet) built
|
||||
with openpyxl: the payload is binary, so ``content`` is ignored (#186).
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_create_file(vault_name, body.path, body.content)
|
||||
|
||||
# Update index
|
||||
await update_single_file(vault_name, result["path"])
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("file_created", {
|
||||
"vault": vault_name,
|
||||
"path": result["path"],
|
||||
})
|
||||
await dispatch_webhooks("file_created", {"vault": vault_name, "path": result["path"]})
|
||||
from backend.plugins import emit_file_created
|
||||
emit_file_created(vault_name, result["path"])
|
||||
|
||||
return {"success": True, "path": result["path"]}
|
||||
|
||||
|
||||
@router.post("/api/vault/{vault_name}/batch-upload", response_model=BatchUploadResponse)
|
||||
async def api_batch_upload(
|
||||
vault_name: str,
|
||||
body: BatchUploadRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Upload multiple files and directories (recursively) into a vault.
|
||||
|
||||
Accepts base64 encoded or plain text files with relative directory paths.
|
||||
Creates missing parent folders safely.
|
||||
|
||||
Args:
|
||||
vault_name: Target vault name.
|
||||
body: BatchUploadRequest with target_dir and files list.
|
||||
|
||||
Returns:
|
||||
BatchUploadResponse with summary of uploaded files and errors.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
import base64
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for f in body.files:
|
||||
if f.is_dir:
|
||||
items.append({"path": f.path, "is_dir": True})
|
||||
continue
|
||||
|
||||
raw_bytes = b""
|
||||
if f.content is not None:
|
||||
# Check if content is base64 encoded data URI or raw base64
|
||||
content_str = f.content
|
||||
if content_str.startswith("data:") and ";base64," in content_str:
|
||||
content_str = content_str.split(";base64,", 1)[1]
|
||||
try:
|
||||
raw_bytes = base64.b64decode(content_str)
|
||||
except Exception:
|
||||
# Fallback to utf-8 text encoding
|
||||
raw_bytes = f.content.encode("utf-8")
|
||||
|
||||
items.append({"path": f.path, "content": raw_bytes, "is_dir": False})
|
||||
|
||||
result = service_batch_upload_files(
|
||||
vault_name,
|
||||
body.target_dir,
|
||||
items,
|
||||
overwrite=body.overwrite,
|
||||
)
|
||||
|
||||
# Update index and SSE notifications for uploaded files
|
||||
for path in result["uploaded"]:
|
||||
try:
|
||||
await update_single_file(vault_name, path)
|
||||
await sse_manager.broadcast("file_created", {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
})
|
||||
await dispatch_webhooks("file_created", {"vault": vault_name, "path": path})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to post-process upload of {path}: {e}")
|
||||
|
||||
# SSE notification for tree refresh
|
||||
if result["uploaded"] or result["created_dirs"]:
|
||||
await sse_manager.broadcast("tree_updated", {
|
||||
"vault": vault_name,
|
||||
"target_dir": result["target_dir"],
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.patch("/api/file/{vault_name}", response_model=FileRenameResponse)
|
||||
async def api_file_rename(
|
||||
vault_name: str,
|
||||
body: FileRenameRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Rename a file in a vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
body: Request body with current path and new name.
|
||||
|
||||
Returns:
|
||||
FileRenameResponse with old and new paths.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_rename_file(vault_name, body.path, body.new_name)
|
||||
old_path_str = result["old_path"]
|
||||
new_path_str = result["new_path"]
|
||||
|
||||
# Update index
|
||||
await handle_file_move(vault_name, old_path_str, new_path_str)
|
||||
|
||||
# Update bookmarks, history, and shares
|
||||
update_bookmarks_after_rename(vault_name, old_path_str, new_path_str)
|
||||
update_history_after_rename(vault_name, old_path_str, new_path_str)
|
||||
update_shares_after_rename(vault_name, old_path_str, new_path_str)
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("file_renamed", {
|
||||
"vault": vault_name,
|
||||
"old_path": old_path_str,
|
||||
"new_path": new_path_str,
|
||||
})
|
||||
await dispatch_webhooks("file_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
|
||||
|
||||
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
|
||||
|
||||
|
||||
@router.post("/api/move/{vault_name}", response_model=FileMoveResponse)
|
||||
async def api_file_move(
|
||||
vault_name: str,
|
||||
body: FileMoveRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Move a file or directory to a different parent directory within the same vault.
|
||||
|
||||
Supports both files and directories. The item keeps its original name;
|
||||
only the parent directory changes.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
body: Request body with source_path and destination_dir.
|
||||
|
||||
Returns:
|
||||
FileMoveResponse with old and new paths.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_move_path(vault_name, body.source_path, body.destination_dir)
|
||||
old_path_str = result["old_path"]
|
||||
new_path_str = result["new_path"]
|
||||
item_type = result["item_type"]
|
||||
|
||||
# Update index
|
||||
if item_type == "directory":
|
||||
from backend.indexer import reload_single_vault
|
||||
await reload_single_vault(vault_name)
|
||||
else:
|
||||
await handle_file_move(vault_name, old_path_str, new_path_str)
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("item_moved", {
|
||||
"vault": vault_name,
|
||||
"old_path": old_path_str,
|
||||
"new_path": new_path_str,
|
||||
"item_type": item_type,
|
||||
})
|
||||
await dispatch_webhooks("item_moved", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type})
|
||||
|
||||
return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}
|
||||
@@ -0,0 +1,143 @@
|
||||
"""System health endpoints (ROADMAP #85, tranche 1).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/health``, ``/api/health/detailed``),
|
||||
même ``response_model`` (:class:`backend.schemas.HealthResponse`), même
|
||||
dépendance admin. Seule différence : la version est lue via
|
||||
:func:`backend.version.get_version` au lieu de ``app.version`` (valeur
|
||||
identique, figée au démarrage depuis le fichier ``VERSION``).
|
||||
|
||||
Note : ``uptime_seconds`` reprend l'expression d'origine
|
||||
(``'_SERVER_START_TIME' in globals()``), qui vaut toujours 0 — le global
|
||||
n'est défini nulle part dans ``backend.main`` (voir ``backend.admin`` qui
|
||||
possède son propre compteur). Ce comportement est préservé tel quel ; le
|
||||
corriger fera l'objet d'une tranche ultérieure avec test dédié.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from backend.auth.middleware import require_admin
|
||||
from backend.indexer import index
|
||||
from backend.schemas import HealthResponse
|
||||
from backend.version import get_git_commit, get_git_describe, get_version
|
||||
|
||||
router = APIRouter(tags=["System"])
|
||||
|
||||
|
||||
@router.get("/api/health", response_model=HealthResponse)
|
||||
async def api_health():
|
||||
"""Health check endpoint for Docker and monitoring.
|
||||
|
||||
Returns:
|
||||
Application status, version, vault count and total file count.
|
||||
"""
|
||||
total_files = sum(len(v["files"]) for v in index.values())
|
||||
total_tokens = sum(len(v.get("files", [])) * 1000 for v in index.values()) # rough approx
|
||||
import time
|
||||
|
||||
from backend.indexer import _last_full_index_ts
|
||||
# `_SERVER_START_TIME` n'existe dans aucun module (comportement d'origine
|
||||
# préservé : uptime toujours 0 — voir docstring du module).
|
||||
uptime = int(time.time() - _SERVER_START_TIME) if '_SERVER_START_TIME' in globals() else 0 # noqa: F821
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": get_version(),
|
||||
"vaults": len(index),
|
||||
"total_files": total_files,
|
||||
"total_tokens": total_tokens,
|
||||
"last_full_index_ts": _last_full_index_ts,
|
||||
"uptime_seconds": uptime,
|
||||
"git_describe": get_git_describe(),
|
||||
"git_commit": get_git_commit(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/health/detailed", response_model=HealthResponse)
|
||||
async def api_health_detailed(current_user=Depends(require_admin)):
|
||||
"""Detailed health check — admin only.
|
||||
|
||||
Returns enriched metrics including memory, disk, SSE connections, and backup stats.
|
||||
"""
|
||||
|
||||
import psutil
|
||||
|
||||
from backend.admin import _count_active_sessions, _get_disk_stats
|
||||
from backend.indexer import _last_full_index_ts, index
|
||||
|
||||
total_files = sum(len(v["files"]) for v in index.values())
|
||||
total_tokens = sum(len(v.get("files", [])) * 1000 for v in index.values())
|
||||
import time
|
||||
uptime = int(time.time() - _SERVER_START_TIME) if '_SERVER_START_TIME' in globals() else 0 # noqa: F821 — voir ci-dessus
|
||||
|
||||
# Memory
|
||||
vm = psutil.virtual_memory()
|
||||
mem_used_mb = round(vm.used / (1024 ** 2), 1)
|
||||
mem_total_mb = round(vm.total / (1024 ** 2), 1)
|
||||
mem_pct = round(vm.percent, 1)
|
||||
|
||||
# CPU
|
||||
cpu_pct = psutil.cpu_percent(interval=None)
|
||||
|
||||
# Disk
|
||||
disk_used_gb, disk_total_gb = _get_disk_stats()
|
||||
disk_free_gb = round(disk_total_gb - disk_used_gb, 2)
|
||||
disk_pct = round((disk_used_gb / disk_total_gb * 100) if disk_total_gb > 0 else 0, 1)
|
||||
|
||||
# SSE connections (approximation)
|
||||
active_sessions = _count_active_sessions()
|
||||
|
||||
# Backups
|
||||
from backend.admin import _scan_backups
|
||||
backup_rows = _scan_backups()
|
||||
total_backups = len(backup_rows)
|
||||
total_backup_size_mb = round(sum(r["size"] for r in backup_rows) / (1024 ** 2), 2)
|
||||
oldest_backup_age_days = 0.0
|
||||
if backup_rows:
|
||||
now_ts = int(time.time())
|
||||
oldest_ts = min(r["timestamp"] for r in backup_rows)
|
||||
oldest_backup_age_days = round((now_ts - oldest_ts) / 86400, 2)
|
||||
|
||||
# Index details
|
||||
index_detail = {}
|
||||
for name, data in index.items():
|
||||
index_detail[name] = {
|
||||
"file_count": len(data["files"]),
|
||||
"tag_count": len(data.get("tags", [])),
|
||||
"token_count_approx": len(data.get("files", [])) * 1000,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": get_version(),
|
||||
"vaults": len(index),
|
||||
"total_files": total_files,
|
||||
"total_tokens": total_tokens,
|
||||
"last_full_index_ts": _last_full_index_ts,
|
||||
"uptime_seconds": uptime,
|
||||
"git_describe": get_git_describe(),
|
||||
"git_commit": get_git_commit(),
|
||||
# Enriched fields
|
||||
"memory": {
|
||||
"used_mb": mem_used_mb,
|
||||
"total_mb": mem_total_mb,
|
||||
"percent": mem_pct,
|
||||
},
|
||||
"cpu": {
|
||||
"percent": cpu_pct,
|
||||
},
|
||||
"disk": {
|
||||
"used_gb": disk_used_gb,
|
||||
"total_gb": disk_total_gb,
|
||||
"free_gb": disk_free_gb,
|
||||
"percent": disk_pct,
|
||||
},
|
||||
"connections": {
|
||||
"active_sse": active_sessions,
|
||||
},
|
||||
"backups": {
|
||||
"total_count": total_backups,
|
||||
"total_size_mb": total_backup_size_mb,
|
||||
"oldest_age_days": oldest_backup_age_days,
|
||||
},
|
||||
"index": index_detail,
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Shared helpers for the file routers (ROADMAP #85, tranche 6a).
|
||||
|
||||
Petites fonctions pures extraites de :mod:`backend.main` sans changement
|
||||
de comportement. Regroupées ici car utilisées par plusieurs routers
|
||||
(``files_read`` aujourd'hui, ``files_media`` / mutations ensuite) :
|
||||
- :func:`content_disposition` — aussi utilisée par ``_stream_file_with_range``
|
||||
(resté dans ``main`` jusqu'à la tranche media).
|
||||
- :func:`media_max_inline_bytes` — aussi utilisée par ``/api/media``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
|
||||
def content_disposition(disposition: str, filename: str) -> str:
|
||||
"""Build a header-safe Content-Disposition value.
|
||||
|
||||
HTTP header values must be ASCII. Unicode filenames are sent per
|
||||
RFC 5987 via ``filename*`` (percent-encoded UTF-8) with a pure-ASCII
|
||||
``filename`` fallback. This avoids a UnicodeDecodeError / HTTP 500 when
|
||||
the filename contains accented characters (e.g. 'Bière blonde…pdf').
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
ascii_name = "".join(c for c in filename if c.isascii() and (c.isalnum() or c in " _-.")).strip() or "file"
|
||||
ext = Path(filename).suffix
|
||||
if ext and not Path(ascii_name).suffix:
|
||||
ascii_name = ascii_name + ext
|
||||
return f"{disposition}; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(filename)}"
|
||||
|
||||
|
||||
def media_max_inline_bytes() -> int:
|
||||
"""Maximum size (bytes) for inline audio/video playback (roadmap #109-A3).
|
||||
|
||||
Configurable via ``OBSIGATE_MEDIA_MAX_INLINE_MB`` (default 500 MB). Files
|
||||
above the limit are not streamed in the viewer (the UI falls back to the
|
||||
download button), which keeps a single uvicorn worker from being pinned by
|
||||
multi-gigabyte media. Invalid or non-positive values fall back to default.
|
||||
"""
|
||||
default_mb = 500
|
||||
raw = os.environ.get("OBSIGATE_MEDIA_MAX_INLINE_MB", "").strip()
|
||||
if not raw:
|
||||
return default_mb * 1024 * 1024
|
||||
try:
|
||||
mb = float(raw)
|
||||
except ValueError:
|
||||
return default_mb * 1024 * 1024
|
||||
if mb <= 0:
|
||||
return default_mb * 1024 * 1024
|
||||
return int(mb * 1024 * 1024)
|
||||
|
||||
|
||||
def stream_file_with_range(file_path: Path, request: Request, media_type: str):
|
||||
"""Return a file response honouring the HTTP ``Range`` header (roadmap #109).
|
||||
|
||||
Extrait de :mod:`backend.main` (``_stream_file_with_range``) sans
|
||||
changement de comportement. Shared by ``pdf/stream`` and ``/api/media``:
|
||||
a plain :class:`FileResponse` with ``Accept-Ranges: bytes`` when no range
|
||||
is requested, or a :class:`StreamingResponse` (206 Partial Content,
|
||||
64 KiB chunks) for a valid single range. An unsatisfiable range yields
|
||||
``416`` with a ``Content-Range: bytes */size`` header.
|
||||
|
||||
Reads are offloaded to threads so the event loop is never blocked
|
||||
(ASYNC230), matching the previous inline implementation.
|
||||
"""
|
||||
file_size = file_path.stat().st_size
|
||||
range_header = request.headers.get("range")
|
||||
disposition = content_disposition("inline", file_path.name)
|
||||
|
||||
if range_header:
|
||||
# Parse "bytes=start-end" (single range only; multi-range is not used by viewers)
|
||||
m = re.match(r"bytes=(\d*)-(\d*)", range_header)
|
||||
if not m:
|
||||
raise HTTPException(status_code=416,
|
||||
headers={"Content-Range": f"bytes */{file_size}"})
|
||||
start_s, end_s = m.group(1), m.group(2)
|
||||
if start_s == "" and end_s == "":
|
||||
raise HTTPException(status_code=416,
|
||||
headers={"Content-Range": f"bytes */{file_size}"})
|
||||
if start_s == "":
|
||||
# suffix range: last N bytes
|
||||
length = min(int(end_s), file_size)
|
||||
start = file_size - length
|
||||
end = file_size - 1
|
||||
else:
|
||||
start = int(start_s)
|
||||
end = int(end_s) if end_s else file_size - 1
|
||||
end = min(end, file_size - 1)
|
||||
if start > end or start >= file_size:
|
||||
raise HTTPException(status_code=416,
|
||||
headers={"Content-Range": f"bytes */{file_size}"})
|
||||
|
||||
chunk_size = end - start + 1
|
||||
|
||||
async def _partial():
|
||||
f = await asyncio.to_thread(open, str(file_path), "rb")
|
||||
try:
|
||||
await asyncio.to_thread(f.seek, start)
|
||||
remaining = chunk_size
|
||||
while remaining > 0:
|
||||
data = await asyncio.to_thread(f.read, min(64 * 1024, remaining))
|
||||
if not data:
|
||||
break
|
||||
remaining -= len(data)
|
||||
yield data
|
||||
finally:
|
||||
await asyncio.to_thread(f.close)
|
||||
|
||||
return StreamingResponse(
|
||||
_partial(),
|
||||
status_code=206,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(chunk_size),
|
||||
"Content-Disposition": disposition,
|
||||
},
|
||||
)
|
||||
|
||||
return FileResponse(str(file_path), media_type=media_type, headers={
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Disposition": disposition})
|
||||
@@ -0,0 +1,160 @@
|
||||
"""History endpoints — recent, bookmarks, saved searches (ROADMAP #85, tranche 8).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins, mêmes modèles (``BookmarkToggleRequest``
|
||||
déménagé dans :mod:`backend.schemas`), mêmes dépendances
|
||||
d'authentification.
|
||||
|
||||
Adaptations strictement équivalentes :
|
||||
- ``_resolve_safe_path`` / ``_backup_file`` → :mod:`backend.services.paths`
|
||||
et :mod:`backend.services.backups` (pass-through).
|
||||
- ``_load_config`` vient de :mod:`backend.routers.config`.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import frontmatter
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.history import get_bookmarks, toggle_bookmark
|
||||
from backend.indexer import find_file_in_index, get_vault_data, update_single_file
|
||||
from backend.routers.config import _load_config
|
||||
from backend.saved_searches import delete_saved, get_saved, save_search
|
||||
from backend.schemas import (
|
||||
BookmarksResponse,
|
||||
BookmarkToggleRequest,
|
||||
BookmarkToggleResponse,
|
||||
RecentResponse,
|
||||
SavedSearch,
|
||||
StatusResponse,
|
||||
)
|
||||
from backend.services.backups import create_backup
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.recent import humanize_mtime, list_recent
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
router = APIRouter(tags=["Bookmarks"])
|
||||
|
||||
|
||||
@router.get("/api/recent", response_model=RecentResponse)
|
||||
async def api_recent(limit: int | None = Query(None), vault: str | None = Query(None), mode: str | None = Query("opened"), current_user=Depends(require_auth)):
|
||||
config = _load_config()
|
||||
actual_limit = limit if limit is not None else config.get("recent_files_limit", 20)
|
||||
|
||||
username = current_user.get("username")
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
|
||||
return list_recent(
|
||||
username,
|
||||
user_vaults,
|
||||
vault=vault,
|
||||
limit=actual_limit,
|
||||
mode=mode or "opened",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/bookmarks", response_model=BookmarksResponse)
|
||||
async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
|
||||
if not username:
|
||||
return {"files": []}
|
||||
|
||||
history = get_bookmarks(username, vault_filter=vault)
|
||||
files_resp = []
|
||||
for item in history:
|
||||
v_name = item["vault"]
|
||||
# #194 : check_vault_access — "*" n'inclut pas les dossiers persos.
|
||||
if not check_vault_access(v_name, current_user):
|
||||
continue
|
||||
|
||||
# Find in index to get metadata
|
||||
f_idx = find_file_in_index(item["path"], v_name)
|
||||
if f_idx:
|
||||
files_resp.append({
|
||||
"path": f_idx["path"],
|
||||
"title": f_idx.get("title") or item["path"].split("/")[-1],
|
||||
"vault": v_name,
|
||||
"mtime": item["bookmarked_at"],
|
||||
"mtime_human": humanize_mtime(item["bookmarked_at"]),
|
||||
"size_bytes": f_idx.get("size", 0),
|
||||
"tags": [f"#{t}" for t in f_idx.get("tags", [])][:5],
|
||||
"bookmarked": True
|
||||
})
|
||||
else:
|
||||
files_resp.append({
|
||||
"path": item["path"],
|
||||
"title": item.get("title") or item["path"].split("/")[-1],
|
||||
"vault": v_name,
|
||||
"mtime": item["bookmarked_at"],
|
||||
"mtime_human": humanize_mtime(item["bookmarked_at"]),
|
||||
"tags": [],
|
||||
"bookmarked": True
|
||||
})
|
||||
return {
|
||||
"files": files_resp,
|
||||
"total": len(files_resp)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/bookmarks/toggle", response_model=BookmarkToggleResponse)
|
||||
async def api_toggle_bookmark(req: BookmarkToggleRequest, current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
if not username:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
# Check vault access
|
||||
if not check_vault_access(req.vault, current_user):
|
||||
raise HTTPException(status_code=403, detail="Access denied to vault")
|
||||
|
||||
is_now_bookmarked = toggle_bookmark(username, req.vault, req.path, req.title or "")
|
||||
|
||||
# Update the file's YAML frontmatter: favoris: true/false
|
||||
vault_data = get_vault_data(req.vault)
|
||||
if vault_data:
|
||||
file_path = resolve_safe_path(Path(vault_data["path"]), req.path)
|
||||
if file_path.exists() and file_path.suffix == ".md":
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
post = frontmatter.loads(raw)
|
||||
if is_now_bookmarked:
|
||||
post.metadata["favoris"] = True
|
||||
elif "favoris" in post.metadata:
|
||||
del post.metadata["favoris"]
|
||||
new_raw = frontmatter.dumps(post)
|
||||
create_backup(file_path, req.vault, req.path)
|
||||
file_path.write_text(new_raw, encoding="utf-8")
|
||||
await update_single_file(req.vault, str(file_path))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update favoris metadata on {req.vault}/{req.path}: {e}")
|
||||
|
||||
return {"bookmarked": is_now_bookmarked}
|
||||
|
||||
|
||||
@router.get("/api/saved-searches", response_model=list[SavedSearch])
|
||||
async def api_saved_searches(current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
if not username:
|
||||
raise HTTPException(401)
|
||||
return get_saved(username)
|
||||
|
||||
|
||||
@router.post("/api/saved-searches", response_model=SavedSearch)
|
||||
async def api_save_search(body: dict = Body(...), current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
if not username:
|
||||
raise HTTPException(401)
|
||||
return save_search(username, body)
|
||||
|
||||
|
||||
@router.delete("/api/saved-searches/{search_id}", response_model=StatusResponse)
|
||||
async def api_delete_saved_search(search_id: str, current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
if not username:
|
||||
raise HTTPException(401)
|
||||
if not delete_saved(username, search_id):
|
||||
raise HTTPException(404, "Not found")
|
||||
return {"status": "deleted"}
|
||||
@@ -0,0 +1,96 @@
|
||||
"""External notification channels endpoints (#168).
|
||||
|
||||
Channel CRUD is admin-only (secrets involved); sending a test notification
|
||||
requires authentication. Responses mask secrets (``***`` + ``has_secret``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from backend import notify as _notify
|
||||
from backend.auth.middleware import require_admin, require_auth
|
||||
from backend.schemas import StatusResponse
|
||||
|
||||
router = APIRouter(prefix="/api/notify", tags=["notify"])
|
||||
|
||||
|
||||
class NotifyChannel(BaseModel):
|
||||
"""Public view of a notification channel (secrets masked)."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
id: str = Field(description="Channel id")
|
||||
name: str = Field(description="Display name")
|
||||
type: str = Field(description="discord | telegram | smtp | webhook")
|
||||
enabled: bool = Field(description="Whether the channel receives broadcasts")
|
||||
config: dict[str, Any] = Field(description="Channel config (secrets masked)")
|
||||
has_secret: bool = Field(description="True when a secret is configured")
|
||||
|
||||
|
||||
class NotifySendResult(BaseModel):
|
||||
"""Outcome of a test send / broadcast."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
ok: bool = Field(description="True when every delivery succeeded")
|
||||
deliveries: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
@router.get("/channels", response_model=list[NotifyChannel])
|
||||
async def api_notify_list(current_user=Depends(require_admin)):
|
||||
"""List notification channels (admin)."""
|
||||
return _notify.list_channels()
|
||||
|
||||
|
||||
@router.post("/channels", response_model=NotifyChannel)
|
||||
async def api_notify_create(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Create a channel (``{name, type, config}``). Secrets go to the secret store."""
|
||||
try:
|
||||
return _notify.create_channel(
|
||||
str(body.get("name") or ""),
|
||||
str(body.get("type") or ""),
|
||||
dict(body.get("config") or {}),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.patch("/channels/{channel_id}", response_model=NotifyChannel)
|
||||
async def api_notify_update(channel_id: str, body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Update a channel (name / enabled / config)."""
|
||||
try:
|
||||
result = _notify.update_channel(channel_id, body)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
if result is None:
|
||||
raise HTTPException(404, "Channel not found")
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/channels/{channel_id}", response_model=StatusResponse)
|
||||
async def api_notify_delete(channel_id: str, current_user=Depends(require_admin)):
|
||||
"""Delete a channel and its secret."""
|
||||
if not _notify.delete_channel(channel_id):
|
||||
raise HTTPException(404, "Channel not found")
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/test", response_model=NotifySendResult)
|
||||
async def api_notify_test(body: dict = Body(...), current_user=Depends(require_auth)):
|
||||
"""Send a test notification (broadcast or single ``channel_id``)."""
|
||||
title = str(body.get("title") or "Test ObsiGate")
|
||||
message = str(body.get("message") or "Notification de test.")
|
||||
channel_id = str(body.get("channel_id") or "")
|
||||
if channel_id:
|
||||
channel = next((c for c in _notify._read_channels() if c.get("id") == channel_id), None)
|
||||
if channel is None:
|
||||
raise HTTPException(404, "Channel not found")
|
||||
try:
|
||||
_notify.send_via_channel(channel, title, message, "manual")
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"Envoi échoué : {e}") from e
|
||||
return {"ok": True, "deliveries": [{"channel_id": channel_id, "ok": True}]}
|
||||
deliveries = _notify.broadcast("manual", title, message)
|
||||
return {"ok": all(d.get("ok") for d in deliveries), "deliveries": deliveries}
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Real-time endpoints — SSE stream & collaboration WebSocket (ROADMAP #85, tranche 9).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/events``,
|
||||
``/ws/collab/{vault}/{path}``), même authentification (Depend pour le SSE,
|
||||
manuelle pour le WebSocket — les ``Depends`` FastAPI ne s'exécutent pas sur
|
||||
les routes WebSocket).
|
||||
|
||||
Pas de tags déclarés : assignation par chemin via
|
||||
``openapi_docs.tag_for_path`` comme avant (``/api/events`` → System).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json as _json
|
||||
|
||||
from fastapi import APIRouter, Depends, WebSocket
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.collab import authenticate_websocket, collab_manager
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import get_vault_root
|
||||
from backend.sse import sse_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/events",
|
||||
response_class=StreamingResponse,
|
||||
responses={200: {"content": {"text/event-stream": {}}, "description": "Server-Sent Events stream"}},
|
||||
)
|
||||
async def api_events(current_user=Depends(require_auth)):
|
||||
"""SSE stream for real-time index update notifications.
|
||||
|
||||
Sends keepalive comments every 30s. Events:
|
||||
- ``index_updated``: partial index change (file create/modify/delete/move)
|
||||
- ``index_reloaded``: full re-index completed
|
||||
- ``vault_added``: new vault added dynamically
|
||||
- ``vault_removed``: vault removed dynamically
|
||||
"""
|
||||
queue = await sse_manager.connect()
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
# Send initial connection event
|
||||
yield f"event: connected\ndata: {_json.dumps({'sse_clients': sse_manager.client_count})}\n\n"
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||
yield f"event: {msg['event']}\ndata: {msg['data']}\n\n"
|
||||
except asyncio.TimeoutError:
|
||||
# Keepalive comment
|
||||
yield ": keepalive\n\n"
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
finally:
|
||||
sse_manager.disconnect(queue)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.websocket("/ws/collab/{vault_name}/{path:path}")
|
||||
async def collab_websocket(websocket: WebSocket, vault_name: str, path: str):
|
||||
"""Real-time collaborative editing over WebSocket (ROADMAP #62).
|
||||
|
||||
One *room* is created per ``vault::path``; all clients editing the same
|
||||
file share Yjs/CRDT updates, awareness (cursors/selection) and a debounced
|
||||
server-side persistence of the markdown content.
|
||||
|
||||
Authentication is performed manually (FastAPI ``Depends`` do not run for
|
||||
WebSocket routes) and vault access is enforced per connection.
|
||||
"""
|
||||
from backend.services.errors import ServiceError
|
||||
|
||||
user = authenticate_websocket(websocket)
|
||||
if user is None:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
|
||||
if not check_vault_access(vault_name, user):
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
try:
|
||||
vault_root = get_vault_root(vault_name)
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
except ServiceError:
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
await collab_manager.connect(websocket, vault_name, path, file_path, user)
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Scheduled tasks endpoints (#170).
|
||||
|
||||
Tasks reuse the existing mutation/notification services — this router only
|
||||
validates, persists and triggers. File-writing actions check vault access
|
||||
at creation time; the background tick re-checks nothing (system context) but
|
||||
records failures and notifies on ``schedule_failure`` (#168).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from backend import scheduler as _scheduler
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.schemas import StatusResponse
|
||||
|
||||
router = APIRouter(prefix="/api/scheduler", tags=["scheduler"])
|
||||
|
||||
|
||||
class ScheduledTask(BaseModel):
|
||||
"""A programmed automatic task."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
id: str = Field(description="Task id")
|
||||
name: str = Field(description="Display name")
|
||||
action: dict[str, Any] = Field(description="{kind, params}")
|
||||
schedule: dict[str, Any] = Field(description="{kind, ...}")
|
||||
enabled: bool = Field(description="Whether the tick executes it")
|
||||
created_by: str = Field(description="Owner username")
|
||||
created_at: str = Field(description="ISO-8601 creation time")
|
||||
last_run_at: str | None = Field(default=None)
|
||||
last_status: str | None = Field(default=None)
|
||||
last_error: str | None = Field(default=None)
|
||||
run_count: int = Field(default=0)
|
||||
next_run_at: str = Field(description="ISO-8601 next due time")
|
||||
|
||||
|
||||
class TaskRunResult(BaseModel):
|
||||
"""Outcome of a manual or due run."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
task_id: str = Field(description="Task id")
|
||||
ok: bool = Field(description="True on success")
|
||||
result: dict[str, Any] | None = Field(default=None)
|
||||
error: str | None = Field(default=None)
|
||||
|
||||
|
||||
def _check_action_vault(action: dict[str, Any], user: dict[str, Any]) -> None:
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.vaults import get_vault_root
|
||||
|
||||
kind = (action or {}).get("kind")
|
||||
params = (action or {}).get("params") or {}
|
||||
if kind in ("create_file", "append_to_file"):
|
||||
vault = str(params.get("vault") or "")
|
||||
if not check_vault_access(vault, user):
|
||||
raise HTTPException(403, f"No access to vault '{vault}'")
|
||||
try:
|
||||
get_vault_root(vault)
|
||||
except ServiceError as e:
|
||||
raise HTTPException(404, f"Unknown vault '{vault}'") from e
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=list[ScheduledTask])
|
||||
async def api_scheduler_list(current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""List scheduled tasks (newest first)."""
|
||||
return _scheduler.list_tasks()
|
||||
|
||||
|
||||
@router.post("/tasks", response_model=ScheduledTask)
|
||||
async def api_scheduler_create(body: dict = Body(...), current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Create a task (``{name, action, schedule, enabled?}``)."""
|
||||
action = dict(body.get("action") or {})
|
||||
_check_action_vault(action, current_user)
|
||||
try:
|
||||
return _scheduler.create_task(
|
||||
str(body.get("name") or ""),
|
||||
action,
|
||||
dict(body.get("schedule") or {}),
|
||||
created_by=str(current_user.get("username", "api")),
|
||||
enabled=bool(body.get("enabled", True)),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.patch("/tasks/{task_id}", response_model=ScheduledTask)
|
||||
async def api_scheduler_update(task_id: str, body: dict = Body(...), current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Update a task (name / enabled / action / schedule)."""
|
||||
if "action" in body:
|
||||
_check_action_vault(dict(body["action"] or {}), current_user)
|
||||
try:
|
||||
result = _scheduler.update_task(task_id, body)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
if result is None:
|
||||
raise HTTPException(404, "Task not found")
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/tasks/{task_id}", response_model=StatusResponse)
|
||||
async def api_scheduler_delete(task_id: str, current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Delete a task."""
|
||||
if not _scheduler.delete_task(task_id):
|
||||
raise HTTPException(404, "Task not found")
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/run", response_model=TaskRunResult)
|
||||
async def api_scheduler_run(task_id: str, current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Execute a task immediately (manual run)."""
|
||||
try:
|
||||
return _scheduler.run_task(task_id, manual=True)
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Task not found") from None
|
||||
@@ -0,0 +1,361 @@
|
||||
"""Search, suggest, graph & index-reload endpoints (ROADMAP #85, tranche 5).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins, mêmes modèles de réponse (déménagés dans
|
||||
:mod:`backend.schemas`), mêmes dépendances d'authentification. La logique
|
||||
métier vit déjà dans :mod:`backend.services.search`,
|
||||
:mod:`backend.search`, :mod:`backend.services.graph` et
|
||||
:mod:`backend.services.mutations`.
|
||||
|
||||
Adaptations strictement équivalentes :
|
||||
- Le pool ``_search_executor`` de ``main`` vit désormais dans
|
||||
:mod:`backend.search_executor` (même dimensionnement, même cycle de vie
|
||||
géré par le lifespan de ``main``) : accès via
|
||||
:func:`get_search_executor`.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
|
||||
from backend.audit import log_file_save
|
||||
from backend.auth.middleware import check_vault_access, require_admin, require_auth
|
||||
from backend.indexer import get_vault_data, reload_index, update_single_file
|
||||
from backend.schemas import (
|
||||
AdvancedSearchResponse,
|
||||
GraphResponse,
|
||||
ReloadResponse,
|
||||
ReplaceResponse,
|
||||
SearchResponse,
|
||||
SuggestResponse,
|
||||
TagsResponse,
|
||||
TagSuggestResponse,
|
||||
TreeSearchResponse,
|
||||
VaultPathsResponse,
|
||||
VaultStatsResponse,
|
||||
)
|
||||
from backend.search import suggest_tags, suggest_titles
|
||||
from backend.search_executor import get_search_executor
|
||||
from backend.services.graph import get_graph as service_get_graph
|
||||
from backend.services.mutations import (
|
||||
replace_in_files as service_replace_in_files,
|
||||
)
|
||||
from backend.services.search import advanced_search_vaults, list_paths, search_paths, search_vaults
|
||||
from backend.services.search import list_tags as service_list_tags
|
||||
from backend.sse import sse_manager
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
router = APIRouter(tags=["search"])
|
||||
|
||||
|
||||
@router.get("/api/search", response_model=SearchResponse)
|
||||
async def api_search(
|
||||
q: str = Query("", description="Search query"),
|
||||
vault: str = Query("all", description="Vault filter"),
|
||||
tag: str | None = Query(None, description="Tag filter"),
|
||||
limit: int = Query(50, ge=1, le=200, description="Results per page"),
|
||||
offset: int = Query(0, ge=0, description="Pagination offset"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Full-text search across vaults with relevance scoring.
|
||||
|
||||
Supports combining free-text queries with tag filters.
|
||||
Results are ranked by a multi-factor scoring algorithm.
|
||||
Pagination via ``limit`` and ``offset`` (defaults preserve backward compat).
|
||||
|
||||
Args:
|
||||
q: Free-text search string.
|
||||
vault: Vault name or ``"all"`` to search everywhere.
|
||||
tag: Comma-separated tag names to require.
|
||||
limit: Max results per page (1–200).
|
||||
offset: Pagination offset.
|
||||
|
||||
Returns:
|
||||
``SearchResponse`` with ranked results and snippets.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
# Fetch the full result set (capped at DEFAULT_SEARCH_LIMIT internally) and
|
||||
# paginate in the shared service so routes and tools share the same logic.
|
||||
return await loop.run_in_executor(
|
||||
get_search_executor(),
|
||||
# #194 : filtre par vault accessible AVANT pagination ("*" sans les
|
||||
# dossiers persos) — sinon un user voyait les notes des autres.
|
||||
# #196 : les documents reçus par partage dirigé sont ajoutés aux
|
||||
# résultats (vault virtuel "home-<user>/Partage").
|
||||
partial(
|
||||
search_vaults, q, vault, tag, limit, offset,
|
||||
is_allowed=lambda v: check_vault_access(v, current_user),
|
||||
username=current_user["username"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/tags", response_model=TagsResponse)
|
||||
async def api_tags(vault: str | None = Query(None, description="Vault filter"), current_user=Depends(require_auth)):
|
||||
"""Return all unique tags with occurrence counts.
|
||||
|
||||
Args:
|
||||
vault: Optional vault name to restrict tag aggregation.
|
||||
|
||||
Returns:
|
||||
``TagsResponse`` with tags sorted by descending count.
|
||||
"""
|
||||
return {"vault_filter": vault, "tags": service_list_tags(vault)}
|
||||
|
||||
|
||||
@router.get("/api/tree-search", response_model=TreeSearchResponse)
|
||||
async def api_tree_search(
|
||||
q: str = Query("", description="Search query"),
|
||||
vault: str = Query("all", description="Vault filter"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Search for files and directories in the tree structure using pre-built index.
|
||||
|
||||
Uses the in-memory path index for instant filtering without filesystem access.
|
||||
|
||||
Args:
|
||||
q: Search string to match against file/directory paths.
|
||||
vault: Vault name or "all" to search everywhere.
|
||||
|
||||
Returns:
|
||||
``TreeSearchResponse`` with matching paths.
|
||||
"""
|
||||
return search_paths(q, vault)
|
||||
|
||||
|
||||
@router.get("/api/vault/{vault_name}/paths", response_model=VaultPathsResponse)
|
||||
async def api_vault_paths(
|
||||
vault_name: str,
|
||||
limit: int = Query(5000, ge=1, le=20000, description="Maximum number of indexed paths to return"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return a flat list of every indexed file and directory in a vault.
|
||||
|
||||
Used by the AI assistant ``@`` mention menu to filter paths instantly on
|
||||
the client (one request instead of one per keystroke).
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
limit: Maximum number of entries returned.
|
||||
|
||||
Returns:
|
||||
``VaultPathsResponse`` with the vault's indexed paths.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
return list_paths(vault_name, limit=limit)
|
||||
|
||||
|
||||
@router.get("/api/search/advanced", response_model=AdvancedSearchResponse)
|
||||
async def api_advanced_search(
|
||||
q: str = Query("", description="Advanced search query (supports tag:, vault:, title:, path:, ext: operators)"),
|
||||
vault: str = Query("all", description="Vault filter"),
|
||||
tag: str | None = Query(None, description="Comma-separated tag filter"),
|
||||
limit: int = Query(50, ge=1, le=200, description="Results per page"),
|
||||
offset: int = Query(0, ge=0, description="Pagination offset"),
|
||||
sort: str = Query("relevance", description="Sort by 'relevance' or 'modified'"),
|
||||
case_sensitive: bool = Query(False, description="Match case"),
|
||||
whole_word: bool = Query(False, description="Match whole words only"),
|
||||
regex: bool = Query(False, description="Treat query as regex"),
|
||||
include_paths: str | None = Query(None, description="Comma-separated glob patterns to include"),
|
||||
exclude_paths: str | None = Query(None, description="Comma-separated glob patterns to exclude"),
|
||||
created: str | None = Query(None, description="Created date filter (>date, <date, date..date)"),
|
||||
modified: str | None = Query(None, description="Modified date filter (>date, <date, date..date, <Nd)"),
|
||||
size: str | None = Query(None, description="Size filter (>size, <size, size..size, e.g. >1MB, <10KB)"),
|
||||
semantic: bool = Query(False, description="Fuse TF-IDF with semantic embeddings (RRF)"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Advanced full-text search with TF-IDF scoring, facets, and pagination.
|
||||
|
||||
Supports advanced query operators:
|
||||
- ``tag:<name>`` or ``#<name>`` — filter by tag
|
||||
- ``vault:<name>`` — filter by vault
|
||||
- ``title:<text>`` — filter by title substring
|
||||
- ``path:<text>`` — filter by path substring
|
||||
- ``ext:<type>`` — filter by file extension
|
||||
- ``created:>2024-01-01`` — filter by creation date
|
||||
- ``modified:<7d`` or ``modified:2024-01-01..2024-06-01`` — filter by modification date
|
||||
- ``size:>1MB`` or ``size:100KB..1MB`` — filter by file size
|
||||
- Remaining text is scored using TF-IDF with accent normalization.
|
||||
- Toggles: case_sensitive, whole_word, regex
|
||||
- Path filters: include_paths, exclude_paths (glob patterns)
|
||||
- ``semantic=true`` — fuse the TF-IDF ranking with the semantic (embedding)
|
||||
ranking via Reciprocal Rank Fusion and expose ``semantic_score`` per result.
|
||||
|
||||
Results include ``<mark>``-highlighted snippets and faceted tag/vault counts.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
search_fn = partial(advanced_search_vaults, q, vault=vault, tag=tag,
|
||||
limit=limit, offset=offset, sort=sort,
|
||||
case_sensitive=case_sensitive, whole_word=whole_word, regex=regex,
|
||||
include_paths=include_paths, exclude_paths=exclude_paths,
|
||||
created=created, modified=modified, size=size, semantic=semantic)
|
||||
try:
|
||||
return await loop.run_in_executor(get_search_executor(), search_fn)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/api/search/replace", response_model=ReplaceResponse)
|
||||
async def api_search_replace(
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Find and replace across vault files."""
|
||||
query = body.get("query", "")
|
||||
replacement = body.get("replacement", "")
|
||||
vault_filter = body.get("vault", "all")
|
||||
case_sensitive = body.get("case_sensitive", False)
|
||||
whole_word = body.get("whole_word", False)
|
||||
regex_mode = body.get("regex", False)
|
||||
include_paths = body.get("include_paths")
|
||||
exclude_paths = body.get("exclude_paths")
|
||||
replace_all = body.get("replace_all", False)
|
||||
dry_run = body.get("dry_run", not replace_all)
|
||||
|
||||
if not query:
|
||||
raise HTTPException(400, "Query is required")
|
||||
|
||||
result = service_replace_in_files(
|
||||
query,
|
||||
replacement,
|
||||
vault=vault_filter,
|
||||
case_sensitive=case_sensitive,
|
||||
whole_word=whole_word,
|
||||
regex=regex_mode,
|
||||
include_paths=include_paths,
|
||||
exclude_paths=exclude_paths,
|
||||
replace_all=replace_all,
|
||||
dry_run=dry_run,
|
||||
is_vault_allowed=lambda v: check_vault_access(v, current_user),
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
return result
|
||||
|
||||
# Side effects for applied replacements (audit + incremental index).
|
||||
for match in result.get("replaced", []):
|
||||
log_file_save(current_user["username"], match["vault"], match["path"], match.get("size", 0))
|
||||
vault_data = get_vault_data(match["vault"])
|
||||
if vault_data:
|
||||
abs_path = str(Path(vault_data["path"]) / match["path"])
|
||||
await update_single_file(match["vault"], abs_path)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/api/suggest", response_model=SuggestResponse)
|
||||
async def api_suggest(
|
||||
q: str = Query("", description="Prefix to search for in file titles"),
|
||||
vault: str = Query("all", description="Vault filter"),
|
||||
limit: int = Query(10, ge=1, le=50, description="Max suggestions"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Suggest file titles matching a prefix (accent-insensitive).
|
||||
|
||||
Used for autocomplete in the search input.
|
||||
|
||||
Args:
|
||||
q: User-typed prefix (minimum 2 characters).
|
||||
vault: Vault name or ``"all"``.
|
||||
limit: Max number of suggestions.
|
||||
|
||||
Returns:
|
||||
``SuggestResponse`` with matching file title suggestions.
|
||||
"""
|
||||
suggestions = suggest_titles(q, vault_filter=vault, limit=limit)
|
||||
return {"query": q, "suggestions": suggestions}
|
||||
|
||||
|
||||
@router.get("/api/tags/suggest", response_model=TagSuggestResponse)
|
||||
async def api_tags_suggest(
|
||||
q: str = Query("", description="Prefix to search for in tags"),
|
||||
vault: str = Query("all", description="Vault filter"),
|
||||
limit: int = Query(10, ge=1, le=50, description="Max suggestions"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Suggest tags matching a prefix (accent-insensitive).
|
||||
|
||||
Used for autocomplete when typing ``tag:`` or ``#`` in the search input.
|
||||
|
||||
Args:
|
||||
q: User-typed prefix (with or without ``#``, minimum 2 characters).
|
||||
vault: Vault name or ``"all"``.
|
||||
limit: Max number of suggestions.
|
||||
|
||||
Returns:
|
||||
``TagSuggestResponse`` with matching tag suggestions and counts.
|
||||
"""
|
||||
suggestions = suggest_tags(q, vault_filter=vault, limit=limit)
|
||||
return {"query": q, "suggestions": suggestions}
|
||||
|
||||
|
||||
@router.get("/api/index/reload", response_model=ReloadResponse)
|
||||
async def api_reload(current_user=Depends(require_admin)):
|
||||
"""Force a full re-index of all configured vaults.
|
||||
|
||||
Returns:
|
||||
``ReloadResponse`` with per-vault file and tag counts.
|
||||
"""
|
||||
stats = await reload_index()
|
||||
await sse_manager.broadcast("index_reloaded", {
|
||||
"vaults": list(stats.keys()),
|
||||
"stats": stats,
|
||||
})
|
||||
return {"status": "ok", "vaults": stats}
|
||||
|
||||
|
||||
@router.get("/api/graph/{vault_name}", response_model=GraphResponse)
|
||||
async def api_graph(
|
||||
vault_name: str,
|
||||
path: str = Query("", description="Relative path to focus on"),
|
||||
depth: int = Query(1, ge=0, le=3, description="How many levels deep to expand"),
|
||||
scope: str = Query("directory", description="'directory' (default) or 'full' for entire vault"),
|
||||
tag: str = Query("", description="Filter: only show files with this tag"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return graph data (nodes and edges) for a vault or directory.
|
||||
|
||||
Nodes represent files and directories. Edges represent parent-child
|
||||
relationships and wikilinks between markdown files.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative directory path to focus on (empty = root).
|
||||
depth: Expansion depth (0 = only direct children, 1-3 = deeper).
|
||||
scope: 'directory' for subtree, 'full' for entire vault.
|
||||
tag: Optional tag filter (only files with this tag appear).
|
||||
|
||||
Returns:
|
||||
``GraphResponse`` with nodes and edges.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
return service_get_graph(vault_name, path=path, depth=depth, scope=scope, tag=tag)
|
||||
|
||||
|
||||
@router.get("/api/index/reload/{vault_name}", response_model=VaultStatsResponse)
|
||||
async def api_reload_vault(vault_name: str, current_user=Depends(require_admin)):
|
||||
"""Force a re-index of a single vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault to reindex.
|
||||
|
||||
Returns:
|
||||
Dict with vault statistics.
|
||||
"""
|
||||
try:
|
||||
from backend.indexer import reload_single_vault
|
||||
stats = await reload_single_vault(vault_name)
|
||||
await sse_manager.broadcast("vault_reloaded", {
|
||||
"vault": vault_name,
|
||||
"stats": stats,
|
||||
})
|
||||
return {"status": "ok", "vault": vault_name, "stats": stats}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Public share endpoints (ROADMAP #85, tranche 3).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/share/*``, ``/api/shares``,
|
||||
``/s/{token}*``), mêmes modèles de réponse, mêmes dépendances
|
||||
d'authentification (les pages ``/s/*`` restent publiques). La logique
|
||||
métier vit déjà dans :mod:`backend.share`.
|
||||
|
||||
Adaptations strictement équivalentes (pas de changement de comportement) :
|
||||
- ``_resolve_safe_path`` / ``_backup_file`` de ``main`` n'étaient que des
|
||||
wrappers directs : appelés ici via :mod:`backend.services.paths` et
|
||||
:mod:`backend.services.backups` (mêmes signatures, mêmes exceptions
|
||||
``ServiceError`` toujours mappées par le handler global de ``main``).
|
||||
- ``_render_markdown`` vient de :mod:`backend.render` (#85 T9, sans cycle
|
||||
d'import).
|
||||
"""
|
||||
|
||||
import html as html_mod
|
||||
import json as _json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import frontmatter
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
|
||||
from backend.auth.middleware import check_vault_access, get_current_user, require_auth
|
||||
from backend.auth.user_store import get_user
|
||||
from backend.indexer import get_vault_data, parse_markdown_file, update_single_file
|
||||
from backend.render import _render_markdown
|
||||
from backend.schemas import ShareModel, StatusResponse
|
||||
from backend.secret_redactor import redact_file_content
|
||||
from backend.services.backups import create_backup
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.share import (
|
||||
create_share,
|
||||
get_share_by_token,
|
||||
list_shares,
|
||||
record_access,
|
||||
revoke_share,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
# Lazy import: WeasyPrint PDF export (requires GTK, may not be available everywhere)
|
||||
try:
|
||||
from backend.pdf_export import build_pdf_html, generate_pdf
|
||||
except Exception: # pragma: no cover - WeasyPrint/GTK missing
|
||||
generate_pdf = None # type: ignore[assignment]
|
||||
build_pdf_html = None # type: ignore[assignment]
|
||||
|
||||
logging.getLogger("obsigate").warning("PDF export unavailable (WeasyPrint/GTK not found)")
|
||||
|
||||
router = APIRouter(tags=["sharing"])
|
||||
|
||||
|
||||
def _gate_share(share: dict | None, user: dict | None) -> dict:
|
||||
"""#196 — directed shares: auth + membership check for ``/s/*`` pages.
|
||||
|
||||
Public share (``shared_with`` empty) → pass-through, no auth required.
|
||||
Directed share → requires an authenticated user who is the creator, an
|
||||
admin, or listed in ``shared_with``. Anything else answers **404** (never
|
||||
403) so an outsider cannot learn that a token exists.
|
||||
"""
|
||||
if share is None:
|
||||
raise HTTPException(404, "Share not found or expired")
|
||||
recipients = share.get("shared_with") or []
|
||||
if not recipients:
|
||||
return share
|
||||
if user is None:
|
||||
raise HTTPException(404, "Share not found or expired")
|
||||
if user["username"] == share.get("created_by") or user.get("role") == "admin" \
|
||||
or user["username"] in recipients:
|
||||
return share
|
||||
raise HTTPException(404, "Share not found or expired")
|
||||
|
||||
|
||||
@router.post("/api/share/{vault_name}", response_model=ShareModel)
|
||||
async def api_share_create(
|
||||
vault_name: str,
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Create a public share link for a document.
|
||||
|
||||
Also sets ``publish: true`` in the file's YAML frontmatter so the
|
||||
frontend can visually indicate the file is publicly shared.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
|
||||
path = body.get("path") or ""
|
||||
if not path:
|
||||
raise HTTPException(400, "Chemin de fichier requis")
|
||||
expires = body.get("expires_in_hours")
|
||||
# #196 — directed share: validate recipients before creating anything.
|
||||
recipients = body.get("shared_with") or []
|
||||
if not isinstance(recipients, list):
|
||||
raise HTTPException(400, "shared_with doit être une liste d'utilisateurs")
|
||||
for name in recipients:
|
||||
if not isinstance(name, str) or not get_user(name):
|
||||
raise HTTPException(400, f"Utilisateur inconnu : {name}")
|
||||
share = create_share(vault_name, path, current_user["username"], expires, recipients)
|
||||
share["url"] = f"/s/{share['token']}"
|
||||
|
||||
# Set publish: true in the file's frontmatter
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if vault_data:
|
||||
file_path = resolve_safe_path(Path(vault_data["path"]), path)
|
||||
if file_path.exists() and file_path.suffix == ".md":
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
post = frontmatter.loads(raw)
|
||||
if not post.metadata.get("publish"):
|
||||
post.metadata["publish"] = True
|
||||
new_raw = frontmatter.dumps(post)
|
||||
create_backup(file_path, vault_name, path)
|
||||
file_path.write_text(new_raw, encoding="utf-8")
|
||||
await update_single_file(vault_name, str(file_path))
|
||||
logger.info(f"Set publish:true on {vault_name}/{path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to set publish metadata on {vault_name}/{path}: {e}")
|
||||
|
||||
return share
|
||||
|
||||
|
||||
@router.get("/api/shares", response_model=list[ShareModel])
|
||||
async def api_shares_list(vault: str | None = Query(None), current_user=Depends(require_auth)):
|
||||
"""List shares (optionally filtered by vault).
|
||||
|
||||
#196 : un non-admin ne voit que SES partages (créés ou reçus) ; un admin
|
||||
voit tout (comportement historique).
|
||||
"""
|
||||
username = None if current_user.get("role") == "admin" else current_user["username"]
|
||||
shares = list_shares(vault, username)
|
||||
for s in shares:
|
||||
s["url"] = f"/s/{s['token']}"
|
||||
return shares
|
||||
|
||||
|
||||
@router.delete("/api/share/{share_id}", response_model=StatusResponse)
|
||||
async def api_share_revoke(share_id: str, current_user=Depends(require_auth)):
|
||||
# #196 — only the creator or an admin may revoke; a recipient cannot.
|
||||
from backend.share import _read
|
||||
share = _read()["shares"].get(share_id)
|
||||
if share and current_user.get("role") != "admin" \
|
||||
and share.get("created_by") != current_user["username"]:
|
||||
raise HTTPException(403, "Seul le créateur du partage peut le révoquer")
|
||||
if not revoke_share(share_id):
|
||||
raise HTTPException(404, "Share not found")
|
||||
return {"status": "revoked"}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/s/{token}/pdf",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/pdf": {}}, "description": "Shared document as PDF"}},
|
||||
)
|
||||
async def public_share_pdf_download(token: str, current_user=Depends(get_current_user)):
|
||||
"""Download shared document as real PDF via WeasyPrint."""
|
||||
if generate_pdf is None:
|
||||
raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)")
|
||||
share = get_share_by_token(token)
|
||||
share = _gate_share(share, current_user)
|
||||
vault_data = get_vault_data(share["vault"])
|
||||
if not vault_data:
|
||||
raise HTTPException(404, "Vault not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, share["path"])
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
raise HTTPException(500, "Cannot read file")
|
||||
record_access(token)
|
||||
raw = redact_file_content(raw, str(file_path))
|
||||
post = parse_markdown_file(raw)
|
||||
ext = file_path.suffix.lower()
|
||||
if ext == ".md":
|
||||
html = _render_markdown(post.content, share["vault"], file_path)
|
||||
else:
|
||||
html = f'<pre style="font-family:monospace;font-size:12px;line-height:1.6;white-space:pre-wrap">{html_mod.escape(raw)}</pre>'
|
||||
title = post.metadata.get("title", file_path.stem)
|
||||
pdf_html = build_pdf_html(html, str(title))
|
||||
pdf_bytes = generate_pdf(pdf_html, str(title))
|
||||
safe_name = "".join(c for c in str(title) if c.isascii() and (c.isalnum() or c in " _-.")).strip() or "document"
|
||||
return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{safe_name}.pdf"'})
|
||||
|
||||
|
||||
@router.get("/s/{token}/raw", response_class=FileResponse)
|
||||
async def public_share_raw(token: str, current_user=Depends(get_current_user)):
|
||||
"""Download the raw (original) shared document."""
|
||||
share = get_share_by_token(token)
|
||||
share = _gate_share(share, current_user)
|
||||
vault_data = get_vault_data(share["vault"])
|
||||
if not vault_data:
|
||||
raise HTTPException(404, "Vault not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, share["path"])
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
record_access(token)
|
||||
return FileResponse(path=str(file_path), filename=file_path.name, media_type="application/octet-stream")
|
||||
|
||||
|
||||
@router.get("/s/{token}", response_class=HTMLResponse)
|
||||
async def public_share_view(request: Request, token: str, current_user=Depends(get_current_user)):
|
||||
"""Public share view — no authentication required (unless directed #196)."""
|
||||
from backend.csp import inject_csp_nonce
|
||||
|
||||
share = get_share_by_token(token)
|
||||
share = _gate_share(share, current_user)
|
||||
vault_data = get_vault_data(share["vault"])
|
||||
if not vault_data:
|
||||
raise HTTPException(404, "Vault not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, share["path"])
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
raise HTTPException(500, "Cannot read file")
|
||||
record_access(token)
|
||||
raw = redact_file_content(raw, str(file_path))
|
||||
post = parse_markdown_file(raw)
|
||||
ext = file_path.suffix.lower()
|
||||
|
||||
if ext == ".md":
|
||||
html = _render_markdown(post.content, share["vault"], file_path)
|
||||
else:
|
||||
escaped = html_mod.escape(raw)
|
||||
html = f'<pre style="background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:16px;overflow-x:auto;font-size:0.85rem;line-height:1.6"><code>{escaped}</code></pre>'
|
||||
|
||||
title = post.metadata.get("title", file_path.stem)
|
||||
|
||||
# Escape everything user-controlled before embedding in HTML/JS (BUG-022).
|
||||
title_esc = html_mod.escape(str(title))
|
||||
# Neutralise ``</script>`` in the JS string literal too.
|
||||
title_download_js = (
|
||||
_json.dumps(f"{title}.md")
|
||||
.replace("<", "\\u003c")
|
||||
.replace(">", "\\u003e")
|
||||
.replace("&", "\\u0026")
|
||||
)
|
||||
|
||||
# JSON-escape raw content for embedding in HTML, and neutralise ``</script>``.
|
||||
raw_json = (
|
||||
_json.dumps(raw)
|
||||
.replace("<", "\\u003c")
|
||||
.replace(">", "\\u003e")
|
||||
.replace("&", "\\u0026")
|
||||
)
|
||||
fm_html = ""
|
||||
if post.metadata:
|
||||
fm_items = []
|
||||
skip_keys = {"title", "titre"}
|
||||
for k, v in post.metadata.items():
|
||||
if k in skip_keys:
|
||||
continue
|
||||
if isinstance(v, list):
|
||||
v = ", ".join(str(x) for x in v)
|
||||
elif isinstance(v, bool):
|
||||
v = "✓" if v else "✗"
|
||||
elif v is None:
|
||||
v = "—"
|
||||
fm_items.append(
|
||||
f'<div class="fm-row"><span class="fm-key">{html_mod.escape(str(k))}</span>'
|
||||
f'<span class="fm-val">{html_mod.escape(str(v))}</span></div>'
|
||||
)
|
||||
if fm_items:
|
||||
fm_html = f'<div class="fm-section"><div class="fm-header">Frontmatter</div><div class="fm-body">{"".join(fm_items)}</div></div>'
|
||||
|
||||
return HTMLResponse(
|
||||
inject_csp_nonce(
|
||||
f"""<!DOCTYPE html><html lang="fr" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{title_esc} — ObsiGate Share</title>
|
||||
<style>
|
||||
:root {{ --bg:#1a1a2e; --bg-card:#16213e; --text:#e0e0e0; --text-muted:#888; --accent:#6366f1; --border:#2a2a4a; --banner-bg:var(--accent); --banner-text:#fff; }}
|
||||
[data-theme="light"] {{ --bg:#f8f9fa; --bg-card:#fff; --text:#1a1a2e; --text-muted:#666; --accent:#4f46e5; --border:#ddd; --banner-bg:#eef2ff; --banner-text:#4338ca; }}
|
||||
*{{box-sizing:border-box;margin:0;padding:0}}
|
||||
body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);line-height:1.7;min-height:100vh}}
|
||||
.toolbar{{position:sticky;top:0;z-index:10;background:var(--bg-card);border-bottom:1px solid var(--border);padding:8px 16px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}}
|
||||
.toolbar-title{{font-weight:600;font-size:0.9rem;margin-right:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
||||
.toolbar-btn{{padding:6px 12px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);cursor:pointer;font-size:0.8rem;display:flex;align-items:center;gap:5px;transition:all .15s}}
|
||||
.toolbar-btn:hover{{background:var(--accent);color:#fff;border-color:var(--accent)}}
|
||||
.toolbar-btn svg{{width:15px;height:15px;flex-shrink:0}}
|
||||
.toolbar-btn:hover svg{{stroke:#fff}}
|
||||
.share-banner{{background:var(--banner-bg);color:var(--banner-text);padding:6px 16px;font-size:0.8rem;text-align:center;display:flex;align-items:center;justify-content:center;gap:6px}}
|
||||
.share-banner svg{{width:14px;height:14px;flex-shrink:0}}
|
||||
.content{{max-width:820px;margin:0 auto;padding:24px 20px 60px}}
|
||||
.content h1{{font-size:1.8rem;margin-bottom:16px;border-bottom:2px solid var(--border);padding-bottom:8px}}
|
||||
.content h2{{font-size:1.4rem;margin:24px 0 12px}}
|
||||
.content h3{{font-size:1.15rem;margin:20px 0 8px}}
|
||||
.content p{{margin:8px 0}}
|
||||
.content pre{{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:12px 16px;overflow-x:auto;font-size:0.85rem}}
|
||||
.content code{{font-size:0.9em;background:var(--bg-card);padding:1px 4px;border-radius:3px}}
|
||||
.content pre code{{background:none;padding:0}}
|
||||
.content a{{color:var(--accent)}}.content img{{max-width:100%;border-radius:6px}}
|
||||
.fm-section{{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:20px}}
|
||||
.fm-header{{font-weight:600;font-size:0.8rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px}}
|
||||
.fm-body{{display:grid;grid-template-columns:1fr 2fr;gap:4px 12px;font-size:0.85rem}}
|
||||
.fm-row{{display:contents}}
|
||||
.fm-key{{color:var(--accent);font-weight:500}}
|
||||
.fm-val{{color:var(--text);word-break:break-word}}
|
||||
.content blockquote{{border-left:3px solid var(--accent);padding-left:16px;color:var(--text-muted);margin:12px 0}}
|
||||
.content table{{border-collapse:collapse;width:100%;margin:12px 0}}
|
||||
.content th,.content td{{border:1px solid var(--border);padding:8px 12px;text-align:left}}
|
||||
.content th{{background:var(--bg-card)}}
|
||||
@media print{{.toolbar,.share-banner{{display:none}}body{{background:#fff;color:#000}}}}
|
||||
@media(max-width:600px){{.content{{padding:16px 12px 40px}}.toolbar{{gap:4px}}.toolbar-btn{{padding:4px 8px;font-size:0.7rem}}}}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="share-banner">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
Document partagé via ObsiGate
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<span class="toolbar-title">{title_esc}</span>
|
||||
<button class="toolbar-btn" data-share-theme title="Thème clair/sombre">
|
||||
<svg id="theme-icon-dark" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
<svg id="theme-icon-light" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||
</button>
|
||||
<button class="toolbar-btn" data-share-md title="Télécharger en Markdown">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
.md
|
||||
</button>
|
||||
<button class="toolbar-btn" data-share-pdf title="Télécharger en PDF">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
PDF
|
||||
</button>
|
||||
</div>
|
||||
<div class="content" id="content">{fm_html}{html}</div>
|
||||
<script id="raw-content" type="text/plain" style="display:none">{raw_json}</script>
|
||||
<script>
|
||||
function toggleTheme(){{var t=document.documentElement;var isDark=t.dataset.theme==="dark";t.dataset.theme=isDark?"light":"dark";document.getElementById("theme-icon-dark").style.display=isDark?"none":"";document.getElementById("theme-icon-light").style.display=isDark?"":"none";localStorage.setItem("obsigate-share-theme",t.dataset.theme)}}
|
||||
(function(){{var s=localStorage.getItem("obsigate-share-theme");if(!s)s="dark";document.documentElement.dataset.theme=s;var isDark=s==="dark";document.getElementById("theme-icon-dark").style.display=isDark?"":"none";document.getElementById("theme-icon-light").style.display=isDark?"none":""}})();
|
||||
function exportMD(){{var raw=JSON.parse(document.getElementById("raw-content").textContent);var b=new Blob([raw],{{type:"text/markdown"}});var a=document.createElement("a");a.href=URL.createObjectURL(b);a.download={title_download_js};a.click()}}
|
||||
document.querySelector("[data-share-theme]").addEventListener("click",toggleTheme);
|
||||
document.querySelector("[data-share-md]").addEventListener("click",exportMD);
|
||||
document.querySelector("[data-share-pdf]").addEventListener("click",function(){{location.href=location.pathname+"/pdf"}});
|
||||
</script></body></html>""",
|
||||
request.state.csp_nonce,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Vault management endpoints (ROADMAP #85, tranche 8).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/vaults*``), mêmes modèles de réponse
|
||||
(``VaultInfo`` déménagé dans :mod:`backend.schemas`), mêmes dépendances
|
||||
d'authentification.
|
||||
|
||||
Le handle du file-watcher vit désormais dans :mod:`backend.watcher_state`
|
||||
(partagé avec le lifespan de ``main``) au lieu du global de ``main``.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
|
||||
from backend.auth.middleware import require_admin, require_auth
|
||||
from backend.indexer import add_vault_to_index, index, remove_vault_from_index
|
||||
from backend.schemas import VaultActionResponse, VaultInfo, VaultsStatusResponse, VaultStatsResponse
|
||||
from backend.services.vaults import list_accessible_vaults
|
||||
from backend.sse import sse_manager
|
||||
from backend.watcher_state import get_watcher
|
||||
|
||||
router = APIRouter(tags=["vaults"])
|
||||
|
||||
|
||||
@router.get("/api/vaults", response_model=list[VaultInfo])
|
||||
async def api_vaults(current_user=Depends(require_auth)):
|
||||
"""List configured vaults the user has access to.
|
||||
|
||||
Returns:
|
||||
List of vault summary objects filtered by user permissions.
|
||||
"""
|
||||
return list_accessible_vaults(current_user)
|
||||
|
||||
|
||||
@router.post("/api/vaults/add", response_model=VaultStatsResponse)
|
||||
async def api_add_vault(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Add a new vault dynamically without restarting.
|
||||
|
||||
Body:
|
||||
name: Display name for the vault.
|
||||
path: Absolute filesystem path to the vault directory.
|
||||
"""
|
||||
name = body.get("name", "").strip()
|
||||
vault_path = body.get("path", "").strip()
|
||||
|
||||
if not name or not vault_path:
|
||||
raise HTTPException(status_code=400, detail="Both 'name' and 'path' are required")
|
||||
|
||||
if name in index:
|
||||
raise HTTPException(status_code=409, detail=f"Vault '{name}' already exists")
|
||||
|
||||
if not Path(vault_path).exists():
|
||||
raise HTTPException(status_code=400, detail=f"Path does not exist: {vault_path}")
|
||||
|
||||
stats = await add_vault_to_index(name, vault_path)
|
||||
|
||||
# #194 : persister, sinon ce vault disparaît au prochain rebuild/redémarrage.
|
||||
from backend.indexer import persist_vault
|
||||
|
||||
persist_vault(name)
|
||||
|
||||
# Start watching the new vault
|
||||
watcher = get_watcher()
|
||||
if watcher:
|
||||
await watcher.add_vault(name, vault_path)
|
||||
|
||||
await sse_manager.broadcast("vault_added", {"vault": name, "stats": stats})
|
||||
return {"status": "ok", "vault": name, "stats": stats}
|
||||
|
||||
|
||||
@router.delete("/api/vaults/{vault_name}", response_model=VaultActionResponse)
|
||||
async def api_remove_vault(vault_name: str, current_user=Depends(require_admin)):
|
||||
"""Remove a vault from the index and stop watching it.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault to remove.
|
||||
"""
|
||||
if vault_name not in index:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
# Stop watching
|
||||
watcher = get_watcher()
|
||||
if watcher:
|
||||
await watcher.remove_vault(vault_name)
|
||||
|
||||
await remove_vault_from_index(vault_name)
|
||||
# #194 : plus de trace au redémarrage (les vaults d'env, eux, reviennent).
|
||||
from backend.indexer import unpersist_vault
|
||||
|
||||
unpersist_vault(vault_name)
|
||||
await sse_manager.broadcast("vault_removed", {"vault": vault_name})
|
||||
return {"status": "ok", "vault": vault_name}
|
||||
|
||||
|
||||
@router.get("/api/vaults/status", response_model=VaultsStatusResponse)
|
||||
async def api_vaults_status(current_user=Depends(require_auth)):
|
||||
"""Detailed status of all vaults including watcher state.
|
||||
|
||||
Returns per-vault: file count, tag count, watching status, vault path.
|
||||
"""
|
||||
watcher = get_watcher()
|
||||
statuses = {}
|
||||
for vname, vdata in index.items():
|
||||
watching = watcher is not None and vname in watcher.observers
|
||||
statuses[vname] = {
|
||||
"file_count": len(vdata.get("files", [])),
|
||||
"tag_count": len(vdata.get("tags", {})),
|
||||
"path": vdata.get("path", ""),
|
||||
"watching": watching,
|
||||
}
|
||||
return {
|
||||
"vaults": statuses,
|
||||
"watcher_active": watcher is not None,
|
||||
"sse_clients": sse_manager.client_count,
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Webhook CRUD endpoints (ROADMAP #85, tranche 2).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/webhooks``), même modèle de réponse
|
||||
(:class:`backend.schemas.WebhookModel`), même dépendance admin. La logique
|
||||
métier vit déjà dans :mod:`backend.webhooks` (validation d'URL anti-SSRF,
|
||||
store ``webhook_secrets.json`` — BUG-026).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
|
||||
from backend.auth.middleware import require_admin
|
||||
from backend.schemas import StatusResponse, WebhookModel
|
||||
from backend.webhooks import (
|
||||
create_webhook,
|
||||
delete_webhook,
|
||||
get_webhooks,
|
||||
update_webhook,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[WebhookModel])
|
||||
async def api_webhooks_list(current_user=Depends(require_admin)):
|
||||
return get_webhooks()
|
||||
|
||||
|
||||
@router.post("", response_model=WebhookModel)
|
||||
async def api_webhooks_create(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
name = body.get("name", "Unnamed")
|
||||
url = body.get("url", "")
|
||||
events = body.get("events", [])
|
||||
secret = body.get("secret")
|
||||
if not url:
|
||||
raise HTTPException(400, "URL is required")
|
||||
# #198 : filtres optionnels vault / path_prefix (None = tout recevoir).
|
||||
return create_webhook(name, url, events, secret,
|
||||
vault=body.get("vault"), path_prefix=body.get("path_prefix"))
|
||||
|
||||
|
||||
@router.patch("/{webhook_id}", response_model=WebhookModel)
|
||||
async def api_webhooks_update(
|
||||
webhook_id: str, body: dict = Body(...), current_user=Depends(require_admin)
|
||||
):
|
||||
result = update_webhook(webhook_id, body)
|
||||
if not result:
|
||||
raise HTTPException(404, "Webhook not found")
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{webhook_id}", response_model=StatusResponse)
|
||||
async def api_webhooks_delete(webhook_id: str, current_user=Depends(require_admin)):
|
||||
if not delete_webhook(webhook_id):
|
||||
raise HTTPException(404, "Webhook not found")
|
||||
return {"status": "deleted"}
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Scheduled tasks — automatic agent actions, type cron (#170).
|
||||
|
||||
Tasks are persisted in ``data/scheduled_tasks.json`` (guarded by an RLock,
|
||||
same pattern as the other JSON stores). Supported actions reuse the existing
|
||||
mutation/notification services — no new write path:
|
||||
|
||||
* ``create_file`` → ``backend.services.mutations.create_file``;
|
||||
* ``append_to_file`` → ``backend.services.mutations.append_to_file``;
|
||||
* ``notify`` → ``backend.notify.broadcast`` (trigger ``manual``).
|
||||
|
||||
Supported schedules:
|
||||
|
||||
* ``interval_hours`` — every N hours (N >= 0.25);
|
||||
* ``daily_time`` — once a day at ``HH:MM`` (local server time);
|
||||
* ``once_at`` — one shot at an ISO-8601 datetime (past = due immediately).
|
||||
|
||||
On failure the task records ``last_error`` and a ``schedule_failure``
|
||||
broadcast is emitted to the notification channels (#168) — best effort,
|
||||
never recursive (a failing ``notify`` action does not rebroadcast).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("obsigate.scheduler")
|
||||
|
||||
DATA_DIR = Path(os.environ.get("OBSIGATE_DATA_DIR", "data"))
|
||||
TASKS_FILE = DATA_DIR / "scheduled_tasks.json"
|
||||
|
||||
ACTION_KINDS = ("create_file", "append_to_file", "notify")
|
||||
SCHEDULE_KINDS = ("interval_hours", "daily_time", "once_at")
|
||||
|
||||
_lock = threading.RLock()
|
||||
|
||||
|
||||
# ── Store ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _read_tasks() -> list[dict[str, Any]]:
|
||||
if not TASKS_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(TASKS_FILE.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, list) else []
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return []
|
||||
|
||||
|
||||
def _write_tasks(tasks: list[dict[str, Any]]) -> None:
|
||||
TASKS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = TASKS_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(tasks, indent=2, default=str), encoding="utf-8")
|
||||
tmp.replace(TASKS_FILE)
|
||||
|
||||
|
||||
def list_tasks() -> list[dict[str, Any]]:
|
||||
"""Return all scheduled tasks (newest first)."""
|
||||
return sorted(_read_tasks(), key=lambda t: t.get("created_at", ""), reverse=True)
|
||||
|
||||
|
||||
def get_task(task_id: str) -> dict[str, Any] | None:
|
||||
"""Return one task by id, or None."""
|
||||
for task in _read_tasks():
|
||||
if task.get("id") == task_id:
|
||||
return task
|
||||
return None
|
||||
|
||||
|
||||
# ── Validation ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_action(action: dict[str, Any]) -> dict[str, Any]:
|
||||
kind = action.get("kind")
|
||||
if kind not in ACTION_KINDS:
|
||||
raise ValueError(f"Action inconnue : {kind} (attendu : {', '.join(ACTION_KINDS)})")
|
||||
params = dict(action.get("params") or {})
|
||||
if kind in ("create_file", "append_to_file"):
|
||||
if not str(params.get("vault") or "").strip():
|
||||
raise ValueError("params.vault requis pour create_file/append_to_file")
|
||||
if not str(params.get("path") or "").strip():
|
||||
raise ValueError("params.path requis pour create_file/append_to_file")
|
||||
if kind == "append_to_file" and not str(params.get("content") or ""):
|
||||
raise ValueError("params.content requis pour append_to_file")
|
||||
elif kind == "notify":
|
||||
if not str(params.get("title") or "").strip():
|
||||
raise ValueError("params.title requis pour notify")
|
||||
if not str(params.get("message") or "").strip():
|
||||
raise ValueError("params.message requis pour notify")
|
||||
return {"kind": kind, "params": params}
|
||||
|
||||
|
||||
def _validate_schedule(schedule: dict[str, Any]) -> dict[str, Any]:
|
||||
kind = schedule.get("kind")
|
||||
if kind not in SCHEDULE_KINDS:
|
||||
raise ValueError(f"Planification inconnue : {kind} (attendu : {', '.join(SCHEDULE_KINDS)})")
|
||||
if kind == "interval_hours":
|
||||
hours = float(schedule.get("hours") or 0)
|
||||
if hours < 0.25:
|
||||
raise ValueError("hours doit être >= 0.25")
|
||||
return {"kind": kind, "hours": hours}
|
||||
if kind == "daily_time":
|
||||
at = str(schedule.get("at") or "").strip()
|
||||
try:
|
||||
datetime.strptime(at, "%H:%M")
|
||||
except ValueError:
|
||||
raise ValueError("at doit être au format HH:MM (ex. 08:30)") from None
|
||||
return {"kind": kind, "at": at}
|
||||
# once_at
|
||||
at = str(schedule.get("at") or "").strip()
|
||||
try:
|
||||
parsed = datetime.fromisoformat(at)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
raise ValueError("at doit être une date ISO-8601 (ex. 2026-10-05T08:30:00)") from None
|
||||
return {"kind": kind, "at": parsed.isoformat()}
|
||||
|
||||
|
||||
# ── CRUD ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def create_task(
|
||||
name: str,
|
||||
action: dict[str, Any],
|
||||
schedule: dict[str, Any],
|
||||
*,
|
||||
created_by: str = "api",
|
||||
enabled: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a scheduled task. Raises ValueError on invalid action/schedule."""
|
||||
validated_action = _validate_action(action)
|
||||
validated_schedule = _validate_schedule(schedule)
|
||||
now = datetime.now(timezone.utc)
|
||||
with _lock:
|
||||
tasks = _read_tasks()
|
||||
task = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": (name or validated_action["kind"]).strip() or validated_action["kind"],
|
||||
"action": validated_action,
|
||||
"schedule": validated_schedule,
|
||||
"enabled": bool(enabled),
|
||||
"created_by": created_by,
|
||||
"created_at": now.isoformat(),
|
||||
"last_run_at": None,
|
||||
"last_status": None,
|
||||
"last_error": None,
|
||||
"run_count": 0,
|
||||
"next_run_at": compute_next_run(
|
||||
{"schedule": validated_schedule, "last_run_at": None}, now
|
||||
).isoformat(),
|
||||
}
|
||||
tasks.append(task)
|
||||
_write_tasks(tasks)
|
||||
logger.info(f"Scheduled task created: '{task['name']}' ({validated_schedule['kind']})")
|
||||
return task
|
||||
|
||||
|
||||
def update_task(task_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Update name/enabled/action/schedule. Returns None when unknown."""
|
||||
with _lock:
|
||||
tasks = _read_tasks()
|
||||
for task in tasks:
|
||||
if task.get("id") != task_id:
|
||||
continue
|
||||
if updates.get("name"):
|
||||
task["name"] = str(updates["name"])
|
||||
if "enabled" in updates:
|
||||
task["enabled"] = bool(updates["enabled"])
|
||||
if "action" in updates:
|
||||
task["action"] = _validate_action(updates["action"])
|
||||
if "schedule" in updates:
|
||||
task["schedule"] = _validate_schedule(updates["schedule"])
|
||||
task["next_run_at"] = compute_next_run(task).isoformat()
|
||||
_write_tasks(tasks)
|
||||
return task
|
||||
return None
|
||||
|
||||
|
||||
def delete_task(task_id: str) -> bool:
|
||||
"""Delete a task. Returns False when unknown."""
|
||||
with _lock:
|
||||
tasks = _read_tasks()
|
||||
remaining = [t for t in tasks if t.get("id") != task_id]
|
||||
if len(remaining) == len(tasks):
|
||||
return False
|
||||
_write_tasks(remaining)
|
||||
return True
|
||||
|
||||
|
||||
# ── Scheduling ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datetime:
|
||||
"""Compute the next due datetime for *task*."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=timezone.utc)
|
||||
schedule = task.get("schedule", {})
|
||||
kind = schedule.get("kind")
|
||||
last_run_at = task.get("last_run_at")
|
||||
last = None
|
||||
if last_run_at:
|
||||
try:
|
||||
last = datetime.fromisoformat(str(last_run_at))
|
||||
if last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
last = None
|
||||
if kind == "interval_hours":
|
||||
hours = float(schedule.get("hours", 24))
|
||||
base = last or now
|
||||
nxt = base + timedelta(hours=hours)
|
||||
# Première planification : due dès maintenant + intervalle ? Non —
|
||||
# la tâche démarre au prochain intervalle, sauf retard déjà accumulé.
|
||||
if last is None:
|
||||
nxt = now + timedelta(hours=hours)
|
||||
return max(now, nxt)
|
||||
if kind == "daily_time":
|
||||
hour, minute = (str(schedule.get("at", "08:00")) + ":00").split(":")[:2]
|
||||
candidate = now.replace(hour=int(hour), minute=int(minute), second=0, microsecond=0)
|
||||
if candidate <= now:
|
||||
candidate += timedelta(days=1)
|
||||
return candidate
|
||||
if kind == "once_at":
|
||||
try:
|
||||
at = datetime.fromisoformat(str(schedule.get("at")))
|
||||
if at.tzinfo is None:
|
||||
at = at.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return now
|
||||
if task.get("last_run_at"):
|
||||
return datetime.max.replace(tzinfo=timezone.utc) # déjà exécutée
|
||||
return at
|
||||
return now + timedelta(hours=24)
|
||||
|
||||
|
||||
def _execute_action(task: dict[str, Any]) -> dict[str, Any]:
|
||||
action = task["action"]
|
||||
kind = action["kind"]
|
||||
params = action["params"]
|
||||
if kind == "create_file":
|
||||
from backend.services.mutations import create_file
|
||||
|
||||
return create_file(
|
||||
params["vault"],
|
||||
params["path"],
|
||||
params.get("content", ""),
|
||||
overwrite=bool(params.get("overwrite", False)),
|
||||
)
|
||||
if kind == "append_to_file":
|
||||
from backend.services.mutations import append_to_file
|
||||
|
||||
return append_to_file(params["vault"], params["path"], params.get("content", ""))
|
||||
if kind == "notify":
|
||||
from backend.notify import broadcast
|
||||
|
||||
results = broadcast("manual", str(params["title"]), str(params.get("message", "")))
|
||||
return {"broadcast": results}
|
||||
raise ValueError(f"Action inconnue : {kind}")
|
||||
|
||||
|
||||
def run_task(task_id: str, *, manual: bool = False) -> dict[str, Any]:
|
||||
"""Execute one task now (manual or due). Records status; notifies on failure."""
|
||||
with _lock:
|
||||
tasks = _read_tasks()
|
||||
task = next((t for t in tasks if t.get("id") == task_id), None)
|
||||
if task is None:
|
||||
raise KeyError(task_id)
|
||||
if not task.get("enabled", True) and not manual:
|
||||
return {"task_id": task_id, "skipped": True, "reason": "disabled"}
|
||||
try:
|
||||
result = _execute_action(task)
|
||||
task["last_run_at"] = datetime.now(timezone.utc).isoformat()
|
||||
task["last_status"] = "ok"
|
||||
task["last_error"] = None
|
||||
task["run_count"] = int(task.get("run_count", 0)) + 1
|
||||
if task.get("schedule", {}).get("kind") == "once_at":
|
||||
task["enabled"] = False # one-shot consommé
|
||||
task["next_run_at"] = compute_next_run(task).isoformat()
|
||||
_write_tasks(tasks)
|
||||
if manual:
|
||||
from backend.notify import broadcast
|
||||
|
||||
broadcast("schedule_success", f"Tâche « {task['name']} » OK", "Exécution manuelle réussie.")
|
||||
return {"task_id": task_id, "ok": True, "result": result}
|
||||
except Exception as e:
|
||||
task["last_run_at"] = datetime.now(timezone.utc).isoformat()
|
||||
task["last_status"] = "error"
|
||||
task["last_error"] = str(e)
|
||||
task["run_count"] = int(task.get("run_count", 0)) + 1
|
||||
task["next_run_at"] = compute_next_run(task).isoformat()
|
||||
_write_tasks(tasks)
|
||||
logger.warning(f"Scheduled task '{task.get('name')}' failed: {e}")
|
||||
if task["action"]["kind"] != "notify":
|
||||
try:
|
||||
from backend.notify import broadcast
|
||||
|
||||
broadcast(
|
||||
"schedule_failure",
|
||||
f"Échec tâche « {task.get('name')} »",
|
||||
f"{e}",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failure notification broadcast failed", exc_info=True)
|
||||
return {"task_id": task_id, "ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def tick(now: datetime | None = None) -> list[dict[str, Any]]:
|
||||
"""Run every due task. Returns per-task outcomes (empty when idle)."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
outcomes: list[dict[str, Any]] = []
|
||||
for task in _read_tasks():
|
||||
if not task.get("enabled", True):
|
||||
continue
|
||||
try:
|
||||
next_run = datetime.fromisoformat(str(task.get("next_run_at") or ""))
|
||||
if next_run.tzinfo is None:
|
||||
next_run = next_run.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
next_run = compute_next_run(task, now)
|
||||
if next_run <= now:
|
||||
outcomes.append(run_task(task["id"]))
|
||||
return outcomes
|
||||
@@ -151,6 +151,54 @@ class BacklinksResponse(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
class ChatMessageItem(BaseModel):
|
||||
"""One chat message (``GET/POST /api/file/{vault}/chat`` + ``/api/chat``)."""
|
||||
|
||||
id: str = Field(description="Message id")
|
||||
user: str = Field(description="Author username")
|
||||
text: str = Field(description="Message body")
|
||||
ts: float = Field(description="Unix timestamp")
|
||||
attachment: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Optional image/video/url attachment {name, url, mime, kind}",
|
||||
)
|
||||
preview: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Link preview card {url, title, description, image, site} (#191)",
|
||||
)
|
||||
html: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
"Rendered (mistune + sanitized) HTML of ``text``, like a document "
|
||||
"preview — tables, lists, fenced code blocks (#193)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ChatHistoryResponse(BaseModel):
|
||||
"""Response for ``GET /api/file/{vault}/chat``."""
|
||||
|
||||
messages: list[ChatMessageItem] = Field(description="Messages, chronological")
|
||||
read: dict[str, float] = Field(
|
||||
default_factory=dict,
|
||||
description="{username: last read unix ts} per participant (#192)",
|
||||
)
|
||||
|
||||
|
||||
class ChatReadResponse(BaseModel):
|
||||
"""Response for ``POST /api/chat/read`` (#192 read receipts)."""
|
||||
|
||||
read: dict[str, float] = Field(description="{username: last read unix ts}")
|
||||
status: str = Field(description="'ok'")
|
||||
|
||||
|
||||
class ChatMessageResponse(BaseModel):
|
||||
"""Response for ``POST /api/file/{vault}/chat``."""
|
||||
|
||||
message: ChatMessageItem
|
||||
status: str = Field(description="'ok'")
|
||||
|
||||
|
||||
class BackupsListResponse(BaseModel):
|
||||
"""Response for ``GET /api/backups``."""
|
||||
|
||||
@@ -188,6 +236,554 @@ class BackupsAutoResponse(BaseModel):
|
||||
since_hours: int | float = Field(description="Look-back window in hours")
|
||||
|
||||
|
||||
class DiffResponse(BaseModel):
|
||||
"""Response containing a unified diff between two file versions (#85 — extrait de backend.main, inchangé)."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
version: int = Field(description="Backup version timestamp (left/old side)")
|
||||
compare_with: int | None = Field(default=None, description="Other backup version or null for current file (right/new side)")
|
||||
diff: str = Field(description="Unified diff (empty if no changes)")
|
||||
|
||||
|
||||
class RestoreRequest(BaseModel):
|
||||
"""Request to restore a file from a backup (#85 — extrait de backend.main, inchangé)."""
|
||||
|
||||
version: int = Field(description="Timestamp of the backup version to restore")
|
||||
|
||||
|
||||
class RestoreResponse(BaseModel):
|
||||
"""Response after restoring a file from backup (#85 — extrait de backend.main, inchangé)."""
|
||||
|
||||
success: bool = Field(description="Whether restore succeeded")
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
restored_from: int = Field(description="Timestamp of the backup used")
|
||||
current_backed_up: int | None = Field(default=None, description="Timestamp of the backup created from the current version before restore, if any")
|
||||
|
||||
|
||||
class BackupEntry(BaseModel):
|
||||
"""A single backup version of a file (#85 — extrait de backend.main, inchangé)."""
|
||||
|
||||
timestamp: int = Field(description="Unix timestamp of when the backup was created")
|
||||
datetime: str = Field(description="ISO 8601 datetime string")
|
||||
size: int = Field(description="File size in bytes")
|
||||
filename: str = Field(description="Backup filename on disk")
|
||||
|
||||
|
||||
class BackupListResponse(BaseModel):
|
||||
"""Response listing all available backups for a file (#85 — extrait de backend.main, inchangé)."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
backups: list[BackupEntry] = Field(description="Available backups, newest first")
|
||||
|
||||
|
||||
class DiffRequest(BaseModel):
|
||||
"""Request parameters for generating a diff (#85 — extrait de backend.main, inchangé)."""
|
||||
|
||||
version: int = Field(description="Timestamp of the backup version to compare")
|
||||
compare_with: int | None = Field(default=None, description="Timestamp of another backup version. If omitted, compares with the current file.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Files — browse / read (#85 — extrait de backend.main, inchangé)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BrowseItem(BaseModel):
|
||||
"""A single entry (file or directory) returned by the browse endpoint."""
|
||||
|
||||
name: str = Field(description="File or directory name")
|
||||
path: str = Field(description="Relative path within vault")
|
||||
type: str = Field(description="'file' or 'directory'")
|
||||
children_count: int | None = Field(default=None, description="Number of children (directories only)")
|
||||
size: int | None = Field(default=None, description="File size in bytes")
|
||||
extension: str | None = Field(default=None, description="File extension")
|
||||
modified: float | None = Field(default=None, description="Modification time, epoch seconds (#197)")
|
||||
modified_iso: str | None = Field(default=None, description="Modification time, ISO 8601 (#197)")
|
||||
|
||||
|
||||
class BrowseResponse(BaseModel):
|
||||
"""Paginated directory listing for a vault."""
|
||||
|
||||
vault: str
|
||||
path: str
|
||||
items: list[BrowseItem]
|
||||
|
||||
|
||||
class FileContentResponse(BaseModel):
|
||||
"""Rendered file content with metadata."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path within the vault")
|
||||
title: str = Field(description="File title (from frontmatter or filename)")
|
||||
tags: list[str] = Field(description="Extracted tags from frontmatter and inline #tags")
|
||||
frontmatter: dict[str, Any] = Field(description="YAML frontmatter as key-value dict")
|
||||
html: str = Field(description="Rendered HTML content")
|
||||
raw_length: int = Field(description="Length of raw file content in characters")
|
||||
extension: str = Field(description="File extension (e.g. .md, .txt)")
|
||||
is_markdown: bool = Field(description="Whether the file is markdown")
|
||||
unsupported: bool | None = Field(default=False, description="True for binary/unsupported files")
|
||||
size_bytes: int | None = Field(default=None, description="File size in bytes (for unsupported files)")
|
||||
is_pdf: bool | None = Field(default=None, description="True for PDF files")
|
||||
is_image: bool | None = Field(default=None, description="True for image files")
|
||||
is_audio: bool | None = Field(default=None, description="True for audio files (HTML5 <audio>, roadmap #109)")
|
||||
is_video: bool | None = Field(default=None, description="True for video files (HTML5 <video>, roadmap #109)")
|
||||
media_too_large: bool | None = Field(default=None, description="True when audio/video exceeds the inline streaming limit")
|
||||
stream_url: str | None = Field(default=None, description="Byte-range streaming URL under /api/media (audio/video)")
|
||||
media_mime: str | None = Field(default=None, description="MIME type for audio/video files")
|
||||
is_csv: bool | None = Field(default=None, description="True for CSV files")
|
||||
is_xlsx: bool | None = Field(default=None, description="True for Excel .xlsx files")
|
||||
xlsx_readonly: bool | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"True when the table is served read-only (.xls/.ods, #153 A16): "
|
||||
"the viewer hides the editable-cell wiring and the save/structure "
|
||||
"endpoints refuse the format"
|
||||
),
|
||||
)
|
||||
xlsx_sheets: list[dict[str, Any]] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Rendered xlsx sheets [{name, html, rows, cols, total_rows, "
|
||||
"total_cols, max_rows, max_cols, truncated}] — `truncated` is true "
|
||||
"when the sheet exceeds the 500x40 render caps (#153 A8)"
|
||||
),
|
||||
)
|
||||
xlsx_revision: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Optimistic-concurrency token of the spreadsheet (#156-A12): the "
|
||||
"client sends it back as the `if_match` of a write so a change made "
|
||||
"elsewhere is refused (409 `conflict`) instead of overwritten"
|
||||
),
|
||||
)
|
||||
xlsx_lossy_features: list[str] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Workbook parts an openpyxl save would drop (#153 A1) — e.g. "
|
||||
"cached_values, slicers, form_controls, connections, custom_xml, "
|
||||
"signature, rich_comments, macros. Empty/absent = nothing at risk."
|
||||
),
|
||||
)
|
||||
is_json: bool | None = Field(default=None, description="True for JSON files")
|
||||
is_excalidraw: bool | None = Field(default=None, description="True for Excalidraw diagram files")
|
||||
excalidraw_data: dict[str, Any] | None = Field(default=None, description="Excalidraw diagram data (elements, appState, files)")
|
||||
excalidraw_data_compressed: str | None = Field(default=None, description="Compressed Excalidraw data for .excalidraw.md files")
|
||||
pdf_metadata: dict[str, Any] | None = Field(default=None, description="PDF metadata")
|
||||
pdf_toc: list[dict[str, Any]] | None = Field(default=None, description="PDF table of contents")
|
||||
image_mime: str | None = Field(default=None, description="MIME type for image files")
|
||||
|
||||
|
||||
class XlsxDashboardNamedRange(BaseModel):
|
||||
"""One named range of a workbook (#153 A17)."""
|
||||
|
||||
name: str = Field(description="Range name as declared in the workbook")
|
||||
scope: str = Field(description="Sheet name when sheet-scoped, empty when workbook-wide")
|
||||
ref: str = Field(description="Formula-style reference, e.g. Data!$A$1:$B$5")
|
||||
|
||||
|
||||
class XlsxDashboardSheetKpi(BaseModel):
|
||||
"""One KPI card of a sheet dashboard (#153 A17)."""
|
||||
|
||||
label: str = Field(description="A1 reference of the numeric cell")
|
||||
value: float = Field(description="Numeric value of the cell")
|
||||
|
||||
|
||||
class XlsxDashboardSheet(BaseModel):
|
||||
"""Per-sheet KPI stats of a workbook dashboard (#153 A17)."""
|
||||
|
||||
name: str = Field(description="Sheet name")
|
||||
cells: int = Field(description="Non-empty cells inside the 500x40 caps")
|
||||
rows: int = Field(description="Rows carrying at least one non-empty cell")
|
||||
cols: int = Field(description="Columns carrying at least one non-empty cell")
|
||||
formulas: int = Field(description="Cells whose value is a formula")
|
||||
numeric: int = Field(description="Cells carrying a numeric value")
|
||||
kpi: list[XlsxDashboardSheetKpi] = Field(description="First numeric cells as KPI cards")
|
||||
|
||||
|
||||
class XlsxDashboardResponse(BaseModel):
|
||||
"""Dashboard metadata of an .xlsx workbook (#153 A17)."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path within the vault")
|
||||
named_ranges: list[XlsxDashboardNamedRange] = Field(description="Named ranges, sorted by name")
|
||||
objects: dict[str, int] = Field(description="Object counts: {charts, pivots}")
|
||||
sheets: list[XlsxDashboardSheet] = Field(description="Per-sheet KPI stats")
|
||||
|
||||
|
||||
class XlsxSheetWindowResponse(BaseModel):
|
||||
"""One window of rows of a single .xlsx sheet (lazy loading, #153 A9).
|
||||
|
||||
Served by ``GET /api/file/{vault_name}/xlsx/sheet``; the row numbers and
|
||||
the ``data-cell`` references in ``html`` are the real A1 coordinates of the
|
||||
sheet, whatever the window.
|
||||
"""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path within the vault")
|
||||
sheet: str = Field(description="Sheet name (as shown in the tab)")
|
||||
offset: int = Field(description="0-based index of the first returned row")
|
||||
limit: int = Field(description="Maximum number of rows returned (capped server-side)")
|
||||
rows: int = Field(description="Rows actually returned in this window")
|
||||
cols: int = Field(description="Columns of the rendered window")
|
||||
total_rows: int = Field(description="Rows the sheet declares")
|
||||
total_cols: int = Field(description="Columns the sheet declares")
|
||||
max_rows: int = Field(description="Row cap of the renderer (500) — the coverage of this window")
|
||||
max_cols: int = Field(description="Column cap of the renderer (40)")
|
||||
truncated: bool = Field(
|
||||
description="True when the sheet exceeds the 500x40 render caps"
|
||||
)
|
||||
has_more: bool = Field(description="True when rows remain after this window")
|
||||
html: str = Field(description="Rendered HTML table for the window")
|
||||
|
||||
|
||||
class FileRawResponse(BaseModel):
|
||||
"""Raw text content of a file."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path within the vault")
|
||||
raw: str = Field(description="Raw file content as text")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Files — mutations (#85 — extrait de backend.main, inchangé)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FileSaveResponse(BaseModel):
|
||||
"""Confirmation after saving a file."""
|
||||
|
||||
status: str = Field(description="Always 'ok'")
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path within the vault")
|
||||
size: int = Field(description="Size of saved content in characters")
|
||||
# #156-A12 — optimistic-concurrency token of the file AFTER the write, so a
|
||||
# client can chain writes without re-reading (absent on non-spreadsheets).
|
||||
revision: str | None = Field(
|
||||
default=None,
|
||||
description="Opaque revision of the saved spreadsheet (send it back as `if_match`)",
|
||||
)
|
||||
|
||||
|
||||
class FileDeleteResponse(BaseModel):
|
||||
"""Confirmation after deleting a file."""
|
||||
|
||||
status: str = Field(description="Always 'ok'")
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path within the vault")
|
||||
|
||||
|
||||
class DirectoryCreateRequest(BaseModel):
|
||||
"""Request to create a new directory."""
|
||||
|
||||
path: str = Field(description="Relative path of the new directory")
|
||||
|
||||
|
||||
class DirectoryCreateResponse(BaseModel):
|
||||
"""Response after creating a directory."""
|
||||
|
||||
success: bool = Field(description="Whether creation succeeded")
|
||||
path: str = Field(description="Path of the created directory")
|
||||
|
||||
|
||||
class DirectoryRenameRequest(BaseModel):
|
||||
"""Request to rename a directory."""
|
||||
|
||||
path: str = Field(description="Current path of the directory")
|
||||
new_name: str = Field(description="New name for the directory")
|
||||
|
||||
|
||||
class DirectoryRenameResponse(BaseModel):
|
||||
"""Response after renaming a directory."""
|
||||
|
||||
success: bool = Field(description="Whether rename succeeded")
|
||||
old_path: str = Field(description="Original directory path")
|
||||
new_path: str = Field(description="New directory path")
|
||||
|
||||
|
||||
class DirectoryDeleteResponse(BaseModel):
|
||||
"""Response after deleting a directory."""
|
||||
|
||||
success: bool = Field(description="Whether deletion succeeded")
|
||||
deleted_count: int = Field(description="Number of files recursively deleted")
|
||||
|
||||
|
||||
class FileCreateRequest(BaseModel):
|
||||
"""Request to create a new file."""
|
||||
|
||||
path: str = Field(description="Relative path of the new file")
|
||||
content: str = Field(default="", description="Initial content")
|
||||
|
||||
|
||||
class FileCreateResponse(BaseModel):
|
||||
"""Response after creating a file."""
|
||||
|
||||
success: bool = Field(description="Whether creation succeeded")
|
||||
path: str = Field(description="Path of the created file")
|
||||
|
||||
|
||||
class BatchUploadFileItem(BaseModel):
|
||||
"""A single file/dir entry in a batch upload request."""
|
||||
|
||||
path: str = Field(description="Relative path of the item within the batch")
|
||||
content: str | None = Field(default=None, description="Base64 encoded or text content for files")
|
||||
is_dir: bool = Field(default=False, description="True if entry represents an empty directory")
|
||||
|
||||
|
||||
class BatchUploadRequest(BaseModel):
|
||||
"""Request payload for batch file/directory upload."""
|
||||
|
||||
target_dir: str = Field(default="", description="Base directory in vault to upload into (empty for root)")
|
||||
files: list[BatchUploadFileItem] = Field(description="List of files and directories to upload")
|
||||
overwrite: bool = Field(default=True, description="Whether to overwrite existing files (creates backups)")
|
||||
|
||||
|
||||
class BatchUploadResponse(BaseModel):
|
||||
"""Response from batch file/directory upload."""
|
||||
|
||||
success: bool = Field(description="True if all files uploaded without error")
|
||||
vault: str = Field(description="Vault name")
|
||||
target_dir: str = Field(description="Target directory")
|
||||
uploaded: list[str] = Field(description="List of created/updated file paths")
|
||||
created_dirs: list[str] = Field(description="List of created directory paths")
|
||||
errors: list[dict[str, Any]] = Field(default_factory=list, description="List of items that failed")
|
||||
total_files: int = Field(description="Total uploaded files count")
|
||||
|
||||
|
||||
class FileRenameRequest(BaseModel):
|
||||
"""Request to rename a file."""
|
||||
|
||||
path: str = Field(description="Current path of the file")
|
||||
new_name: str = Field(description="New name for the file")
|
||||
|
||||
|
||||
class FileRenameResponse(BaseModel):
|
||||
"""Response after renaming a file."""
|
||||
|
||||
success: bool = Field(description="Whether rename succeeded")
|
||||
old_path: str
|
||||
new_path: str
|
||||
|
||||
|
||||
class FileMoveRequest(BaseModel):
|
||||
"""Request to move a file or directory to a different parent directory."""
|
||||
|
||||
source_path: str = Field(description="Current relative path of the file/directory")
|
||||
destination_dir: str = Field(description="Target directory relative path (empty string for vault root)")
|
||||
|
||||
|
||||
class FileMoveResponse(BaseModel):
|
||||
"""Response after moving a file or directory."""
|
||||
|
||||
success: bool = Field(description="Whether move succeeded")
|
||||
old_path: str = Field(description="Original path")
|
||||
new_path: str = Field(description="New path after move")
|
||||
item_type: str = Field(description="Type of item moved: 'file' or 'directory'")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vaults & history (#85 — extrait de backend.main, inchangé)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class VaultInfo(BaseModel):
|
||||
"""Summary information about a configured vault."""
|
||||
|
||||
name: str = Field(description="Display name of the vault")
|
||||
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)")
|
||||
|
||||
|
||||
class BookmarkToggleRequest(BaseModel):
|
||||
"""Request to toggle a bookmark on a file."""
|
||||
|
||||
vault: str
|
||||
path: str
|
||||
title: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search / suggest / graph (#85 — extrait de backend.main, inchangé)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SearchResultItem(BaseModel):
|
||||
"""A single search result."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
title: str = Field(description="File title")
|
||||
tags: list[str] = Field(description="File tags")
|
||||
score: int = Field(description="Relevance score")
|
||||
snippet: str = Field(description="Content excerpt with highlights")
|
||||
modified: str | None = Field(default=None, description="ISO 8601 modification timestamp")
|
||||
share_token: str | None = Field(default=None, description="#196 token de partage dirigé (document reçu)")
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
"""Full-text search response with optional pagination."""
|
||||
|
||||
query: str = Field(description="Original search query")
|
||||
vault_filter: str = Field(description="Vault filter applied ('all' or vault name)")
|
||||
tag_filter: str | None = Field(default=None, description="Tag filter applied")
|
||||
count: int = Field(description="Number of results in this response")
|
||||
total: int = Field(default=0, description="Total results before pagination")
|
||||
offset: int = Field(default=0, description="Current pagination offset")
|
||||
limit: int = Field(default=200, description="Page size")
|
||||
results: list[SearchResultItem] = Field(description="Search result items")
|
||||
|
||||
|
||||
class TagsResponse(BaseModel):
|
||||
"""Tag aggregation response."""
|
||||
|
||||
vault_filter: str | None = Field(default=None, description="Vault filter applied")
|
||||
tags: dict[str, int] = Field(description="Tag name → count mapping")
|
||||
|
||||
|
||||
class TreeSearchResult(BaseModel):
|
||||
"""A single tree search result item."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Full relative path")
|
||||
name: str = Field(description="File or directory name")
|
||||
type: str = Field(description="'file' or 'directory'")
|
||||
matched_path: str = Field(description="Path segment that matched the query")
|
||||
|
||||
|
||||
class TreeSearchResponse(BaseModel):
|
||||
"""Tree search response with matching paths."""
|
||||
|
||||
query: str = Field(description="Search query")
|
||||
vault_filter: str = Field(description="Vault filter applied")
|
||||
results: list[TreeSearchResult] = Field(description="Matching files and directories")
|
||||
|
||||
|
||||
class VaultPathEntry(BaseModel):
|
||||
"""A single indexed path (file or directory) in a vault."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Full relative path")
|
||||
name: str = Field(description="File or directory name")
|
||||
type: str = Field(description="'file' or 'directory'")
|
||||
size: int | None = Field(default=None, description="File size in bytes (#197, files only)")
|
||||
mtime: float | None = Field(default=None, description="Modification time, epoch seconds (#197, files only)")
|
||||
mtime_iso: str | None = Field(default=None, description="Modification time, ISO 8601 (#197, files only)")
|
||||
|
||||
|
||||
class VaultPathsResponse(BaseModel):
|
||||
"""Flat list of every indexed path in a vault (capped)."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
count: int = Field(description="Number of returned entries")
|
||||
results: list[VaultPathEntry] = Field(description="Indexed files and directories")
|
||||
|
||||
|
||||
class AdvancedSearchResultItem(BaseModel):
|
||||
"""A single advanced search result with highlighted snippet."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
title: str = Field(description="File title")
|
||||
tags: list[str] = Field(description="File tags")
|
||||
score: float = Field(description="TF-IDF relevance score (or fused RRF score in semantic mode)")
|
||||
semantic_score: float = Field(default=0.0, description="Cosine similarity from the semantic index (0 when unavailable)")
|
||||
snippet: str = Field(description="Content excerpt with <mark> highlights")
|
||||
modified: str = Field(description="ISO 8601 modification timestamp")
|
||||
extension: str = Field(default="", description="File extension")
|
||||
|
||||
|
||||
class SearchFacets(BaseModel):
|
||||
"""Faceted counts for search results."""
|
||||
|
||||
tags: dict[str, int] = Field(default_factory=dict)
|
||||
vaults: dict[str, int] = Field(default_factory=dict)
|
||||
extensions: dict[str, int] = Field(default_factory=dict, description="Counts per file extension, dotless and lowercase (query-ready for the ext: operator)")
|
||||
|
||||
|
||||
class AdvancedSearchResponse(BaseModel):
|
||||
"""Advanced search response with TF-IDF scoring, facets, and pagination."""
|
||||
|
||||
results: list[AdvancedSearchResultItem] = Field(description="Search results")
|
||||
total: int = Field(description="Total number of matching results")
|
||||
offset: int = Field(description="Current pagination offset")
|
||||
limit: int = Field(description="Page size")
|
||||
facets: SearchFacets = Field(description="Faceted counts by tag, vault and file extension")
|
||||
query_time_ms: float = Field(default=0, description="Server-side query time in milliseconds")
|
||||
semantic_available: bool = Field(default=False, description="True when the semantic (embedding) index is ready")
|
||||
|
||||
|
||||
class TitleSuggestion(BaseModel):
|
||||
"""A file title suggestion for autocomplete."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
title: str = Field(description="File title")
|
||||
tags: list[str] = Field(default_factory=list, description="File tags")
|
||||
|
||||
|
||||
class SuggestResponse(BaseModel):
|
||||
"""Autocomplete suggestions for file titles."""
|
||||
|
||||
query: str = Field(description="Original query string")
|
||||
suggestions: list[TitleSuggestion] = Field(description="Matching file suggestions")
|
||||
|
||||
|
||||
class TagSuggestion(BaseModel):
|
||||
"""A tag suggestion for autocomplete."""
|
||||
|
||||
tag: str = Field(description="Tag name")
|
||||
count: int = Field(description="Number of files with this tag")
|
||||
|
||||
|
||||
class TagSuggestResponse(BaseModel):
|
||||
"""Autocomplete suggestions for tags."""
|
||||
|
||||
query: str = Field(description="Original query string")
|
||||
suggestions: list[TagSuggestion] = Field(description="Matching tag suggestions")
|
||||
|
||||
|
||||
class GraphNode(BaseModel):
|
||||
"""A single node in the graph view."""
|
||||
|
||||
id: str = Field(description="Unique node identifier")
|
||||
name: str = Field(description="Display name")
|
||||
type: str = Field(description="'vault', 'directory', or 'file'")
|
||||
path: str = Field(description="Relative path within vault")
|
||||
size: int = Field(default=0, description="File size in bytes")
|
||||
tags: list[str] = Field(default_factory=list, description="Tags from frontmatter")
|
||||
incoming_count: int = Field(default=0, description="Number of incoming wikilinks")
|
||||
outgoing_count: int = Field(default=0, description="Number of outgoing wikilinks")
|
||||
|
||||
|
||||
class GraphEdge(BaseModel):
|
||||
"""An edge between two nodes in the graph view."""
|
||||
|
||||
source: str = Field(description="Source node ID")
|
||||
target: str = Field(description="Target node ID")
|
||||
relation: str = Field(description="'parent', 'wikilink', or 'backlink'")
|
||||
|
||||
|
||||
class GraphResponse(BaseModel):
|
||||
"""Graph data for a vault or directory."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Root path for the graph")
|
||||
scope: str = Field(default="directory", description="'directory' or 'full'")
|
||||
nodes: list[GraphNode] = Field(description="Graph nodes (files and directories)")
|
||||
edges: list[GraphEdge] = Field(description="Graph edges (parent and wikilink relations)")
|
||||
|
||||
|
||||
class ReloadResponse(BaseModel):
|
||||
"""Index reload confirmation with per-vault stats."""
|
||||
|
||||
status: str = Field(description="Reload status ('ok' or 'error')")
|
||||
vaults: dict[str, Any] = Field(description="Per-vault file counts after reload")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PDF
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -322,6 +918,7 @@ class VaultFileEntry(BaseModel):
|
||||
modified_iso: str | None = None
|
||||
extension: str = ""
|
||||
rel_dir: str | None = None
|
||||
tags: list[str] = Field(default_factory=list, description="Tags de l'index (#158)")
|
||||
|
||||
|
||||
class VaultFilesResponse(BaseModel):
|
||||
@@ -408,6 +1005,31 @@ class DashboardResponse(BaseModel):
|
||||
total_images: int = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System / health (#85 — extrait de backend.main, comportement inchangé)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Application health status.
|
||||
|
||||
Déplacé depuis :mod:`backend.main` sans modification : pas de
|
||||
``extra="allow"`` ici, pour préserver la validation actuelle des
|
||||
réponses (les champs enrichis de ``/api/health/detailed`` restent
|
||||
filtrés comme avant).
|
||||
"""
|
||||
|
||||
status: str = Field(description="Health status ('ok' or 'error')")
|
||||
version: str = Field(description="Application version (x.y.z — latest release tag)")
|
||||
vaults: int = Field(description="Number of configured vaults")
|
||||
total_files: int = Field(description="Total indexed files across all vaults")
|
||||
total_tokens: int = Field(description="Total indexed tokens (approx.) across all vaults", default=0)
|
||||
last_full_index_ts: str = Field(description="ISO timestamp of last full index rebuild", default="")
|
||||
uptime_seconds: int = Field(description="Server uptime in seconds", default=0)
|
||||
git_describe: str = Field(default="", description="Full git describe string (commits beyond tag), empty if no git")
|
||||
git_commit: str = Field(default="", description="Short HEAD commit hash, empty if no git")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Webhooks, sharing & conflicts
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -425,6 +1047,8 @@ class WebhookModel(BaseModel):
|
||||
enabled: bool = True
|
||||
created_at: str | None = None
|
||||
last_fired_at: str | None = None
|
||||
vault: str | None = Field(default=None, description="#198 vault filter (None = all vaults)")
|
||||
path_prefix: str | None = Field(default=None, description="#198 path-prefix filter (None = whole vault)")
|
||||
|
||||
|
||||
class ShareModel(BaseModel):
|
||||
@@ -441,6 +1065,7 @@ class ShareModel(BaseModel):
|
||||
expires_at: str | None = None
|
||||
access_count: int = 0
|
||||
last_accessed: str | None = None
|
||||
shared_with: list[str] = Field(default_factory=list, description="#196 destinataires (partage dirigé)")
|
||||
|
||||
|
||||
class ConflictEntry(BaseModel):
|
||||
|
||||
+40
-12
@@ -12,7 +12,12 @@ from sortedcontainers import SortedList
|
||||
|
||||
from backend import indexer as _indexer
|
||||
from backend import semantic_search as _semantic
|
||||
from backend.indexer import index
|
||||
|
||||
# NOTE: the shared index is read through ``_indexer.index`` everywhere, never
|
||||
# via ``from backend.indexer import index``. That import binds the dict object
|
||||
# once, so a module reload of ``backend.indexer`` (tests, dev reload) rebinds
|
||||
# the module-level name to a FRESH dict while this module keeps writing to the
|
||||
# stale one — the inverted index then silently indexes nothing (BUG-089).
|
||||
from backend.services.regex_safety import (
|
||||
MAX_REGEX_MATCHES,
|
||||
truncate_for_regex,
|
||||
@@ -368,12 +373,19 @@ class InvertedIndex:
|
||||
self.doc_vault: dict[str, str] = {}
|
||||
self.vault_docs: dict[str, set] = defaultdict(set)
|
||||
self.tag_docs: dict[str, set] = defaultdict(set)
|
||||
self.doc_tags: dict[str, set] = defaultdict(set)
|
||||
self._sorted_tokens: SortedList = SortedList()
|
||||
self._ready: bool = False # True after initial build
|
||||
|
||||
def is_stale(self) -> bool:
|
||||
"""Return True if the index has not been built yet."""
|
||||
return not self._ready
|
||||
def is_ready(self) -> bool:
|
||||
"""Return True once the initial build has completed.
|
||||
|
||||
The index is then kept current incrementally by ``add_document()`` /
|
||||
``remove_document()``, so it never goes stale: there is no generation
|
||||
counter, no cooldown and no lazy rebuild. Searches simply fall back to
|
||||
a full scan while this is False (see ``search()``).
|
||||
"""
|
||||
return self._ready
|
||||
|
||||
def rebuild(self) -> None:
|
||||
"""Rebuild inverted index from the global ``index`` dict.
|
||||
@@ -392,8 +404,9 @@ class InvertedIndex:
|
||||
self.doc_vault = {}
|
||||
self.vault_docs = defaultdict(set)
|
||||
self.tag_docs = defaultdict(set)
|
||||
self.doc_tags = defaultdict(set)
|
||||
|
||||
for vault_name, vault_data in index.items():
|
||||
for vault_name, vault_data in _indexer.index.items():
|
||||
for file_info in vault_data.get("files", []):
|
||||
doc_key = f"{vault_name}::{file_info['path']}"
|
||||
self.doc_count += 1
|
||||
@@ -406,6 +419,7 @@ class InvertedIndex:
|
||||
# --- Per-document tag index ---
|
||||
for tag in file_info.get("tags", []):
|
||||
self.tag_docs[tag.lower()].add(doc_key)
|
||||
self.doc_tags[file_info['path']].add(tag.lower())
|
||||
|
||||
# --- Title tokens ---
|
||||
title_tokens = tokenize(file_info.get("title", ""))
|
||||
@@ -537,6 +551,10 @@ class InvertedIndex:
|
||||
self.doc_vault.pop(doc_key, None)
|
||||
if vault_name in self.vault_docs:
|
||||
self.vault_docs[vault_name].discard(doc_key)
|
||||
# Drop the empty entry so a fully removed vault leaves no trace
|
||||
# (it is a defaultdict: a bare lookup would recreate the key).
|
||||
if not self.vault_docs[vault_name]:
|
||||
del self.vault_docs[vault_name]
|
||||
# Tags (per-document, NOT the global tag_norm_map)
|
||||
for tag in file_info.get("tags", []):
|
||||
td = self.tag_docs.get(tag.lower())
|
||||
@@ -678,7 +696,7 @@ _indexer.set_index_change_hook(_on_index_change_hook)
|
||||
|
||||
def init_inverted_index():
|
||||
"""Force initial inverted index build. Called after build_index completes on startup."""
|
||||
if any(vdata.get("files") for vdata in index.values()):
|
||||
if any(vdata.get("files") for vdata in _indexer.index.values()):
|
||||
_inverted_index.rebuild()
|
||||
logger.info("Inverted index initialized.")
|
||||
|
||||
@@ -739,7 +757,7 @@ def search(
|
||||
results: list[dict[str, Any]] = []
|
||||
|
||||
inv = get_inverted_index()
|
||||
use_index = (not inv.is_stale()) and inv.doc_count > 0
|
||||
use_index = inv.is_ready() and inv.doc_count > 0
|
||||
|
||||
if use_index:
|
||||
# BUG-033: retrieve candidates from the inverted index instead of
|
||||
@@ -774,7 +792,7 @@ def search(
|
||||
else:
|
||||
candidates = [
|
||||
(vault_name, file_info)
|
||||
for vault_name, vault_data in index.items()
|
||||
for vault_name, vault_data in _indexer.index.items()
|
||||
if vault_filter == "all" or vault_name == vault_filter
|
||||
for file_info in vault_data["files"]
|
||||
]
|
||||
@@ -1309,6 +1327,7 @@ def advanced_search(
|
||||
scored_results: list[tuple[float, dict[str, Any]]] = []
|
||||
facet_tags: dict[str, int] = defaultdict(int)
|
||||
facet_vaults: dict[str, int] = defaultdict(int)
|
||||
facet_extensions: dict[str, int] = defaultdict(int)
|
||||
|
||||
# Pre-compute prefix expansions once per term (avoid repeated binary search)
|
||||
prefix_expansions: dict[str, list[str]] = {}
|
||||
@@ -1408,6 +1427,11 @@ def advanced_search(
|
||||
facet_vaults[result["vault"]] = facet_vaults.get(result["vault"], 0) + 1
|
||||
for tag in result.get("tags", []):
|
||||
facet_tags[tag] = facet_tags.get(tag, 0) + 1
|
||||
# Extension normalised without leading dot ("md", not ".md") so it can be
|
||||
# fed back directly as the `ext:` query operator.
|
||||
ext = str(result.get("extension") or "").lower().lstrip(".")
|
||||
if ext:
|
||||
facet_extensions[ext] = facet_extensions.get(ext, 0) + 1
|
||||
|
||||
total = len(scored_results)
|
||||
page = scored_results[offset: offset + limit]
|
||||
@@ -1421,6 +1445,7 @@ def advanced_search(
|
||||
"facets": {
|
||||
"tags": dict(sorted(facet_tags.items(), key=lambda x: -x[1])[:20]),
|
||||
"vaults": dict(sorted(facet_vaults.items(), key=lambda x: -x[1])),
|
||||
"extensions": dict(sorted(facet_extensions.items(), key=lambda x: -x[1])),
|
||||
},
|
||||
"query_time_ms": elapsed_ms,
|
||||
"semantic_available": semantic_available,
|
||||
@@ -1523,7 +1548,7 @@ def suggest_titles(
|
||||
prefix: str,
|
||||
vault_filter: str = "all",
|
||||
limit: int = SUGGEST_LIMIT,
|
||||
) -> list[dict[str, str]]:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Suggest file titles matching a prefix (accent-insensitive).
|
||||
|
||||
Args:
|
||||
@@ -1532,7 +1557,7 @@ def suggest_titles(
|
||||
limit: Maximum suggestions.
|
||||
|
||||
Returns:
|
||||
List of ``{"vault", "path", "title"}`` dicts.
|
||||
List of ``{"vault", "path", "title", "tags"}`` dicts.
|
||||
"""
|
||||
if not prefix or len(prefix) < MIN_PREFIX_LENGTH:
|
||||
return []
|
||||
@@ -1550,7 +1575,10 @@ def suggest_titles(
|
||||
key = f"{entry['vault']}::{entry['path']}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
results.append(entry)
|
||||
# Add tags from the index
|
||||
entry_with_tags: dict[str, Any] = dict(entry)
|
||||
entry_with_tags["tags"] = list(inv.doc_tags.get(entry["path"], set()))
|
||||
results.append(entry_with_tags)
|
||||
if len(results) >= limit:
|
||||
return results
|
||||
|
||||
@@ -1603,7 +1631,7 @@ def get_all_tags(vault_filter: str | None = None) -> dict[str, int]:
|
||||
Dict mapping tag names to their total occurrence count.
|
||||
"""
|
||||
merged: dict[str, int] = {}
|
||||
for vault_name, vault_data in index.items():
|
||||
for vault_name, vault_data in _indexer.index.items():
|
||||
if vault_filter and vault_filter != "all" and vault_name != vault_filter:
|
||||
continue
|
||||
for tag, count in vault_data.get("tags", {}).items():
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Shared thread pool for CPU-bound search (ROADMAP #85, tranche 5).
|
||||
|
||||
Holder extrait de :mod:`backend.main` sans changement de comportement :
|
||||
un seul pool (2 workers, préfixe ``"search"``) créé au démarrage et arrêté
|
||||
à l'extinction par le lifespan de ``main``. Les routers et les endpoints
|
||||
restants y accèdent via :func:`get_search_executor` au lieu du global de
|
||||
``main`` (plus d'import circulaire potentiel).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
_executor: ThreadPoolExecutor | None = None
|
||||
|
||||
|
||||
def init_search_executor(max_workers: int = 2) -> ThreadPoolExecutor:
|
||||
"""Create (or reuse) the shared search thread pool."""
|
||||
global _executor
|
||||
if _executor is None:
|
||||
_executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="search")
|
||||
return _executor
|
||||
|
||||
|
||||
def shutdown_search_executor() -> None:
|
||||
"""Stop the shared search thread pool (best-effort, non-blocking)."""
|
||||
global _executor
|
||||
if _executor is not None:
|
||||
_executor.shutdown(wait=False)
|
||||
_executor = None
|
||||
|
||||
|
||||
def get_search_executor() -> ThreadPoolExecutor | None:
|
||||
"""Return the shared search thread pool (``None`` before startup)."""
|
||||
return _executor
|
||||
+191
-38
@@ -1,48 +1,104 @@
|
||||
"""
|
||||
Secret redactor: masks sensitive patterns in rendered text.
|
||||
|
||||
Scans for common secret patterns and replaces them with [MASQUÉ]
|
||||
before content is served to the frontend. Prevents accidental
|
||||
exposure of API keys, tokens, and passwords in previews.
|
||||
Scans for common secret patterns and replaces them with a French mask
|
||||
label (``[CLÉ API MASQUÉE]``, ``[MOT DE PASSE MASQUÉ]``, …) before content
|
||||
is served to the frontend. Prevents accidental exposure of API keys,
|
||||
tokens, and passwords in previews.
|
||||
|
||||
Patterns detected:
|
||||
- Generic API keys (long alphanumeric strings with key/secret/token prefix)
|
||||
- Generic API keys (``api_key=…``, ``token: …`` — values of 8+ chars)
|
||||
- Passwords (``password=…``, ``"passwd": "…"`` — any length)
|
||||
- JWT tokens (eyJ... base64url)
|
||||
- AWS-style keys (AKIA..., sk-..., etc.)
|
||||
- Provider key formats: OpenAI/Anthropic/OpenRouter (``sk-``), Stripe,
|
||||
GitLab, Google (``AIza…`` / ``ya29.``), AWS, GitHub, Slack, SendGrid,
|
||||
Hugging Face, npm, Docker, Resend, Square, Atlassian, Discord,
|
||||
Telegram, ``Bearer …`` tokens
|
||||
- Private key blocks (-----BEGIN ... PRIVATE KEY-----)
|
||||
- Connection strings with passwords
|
||||
- Bare hex secrets next to a secret keyword (BUG-035)
|
||||
|
||||
Interactive masking (feature #188): :func:`redact_with_placeholders`
|
||||
returns the text with every mask replaced by an opaque placeholder plus
|
||||
the list of ``(label, secret)`` entries; :func:`restore_masks` turns the
|
||||
placeholders back into plain labels (public shares, PDF exports, AI
|
||||
context) or into clickable ``<span class="secret-mask" data-secret="…">``
|
||||
badges (authenticated app preview) so a click copies the real value to
|
||||
the clipboard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html as _html
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger("obsigate.redactor")
|
||||
|
||||
# --- Patterns ---
|
||||
# Order matters: more specific patterns first
|
||||
_PATTERNS = [
|
||||
# Each entry is ``(pattern, replacement, secret_group)``:
|
||||
# * ``replacement``: a literal label, a ``\\1``-style template, or a
|
||||
# callable receiving the match and returning the visible label;
|
||||
# * ``secret_group``: index of the group holding the value that a click
|
||||
# copies to the clipboard (feature #188).
|
||||
_PATTERNS: list[tuple[re.Pattern[str], object, int]] = [
|
||||
# Private key blocks
|
||||
(re.compile(r'-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----.*?-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----', re.DOTALL), '[CLÉ PRIVÉE MASQUÉE]'),
|
||||
|
||||
(re.compile(r'-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----.*?-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----', re.DOTALL), '[CLÉ PRIVÉE MASQUÉE]', 0),
|
||||
|
||||
# JWT tokens (base64url encoded, starts with eyJ)
|
||||
(re.compile(r'eyJ[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}'), '[JWT MASQUÉ]'),
|
||||
|
||||
(re.compile(r'eyJ[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}'), '[JWT MASQUÉ]', 0),
|
||||
|
||||
# Connection strings with passwords
|
||||
(re.compile(r'(?:mongodb|mysql|postgres(?:ql)?|redis|sqlite)://[^:]+:[^@\s]+@'), '[CONNECTION_STRING MASQUÉE]'),
|
||||
|
||||
# Generic API key patterns: key=... or token=... or secret=...
|
||||
(re.compile(r'(?:api[_-]?key|apikey|secret|token|password|passwd|auth[_-]?token)\s*[:=]\s*[\'"]?([^\s\'"]{20,})[\'"]?', re.IGNORECASE),
|
||||
lambda m: f'{m.group(0).split("=")[0].split(":")[0]}=[MASQUÉ]' if "=" in m.group(0) or ":" in m.group(0) else '[MASQUÉ]'),
|
||||
|
||||
# Prefixed API keys (sk-..., pk-..., rk-...)
|
||||
(re.compile(r'(?:sk|pk|rk)-[a-zA-Z0-9]{20,}'), '[CLÉ API MASQUÉE]'),
|
||||
|
||||
# AWS access keys
|
||||
(re.compile(r'AKIA[0-9A-Z]{16}'), '[AWS_KEY MASQUÉ]'),
|
||||
|
||||
(re.compile(r'(?:mongodb|mysql|postgres(?:ql)?|redis|sqlite)://[^:]+:[^@\s]+@'), '[CONNECTION_STRING MASQUÉE]', 0),
|
||||
|
||||
# Passwords — any length, bare or quoted (``password=…``,
|
||||
# ``"passwd": "…"``). The left side may carry a qualifier
|
||||
# (``db_password``, ``DATABASE.PASSWORD``, ``user_pwd``); a *bare*
|
||||
# ``PWD=`` (shell working directory) must NOT match, hence ``pwd``
|
||||
# only in its qualified branch.
|
||||
(re.compile(
|
||||
r'(?i)((?:[A-Za-z0-9_.-]*(?:password|passwd|passphrase|mot\s+de\s+passe)'
|
||||
r'|[A-Za-z0-9_.-]+pwd)["\']?\s*[:=]\s*["\']?)([^\s"\',;]{4,})'),
|
||||
r'\1[MOT DE PASSE MASQUÉ]', 2),
|
||||
|
||||
# Generic API key assignments: api_key=…, token=…, secret=… — values
|
||||
# of 8+ characters (short enough to catch real keys, long enough to
|
||||
# skip plain words).
|
||||
(re.compile(r'(?i)([A-Za-z0-9_.-]*(?:api[_-]?key|apikey|secret|token|auth[_-]?token)["\']?\s*[:=]\s*["\']?)([^\s\'"]{8,})'),
|
||||
lambda m: f'{m.group(1)}[MASQUÉ]' if ("=" in m.group(0) or ":" in m.group(0)) else '[MASQUÉ]', 2),
|
||||
|
||||
# GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_)
|
||||
(re.compile(r'gh[pousr]_[a-zA-Z0-9]{36,}'), '[GITHUB_TOKEN MASQUÉ]'),
|
||||
|
||||
(re.compile(r'gh[pousr]_[a-zA-Z0-9]{36,}'), '[GITHUB_TOKEN MASQUÉ]', 0),
|
||||
|
||||
# AWS access keys
|
||||
(re.compile(r'(?:AKIA|ASIA)[0-9A-Z]{16}'), '[AWS_KEY MASQUÉ]', 0),
|
||||
|
||||
# Provider key formats (feature #188) — one alternation covering the
|
||||
# large majority of token shapes in the wild.
|
||||
(re.compile(
|
||||
r'(?<![A-Za-z0-9])(?:'
|
||||
r'sk-[A-Za-z0-9_\-]{16,}' # OpenAI / Anthropic / OpenRouter
|
||||
r'|sk_(?:live|test)_[A-Za-z0-9]{10,}' # Stripe secret key
|
||||
r'|pk_(?:live|test)_[A-Za-z0-9]{10,}' # Stripe publishable key
|
||||
r'|whsec_[A-Za-z0-9]{16,}' # Stripe / Svix webhook secret
|
||||
r'|glpat-[A-Za-z0-9_\-]{20,}' # GitLab personal access token
|
||||
r'|github_pat_[A-Za-z0-9_]{22,}' # GitHub fine-grained PAT
|
||||
r'|npm_[A-Za-z0-9]{36}' # npm automation token
|
||||
r'|dckr_pat_[A-Za-z0-9_\-]{20,}' # Docker Hub token
|
||||
r'|hf_[A-Za-z0-9]{30,}' # Hugging Face token
|
||||
r'|AIza[0-9A-Za-z_\-]{35}' # Google API key
|
||||
r'|ya29\.[0-9A-Za-z_\-]{20,}' # Google OAuth access token
|
||||
r'|xox[baprs]-[0-9A-Za-z\-]{10,}' # Slack token
|
||||
r'|SG\.[A-Za-z0-9_\-]{16,}' # SendGrid API key
|
||||
r'|re_[A-Za-z0-9]{40}' # Resend API key
|
||||
r'|sq0[a-z]{3}-[A-Za-z0-9_\-]{16,}' # Square access token
|
||||
r'|ATATT[A-Za-z0-9_\-]{20,}' # Atlassian access token
|
||||
r'|[NOP][A-Za-z0-9_\-]{23,28}\.[A-Za-z0-9_\-]{6}\.[A-Za-z0-9_\-]{27,}' # Discord bot token
|
||||
r'|\d{8,10}:[A-Za-z0-9_\-]{35}' # Telegram bot token
|
||||
r'|Bearer\s+[A-Za-z0-9._~+/=\-]{20,}' # Authorization: Bearer …
|
||||
r')'),
|
||||
'[CLÉ API MASQUÉE]', 0),
|
||||
|
||||
]
|
||||
|
||||
# BUG-035: bare 40–64 char hex strings used to be redacted unconditionally,
|
||||
@@ -61,14 +117,23 @@ _HASH_CONTEXT_RE = re.compile(
|
||||
#: How far before the hex string a keyword may appear to count as context.
|
||||
_HEX_CONTEXT_WINDOW = 60
|
||||
|
||||
# --- Interactive masking (feature #188) ---
|
||||
# Private-use-area sentinels: they survive markdown rendering (mistune
|
||||
# treats them as plain text, fenced code blocks included) and are
|
||||
# stripped by ``backend.render._heading_slugify``.
|
||||
_PLACEHOLDER_OPEN = "\uE000"
|
||||
_PLACEHOLDER_CLOSE = "\uE001"
|
||||
_PLACEHOLDER_RE = re.compile("\uE000(\\d+)\uE001")
|
||||
|
||||
def _redact_bare_hex_secrets(text: str) -> tuple:
|
||||
|
||||
def _redact_bare_hex_secrets(text: str, mask) -> tuple[str, int]:
|
||||
"""Redact 40–64 char hex strings only when a secret keyword is nearby.
|
||||
|
||||
Git/SHA/checksum contexts are left untouched (BUG-035).
|
||||
|
||||
Args:
|
||||
text: Text to scan.
|
||||
mask: ``mask(original, label) -> str`` replacement builder.
|
||||
|
||||
Returns:
|
||||
(redacted_text, redaction_count) tuple.
|
||||
@@ -82,12 +147,38 @@ def _redact_bare_hex_secrets(text: str) -> tuple:
|
||||
return match.group(0)
|
||||
if _SECRET_CONTEXT_RE.search(window):
|
||||
count += 1
|
||||
return '[HEX_KEY MASQUÉ]'
|
||||
return mask(match.group(0), '[HEX_KEY MASQUÉ]')
|
||||
return match.group(0)
|
||||
|
||||
return _HEX_RE.sub(_replace, text), count
|
||||
|
||||
|
||||
def _redact(text: str, mask) -> tuple[str, int]:
|
||||
"""Apply every pattern; ``mask(original, label) -> str`` builds the
|
||||
replacement (plain label, or placeholder for the interactive mode)."""
|
||||
count = 0
|
||||
result = text
|
||||
for pattern, replacement, secret_group in _PATTERNS:
|
||||
|
||||
def _sub(match: re.Match, replacement=replacement, secret_group=secret_group) -> str:
|
||||
if callable(replacement):
|
||||
label = replacement(match)
|
||||
else:
|
||||
label = match.expand(str(replacement))
|
||||
return mask(match.group(secret_group), label)
|
||||
|
||||
new_result, n = pattern.subn(_sub, result)
|
||||
count += n
|
||||
result = new_result
|
||||
result, hex_count = _redact_bare_hex_secrets(result, mask)
|
||||
return result, count + hex_count
|
||||
|
||||
|
||||
def _plain_mask(original: str, label: str) -> str:
|
||||
"""Plain masking: only the visible label survives."""
|
||||
return label
|
||||
|
||||
|
||||
def redact(text: str) -> tuple:
|
||||
"""Redact sensitive patterns from text.
|
||||
|
||||
@@ -97,22 +188,84 @@ def redact(text: str) -> tuple:
|
||||
Returns:
|
||||
(redacted_text, redaction_count) tuple.
|
||||
"""
|
||||
count = 0
|
||||
result = text
|
||||
for pattern, replacement in _PATTERNS:
|
||||
if callable(replacement):
|
||||
new_result, n = pattern.subn(replacement, result)
|
||||
else:
|
||||
new_result, n = pattern.subn(str(replacement), result)
|
||||
count += n
|
||||
result = new_result
|
||||
result, hex_count = _redact_bare_hex_secrets(result)
|
||||
count += hex_count
|
||||
result, count = _redact(text, _plain_mask)
|
||||
if count > 0:
|
||||
logger.info(f"Redacted {count} secret(s) from content")
|
||||
return result, count
|
||||
|
||||
|
||||
def redact_with_placeholders(text: str, file_path: str = "") -> tuple[str, list[tuple[str, str]]]:
|
||||
"""Redact *text*, replacing every mask with an opaque placeholder.
|
||||
|
||||
Used by the markdown rendering pipeline: placeholders survive the
|
||||
markdown → HTML conversion (fenced code blocks included, where a
|
||||
literal ``<span>`` would be shown as text), then
|
||||
:func:`restore_masks` turns them back into labels or clickable badges.
|
||||
|
||||
Args:
|
||||
text: The raw text content to scan.
|
||||
file_path: Optional file path for logging context.
|
||||
|
||||
Returns:
|
||||
(text_with_placeholders, entries) where *entries* is the list of
|
||||
``(label, secret)`` tuples referenced by the placeholders, in
|
||||
order of appearance.
|
||||
"""
|
||||
entries: list[tuple[str, str]] = []
|
||||
|
||||
def mask(original: str, label: str) -> str:
|
||||
entries.append((label, original))
|
||||
return f"{_PLACEHOLDER_OPEN}{len(entries) - 1}{_PLACEHOLDER_CLOSE}"
|
||||
|
||||
result, count = _redact(text, mask)
|
||||
if count > 0:
|
||||
logger.warning(f"Redacted {count} potential secret(s) from {file_path or '<unknown>'}")
|
||||
return result, entries
|
||||
|
||||
|
||||
def restore_masks(
|
||||
text: str,
|
||||
entries: list[tuple[str, str]],
|
||||
*,
|
||||
click_to_copy: bool = False,
|
||||
) -> str:
|
||||
"""Turn placeholders produced by :func:`redact_with_placeholders` back
|
||||
into visible masks.
|
||||
|
||||
Args:
|
||||
text: Rendered HTML still containing placeholders.
|
||||
entries: The ``(label, secret)`` list returned alongside.
|
||||
click_to_copy: When True (authenticated app preview), each mask
|
||||
becomes ``<span class="secret-mask" data-secret="…">label</span>``
|
||||
so a click copies the real value. When False (public shares,
|
||||
PDF exports), only the plain label is restored — the secret
|
||||
never reaches the page.
|
||||
|
||||
Returns:
|
||||
The text with every placeholder replaced.
|
||||
"""
|
||||
if not entries:
|
||||
return text
|
||||
|
||||
def _sub(match: re.Match) -> str:
|
||||
idx = int(match.group(1))
|
||||
if idx >= len(entries):
|
||||
return ""
|
||||
label, original = entries[idx]
|
||||
label_esc = _html.escape(str(label), quote=False)
|
||||
if not click_to_copy:
|
||||
return label_esc
|
||||
return (
|
||||
'<span class="secret-mask" data-secret="'
|
||||
+ _html.escape(str(original), quote=True)
|
||||
+ '">'
|
||||
+ label_esc
|
||||
+ "</span>"
|
||||
)
|
||||
|
||||
return _PLACEHOLDER_RE.sub(_sub, text)
|
||||
|
||||
|
||||
def redact_file_content(content: str, file_path: str = "") -> str:
|
||||
"""Redact a file's content for preview rendering.
|
||||
|
||||
|
||||
@@ -457,10 +457,6 @@ class SemanticIndex:
|
||||
"""Return True once a full rebuild has completed."""
|
||||
return self._ready
|
||||
|
||||
def is_stale(self) -> bool:
|
||||
"""Alias used by callers that check index freshness."""
|
||||
return not self._ready
|
||||
|
||||
def _ensure_provider(self) -> EmbeddingProvider:
|
||||
if self.provider is None:
|
||||
self.provider = get_embedding_provider()
|
||||
|
||||
@@ -31,7 +31,7 @@ DEFAULT_MAX_BACKUPS = 10
|
||||
def _default_max_backups() -> int:
|
||||
"""Read ``max_backups_per_file`` from app config (lazy, best-effort)."""
|
||||
try:
|
||||
from backend.main import _load_config
|
||||
from backend.routers.config import _load_config # ROADMAP #85 T7 — déménagé depuis backend.main
|
||||
|
||||
return int(_load_config().get("max_backups_per_file", DEFAULT_MAX_BACKUPS))
|
||||
except Exception: # pragma: no cover - config unavailable
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Duplicate detection & merge services (#166).
|
||||
|
||||
Single source of truth consumed by the REST routes
|
||||
(``/api/duplicates``) and the AI tool layer (``find_duplicates``,
|
||||
``merge_duplicate_notes``).
|
||||
|
||||
Method is deterministic stdlib-only: frontmatter stripped, token-set
|
||||
Jaccard blended with a title similarity. No embedding dependency —
|
||||
the semantic index (#70) stays an optional refinement, not a requirement.
|
||||
|
||||
Fusion never runs without an explicit confirmation: the tool layer
|
||||
registers the merge as ``DANGEROUS`` (two-step propose/apply) and this
|
||||
service takes an automatic backup before any destructive write.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from difflib import SequenceMatcher
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.services.backups import create_backup
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import get_vault_root
|
||||
|
||||
logger = logging.getLogger("obsigate.services.duplicates")
|
||||
|
||||
MAX_FILES_SCANNED = 500
|
||||
MAX_FILE_BYTES = 200_000
|
||||
MAX_CONTENT_CHARS = 50_000
|
||||
|
||||
_WORD_RE = re.compile(r"[\w]+", re.UNICODE)
|
||||
_FRONTMATTER_RE = re.compile(r"\A---\s*\n.*?\n---\s*\n", re.DOTALL)
|
||||
|
||||
|
||||
def _strip_frontmatter(text: str) -> str:
|
||||
"""Remove a leading YAML frontmatter block, if present."""
|
||||
return _FRONTMATTER_RE.sub("", text, count=1)
|
||||
|
||||
|
||||
def _tokens(text: str) -> set[str]:
|
||||
"""Lowercase word tokens (keeps accents), stop-words free but tiny tokens dropped."""
|
||||
return {t for t in _WORD_RE.findall(text.lower()) if len(t) > 2}
|
||||
|
||||
|
||||
def similarity_score(a: str, b: str) -> float:
|
||||
"""Blend Jaccard (0.7) + title/first-line similarity (0.3) in [0, 1].
|
||||
|
||||
Pure function — unit-tested directly.
|
||||
"""
|
||||
ta, tb = _tokens(_strip_frontmatter(a)), _tokens(_strip_frontmatter(b))
|
||||
if not ta or not tb:
|
||||
return 0.0
|
||||
jaccard = len(ta & tb) / len(ta | tb)
|
||||
head_a = (a.strip().splitlines() or [""])[:1][0][:200].lower()
|
||||
head_b = (b.strip().splitlines() or [""])[:1][0][:200].lower()
|
||||
title_sim = SequenceMatcher(None, head_a, head_b).ratio() if head_a and head_b else 0.0
|
||||
return round(0.7 * jaccard + 0.3 * title_sim, 4)
|
||||
|
||||
|
||||
def _iter_markdown_files(root: Path, subdir: str = "") -> list[Path]:
|
||||
base = resolve_safe_path(root, subdir) if subdir else root.resolve()
|
||||
if not base.exists() or not base.is_dir():
|
||||
raise ServiceError(
|
||||
f"Directory not found: {subdir or '.'}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"path": subdir},
|
||||
)
|
||||
files = sorted(
|
||||
(p for p in base.rglob("*.md") if p.is_file() and not p.is_symlink()),
|
||||
key=lambda p: str(p),
|
||||
)
|
||||
return files[:MAX_FILES_SCANNED]
|
||||
|
||||
|
||||
def _read_capped(path: Path) -> str:
|
||||
try:
|
||||
if path.stat().st_size > MAX_FILE_BYTES:
|
||||
return ""
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return ""
|
||||
return text[:MAX_CONTENT_CHARS]
|
||||
|
||||
|
||||
def find_duplicate_pairs(
|
||||
vault: str,
|
||||
threshold: float = 0.75,
|
||||
limit: int = 50,
|
||||
subdir: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Return candidate duplicate pairs ordered by descending score.
|
||||
|
||||
Args:
|
||||
vault: Vault name.
|
||||
threshold: Minimum blended score in [0.3, 1.0].
|
||||
limit: Max pairs returned (1-200).
|
||||
subdir: Optional vault-relative directory scope.
|
||||
"""
|
||||
if not 0.3 <= threshold <= 1.0:
|
||||
raise ServiceError(
|
||||
"threshold must be between 0.3 and 1.0",
|
||||
code="invalid_arguments",
|
||||
status=400,
|
||||
)
|
||||
limit = max(1, min(limit, 200))
|
||||
root = get_vault_root(vault)
|
||||
files = _iter_markdown_files(root, subdir)
|
||||
contents: dict[str, str] = {}
|
||||
token_sets: dict[str, set[str]] = {}
|
||||
for path in files:
|
||||
rel = str(path.relative_to(root)).replace("\\", "/")
|
||||
text = _read_capped(path)
|
||||
if not text.strip():
|
||||
continue
|
||||
contents[rel] = text
|
||||
token_sets[rel] = _tokens(_strip_frontmatter(text))
|
||||
|
||||
rels = sorted(contents)
|
||||
pairs: list[dict[str, Any]] = []
|
||||
for i in range(len(rels)):
|
||||
for j in range(i + 1, len(rels)):
|
||||
a, b = rels[i], rels[j]
|
||||
ta, tb = token_sets[a], token_sets[b]
|
||||
if not ta or not tb:
|
||||
continue
|
||||
# Cheap pre-filter: Jaccard lower bound before the full score.
|
||||
inter = len(ta & tb)
|
||||
union = len(ta | tb)
|
||||
if union == 0 or inter / union < threshold * 0.6:
|
||||
continue
|
||||
score = similarity_score(contents[a], contents[b])
|
||||
if score >= threshold:
|
||||
pairs.append({"file_a": a, "file_b": b, "score": score})
|
||||
pairs.sort(key=lambda p: p["score"], reverse=True)
|
||||
return {
|
||||
"vault": vault,
|
||||
"threshold": threshold,
|
||||
"files_scanned": len(contents),
|
||||
"truncated": len(files) >= MAX_FILES_SCANNED,
|
||||
"pairs": pairs[:limit],
|
||||
}
|
||||
|
||||
|
||||
def merge_duplicates(
|
||||
vault: str,
|
||||
source_path: str,
|
||||
target_path: str,
|
||||
strategy: str = "append",
|
||||
) -> dict[str, Any]:
|
||||
"""Merge *source_path* into *target_path*, then delete the source.
|
||||
|
||||
Strategies:
|
||||
``append`` — source content appended after target (separator + origin
|
||||
marker), source deleted.
|
||||
``prefer_target`` — source deleted, target untouched (dedupe only).
|
||||
``prefer_source`` — target overwritten with source content, source deleted.
|
||||
|
||||
A backup of both files is taken first; the source deletion also goes
|
||||
through the backup-aware mutation service.
|
||||
"""
|
||||
from backend.services import mutations as _mutations
|
||||
|
||||
if strategy not in ("append", "prefer_target", "prefer_source"):
|
||||
raise ServiceError(
|
||||
f"Unknown strategy: {strategy}",
|
||||
code="invalid_arguments",
|
||||
status=400,
|
||||
)
|
||||
if source_path == target_path:
|
||||
raise ServiceError(
|
||||
"source_path and target_path must differ",
|
||||
code="invalid_arguments",
|
||||
status=400,
|
||||
)
|
||||
root = get_vault_root(vault)
|
||||
src = resolve_safe_path(root, source_path)
|
||||
dst = resolve_safe_path(root, target_path)
|
||||
if not src.is_file() or src.suffix.lower() != ".md":
|
||||
raise ServiceError(
|
||||
f"Source not found: {source_path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"path": source_path},
|
||||
)
|
||||
if not dst.is_file() or dst.suffix.lower() != ".md":
|
||||
raise ServiceError(
|
||||
f"Target not found: {target_path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"path": target_path},
|
||||
)
|
||||
# Backup préalable (jamais de fusion sans filet — critère #166).
|
||||
create_backup(src, vault, source_path)
|
||||
create_backup(dst, vault, target_path)
|
||||
|
||||
if strategy == "prefer_target":
|
||||
result = _mutations.delete_file(vault, source_path)
|
||||
return {"strategy": strategy, "target": target_path, "deleted": source_path, "delete": result}
|
||||
if strategy == "prefer_source":
|
||||
content = src.read_text(encoding="utf-8", errors="replace")
|
||||
result = _mutations.edit_file(vault, target_path, content)
|
||||
deleted = _mutations.delete_file(vault, source_path)
|
||||
return {"strategy": strategy, "target": target_path, "edit": result, "deleted": source_path, "delete": deleted}
|
||||
# append
|
||||
target_text = dst.read_text(encoding="utf-8", errors="replace")
|
||||
source_text = src.read_text(encoding="utf-8", errors="replace")
|
||||
merged = target_text.rstrip() + f"\n\n---\n\n_Fusionné depuis `{source_path}` (#166)_\n\n" + source_text.lstrip()
|
||||
result = _mutations.edit_file(vault, target_path, merged)
|
||||
deleted = _mutations.delete_file(vault, source_path)
|
||||
return {"strategy": strategy, "target": target_path, "edit": result, "deleted": source_path, "delete": deleted}
|
||||
+1044
-36
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from backend.auth.middleware import is_home_vault
|
||||
from backend.history import get_recent_opened, is_bookmarked
|
||||
from backend.indexer import find_file_in_index, index
|
||||
|
||||
@@ -31,6 +32,10 @@ def humanize_mtime(mtime: float) -> str:
|
||||
|
||||
|
||||
def _can_access(vault: str, user_vaults: list[str]) -> bool:
|
||||
# #194 : un dossier perso ne bénéficie jamais de "*" (même règle que
|
||||
# backend.auth.middleware.check_vault_access).
|
||||
if is_home_vault(vault):
|
||||
return vault in user_vaults
|
||||
return "*" in user_vaults or vault in user_vaults
|
||||
|
||||
|
||||
|
||||
+112
-8
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -11,15 +12,29 @@ def search_vaults(
|
||||
tag: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
is_allowed: Callable[[str], bool] | None = None,
|
||||
username: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Full-text search with pagination, returned as the API response payload.
|
||||
|
||||
No permission filtering is applied here: callers that need it (the tool
|
||||
layer) filter the ``results`` list themselves.
|
||||
``is_allowed`` (#194) filters the raw hits **before** pagination, so a
|
||||
restricted vault set neither distorts ``total`` nor the returned page.
|
||||
The tool layer filters on its own and leaves it as ``None``.
|
||||
|
||||
``username`` (#196) : les documents **reçus** par cet utilisateur via un
|
||||
partage dirigé sont ajoutés aux résultats (lecture seule, sans indexer
|
||||
les fichiers d'autrui — le contenu est lu à la volée et mis en cache
|
||||
TF-IDF côté index, pas sur disque).
|
||||
"""
|
||||
from backend.search import search
|
||||
|
||||
all_results = search(q, vault_filter=vault, tag_filter=tag)
|
||||
if is_allowed is not None:
|
||||
all_results = [r for r in all_results if is_allowed(r.get("vault", ""))]
|
||||
|
||||
if username is not None:
|
||||
all_results = all_results + _shared_results(username, q, vault)
|
||||
|
||||
total = len(all_results)
|
||||
page = all_results[offset: offset + limit]
|
||||
return {
|
||||
@@ -34,6 +49,67 @@ def search_vaults(
|
||||
}
|
||||
|
||||
|
||||
def _shared_results(username: str, q: str, vault: str) -> list[dict[str, Any]]:
|
||||
"""Search results for documents shared TO *username* (#196).
|
||||
|
||||
Reads the shared file content on the fly (read-only, source vault on
|
||||
disk) and returns hits shaped exactly like ordinary search results so
|
||||
the frontend can open them via the normal share page.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from backend.indexer import get_vault_data
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.share import list_shares
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
if not q:
|
||||
return out
|
||||
q_lower = q.lower()
|
||||
|
||||
for s in list_shares(user=username):
|
||||
if s.get("created_by") == username:
|
||||
continue # own share — already indexed in its own vault
|
||||
if vault not in ("all", f"home-{username}"):
|
||||
continue
|
||||
data = get_vault_data(s["vault"])
|
||||
if not data:
|
||||
continue
|
||||
try:
|
||||
fp = resolve_safe_path(Path(data["path"]), s["path"])
|
||||
if not fp.exists() or fp.suffix.lower() != ".md":
|
||||
continue
|
||||
raw = fp.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
continue
|
||||
title = fp.stem
|
||||
occurrences = raw.lower().count(q_lower)
|
||||
if q_lower in title.lower():
|
||||
occurrences += 1
|
||||
if occurrences == 0:
|
||||
continue
|
||||
out.append({
|
||||
"vault": f"home-{username}",
|
||||
"path": f"Partage/{fp.name}",
|
||||
"title": title,
|
||||
"tags": [],
|
||||
"score": min(occurrences, 10),
|
||||
"snippet": _snippet(raw, q_lower),
|
||||
"modified": None,
|
||||
"share_token": s["token"],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _snippet(text: str, q_lower: str, width: int = 160) -> str:
|
||||
"""Small excerpt around the first match (#196, shared-file search)."""
|
||||
idx = text.lower().find(q_lower)
|
||||
if idx < 0:
|
||||
return text[:width]
|
||||
start = max(0, idx - width // 2)
|
||||
return "…" + text[start:start + width].replace("\n", " ") + "…"
|
||||
|
||||
|
||||
def list_tags(vault: str | None = None) -> dict[str, int]:
|
||||
"""Return tag → count, optionally restricted to a single vault."""
|
||||
from backend.search import get_all_tags
|
||||
@@ -122,23 +198,51 @@ def list_paths(vault: str, limit: int = 5000) -> dict[str, Any]:
|
||||
once lets the client filter files/directories instantly instead of issuing
|
||||
a request per keystroke.
|
||||
|
||||
#197 (synchro TikTak) : chaque entrée fichier expose aussi ``size``,
|
||||
``mtime`` (epoch) et ``mtime_iso`` — un client externe peut détecter les
|
||||
fichiers modifiés depuis un horodatage en **un seul appel**, sans relire
|
||||
chaque fichier. Les valeurs viennent de l'index (source unique, même
|
||||
contrat que ``list_all_files``) ; les répertoires portent ``None``.
|
||||
|
||||
Args:
|
||||
vault: Vault name.
|
||||
limit: Maximum number of entries returned.
|
||||
|
||||
Returns:
|
||||
``{"vault", "count", "results": [{vault, path, name, type}]}``.
|
||||
``{"vault", "count", "results": [{vault, path, name, type, size?,
|
||||
mtime?, mtime_iso?}]}``.
|
||||
"""
|
||||
from backend.indexer import path_index
|
||||
from datetime import datetime
|
||||
|
||||
from backend.indexer import get_vault_data, path_index
|
||||
|
||||
entries = path_index.get(vault, [])
|
||||
results = [
|
||||
{
|
||||
meta: dict[str, dict[str, Any]] = {}
|
||||
data = get_vault_data(vault)
|
||||
if data:
|
||||
for info in data.get("files", []):
|
||||
meta[info["path"]] = info
|
||||
|
||||
results = []
|
||||
for entry in entries[: max(0, limit)]:
|
||||
item: dict[str, Any] = {
|
||||
"vault": vault,
|
||||
"path": entry["path"],
|
||||
"name": entry["name"],
|
||||
"type": entry["type"],
|
||||
"size": None,
|
||||
"mtime": None,
|
||||
"mtime_iso": None,
|
||||
}
|
||||
for entry in entries[: max(0, limit)]
|
||||
]
|
||||
info = meta.get(entry["path"])
|
||||
if info is not None:
|
||||
item["size"] = info.get("size")
|
||||
iso = info.get("modified")
|
||||
item["mtime_iso"] = iso
|
||||
if iso:
|
||||
try:
|
||||
item["mtime"] = datetime.fromisoformat(iso).timestamp()
|
||||
except (ValueError, TypeError):
|
||||
item["mtime"] = None
|
||||
results.append(item)
|
||||
return {"vault": vault, "count": len(results), "results": results}
|
||||
|
||||
@@ -49,6 +49,8 @@ def get_vault_root(vault_name: str) -> Path:
|
||||
|
||||
def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||
"""Return the direct children of a vault directory (directories first)."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from backend.indexer import SUPPORTED_EXTENSIONS
|
||||
from backend.vault_settings import get_vault_setting
|
||||
|
||||
@@ -89,11 +91,14 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||
"children_count": file_count,
|
||||
})
|
||||
elif entry.suffix.lower() in SUPPORTED_EXTENSIONS or entry.name.lower() in ("dockerfile", "makefile"):
|
||||
stat = entry.stat()
|
||||
items.append({
|
||||
"name": entry.name,
|
||||
"path": rel,
|
||||
"type": "file",
|
||||
"size": entry.stat().st_size,
|
||||
"size": stat.st_size,
|
||||
"modified": stat.st_mtime,
|
||||
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||
"extension": entry.suffix.lower(),
|
||||
})
|
||||
except PermissionError:
|
||||
@@ -102,6 +107,29 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||
return {"vault": vault_name, "path": path, "items": items}
|
||||
|
||||
|
||||
def _indexed_tags(vault_name: str, rel_path: str) -> list[str]:
|
||||
"""Tags of a file as stored in the search index (#158).
|
||||
|
||||
Empty list when the file is not indexed yet (binary formats, index still
|
||||
building) — the listing itself comes from the filesystem, tags are a
|
||||
decoration used by the navigation page facets/filters.
|
||||
|
||||
Args:
|
||||
vault_name: Vault name.
|
||||
rel_path: Path of the file relative to the vault root (``/`` separated).
|
||||
|
||||
Returns:
|
||||
The file's tags, or an empty list when unknown.
|
||||
"""
|
||||
try:
|
||||
from backend.search import get_inverted_index
|
||||
|
||||
info = get_inverted_index().doc_info.get(f"{vault_name}::{rel_path}")
|
||||
except Exception:
|
||||
return []
|
||||
return list((info or {}).get("tags") or [])
|
||||
|
||||
|
||||
def list_all_files(
|
||||
vault_name: str,
|
||||
dir: str = "",
|
||||
@@ -189,6 +217,7 @@ def list_all_files(
|
||||
"modified": stat.st_mtime,
|
||||
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||
"extension": ext.lstrip(".") if ext else "",
|
||||
"tags": _indexed_tags(vault_name, rel_path),
|
||||
}
|
||||
if rel_to_dir and rel_to_dir != ".":
|
||||
file_entry["rel_dir"] = rel_to_dir
|
||||
|
||||
+65
-42
@@ -10,6 +10,7 @@ No authentication required for public share views.
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -17,6 +18,10 @@ logger = logging.getLogger("obsigate.share")
|
||||
|
||||
SHARES_FILE = Path("data/shares.json")
|
||||
|
||||
# ROADMAP #85 T10a — verrou autour des read-modify-write (perte de mises à
|
||||
# jour en cas de créations/accès/révocations concurrents).
|
||||
_lock = threading.RLock()
|
||||
|
||||
|
||||
def _read() -> dict:
|
||||
if not SHARES_FILE.exists():
|
||||
@@ -39,28 +44,36 @@ def create_share(
|
||||
path: str,
|
||||
created_by: str,
|
||||
expires_in_hours: int | None = None,
|
||||
shared_with: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Create a new share token for a document."""
|
||||
data = _read()
|
||||
token = secrets.token_hex(32) # 64-char hex token
|
||||
"""Create a new share token for a document.
|
||||
|
||||
expires_at = None
|
||||
if expires_in_hours:
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(hours=expires_in_hours)).isoformat()
|
||||
``shared_with`` non vide (#196) : partage **dirigé** — la page ``/s/…``
|
||||
exige alors une session et n'accepte que les destinataires listés (plus
|
||||
le créateur et les admins). Vide/absent : comportement public inchangé.
|
||||
"""
|
||||
with _lock:
|
||||
data = _read()
|
||||
token = secrets.token_hex(32) # 64-char hex token
|
||||
|
||||
share = {
|
||||
"id": token,
|
||||
"token": token,
|
||||
"vault": vault,
|
||||
"path": path,
|
||||
"created_by": created_by,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"expires_at": expires_at,
|
||||
"access_count": 0,
|
||||
"last_accessed": None,
|
||||
}
|
||||
data["shares"][token] = share
|
||||
_write(data)
|
||||
expires_at = None
|
||||
if expires_in_hours:
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(hours=expires_in_hours)).isoformat()
|
||||
|
||||
share = {
|
||||
"id": token,
|
||||
"token": token,
|
||||
"vault": vault,
|
||||
"path": path,
|
||||
"created_by": created_by,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"expires_at": expires_at,
|
||||
"access_count": 0,
|
||||
"last_accessed": None,
|
||||
"shared_with": list(shared_with) if shared_with else [],
|
||||
}
|
||||
data["shares"][token] = share
|
||||
_write(data)
|
||||
logger.info(f"Created share for {vault}/{path} by {created_by}")
|
||||
return share
|
||||
|
||||
@@ -80,29 +93,38 @@ def get_share_by_token(token: str) -> dict | None:
|
||||
|
||||
def record_access(token: str):
|
||||
"""Increment access counter for a share."""
|
||||
data = _read()
|
||||
share = data["shares"].get(token)
|
||||
if share:
|
||||
share["access_count"] = share.get("access_count", 0) + 1
|
||||
share["last_accessed"] = datetime.now(timezone.utc).isoformat()
|
||||
_write(data)
|
||||
with _lock:
|
||||
data = _read()
|
||||
share = data["shares"].get(token)
|
||||
if share:
|
||||
share["access_count"] = share.get("access_count", 0) + 1
|
||||
share["last_accessed"] = datetime.now(timezone.utc).isoformat()
|
||||
_write(data)
|
||||
|
||||
|
||||
def revoke_share(share_id: str) -> bool:
|
||||
"""Revoke (delete) a share by its token."""
|
||||
data = _read()
|
||||
if share_id in data["shares"]:
|
||||
del data["shares"][share_id]
|
||||
_write(data)
|
||||
logger.info(f"Revoked share {share_id}")
|
||||
return True
|
||||
with _lock:
|
||||
data = _read()
|
||||
if share_id in data["shares"]:
|
||||
del data["shares"][share_id]
|
||||
_write(data)
|
||||
logger.info(f"Revoked share {share_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def list_shares(vault_filter: str | None = None) -> list:
|
||||
"""List all shares, optionally filtered by vault."""
|
||||
def list_shares(vault_filter: str | None = None, user: str | None = None) -> list:
|
||||
"""List shares, optionally filtered by vault and/or requesting user.
|
||||
|
||||
#196 : un non-admin ne voit que les partages qu'il a créés **ou** qui lui
|
||||
sont dirigés. Un admin voit tout. ``user=None`` (anciens appels, tests
|
||||
unitaires) conserve l'ancien comportement : tout lister.
|
||||
"""
|
||||
data = _read()
|
||||
shares = list(data["shares"].values())
|
||||
if user is not None:
|
||||
shares = [s for s in shares if user in (s.get("created_by"), *(s.get("shared_with") or []))]
|
||||
if vault_filter:
|
||||
shares = [s for s in shares if s["vault"] == vault_filter]
|
||||
# Most recent first
|
||||
@@ -112,12 +134,13 @@ def list_shares(vault_filter: str | None = None) -> list:
|
||||
|
||||
def update_shares_after_rename(vault: str, old_path: str, new_path: str):
|
||||
"""Update all shares when a file is renamed."""
|
||||
data = _read()
|
||||
updated = False
|
||||
for sid, s in data["shares"].items():
|
||||
if s.get("vault") == vault and s.get("path") == old_path:
|
||||
s["path"] = new_path
|
||||
updated = True
|
||||
logger.info(f"Updated share {sid}: {vault}/{old_path} -> {new_path}")
|
||||
if updated:
|
||||
_write(data)
|
||||
with _lock:
|
||||
data = _read()
|
||||
updated = False
|
||||
for sid, s in data["shares"].items():
|
||||
if s.get("vault") == vault and s.get("path") == old_path:
|
||||
s["path"] = new_path
|
||||
updated = True
|
||||
logger.info(f"Updated share {sid}: {vault}/{old_path} -> {new_path}")
|
||||
if updated:
|
||||
_write(data)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Server-Sent Events manager (ROADMAP #85, tranche 4).
|
||||
|
||||
Singleton extrait de :mod:`backend.main` sans changement de comportement :
|
||||
les routers montés par ``main`` partagent la même instance (les clients SSE
|
||||
connectés sur ``/api/events`` reçoivent les broadcasts émis depuis
|
||||
n'importe quel router).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json as _json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
|
||||
class SSEManager:
|
||||
"""Manages SSE client connections and broadcasts events."""
|
||||
|
||||
def __init__(self):
|
||||
self._clients: list[asyncio.Queue] = []
|
||||
|
||||
async def connect(self) -> asyncio.Queue:
|
||||
"""Register a new SSE client and return its message queue."""
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
self._clients.append(queue)
|
||||
logger.debug(f"SSE client connected (total: {len(self._clients)})")
|
||||
return queue
|
||||
|
||||
def disconnect(self, queue: asyncio.Queue):
|
||||
"""Remove a disconnected SSE client."""
|
||||
if queue in self._clients:
|
||||
self._clients.remove(queue)
|
||||
logger.debug(f"SSE client disconnected (total: {len(self._clients)})")
|
||||
|
||||
async def broadcast(self, event_type: str, data: dict):
|
||||
"""Send an event to all connected SSE clients."""
|
||||
message = _json.dumps(data, ensure_ascii=False)
|
||||
dead: list[asyncio.Queue] = []
|
||||
for q in self._clients:
|
||||
try:
|
||||
q.put_nowait({"event": event_type, "data": message})
|
||||
except asyncio.QueueFull:
|
||||
dead.append(q)
|
||||
for q in dead:
|
||||
self.disconnect(q)
|
||||
|
||||
@property
|
||||
def client_count(self) -> int:
|
||||
return len(self._clients)
|
||||
|
||||
|
||||
sse_manager = SSEManager()
|
||||
@@ -12,9 +12,14 @@ which ``.gitignore`` excludes via ``_*.py``), hence this explicit facade.
|
||||
from backend.tools import connected as _connected # noqa: F401 (registers connected-source tools)
|
||||
from backend.tools import crawler as _crawler # noqa: F401 (registers the site crawler)
|
||||
from backend.tools import documents as _documents # noqa: F401 (registers document tools)
|
||||
from backend.tools import duplicates as _duplicates # noqa: F401 (registers duplicate tools #166)
|
||||
from backend.tools import notify as _notify_tools # noqa: F401 (registers notify tool #168)
|
||||
from backend.tools import scheduled as _scheduled # noqa: F401 (registers scheduler tools #170)
|
||||
from backend.tools import service as _service # noqa: F401 (registers tools)
|
||||
from backend.tools import spreadsheets as _spreadsheets # noqa: F401 (registers existing-workbook tools #153 A6)
|
||||
from backend.tools import web as _web # noqa: F401 (registers web tools)
|
||||
from backend.tools.context import (
|
||||
FILES_SCOPE_TOOLS,
|
||||
ToolConfirmationRequired,
|
||||
ToolContext,
|
||||
ToolError,
|
||||
@@ -48,6 +53,7 @@ from backend.tools.registry import (
|
||||
from backend.tools.schemas import ToolResult
|
||||
|
||||
__all__ = [
|
||||
"FILES_SCOPE_TOOLS",
|
||||
"ToolConfirmationRequired",
|
||||
"ToolContext",
|
||||
"ToolError",
|
||||
|
||||
@@ -117,6 +117,29 @@ class ToolRateLimitError(ToolError):
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
#: Tools a ``files``-scoped API token (#199, synchro TikTak) may invoke :
|
||||
#: vault file read/write surface only — no external calls (git/web), no
|
||||
#: notifications, no scheduler, no cross-vault analysis beyond file content.
|
||||
FILES_SCOPE_TOOLS: frozenset[str] = frozenset({
|
||||
# Lecture / recherche de fichiers
|
||||
"list_vaults", "list_directory", "list_all_files",
|
||||
"read_file", "read_file_raw", "get_backlinks",
|
||||
"get_graph", "search_fulltext", "search_advanced", "search_paths",
|
||||
"list_tags", "suggest_tags", "list_recent",
|
||||
"list_backups", "diff_backup", "restore_backup",
|
||||
"find_duplicates",
|
||||
# Mutations de fichiers
|
||||
"create_file", "create_directory", "edit_file", "append_to_file",
|
||||
"rename_file", "rename_directory", "move_path", "replace_in_files",
|
||||
"delete_file", "delete_directory", "merge_duplicate_notes",
|
||||
# Tableurs & documents (création/édition de fichiers du vault)
|
||||
"create_xlsx", "create_docx", "create_csv", "create_pdf",
|
||||
"list_xlsx_sheets", "xlsx_to_markdown", "search_workbook",
|
||||
"analyze_range", "update_xlsx_cells", "append_xlsx_rows",
|
||||
"edit_xlsx_structure",
|
||||
})
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolContext:
|
||||
"""Carries the caller identity and execution options for a tool call.
|
||||
@@ -190,6 +213,32 @@ class ToolContext:
|
||||
details={"vault": vault},
|
||||
)
|
||||
|
||||
def require_tool_allowed(self, tool: str) -> None:
|
||||
"""Raise :class:`ToolPermissionError` when the caller's token scope
|
||||
forbids *tool* (#199).
|
||||
|
||||
A ``files``-scoped token is confined to :data:`FILES_SCOPE_TOOLS`
|
||||
(vault file surface). Full-scope and scopeless callers (session
|
||||
tokens, pre-#199 API keys) are unaffected ; an unknown scope fails
|
||||
closed.
|
||||
"""
|
||||
token_scope = self.user.get("_token_scope") or "full"
|
||||
if token_scope == "full":
|
||||
return
|
||||
if token_scope == "files":
|
||||
if tool not in FILES_SCOPE_TOOLS:
|
||||
raise ToolPermissionError(
|
||||
f"Token à portée limitée : outil '{tool}' hors périmètre 'files'",
|
||||
code="token_scope_denied",
|
||||
details={"tool": tool, "scope": "files"},
|
||||
)
|
||||
return
|
||||
raise ToolPermissionError(
|
||||
"Token à portée inconnue : appel refusé",
|
||||
code="token_scope_denied",
|
||||
details={"tool": tool},
|
||||
)
|
||||
|
||||
|
||||
def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
|
||||
"""Resolve a vault-relative path, rejecting traversal outside the vault.
|
||||
|
||||
@@ -18,8 +18,10 @@ import csv as csv_lib
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
from xml.sax import saxutils
|
||||
from typing import Any, cast
|
||||
|
||||
# saxutils.escape uniquement (échappement de chaînes, aucun parsing XML).
|
||||
from xml.sax import saxutils # nosec B406
|
||||
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.mutations import save_raw_file
|
||||
@@ -171,7 +173,9 @@ def _render_markdown_pdf(content: str, title: str) -> bytes | None:
|
||||
escape=False,
|
||||
plugins=["table", "strikethrough", "footnotes", "task_lists"],
|
||||
)
|
||||
html = renderer(content)
|
||||
# mistune 3.3 types `Markdown.__call__` as `str | list[...]` (le
|
||||
# renderer HTML renvoie toujours `str` à l'exécution).
|
||||
html = cast(str, renderer(content))
|
||||
return generate_pdf(build_pdf_html(html, title), title)
|
||||
except Exception as e:
|
||||
# WeasyPrint loads GTK lazily: a missing native library can surface at
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Duplicate detection & merge tools (#166).
|
||||
|
||||
* ``find_duplicates`` — READ, vault-scoped: candidate pairs with scores.
|
||||
* ``merge_duplicate_notes`` — DANGEROUS: confirmed fusion with automatic
|
||||
backup (service layer), never without an explicit approval.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from backend.services import duplicates as _duplicates
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.tools.context import ToolContext, ToolError, ToolRisk
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import FindDuplicatesInput, MergeDuplicatesInput
|
||||
|
||||
|
||||
@tool(
|
||||
name="find_duplicates",
|
||||
description="Find candidate duplicate markdown notes in a vault (similarity scores).",
|
||||
input_model=FindDuplicatesInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def find_duplicates(ctx: ToolContext, params: FindDuplicatesInput) -> dict[str, Any]:
|
||||
"""List duplicate candidates ordered by descending score."""
|
||||
try:
|
||||
return _duplicates.find_duplicate_pairs(
|
||||
params.vault,
|
||||
threshold=params.threshold,
|
||||
limit=params.limit,
|
||||
subdir=params.subdir,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise ToolError(e.message, code=e.code, details=e.details) from e
|
||||
|
||||
|
||||
@tool(
|
||||
name="merge_duplicate_notes",
|
||||
description=(
|
||||
"Merge one note into another and delete the source (backup first). "
|
||||
"Destructive: requires confirmation."
|
||||
),
|
||||
input_model=MergeDuplicatesInput,
|
||||
risk=ToolRisk.DANGEROUS,
|
||||
requires_vault=True,
|
||||
)
|
||||
def merge_duplicate_notes(ctx: ToolContext, params: MergeDuplicatesInput) -> dict[str, Any]:
|
||||
"""Fuse *source_path* into *target_path* using the chosen strategy."""
|
||||
try:
|
||||
return _duplicates.merge_duplicates(
|
||||
params.vault,
|
||||
params.source_path,
|
||||
params.target_path,
|
||||
strategy=params.strategy,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise ToolError(e.message, code=e.code, details=e.details) from e
|
||||
@@ -52,9 +52,23 @@ _STEP_LABELS: dict[str, tuple[str, str | None]] = {
|
||||
"git_search_issues": ("git_issues", "query"),
|
||||
"git_get_file": ("git_file", "path"),
|
||||
"create_xlsx": ("xlsx_create", "path"),
|
||||
"list_xlsx_sheets": ("xlsx_sheets", "path"),
|
||||
"xlsx_to_markdown": ("xlsx_read", "path"),
|
||||
"update_xlsx_cells": ("xlsx_update", "path"),
|
||||
"append_xlsx_rows": ("xlsx_append", "path"),
|
||||
"search_workbook": ("xlsx_search", "query"),
|
||||
"analyze_range": ("xlsx_analyze", "path"),
|
||||
"edit_xlsx_structure": ("xlsx_structure", "path"),
|
||||
"create_docx": ("docx_create", "path"),
|
||||
"create_csv": ("csv_create", "path"),
|
||||
"create_pdf": ("pdf_create", "path"),
|
||||
"find_duplicates": ("duplicates", "vault"),
|
||||
"merge_duplicate_notes": ("duplicates_merge", "source_path"),
|
||||
"notify_external": ("notify", "title"),
|
||||
"create_scheduled_task": ("schedule_create", "name"),
|
||||
"list_scheduled_tasks": ("schedule_list", None),
|
||||
"delete_scheduled_task": ("schedule_delete", "task_id"),
|
||||
"run_scheduled_task_now": ("schedule_run", "task_id"),
|
||||
}
|
||||
|
||||
GENERIC_KEY = "generic"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""External notification tool (#168) — Discord, Telegram, SMTP, webhook.
|
||||
|
||||
``notify_external`` is WRITE (external side effect → confirmation card in the
|
||||
UI, propose/apply over MCP). Delivery itself lives in :mod:`backend.notify`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from backend import notify as _notify
|
||||
from backend.tools.context import ToolContext, ToolError, ToolRisk
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import NotifyExternalInput
|
||||
|
||||
|
||||
@tool(
|
||||
name="notify_external",
|
||||
description="Send a notification through external channels (Discord, Telegram, SMTP, webhook).",
|
||||
input_model=NotifyExternalInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
)
|
||||
def notify_external(ctx: ToolContext, params: NotifyExternalInput) -> dict[str, Any]:
|
||||
"""Broadcast to the trigger scope, or target a single channel id."""
|
||||
try:
|
||||
if params.channel_id:
|
||||
channel = next(
|
||||
(c for c in _notify._read_channels() if c.get("id") == params.channel_id),
|
||||
None,
|
||||
)
|
||||
if channel is None:
|
||||
raise ToolError(f"Unknown channel: {params.channel_id}", code="not_found")
|
||||
if not channel.get("enabled", True):
|
||||
raise ToolError(f"Channel disabled: {params.channel_id}", code="invalid_arguments")
|
||||
_notify.send_via_channel(channel, params.title, params.message, params.trigger)
|
||||
return {"ok": True, "channel_id": params.channel_id}
|
||||
results = _notify.broadcast(params.trigger, params.title, params.message)
|
||||
return {"ok": True, "deliveries": results}
|
||||
except ToolError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ToolError(f"Notification failed: {e}", code="notify_failed") from e
|
||||
@@ -117,9 +117,22 @@ def list_tools(*, scope: ToolScope | None = None) -> list[ToolSpec]:
|
||||
return specs
|
||||
|
||||
|
||||
# ponytail: tool schemas are static after import (registration is decorator
|
||||
# only); keying the cache on len(_REGISTRY) invalidates it if a tool is ever
|
||||
# registered at runtime. Rebuilding 50 pydantic JSON schemas cost ~27 ms per
|
||||
# agent/MCP request.
|
||||
_SCHEMAS_CACHE: dict[Any, list[dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def get_tool_schemas(*, scope: ToolScope | None = None) -> list[dict[str, Any]]:
|
||||
"""Return OpenAI-compatible schemas for registered tools."""
|
||||
return [spec.openai_schema() for spec in list_tools(scope=scope)]
|
||||
"""Return OpenAI-compatible schemas for registered tools (cached)."""
|
||||
key = (scope, len(_REGISTRY))
|
||||
cached = _SCHEMAS_CACHE.get(key)
|
||||
if cached is None:
|
||||
cached = [spec.openai_schema() for spec in list_tools(scope=scope)]
|
||||
_SCHEMAS_CACHE.clear()
|
||||
_SCHEMAS_CACHE[key] = cached
|
||||
return [dict(s) for s in cached]
|
||||
|
||||
|
||||
def _audit(ctx: ToolContext, spec: ToolSpec, arguments: dict[str, Any], *, ok: bool, error: str | None = None) -> None:
|
||||
@@ -203,6 +216,14 @@ def call_tool(
|
||||
_audit(ctx, spec, arguments, ok=False, error=e.code)
|
||||
raise
|
||||
|
||||
# #199 : les tokens à portée limitée sont confinés à la surface fichiers,
|
||||
# y compris pour les outils sans vault (git, web, notify…).
|
||||
try:
|
||||
ctx.require_tool_allowed(name)
|
||||
except ToolPermissionError as e:
|
||||
_audit(ctx, spec, arguments, ok=False, error=e.code)
|
||||
raise
|
||||
|
||||
if spec.risk == ToolRisk.DANGEROUS and vault and vault != "all":
|
||||
try:
|
||||
ctx.require_destructive_allowed(vault)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Scheduled-task tools (#170) — the agent programs its own cron.
|
||||
|
||||
* ``create_scheduled_task`` — WRITE (a future write, confirmed once now).
|
||||
* ``list_scheduled_tasks`` — READ.
|
||||
* ``delete_scheduled_task`` — WRITE (removes a future side effect).
|
||||
* ``run_scheduled_task_now`` — WRITE (immediate side effect).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from backend import scheduler as _scheduler
|
||||
from backend.tools.context import ToolContext, ToolError, ToolRisk
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import (
|
||||
CreateScheduledTaskInput,
|
||||
DeleteScheduledTaskInput,
|
||||
ListVaultsInput,
|
||||
RunScheduledTaskInput,
|
||||
)
|
||||
|
||||
|
||||
@tool(
|
||||
name="create_scheduled_task",
|
||||
description="Program an automatic task (create_file, append_to_file, notify) on a cron-like schedule.",
|
||||
input_model=CreateScheduledTaskInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
)
|
||||
def create_scheduled_task(ctx: ToolContext, params: CreateScheduledTaskInput) -> dict[str, Any]:
|
||||
"""Create a task owned by the requesting user."""
|
||||
try:
|
||||
return _scheduler.create_task(
|
||||
params.name,
|
||||
params.action,
|
||||
params.schedule,
|
||||
created_by=ctx.username,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise ToolError(str(e), code="invalid_arguments") from e
|
||||
|
||||
|
||||
@tool(
|
||||
name="list_scheduled_tasks",
|
||||
description="List automatic tasks programmed in ObsiGate.",
|
||||
input_model=ListVaultsInput,
|
||||
risk=ToolRisk.READ,
|
||||
)
|
||||
def list_scheduled_tasks(ctx: ToolContext, _params: ListVaultsInput) -> list[dict[str, Any]]:
|
||||
"""Return tasks newest first."""
|
||||
return _scheduler.list_tasks()
|
||||
|
||||
|
||||
@tool(
|
||||
name="delete_scheduled_task",
|
||||
description="Delete a programmed automatic task.",
|
||||
input_model=DeleteScheduledTaskInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
)
|
||||
def delete_scheduled_task(ctx: ToolContext, params: DeleteScheduledTaskInput) -> dict[str, Any]:
|
||||
"""Delete by id; unknown id is a not_found tool error."""
|
||||
if not _scheduler.delete_task(params.task_id):
|
||||
raise ToolError(f"Unknown task: {params.task_id}", code="not_found")
|
||||
return {"ok": True, "task_id": params.task_id}
|
||||
|
||||
|
||||
@tool(
|
||||
name="run_scheduled_task_now",
|
||||
description="Execute a programmed task immediately (manual run).",
|
||||
input_model=RunScheduledTaskInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
)
|
||||
def run_scheduled_task_now(ctx: ToolContext, params: RunScheduledTaskInput) -> dict[str, Any]:
|
||||
"""Run now and return the outcome (failures are recorded + notified)."""
|
||||
try:
|
||||
return _scheduler.run_task(params.task_id, manual=True)
|
||||
except KeyError as e:
|
||||
raise ToolError(f"Unknown task: {params.task_id}", code="not_found") from e
|
||||
@@ -315,6 +315,110 @@ class DocxInput(BaseModel):
|
||||
overwrite: bool = Field(True, description="Replace an existing file (with backup)")
|
||||
|
||||
|
||||
class ListXlsxSheetsInput(BaseModel):
|
||||
"""List the sheets of an existing .xlsx workbook (#153 A6)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the .xlsx file")
|
||||
|
||||
|
||||
class XlsxToMarkdownInput(BaseModel):
|
||||
"""Read one sheet of an existing .xlsx workbook as markdown (#153 A6)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the .xlsx file")
|
||||
sheet: str = Field(
|
||||
"", description="Sheet name (empty = the first/active sheet)"
|
||||
)
|
||||
|
||||
|
||||
class SearchWorkbookInput(BaseModel):
|
||||
"""Find a text across the sheets of a spreadsheet (#156 A14)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(
|
||||
..., description="Vault-relative path of the file (.xlsx, .xlsm or .csv)"
|
||||
)
|
||||
query: str = Field(..., description="Text to look for")
|
||||
sheet: str = Field("", description="Restrict to one sheet (empty = all sheets)")
|
||||
case_sensitive: bool = Field(False, description="Match case")
|
||||
|
||||
|
||||
class AnalyzeRangeInput(BaseModel):
|
||||
"""Aggregate the values of an A1 range (#156 A14)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(
|
||||
..., description="Vault-relative path of the file (.xlsx, .xlsm or .csv)"
|
||||
)
|
||||
sheet: str = Field(
|
||||
"", description="Sheet name (empty = the first/active sheet)"
|
||||
)
|
||||
range: str = Field(
|
||||
"",
|
||||
description="A1 range to analyse (e.g. 'B2:B50'); empty = the whole sheet",
|
||||
)
|
||||
|
||||
|
||||
class UpdateXlsxCellsInput(BaseModel):
|
||||
"""Batch-edit cells of an existing .xlsx workbook (#153 A6)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the .xlsx file")
|
||||
sheet: str = Field(
|
||||
"", description="Worksheet title to edit (ignored for a .csv)"
|
||||
)
|
||||
cells: dict[str, str | int | float | bool | None] = Field(
|
||||
..., description="A1 reference -> new value (max 500 per call)"
|
||||
)
|
||||
allow_formula: bool = Field(
|
||||
False,
|
||||
description="Store '='/'@' values as real formulas (off by default, DDE guard)",
|
||||
)
|
||||
force: bool = Field(
|
||||
False,
|
||||
description="Write even when features openpyxl cannot rewrite would be dropped",
|
||||
)
|
||||
|
||||
|
||||
class AppendXlsxRowsInput(BaseModel):
|
||||
"""Append rows at the end of a sheet of an existing .xlsx (#153 A6)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the .xlsx file")
|
||||
sheet: str = Field(..., description="Worksheet title to extend")
|
||||
rows: list[list[str | int | float | bool | None]] = Field(
|
||||
..., description="Rows of cell values, appended below the last used row (max 500)"
|
||||
)
|
||||
allow_formula: bool = Field(
|
||||
False,
|
||||
description="Store '='/'@' values as real formulas (off by default, DDE guard)",
|
||||
)
|
||||
force: bool = Field(
|
||||
False,
|
||||
description="Write even when features openpyxl cannot rewrite would be dropped",
|
||||
)
|
||||
|
||||
|
||||
class EditXlsxStructureInput(BaseModel):
|
||||
"""Structural CRUD on an existing workbook (#156 A14)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the .xlsx/.xlsm file")
|
||||
actions: list[dict[str, Any]] = Field(
|
||||
...,
|
||||
description=(
|
||||
"Ordered structural actions (1-50): sheet_add/sheet_rename/"
|
||||
"sheet_duplicate/sheet_delete, row_insert/row_delete/"
|
||||
"col_insert/col_delete"
|
||||
),
|
||||
)
|
||||
force: bool = Field(
|
||||
False,
|
||||
description="Write even when features openpyxl cannot rewrite would be dropped",
|
||||
)
|
||||
|
||||
|
||||
class CsvInput(BaseModel):
|
||||
"""Create a .csv file in a vault from rows of cells."""
|
||||
|
||||
@@ -337,6 +441,53 @@ class PdfInput(BaseModel):
|
||||
overwrite: bool = Field(True, description="Replace an existing file (with backup)")
|
||||
|
||||
|
||||
class FindDuplicatesInput(BaseModel):
|
||||
"""Find candidate duplicate notes in a vault (#166)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
threshold: float = Field(0.75, ge=0.3, le=1.0, description="Minimum similarity score")
|
||||
limit: int = Field(20, ge=1, le=200, description="Maximum number of pairs")
|
||||
subdir: str = Field("", description="Vault-relative directory scope (empty = whole vault)")
|
||||
|
||||
|
||||
class MergeDuplicatesInput(BaseModel):
|
||||
"""Merge one note into another, then delete the source (#166, destructive)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
source_path: str = Field(..., description="Vault-relative path of the note to absorb")
|
||||
target_path: str = Field(..., description="Vault-relative path of the surviving note")
|
||||
strategy: str = Field("append", description="'append', 'prefer_target' or 'prefer_source'")
|
||||
|
||||
|
||||
class NotifyExternalInput(BaseModel):
|
||||
"""Send a notification through external channels (#168)."""
|
||||
|
||||
title: str = Field(..., min_length=1, description="Notification title")
|
||||
message: str = Field(..., min_length=1, description="Notification body")
|
||||
trigger: str = Field("manual", description="Trigger scope: manual, schedule_failure, schedule_success")
|
||||
channel_id: str = Field("", description="Single channel id (empty = broadcast to trigger)")
|
||||
|
||||
|
||||
class CreateScheduledTaskInput(BaseModel):
|
||||
"""Create an automatic task executed by the scheduler (#170)."""
|
||||
|
||||
name: str = Field(..., min_length=1, description="Task display name")
|
||||
action: dict[str, Any] = Field(..., description="{kind, params} (create_file, append_to_file, notify)")
|
||||
schedule: dict[str, Any] = Field(..., description="{kind, ...} (interval_hours, daily_time, once_at)")
|
||||
|
||||
|
||||
class DeleteScheduledTaskInput(BaseModel):
|
||||
"""Delete a scheduled task by id (#170)."""
|
||||
|
||||
task_id: str = Field(..., min_length=1, description="Task id")
|
||||
|
||||
|
||||
class RunScheduledTaskInput(BaseModel):
|
||||
"""Execute a scheduled task immediately (#170)."""
|
||||
|
||||
task_id: str = Field(..., min_length=1, description="Task id")
|
||||
|
||||
|
||||
class ToolResult(BaseModel):
|
||||
"""Uniform result returned by :func:`backend.tools.registry.call_tool`."""
|
||||
|
||||
|
||||
+17
-11
@@ -17,6 +17,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("obsigate.tools.secrets")
|
||||
@@ -34,6 +35,9 @@ TOOL_KEY_NAMES: tuple[str, ...] = (
|
||||
|
||||
_SECRET_MARKERS = ("API_KEY", "TOKEN")
|
||||
|
||||
# ROADMAP #85 T10a — verrou autour des read-modify-write du store de clés.
|
||||
_lock = threading.RLock()
|
||||
|
||||
|
||||
def _keys_file() -> Path:
|
||||
base = os.environ.get("OBSIGATE_DATA_DIR", "data")
|
||||
@@ -89,21 +93,23 @@ def set_tool_key(name: str, value: str) -> None:
|
||||
if name not in TOOL_KEY_NAMES:
|
||||
raise ValueError(f"Clé non prise en charge: {name}")
|
||||
value = (value or "").strip()
|
||||
keys = _read_keys()
|
||||
if value:
|
||||
keys[name] = value
|
||||
else:
|
||||
keys.pop(name, None)
|
||||
_write_keys(keys)
|
||||
with _lock:
|
||||
keys = _read_keys()
|
||||
if value:
|
||||
keys[name] = value
|
||||
else:
|
||||
keys.pop(name, None)
|
||||
_write_keys(keys)
|
||||
|
||||
|
||||
def delete_tool_key(name: str) -> bool:
|
||||
"""Remove one key from the store; return True when it existed."""
|
||||
if name not in TOOL_KEY_NAMES:
|
||||
raise ValueError(f"Clé non prise en charge: {name}")
|
||||
keys = _read_keys()
|
||||
if name in keys:
|
||||
del keys[name]
|
||||
_write_keys(keys)
|
||||
return True
|
||||
with _lock:
|
||||
keys = _read_keys()
|
||||
if name in keys:
|
||||
del keys[name]
|
||||
_write_keys(keys)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -123,10 +123,17 @@ def list_vaults(ctx: ToolContext, _params: ListVaultsInput) -> list[dict[str, An
|
||||
def list_directory(ctx: ToolContext, params: ListDirectoryInput) -> list[dict[str, Any]]:
|
||||
"""Return the entries of a vault directory (direct children only)."""
|
||||
data = browse_directory(params.vault, params.path)
|
||||
return [
|
||||
{"name": item["name"], "path": item["path"], "type": item["type"]}
|
||||
for item in data["items"]
|
||||
]
|
||||
# #197 : même contrat que la REST — la taille et le mtime permettent la
|
||||
# réconciliation externe sans relire chaque fichier.
|
||||
out = []
|
||||
for item in data["items"]:
|
||||
entry = {"name": item["name"], "path": item["path"], "type": item["type"]}
|
||||
if item["type"] == "file":
|
||||
entry["size"] = item.get("size")
|
||||
entry["mtime"] = item.get("modified")
|
||||
entry["mtime_iso"] = item.get("modified_iso")
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
@tool(
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
"""Spreadsheet tools (#153 A6, #156 A14) — read and mutate existing workbooks.
|
||||
|
||||
Complements :mod:`backend.tools.documents` (``create_xlsx`` creates a *new*
|
||||
file; here the assistant can read and edit one that already exists). The same
|
||||
three formats the viewer edits are supported — ``.xlsx``, ``.xlsm`` and
|
||||
``.csv`` (#156-A14 used to be ``.xlsx`` only, which made a workbook the UI
|
||||
edits invisible to the assistant):
|
||||
|
||||
* ``list_xlsx_sheets`` — READ, sheet names + dimensions;
|
||||
* ``xlsx_to_markdown`` — READ, bounded markdown table for the LLM context;
|
||||
* ``search_workbook`` — READ, find text across every sheet (#156-A14);
|
||||
* ``analyze_range`` — READ, aggregate stats over an A1 range (#156-A14);
|
||||
* ``update_xlsx_cells`` — WRITE, batch cell edits (guarded service);
|
||||
* ``append_xlsx_rows`` — WRITE, append whole rows at the end of a sheet;
|
||||
* ``edit_xlsx_structure`` — WRITE, structural CRUD (sheets/rows/columns).
|
||||
|
||||
Mutation tools go through :func:`backend.services.mutations.edit_xlsx_cells`
|
||||
(or ``save_csv_cells`` / ``mutate_xlsx_structure``), which already carry the
|
||||
#153 P0 guards: per-file lock, atomic replace, formula neutralisation
|
||||
(``allow_formula`` opt-in) and the lossy-write 409. Every write is a WRITE-risk
|
||||
tool, so the registry keeps asking for an explicit confirmation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
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.tools.context import ToolContext, ToolError, ToolRisk
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import (
|
||||
AnalyzeRangeInput,
|
||||
AppendXlsxRowsInput,
|
||||
EditXlsxStructureInput,
|
||||
ListXlsxSheetsInput,
|
||||
SearchWorkbookInput,
|
||||
UpdateXlsxCellsInput,
|
||||
XlsxToMarkdownInput,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("obsigate.tools.spreadsheets")
|
||||
|
||||
# xlsx_to_markdown ceiling: a workbook is a data dump, not prose. The table is
|
||||
# for the LLM context, so both axes are bounded (same spirit as A5's index cap).
|
||||
MAX_MD_ROWS = 100
|
||||
MAX_MD_COLS = 20
|
||||
MAX_MD_CHARS = 20_000
|
||||
|
||||
# #156-A14 — the newer read tools scan more than the markdown table (a search
|
||||
# or an aggregate must not stop at row 100) but stay bounded all the same:
|
||||
# a runaway scan would load a whole ledger into the model's context.
|
||||
MAX_SCAN_ROWS = 5_000
|
||||
MAX_SCAN_COLS = 100
|
||||
MAX_SEARCH_RESULTS = 100
|
||||
MAX_RANGE_CELLS = 10_000
|
||||
MAX_RANGE_VALUES = 200
|
||||
|
||||
# The formats the spreadsheet editor (and now the assistant) can handle.
|
||||
SPREADSHEET_EXTENSIONS = (".xlsx", ".xlsm", ".csv")
|
||||
|
||||
_NUMBER_RE = re.compile(r"^-?\d+(?:[.,]\d+)?$")
|
||||
|
||||
|
||||
def _spreadsheet_path(vault: str, path: str) -> Path:
|
||||
"""Resolve and validate a vault-relative ``.xlsx``/``.xlsm``/``.csv`` path."""
|
||||
path = (path or "").strip()
|
||||
if not path.lower().endswith(SPREADSHEET_EXTENSIONS):
|
||||
raise ToolError(
|
||||
"Extension attendue : .xlsx, .xlsm ou .csv", code="invalid_arguments"
|
||||
)
|
||||
try:
|
||||
root = get_vault_root(vault)
|
||||
except ServiceError as e:
|
||||
raise ToolError(e.message, code=e.code, details=e.details) from e
|
||||
return resolve_safe_path(root, path)
|
||||
|
||||
|
||||
def _is_csv(file_path: Path) -> bool:
|
||||
return file_path.suffix.lower() == ".csv"
|
||||
|
||||
|
||||
def _map_service_error(e: ServiceError) -> ToolError:
|
||||
return ToolError(e.message, code=e.code, details=e.details)
|
||||
|
||||
|
||||
def _sheet_titles(file_path: Path) -> list[str]:
|
||||
"""Sheet names of a workbook; a CSV has a single, unnamed “sheet”."""
|
||||
if _is_csv(file_path):
|
||||
return [""]
|
||||
from openpyxl import load_workbook
|
||||
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
try:
|
||||
return list(wb.sheetnames)
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
|
||||
def _read_grid(
|
||||
file_path: Path, sheet: str, max_rows: int, max_cols: int
|
||||
) -> tuple[str, list[list[str]], bool]:
|
||||
"""Read one sheet (or a CSV) as bounded, formatted, trimmed rows.
|
||||
|
||||
Returns ``(title, rows, truncated)``; ``truncated`` is True when real data
|
||||
sits just beyond the row cap (probed one row further) so the caller can say
|
||||
so instead of silently dropping it.
|
||||
"""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from backend.xlsx_reader import _fmt
|
||||
|
||||
if _is_csv(file_path):
|
||||
import csv as csv_mod
|
||||
import io as io_mod
|
||||
|
||||
from backend.xlsx_reader import sniff_csv_delimiter
|
||||
|
||||
stem = file_path.stem
|
||||
if sheet and sheet != stem:
|
||||
raise ToolError(f"Feuille introuvable: {sheet}", code="not_found")
|
||||
raw = file_path.read_text(encoding="utf-8-sig", errors="replace")
|
||||
reader = csv_mod.reader(io_mod.StringIO(raw), delimiter=sniff_csv_delimiter(raw))
|
||||
grid: list[list[str]] = []
|
||||
truncated = False
|
||||
for i, row in enumerate(reader):
|
||||
if i >= max_rows:
|
||||
truncated = any(str(c).strip() for c in row)
|
||||
break
|
||||
grid.append([str(c) for c in row][:max_cols])
|
||||
while grid and not any(c.strip() for c in grid[-1]):
|
||||
grid.pop()
|
||||
return stem, grid, truncated
|
||||
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
except Exception as e:
|
||||
raise ToolError(f"Classeur illisible: {e}", code="invalid") from e
|
||||
try:
|
||||
if sheet:
|
||||
if sheet not in wb.sheetnames:
|
||||
raise ToolError(f"Feuille introuvable: {sheet}", code="not_found")
|
||||
ws = wb[sheet]
|
||||
else:
|
||||
ws = wb.active
|
||||
title = ws.title
|
||||
grid = []
|
||||
for row in ws.iter_rows(
|
||||
min_row=1, max_row=max_rows, max_col=max_cols, values_only=True
|
||||
):
|
||||
grid.append([_fmt(v) for v in row])
|
||||
probe = list(
|
||||
ws.iter_rows(
|
||||
min_row=max_rows + 1,
|
||||
max_row=max_rows + 1,
|
||||
max_col=max_cols,
|
||||
values_only=True,
|
||||
)
|
||||
)
|
||||
truncated = any(any(str(v or "").strip() for v in r) for r in probe)
|
||||
finally:
|
||||
wb.close()
|
||||
while grid and not any(c.strip() for c in grid[-1]):
|
||||
grid.pop()
|
||||
return title, grid, truncated
|
||||
|
||||
|
||||
def _to_number(text: str) -> float | None:
|
||||
"""Coerce a displayed cell to a float, or ``None`` when it is not one."""
|
||||
t = text.strip().replace("\u00a0", "").replace(" ", "")
|
||||
if not _NUMBER_RE.match(t):
|
||||
return None
|
||||
try:
|
||||
return float(t.replace(",", "."))
|
||||
except ValueError: # pragma: no cover - regex already guarantees the shape
|
||||
return None
|
||||
|
||||
|
||||
@tool(
|
||||
name="list_xlsx_sheets",
|
||||
description=(
|
||||
"List the sheets of a spreadsheet (.xlsx, .xlsm or .csv) with their "
|
||||
"dimensions (rows x columns) and whether the display caps truncate "
|
||||
"them. Use before editing to pick the right sheet name."
|
||||
),
|
||||
input_model=ListXlsxSheetsInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def list_xlsx_sheets(ctx: ToolContext, params: ListXlsxSheetsInput) -> dict[str, Any]:
|
||||
"""Return sheet names and extents of the workbook (or of the CSV)."""
|
||||
from backend.xlsx_reader import MAX_COLS, MAX_ROWS
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise ToolError(f"Fichier introuvable: {params.path}", code="not_found")
|
||||
|
||||
extents: list[tuple[str, int, int]] = []
|
||||
if _is_csv(file_path):
|
||||
_, rows, _ = _read_grid(file_path, "", MAX_SCAN_ROWS, MAX_SCAN_COLS)
|
||||
extents.append(
|
||||
(
|
||||
file_path.stem,
|
||||
len(rows),
|
||||
max((len(r) for r in rows), default=0),
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Declared dimensions are enough here (and far cheaper than scanning
|
||||
# every row): the caller just needs a size to decide what to read.
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from backend.xlsx_reader import _sheet_extent
|
||||
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
except Exception as e:
|
||||
raise ToolError(f"Classeur illisible: {e}", code="invalid") from e
|
||||
try:
|
||||
extents = [
|
||||
(ws.title, *_sheet_extent(ws)) for ws in wb.worksheets
|
||||
]
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
sheets = [
|
||||
{
|
||||
"name": name,
|
||||
"total_rows": total_rows,
|
||||
"total_cols": total_cols,
|
||||
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
||||
}
|
||||
for name, total_rows, total_cols in extents
|
||||
]
|
||||
return {"vault": params.vault, "path": params.path, "sheets": sheets}
|
||||
|
||||
|
||||
@tool(
|
||||
name="xlsx_to_markdown",
|
||||
description=(
|
||||
"Read a sheet of a spreadsheet (.xlsx, .xlsm or .csv) as a bounded "
|
||||
"markdown table (up to 100 rows x 20 columns). Use to inspect "
|
||||
"spreadsheet data before answering or editing."
|
||||
),
|
||||
input_model=XlsxToMarkdownInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def xlsx_to_markdown(ctx: ToolContext, params: XlsxToMarkdownInput) -> dict[str, Any]:
|
||||
"""Render one sheet as a markdown table for the LLM context."""
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
title, rows, truncated = _read_grid(file_path, params.sheet, MAX_MD_ROWS, MAX_MD_COLS)
|
||||
|
||||
lines: list[str] = []
|
||||
if rows:
|
||||
header = rows[0]
|
||||
lines.append("| " + " | ".join(header) + " |")
|
||||
lines.append("|" + "|".join("---" for _ in header) + "|")
|
||||
for row in rows[1:]:
|
||||
lines.append("| " + " | ".join(row) + " |")
|
||||
table = "\n".join(lines)[:MAX_MD_CHARS]
|
||||
|
||||
return {
|
||||
"vault": params.vault,
|
||||
"path": params.path,
|
||||
"sheet": title,
|
||||
"rows": len(rows),
|
||||
"cols": max((len(r) for r in rows), default=0),
|
||||
"truncated": truncated,
|
||||
"markdown": table,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="search_workbook",
|
||||
description=(
|
||||
"Search a text across every sheet of a spreadsheet (.xlsx, .xlsm or "
|
||||
".csv) and return the matching cells with their sheet and A1 "
|
||||
"reference (max 100 matches). Use it to find where a value lives "
|
||||
"without dumping whole sheets into the context."
|
||||
),
|
||||
input_model=SearchWorkbookInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def search_workbook(ctx: ToolContext, params: SearchWorkbookInput) -> dict[str, Any]:
|
||||
"""Find a needle across all sheets, bounded and counted per sheet."""
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
needle = (params.query or "").strip()
|
||||
if not needle:
|
||||
raise ToolError("Requête vide", code="invalid_arguments")
|
||||
|
||||
titles = _sheet_titles(file_path)
|
||||
if params.sheet:
|
||||
if params.sheet not in titles:
|
||||
raise ToolError(f"Feuille introuvable: {params.sheet}", code="not_found")
|
||||
titles = [params.sheet]
|
||||
|
||||
hay = needle if params.case_sensitive else needle.lower()
|
||||
matches: list[dict[str, Any]] = []
|
||||
by_sheet: dict[str, int] = {}
|
||||
total = 0
|
||||
for title in titles:
|
||||
sheet_title, rows, _ = _read_grid(
|
||||
file_path, title, MAX_SCAN_ROWS, MAX_SCAN_COLS
|
||||
)
|
||||
label = sheet_title or file_path.stem
|
||||
for r_i, row in enumerate(rows, start=1):
|
||||
for c_i, value in enumerate(row, start=1):
|
||||
if not value:
|
||||
continue
|
||||
haystack = value if params.case_sensitive else value.lower()
|
||||
if hay not in haystack:
|
||||
continue
|
||||
total += 1
|
||||
by_sheet[label] = by_sheet.get(label, 0) + 1
|
||||
if len(matches) < MAX_SEARCH_RESULTS:
|
||||
matches.append(
|
||||
{
|
||||
"sheet": label,
|
||||
"cell": f"{get_column_letter(c_i)}{r_i}",
|
||||
"value": value,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"vault": params.vault,
|
||||
"path": params.path,
|
||||
"query": needle,
|
||||
"total": total,
|
||||
"truncated": total > MAX_SEARCH_RESULTS,
|
||||
"by_sheet": by_sheet,
|
||||
"matches": matches,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="analyze_range",
|
||||
description=(
|
||||
"Aggregate an A1 range of a sheet (.xlsx, .xlsm or .csv): count, sum, "
|
||||
"mean, min and max of the numeric cells, plus a bounded sample of the "
|
||||
"values. Use it to answer a question about a column without reading "
|
||||
"the whole sheet."
|
||||
),
|
||||
input_model=AnalyzeRangeInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def analyze_range(ctx: ToolContext, params: AnalyzeRangeInput) -> dict[str, Any]:
|
||||
"""Numeric aggregates + value sample over an A1 range of the sheet."""
|
||||
from openpyxl.utils.cell import range_boundaries
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
title, rows, _ = _read_grid(file_path, params.sheet, MAX_SCAN_ROWS, MAX_SCAN_COLS)
|
||||
|
||||
label = (params.range or "").strip()
|
||||
if label:
|
||||
try:
|
||||
min_col, min_row, max_col, max_row = range_boundaries(label.upper())
|
||||
except Exception as e:
|
||||
raise ToolError(f"Plage invalide: {label}", code="invalid_arguments") from e
|
||||
if (max_row - min_row + 1) * (max_col - min_col + 1) > MAX_RANGE_CELLS:
|
||||
raise ToolError(
|
||||
f"Plage trop grande (max {MAX_RANGE_CELLS} cellules)",
|
||||
code="invalid_arguments",
|
||||
)
|
||||
selected = [row[min_col - 1 : max_col] for row in rows[min_row - 1 : max_row]]
|
||||
else:
|
||||
selected = rows
|
||||
|
||||
values = [v for row in selected for v in row if isinstance(v, str) and v.strip()]
|
||||
numbers = [n for n in (_to_number(v) for v in values) if n is not None]
|
||||
|
||||
stats: dict[str, Any] = {"count": len(numbers)}
|
||||
if numbers:
|
||||
stats.update(
|
||||
{
|
||||
"sum": round(sum(numbers), 6),
|
||||
"mean": round(sum(numbers) / len(numbers), 6),
|
||||
"min": min(numbers),
|
||||
"max": max(numbers),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"vault": params.vault,
|
||||
"path": params.path,
|
||||
"sheet": title,
|
||||
"range": params.range or "",
|
||||
"rows": len(selected),
|
||||
"cols": max((len(r) for r in selected), default=0),
|
||||
"cells": len(values),
|
||||
"numeric": stats,
|
||||
"values": values[:MAX_RANGE_VALUES],
|
||||
"truncated": len(values) > MAX_RANGE_VALUES,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="update_xlsx_cells",
|
||||
description=(
|
||||
"Edit cells of an existing spreadsheet (.xlsx, .xlsm or .csv). "
|
||||
"``cells`` maps A1 references to new values (max 500). A value "
|
||||
"starting with '=' or '@' is stored as TEXT unless allow_formula is "
|
||||
"set (DDE guard). Editing a workbook carrying features openpyxl "
|
||||
"cannot rewrite requires force=true (cached formula results, "
|
||||
"slicers…). A .csv has no sheet: any ``sheet`` value is ignored."
|
||||
),
|
||||
input_model=UpdateXlsxCellsInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def update_xlsx_cells(ctx: ToolContext, params: UpdateXlsxCellsInput) -> dict[str, Any]:
|
||||
"""Wrap the guarded cell-edit service (``.xlsx``/``.xlsm`` or ``.csv``)."""
|
||||
from backend.services.mutations import edit_xlsx_cells, save_csv_cells
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
if not params.cells:
|
||||
raise ToolError("Aucune cellule fournie", code="invalid_arguments")
|
||||
if not _is_csv(file_path) and not params.sheet:
|
||||
raise ToolError("Feuille requise pour un classeur", code="invalid_arguments")
|
||||
try:
|
||||
if _is_csv(file_path):
|
||||
result = save_csv_cells(params.vault, params.path, dict(params.cells))
|
||||
else:
|
||||
result = edit_xlsx_cells(
|
||||
params.vault,
|
||||
params.path,
|
||||
params.sheet,
|
||||
dict(params.cells),
|
||||
allow_formula=params.allow_formula,
|
||||
force=params.force,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
return {
|
||||
"status": "ok",
|
||||
"vault": result["vault"],
|
||||
"path": result["path"],
|
||||
"sheet": params.sheet,
|
||||
"cells": len(params.cells),
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="append_xlsx_rows",
|
||||
description=(
|
||||
"Append rows at the end of a sheet of an existing spreadsheet "
|
||||
"(.xlsx or .xlsm). Values are typed like in the viewer (numbers, "
|
||||
"TRUE/FALSE, FR dates JJ/MM/AAAA). The workbook is rewritten "
|
||||
"atomically with a backup. Not available for .csv."
|
||||
),
|
||||
input_model=AppendXlsxRowsInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def append_xlsx_rows(ctx: ToolContext, params: AppendXlsxRowsInput) -> dict[str, Any]:
|
||||
"""Append whole rows below the last used row of the sheet."""
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
from backend.services.mutations import _coerce_xlsx_value, edit_xlsx_cells
|
||||
|
||||
if not params.rows:
|
||||
raise ToolError("Aucune ligne fournie", code="invalid_arguments")
|
||||
if len(params.rows) > 500:
|
||||
raise ToolError("Trop de lignes (max 500)", code="invalid_arguments")
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
if _is_csv(file_path):
|
||||
raise ToolError(
|
||||
"Un .csv n'a pas de notion de fin de feuille : utilisez "
|
||||
"update_xlsx_cells avec des références A1",
|
||||
code="invalid_arguments",
|
||||
)
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
try:
|
||||
if params.sheet not in wb.sheetnames:
|
||||
raise ToolError(
|
||||
f"Feuille introuvable: {params.sheet}", code="not_found"
|
||||
)
|
||||
ws = wb[params.sheet]
|
||||
first_free = (ws.max_row or 0) + 1
|
||||
finally:
|
||||
wb.close()
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
except ToolError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ToolError(f"Classeur illisible: {e}", code="invalid") from e
|
||||
|
||||
cells: dict[str, Any] = {}
|
||||
for i, row in enumerate(params.rows):
|
||||
for j, value in enumerate(row):
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
continue
|
||||
ref = f"{get_column_letter(j + 1)}{first_free + i}"
|
||||
cells[ref] = _coerce_xlsx_value(value)
|
||||
if not cells:
|
||||
raise ToolError("Aucune valeur fournie", code="invalid_arguments")
|
||||
|
||||
try:
|
||||
result = edit_xlsx_cells(
|
||||
params.vault,
|
||||
params.path,
|
||||
params.sheet,
|
||||
cells,
|
||||
allow_formula=params.allow_formula,
|
||||
force=params.force,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
return {
|
||||
"status": "ok",
|
||||
"vault": result["vault"],
|
||||
"path": result["path"],
|
||||
"sheet": params.sheet,
|
||||
"rows": len(params.rows),
|
||||
"first_row": first_free,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="edit_xlsx_structure",
|
||||
description=(
|
||||
"Change the structure of an existing .xlsx/.xlsm workbook: add, "
|
||||
"rename, duplicate or delete a sheet, or insert/delete rows and "
|
||||
"columns. ``actions`` is an ordered list of "
|
||||
'{"op": "sheet_add"|"sheet_rename"|"sheet_duplicate"|"sheet_delete"|'
|
||||
'"row_insert"|"row_delete"|"col_insert"|"col_delete", …} '
|
||||
"(1 to 50). Deletions drop data and cannot be undone from the "
|
||||
"assistant — confirm with the user first."
|
||||
),
|
||||
input_model=EditXlsxStructureInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def edit_xlsx_structure(
|
||||
ctx: ToolContext, params: EditXlsxStructureInput
|
||||
) -> dict[str, Any]:
|
||||
"""Apply a batch of structural changes through the guarded service."""
|
||||
from backend.services.mutations import mutate_xlsx_structure
|
||||
|
||||
if not params.actions:
|
||||
raise ToolError("Aucune action fournie", code="invalid_arguments")
|
||||
if len(params.actions) > 50:
|
||||
raise ToolError("Trop d'actions (max 50)", code="invalid_arguments")
|
||||
if str(params.path or "").lower().endswith(".csv"):
|
||||
raise ToolError(
|
||||
"Un .csv n'a pas de structure modifiable", code="invalid_arguments"
|
||||
)
|
||||
try:
|
||||
result = mutate_xlsx_structure(
|
||||
params.vault, params.path, [dict(a) for a in params.actions], force=params.force
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
return {
|
||||
"status": "ok",
|
||||
"vault": result["vault"],
|
||||
"path": result["path"],
|
||||
"actions": len(params.actions),
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Dossier personnel par utilisateur (#194).
|
||||
|
||||
Chaque utilisateur reçoit ``<OBSIGATE_HOME_ROOT>/<username>`` monté comme un
|
||||
vault propre ``home-<username>`` : l'isolation profite de l'ACL par vault
|
||||
déjà en place (``check_vault_access``), aucune ACL par chemin à inventer.
|
||||
|
||||
Tout est idempotent (``ensure_user_home``) pour être rappelé à la création
|
||||
d'un compte ET au démarrage : un dossier supprimé, un registre perdu ou un
|
||||
user créé hors API se réparent au boot.
|
||||
|
||||
La fonctionnalité est inactive tant que ``OBSIGATE_HOME_ROOT`` n'est pas
|
||||
défini (dev, tests, desktop) — comportement inchangé.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("obsigate.user_home")
|
||||
|
||||
# Même grammaire que CreateUserRequest.username_valid (backend/auth/router.py) :
|
||||
# segment de chemin sûr, déjà passé par la validation de l'API. Re-vérifié ici
|
||||
# car le nom sert à construire un chemin (défense en profondeur).
|
||||
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_-]{2,32}$")
|
||||
|
||||
|
||||
def home_root() -> Path | None:
|
||||
"""Racine des dossiers persos, ou ``None`` si la fonctionnalité est inactive."""
|
||||
root = os.environ.get("OBSIGATE_HOME_ROOT", "").strip()
|
||||
return Path(root) if root else None
|
||||
|
||||
|
||||
def home_vault_name(username: str) -> str:
|
||||
"""Nom de vault du dossier perso — pas de ``/`` ni ``::`` (segment d'URL, clé d'index)."""
|
||||
return f"home-{username}"
|
||||
|
||||
|
||||
async def ensure_user_home(username: str) -> str | None:
|
||||
"""Crée (si besoin) le dossier perso de *username*, son vault et son octroi.
|
||||
|
||||
Retourne le nom de vault, ou ``None`` si désactivé / nom invalide /
|
||||
erreur disque (journalisée, réparée au prochain démarrage).
|
||||
"""
|
||||
root = home_root()
|
||||
if root is None:
|
||||
return None
|
||||
if not _USERNAME_RE.match(username):
|
||||
logger.warning(f"Home folder skipped: invalid username {username!r}")
|
||||
return None
|
||||
|
||||
home = root / username
|
||||
try:
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
logger.exception(f"Cannot create home folder {home} for user '{username}'")
|
||||
return None
|
||||
|
||||
name = home_vault_name(username)
|
||||
try:
|
||||
from backend.indexer import add_vault_to_index, index, persist_vault, vault_config
|
||||
from backend.sse import sse_manager
|
||||
|
||||
vault_path = str(home)
|
||||
if name not in index:
|
||||
await add_vault_to_index(name, vault_path)
|
||||
persist_vault(name)
|
||||
from backend.watcher_state import get_watcher
|
||||
|
||||
watcher = get_watcher()
|
||||
if watcher:
|
||||
await watcher.add_vault(name, vault_path)
|
||||
await sse_manager.broadcast("vault_added", {"vault": name})
|
||||
logger.info(f"Home vault '{name}' registered at {vault_path}")
|
||||
elif name not in vault_config:
|
||||
vault_config[name] = {"path": vault_path, "attachmentsPath": None,
|
||||
"scanAttachmentsOnStartup": True}
|
||||
except Exception:
|
||||
logger.exception(f"Cannot register home vault '{name}' for user '{username}'")
|
||||
return None
|
||||
|
||||
_grant(username, name)
|
||||
return name
|
||||
|
||||
|
||||
async def release_user_home(username: str) -> None:
|
||||
"""Retire le vault du dossier perso à la suppression du compte (#194).
|
||||
|
||||
Le dossier sur disque est **conservé** (décision produit : pas de perte
|
||||
de données) ; seul l'index, le watcher et le registre le referment.
|
||||
"""
|
||||
name = home_vault_name(username)
|
||||
try:
|
||||
from backend.indexer import index, remove_vault_from_index, unpersist_vault
|
||||
|
||||
unpersist_vault(name)
|
||||
if name not in index:
|
||||
return
|
||||
await remove_vault_from_index(name)
|
||||
from backend.sse import sse_manager
|
||||
from backend.watcher_state import get_watcher
|
||||
|
||||
watcher = get_watcher()
|
||||
if watcher:
|
||||
await watcher.remove_vault(name)
|
||||
await sse_manager.broadcast("vault_removed", {"vault": name})
|
||||
logger.info(f"Home vault '{name}' released (folder kept)")
|
||||
except Exception:
|
||||
logger.exception(f"Cannot release home vault '{name}'")
|
||||
|
||||
|
||||
def _grant(username: str, vault_name: str) -> None:
|
||||
"""Ajoute le vault à ``user.vaults`` s'il n'y est pas déjà (#194).
|
||||
|
||||
L'octroi est explicite même pour un admin (``vaults: ["*"]``) : ``*`` ne
|
||||
couvre jamais un dossier perso (voir ``check_vault_access``).
|
||||
"""
|
||||
from backend.auth.user_store import get_user, update_user
|
||||
|
||||
user = get_user(username)
|
||||
if not user:
|
||||
return # créé hors API (bootstrap avant users.json) → réparé au boot suivant
|
||||
vaults = user.get("vaults") or []
|
||||
if vault_name in vaults:
|
||||
return
|
||||
update_user(username, {"vaults": [*vaults, vault_name]})
|
||||
|
||||
|
||||
async def ensure_all_user_homes() -> int:
|
||||
"""Passe de réparation/migration au démarrage : un home par user existant.
|
||||
|
||||
Balaye aussi les vaults orphelins (compte supprimé hors route, ex.
|
||||
``create_admin.py delete``) : ils sont refermés, dossier conservé.
|
||||
"""
|
||||
from backend.auth.user_store import get_all_users
|
||||
from backend.indexer import index, vault_config
|
||||
|
||||
root = home_root()
|
||||
if root is None:
|
||||
return 0
|
||||
users = get_all_users()
|
||||
usernames = {u.get("username") for u in users}
|
||||
created = 0
|
||||
for user in users:
|
||||
username = user.get("username")
|
||||
if not username:
|
||||
continue
|
||||
if await ensure_user_home(username):
|
||||
created += 1
|
||||
if created:
|
||||
logger.info(f"User home folders ensured for {created} user(s)")
|
||||
|
||||
# Orphelins : vault home-<x> toujours indexé mais <x> n'existe plus.
|
||||
# Path.parent == root → on ne touche qu'aux dossiers sous la racine Home,
|
||||
# jamais à un vault admin nommé « home-… » par ailleurs.
|
||||
for name in list(index):
|
||||
if not name.startswith("home-"):
|
||||
continue
|
||||
owner = name[len("home-"):]
|
||||
if owner in usernames:
|
||||
continue
|
||||
cfg_path = Path((vault_config.get(name) or {}).get("path", ""))
|
||||
if cfg_path.parent != root:
|
||||
continue
|
||||
logger.warning(f"Orphan home vault '{name}' released (user deleted?)")
|
||||
await release_user_home(owner)
|
||||
return created
|
||||
+3
-2
@@ -22,7 +22,7 @@ Exemples :
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent # racine du dépôt ObsiGate
|
||||
@@ -34,7 +34,8 @@ _ENV_VAR = "OBSIGATE_VERSION"
|
||||
def _run_git(args: list[str]) -> str:
|
||||
"""Run a git command in the repo root; return stdout (stripped) or ''."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
# argv fixe (git + args internes), sans shell : pas d'injection.
|
||||
result = subprocess.run( # nosec B404 B603 B607
|
||||
["git", *args],
|
||||
cwd=str(_ROOT),
|
||||
capture_output=True,
|
||||
|
||||
+2
-1
@@ -280,7 +280,8 @@ class VaultWatcher:
|
||||
for observer in self.observers.values():
|
||||
try:
|
||||
observer.join(timeout=5)
|
||||
except Exception: # nosec B110 — best-effort shutdown, ignore failures
|
||||
# best-effort shutdown, ignore failures (B110) :
|
||||
except Exception: # nosec B110
|
||||
pass
|
||||
self.observers.clear()
|
||||
logger.info("VaultWatcher stopped")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Shared VaultWatcher handle (ROADMAP #85, tranche 8).
|
||||
|
||||
Holder extrait de :mod:`backend.main` sans changement de comportement : le
|
||||
lifespan de ``main`` y dépose l'instance (``set_watcher``) et l'y reprend à
|
||||
l'extinction ; le router ``vaults`` la consulte via :func:`get_watcher`
|
||||
(démarrage/arrêt de surveillance à l'ajout/retrait dynamique de vault,
|
||||
état dans ``/api/vaults/status``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.watcher import VaultWatcher
|
||||
|
||||
_watcher: VaultWatcher | None = None
|
||||
|
||||
|
||||
def get_watcher() -> VaultWatcher | None:
|
||||
"""Return the shared VaultWatcher instance (``None`` if disabled)."""
|
||||
return _watcher
|
||||
|
||||
|
||||
def set_watcher(watcher: VaultWatcher | None) -> None:
|
||||
"""Store (or clear) the shared VaultWatcher instance."""
|
||||
global _watcher
|
||||
_watcher = watcher
|
||||
+143
-46
@@ -16,6 +16,9 @@ Security (BUG-026):
|
||||
|
||||
Events: file_created, file_deleted, file_modified, file_renamed,
|
||||
directory_created, directory_deleted, directory_renamed
|
||||
(#198 : chaque webhook peut porter un filtre optionnel ``vault`` et/ou
|
||||
``path_prefix`` — seuls les événements correspondants sont livrés ; la
|
||||
charge utile inclut toujours ``vault`` et le(s) chemin(s)).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -26,6 +29,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -119,6 +123,65 @@ def is_safe_target(url: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _normalize_vault_filter(vault: str | None) -> str | None:
|
||||
"""Normalize an optional vault filter (``None``/empty = no filtering)."""
|
||||
if not vault or not isinstance(vault, str):
|
||||
return None
|
||||
cleaned = vault.strip()
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _normalize_prefix_filter(prefix: str | None) -> str | None:
|
||||
"""Normalize an optional path-prefix filter (``None``/empty = no filtering)."""
|
||||
if not prefix or not isinstance(prefix, str):
|
||||
return None
|
||||
cleaned = prefix.strip().strip("/")
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _event_paths(data: dict) -> list[str]:
|
||||
"""Vault-relative paths carried by a webhook payload.
|
||||
|
||||
File/directory events use ``path``; renames and moves carry
|
||||
``old_path``/``new_path`` instead.
|
||||
"""
|
||||
paths = []
|
||||
for key in ("path", "old_path", "new_path"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
paths.append(value.strip("/"))
|
||||
return paths
|
||||
|
||||
|
||||
def webhook_matches(wh: dict, event_type: str, data: dict) -> bool:
|
||||
"""True when webhook *wh* must receive (*event_type*, *data*).
|
||||
|
||||
Pure function (no I/O) so the filtering is unit-testable :
|
||||
- event subscription (as before) ;
|
||||
- #198 optional ``vault`` filter — exact match on ``data["vault"]`` ;
|
||||
- #198 optional ``path_prefix`` filter — matches when any carried path
|
||||
equals the prefix or lies underneath it (``Docs`` matches ``Docs`` and
|
||||
``Docs/a.md``, not ``Docs2/b.md``).
|
||||
|
||||
Webhooks created before #198 carry neither key and keep receiving
|
||||
everything they subscribe to (backward compatible).
|
||||
"""
|
||||
if event_type not in wh.get("events", []):
|
||||
return False
|
||||
vault_filter = _normalize_vault_filter(wh.get("vault"))
|
||||
if vault_filter and data.get("vault") != vault_filter:
|
||||
return False
|
||||
prefix = _normalize_prefix_filter(wh.get("path_prefix"))
|
||||
if prefix:
|
||||
wanted = prefix + "/"
|
||||
for candidate in _event_paths(data):
|
||||
if candidate == prefix or candidate.startswith(wanted):
|
||||
break
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _read() -> list:
|
||||
if not WEBHOOKS_FILE.exists():
|
||||
return []
|
||||
@@ -144,6 +207,12 @@ def _read_secrets() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
# ROADMAP #85 T10a — verrou autour des read-modify-write des deux stores
|
||||
# (webhooks + secrets) : perte de mises à jour en cas de mutations
|
||||
# concurrentes.
|
||||
_lock = threading.RLock()
|
||||
|
||||
|
||||
def _write_secrets(secrets: dict):
|
||||
WEBHOOK_SECRETS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = WEBHOOK_SECRETS_FILE.with_suffix(".tmp")
|
||||
@@ -156,12 +225,13 @@ def _write_secrets(secrets: dict):
|
||||
|
||||
|
||||
def _store_secret(wh_id: str, secret: str | None) -> None:
|
||||
secrets = _read_secrets()
|
||||
if secret:
|
||||
secrets[wh_id] = secret
|
||||
else:
|
||||
secrets.pop(wh_id, None)
|
||||
_write_secrets(secrets)
|
||||
with _lock:
|
||||
secrets = _read_secrets()
|
||||
if secret:
|
||||
secrets[wh_id] = secret
|
||||
else:
|
||||
secrets.pop(wh_id, None)
|
||||
_write_secrets(secrets)
|
||||
|
||||
|
||||
def _get_secret(wh: dict) -> str | None:
|
||||
@@ -187,61 +257,88 @@ def get_webhooks() -> list:
|
||||
return [_public_view(wh) for wh in _read()]
|
||||
|
||||
|
||||
def create_webhook(name: str, url: str, events: list[str], secret: str | None = None) -> dict:
|
||||
def create_webhook(
|
||||
name: str,
|
||||
url: str,
|
||||
events: list[str],
|
||||
secret: str | None = None,
|
||||
vault: str | None = None,
|
||||
path_prefix: str | None = None,
|
||||
) -> dict:
|
||||
validate_webhook_url(url)
|
||||
webhooks = _read()
|
||||
wh_id = str(uuid.uuid4())
|
||||
wh = {
|
||||
"id": wh_id,
|
||||
"name": name,
|
||||
"url": url,
|
||||
"events": [e for e in events if e in VALID_EVENTS],
|
||||
"enabled": True,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"last_fired_at": None,
|
||||
}
|
||||
webhooks.append(wh)
|
||||
_write(webhooks)
|
||||
if secret:
|
||||
_store_secret(wh_id, secret)
|
||||
with _lock:
|
||||
webhooks = _read()
|
||||
wh_id = str(uuid.uuid4())
|
||||
wh = {
|
||||
"id": wh_id,
|
||||
"name": name,
|
||||
"url": url,
|
||||
"events": [e for e in events if e in VALID_EVENTS],
|
||||
"enabled": True,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"last_fired_at": None,
|
||||
# #198 : filtres optionnels (None = pas de filtre, reçoit tout).
|
||||
"vault": _normalize_vault_filter(vault),
|
||||
"path_prefix": _normalize_prefix_filter(path_prefix),
|
||||
}
|
||||
webhooks.append(wh)
|
||||
_write(webhooks)
|
||||
if secret:
|
||||
_store_secret(wh_id, secret)
|
||||
logger.info(f"Created webhook '{name}' → {url}")
|
||||
return _public_view(wh)
|
||||
|
||||
|
||||
def update_webhook(wh_id: str, updates: dict) -> dict | None:
|
||||
webhooks = _read()
|
||||
for wh in webhooks:
|
||||
if wh["id"] == wh_id:
|
||||
if updates.get("url"):
|
||||
validate_webhook_url(updates["url"])
|
||||
if "secret" in updates:
|
||||
_store_secret(wh_id, updates["secret"])
|
||||
safe_updates = {
|
||||
k: v for k, v in updates.items()
|
||||
if k not in ("id", "secret")
|
||||
}
|
||||
wh.update(safe_updates)
|
||||
_write(webhooks)
|
||||
return _public_view(wh)
|
||||
with _lock:
|
||||
webhooks = _read()
|
||||
for wh in webhooks:
|
||||
if wh["id"] == wh_id:
|
||||
if updates.get("url"):
|
||||
validate_webhook_url(updates["url"])
|
||||
if "secret" in updates:
|
||||
_store_secret(wh_id, updates["secret"])
|
||||
# #198 : normaliser les filtres ("" réinitialise le filtre).
|
||||
for key, normalizer in (
|
||||
("vault", _normalize_vault_filter),
|
||||
("path_prefix", _normalize_prefix_filter),
|
||||
):
|
||||
if key in updates:
|
||||
updates[key] = normalizer(updates[key])
|
||||
safe_updates = {
|
||||
k: v for k, v in updates.items()
|
||||
if k not in ("id", "secret")
|
||||
}
|
||||
wh.update(safe_updates)
|
||||
_write(webhooks)
|
||||
return _public_view(wh)
|
||||
return None
|
||||
|
||||
|
||||
def delete_webhook(wh_id: str) -> bool:
|
||||
webhooks = _read()
|
||||
new_list = [wh for wh in webhooks if wh["id"] != wh_id]
|
||||
if len(new_list) == len(webhooks):
|
||||
return False
|
||||
_write(new_list)
|
||||
secrets = _read_secrets()
|
||||
if secrets.pop(wh_id, None) is not None:
|
||||
_write_secrets(secrets)
|
||||
with _lock:
|
||||
webhooks = _read()
|
||||
new_list = [wh for wh in webhooks if wh["id"] != wh_id]
|
||||
if len(new_list) == len(webhooks):
|
||||
return False
|
||||
_write(new_list)
|
||||
secrets = _read_secrets()
|
||||
if secrets.pop(wh_id, None) is not None:
|
||||
_write_secrets(secrets)
|
||||
return True
|
||||
|
||||
|
||||
async def dispatch_webhooks(event_type: str, data: dict):
|
||||
"""Fire all enabled webhooks subscribed to event_type."""
|
||||
"""Fire all enabled webhooks subscribed to event_type.
|
||||
|
||||
#198 : les webhooks portant un filtre ``vault``/``path_prefix`` ne sont
|
||||
livrés que pour les événements correspondants (voir :func:`webhook_matches`).
|
||||
"""
|
||||
webhooks = _read()
|
||||
targets = [wh for wh in webhooks if wh.get("enabled", True) and event_type in wh.get("events", [])]
|
||||
targets = [
|
||||
wh for wh in webhooks
|
||||
if wh.get("enabled", True) and webhook_matches(wh, event_type, data)
|
||||
]
|
||||
if not targets:
|
||||
return
|
||||
|
||||
|
||||
+1099
-15
File diff suppressed because it is too large
Load Diff
@@ -115,7 +115,9 @@ $env:VERSION = $Version
|
||||
Write-Info "Version : $Version"
|
||||
|
||||
# ----- Build the image -----
|
||||
$BuildArgs = @("-f", $ComposeFile)
|
||||
# NOTE : `--no-cache` est un flag de `docker compose build` (après `build`),
|
||||
# pas un flag global (avant) — sinon `unknown flag: --no-cache`.
|
||||
$BuildArgs = @("-f", $ComposeFile, "build")
|
||||
if (-not $UseCache) {
|
||||
$BuildArgs += "--no-cache"
|
||||
Write-Info "Construction de l'image Docker (sans cache)..."
|
||||
@@ -123,7 +125,7 @@ if (-not $UseCache) {
|
||||
Write-Info "Construction de l'image Docker (avec cache)..."
|
||||
}
|
||||
|
||||
docker compose @BuildArgs build
|
||||
docker compose @BuildArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Échec de la construction de l'image Docker."
|
||||
exit $LASTEXITCODE
|
||||
|
||||
@@ -223,4 +223,4 @@ $COMPOSE_CMD -f "$COMPOSE_FILE" ps
|
||||
|
||||
echo ""
|
||||
info "Logs récents (Ctrl+C pour quitter) :"
|
||||
$COMPOSE_CMD -f "$COMPOSE_FILE" logs --tail=20 -f
|
||||
$COMPOSE_CMD -f "$COMPOSE_FILE" logs --tail=20
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.27.1"
|
||||
version = "2.68.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.27.1"
|
||||
version = "2.68.0"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -38,5 +38,7 @@ fn main() {
|
||||
println!("cargo:rerun-if-changed=.git/refs/heads/main");
|
||||
println!("cargo:rerun-if-changed=.git/refs/tags");
|
||||
|
||||
println!("cargo:rerun-if-changed=permissions");
|
||||
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
},
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"allow-app-commands",
|
||||
"shell:allow-open",
|
||||
"shell:allow-execute",
|
||||
"dialog:default",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for ObsiGate Desktop","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","shell:allow-execute","dialog:default","notification:default","fs:default","process:default","store:default"]}}
|
||||
{"default":{"identifier":"default","description":"Default capabilities for ObsiGate Desktop","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","allow-app-commands","shell:allow-open","shell:allow-execute","dialog:default","notification:default","fs:default","process:default","store:default"]}}
|
||||
@@ -2210,6 +2210,12 @@
|
||||
"Identifier": {
|
||||
"description": "Permission identifier",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1).",
|
||||
"type": "string",
|
||||
"const": "allow-app-commands",
|
||||
"markdownDescription": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1)."
|
||||
},
|
||||
{
|
||||
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
|
||||
"type": "string",
|
||||
|
||||
@@ -2210,6 +2210,12 @@
|
||||
"Identifier": {
|
||||
"description": "Permission identifier",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1).",
|
||||
"type": "string",
|
||||
"const": "allow-app-commands",
|
||||
"markdownDescription": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1)."
|
||||
},
|
||||
{
|
||||
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
|
||||
"type": "string",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Commandes applicatives ObsiGate — autorisées depuis l'origine distante.
|
||||
#
|
||||
# La fenêtre Tauri redirige vers http://127.0.0.1:<port> (page servie par le
|
||||
# backend Python). Pour Tauri v2 c'est une origine *remote* : sans entrée ACL
|
||||
# explicite, TOUTE commande de l'app (invoke_handler) est rejetée avec
|
||||
# "Command ... not allowed by ACL" — le bouton « Choisir mon dossier » et le
|
||||
# wizard ne faisaient alors rien (erreurs avalées par desktop.js:invoke()).
|
||||
#
|
||||
# Auto-généré en… non : édité à la main. Liste = tauri::generate_handler![…]
|
||||
# dans src/main.rs — ajouter toute nouvelle commande ici.
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-app-commands"
|
||||
description = "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1)."
|
||||
commands.allow = [
|
||||
"get_backend_url",
|
||||
"get_version",
|
||||
"get_config",
|
||||
"save_vault_path",
|
||||
"get_vault_path",
|
||||
"get_wizard_state",
|
||||
"complete_wizard",
|
||||
"pick_vault_folder",
|
||||
"pick_folder",
|
||||
"get_system_theme",
|
||||
"restart_backend",
|
||||
"check_backend_health",
|
||||
"save_window_state",
|
||||
"get_window_state",
|
||||
"add_vault",
|
||||
"remove_vault",
|
||||
"list_vaults",
|
||||
"add_dir",
|
||||
"remove_dir",
|
||||
"list_dirs",
|
||||
]
|
||||
+19
-43
@@ -10,48 +10,23 @@ use log::{info, warn};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod imp {
|
||||
use super::*;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows::core::{Interface, HSTRING, PCWSTR};
|
||||
use windows::Win32::Foundation::PROPERTYKEY;
|
||||
use windows::Win32::System::Com::{
|
||||
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
|
||||
};
|
||||
use windows::Win32::System::Com::StructuredStorage::PROPVARIANT;
|
||||
use windows::Win32::System::Variant::VT_LPWSTR;
|
||||
use windows::Win32::UI::Shell::{
|
||||
ICustomDestinationList, IShellLinkW, SetCurrentProcessExplicitAppUserModelID,
|
||||
DestinationList, EnumerableObjectCollection, ShellLink,
|
||||
};
|
||||
use windows::Win32::UI::Shell::Common::IObjectCollection;
|
||||
use windows::Win32::UI::Shell::PropertiesSystem::IPropertyStore;
|
||||
use super::*;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows::core::{Interface, HSTRING, PCWSTR};
|
||||
use windows::Win32::System::Com::{
|
||||
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
|
||||
};
|
||||
use windows::Win32::UI::Shell::{
|
||||
ICustomDestinationList, IShellLinkW, SetCurrentProcessExplicitAppUserModelID,
|
||||
DestinationList, EnumerableObjectCollection, ShellLink,
|
||||
};
|
||||
use windows::Win32::UI::Shell::Common::IObjectCollection;
|
||||
|
||||
const APP_ID: &str = "com.obsigate.desktop";
|
||||
const APP_ID: &str = "com.obsigate.desktop";
|
||||
|
||||
// PKEY_Title (System.Title): {F29F85E0-4FF9-1068-AB91-08002B27B3D9}, pid 2
|
||||
const PKEY_TITLE: PROPERTYKEY = PROPERTYKEY {
|
||||
fmtid: windows::core::GUID::from_u128(0xF29F85E0_4FF9_1068_AB91_08002B27B3D9),
|
||||
pid: 2,
|
||||
};
|
||||
|
||||
fn wide_null_terminated(s: &str) -> Vec<u16> {
|
||||
std::ffi::OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
/// Set the jump-list display title via the shell link's property store.
|
||||
fn set_link_title(link: &IShellLinkW, title: &str) -> windows::core::Result<()> {
|
||||
let store: IPropertyStore = link.cast()?;
|
||||
let mut wide = wide_null_terminated(title);
|
||||
let mut pv: PROPVARIANT = unsafe { std::mem::zeroed() };
|
||||
unsafe {
|
||||
let inner = &mut *pv.Anonymous.Anonymous;
|
||||
inner.vt = VT_LPWSTR;
|
||||
inner.Anonymous.pwszVal = windows::core::PWSTR(wide.as_mut_ptr());
|
||||
store.SetValue(&PKEY_TITLE, &pv)?;
|
||||
store.Commit()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn wide_null_terminated(s: &str) -> Vec<u16> {
|
||||
std::ffi::OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
/// Build one shell link for a vault. `arg` is the command-line argument the
|
||||
/// app expects to open that vault (see file associations / single-instance).
|
||||
@@ -70,9 +45,10 @@ mod imp {
|
||||
}
|
||||
link.SetDescription(&HSTRING::from(name))?;
|
||||
}
|
||||
// Title (display name) is best-effort — a missing title only means the
|
||||
// jump-list entry falls back to the executable name.
|
||||
let _ = set_link_title(&link, name);
|
||||
// Title (display name): the raw PROPVARIANT setter that used to live
|
||||
// here corrupted the process heap (STATUS_HEAP_CORRUPTION c0000374 —
|
||||
// the shell property store freed our Rust-allocated VT_LPWSTR buffer).
|
||||
// SetDescription below already gives jump-list entries their label.
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
|
||||
+202
-18
@@ -48,6 +48,7 @@ fn pick_free_port() -> u16 {
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
use tauri::Manager;
|
||||
use tauri::webview::WebviewWindowBuilder;
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -160,6 +161,39 @@ fn save_config(config: &AppConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Premier lancement (#160) ───────────────────────────────────────────────
|
||||
|
||||
/// Répertoire de démarrage monté comme vault au premier lancement.
|
||||
const STARTER_VAULT_NAME: &str = "ObsiGate";
|
||||
/// Document d'accueil écrit (si absent) dans le répertoire de démarrage.
|
||||
const WELCOME_DOC_NAME: &str = "Prise en main.md";
|
||||
const WELCOME_DOC: &str = include_str!("prise_en_main.md");
|
||||
|
||||
/// Config par défaut au premier lancement — pure, sans I/O (testable).
|
||||
/// `<home>/ObsiGate` est monté comme vault ET comme contexte initial ; le
|
||||
/// dossier home reste une racine, nommée d'après son dernier segment
|
||||
/// (fin du « bruno » codé en dur, #160).
|
||||
fn default_first_run_config(home: &str) -> AppConfig {
|
||||
let home_path = std::path::Path::new(home);
|
||||
let vault_dir = home_path.join("ObsiGate");
|
||||
let vault_path = vault_dir.to_string_lossy().to_string();
|
||||
AppConfig {
|
||||
vault_path: Some(vault_path.clone()),
|
||||
vaults: vec![VaultConfig {
|
||||
name: STARTER_VAULT_NAME.to_string(),
|
||||
path: vault_path,
|
||||
}],
|
||||
dirs: vec![DirConfig {
|
||||
name: home_path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| home.to_string()),
|
||||
path: home.to_string(),
|
||||
}],
|
||||
..AppConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Backend lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
fn spawn_backend(exe_dir: &PathBuf) -> Result<Child, String> {
|
||||
@@ -320,6 +354,18 @@ async fn pick_vault_folder(app: tauri::AppHandle) -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sélecteur de dossier générique, sans effet de bord (contrairement à
|
||||
/// `pick_vault_folder` qui écrit `vault_path` + `wizard_done` pour le wizard).
|
||||
/// Sert à l'ajout d'un dossier racine depuis la section Configuration (#159).
|
||||
#[tauri::command]
|
||||
async fn pick_folder(app: tauri::AppHandle) -> Result<String, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
match app.dialog().file().blocking_pick_folder() {
|
||||
Some(p) => Ok(p.to_string()),
|
||||
None => Err("No folder selected".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_wizard_state() -> bool {
|
||||
load_config().wizard_done
|
||||
@@ -343,6 +389,13 @@ fn get_system_theme(app: tauri::AppHandle) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// La jump list des raccourcis suit les vaults : à rafraîchir après tout
|
||||
/// ajout/retrait de vault (#159). Best-effort — `update_jumplist` logge et n'échoue jamais.
|
||||
fn refresh_jumplist() {
|
||||
let vaults = load_config().vaults;
|
||||
jumplist::update_jumplist(&jumplist::build_vault_args(&vaults));
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn add_vault(name: String, path: String) -> Result<(), String> {
|
||||
let mut config = load_config();
|
||||
@@ -350,6 +403,7 @@ fn add_vault(name: String, path: String) -> Result<(), String> {
|
||||
config.vaults.retain(|v| v.name != name);
|
||||
config.vaults.push(VaultConfig { name: name.clone(), path });
|
||||
save_config(&config);
|
||||
refresh_jumplist();
|
||||
info!("Vault added: {} — restart backend to apply", name);
|
||||
Ok(())
|
||||
}
|
||||
@@ -359,6 +413,7 @@ fn remove_vault(name: String) -> Result<(), String> {
|
||||
let mut config = load_config();
|
||||
config.vaults.retain(|v| v.name != name);
|
||||
save_config(&config);
|
||||
refresh_jumplist();
|
||||
info!("Vault removed: {} — restart backend to apply", name);
|
||||
Ok(())
|
||||
}
|
||||
@@ -513,7 +568,7 @@ fn main() {
|
||||
// Load config for window state
|
||||
let mut config = load_config();
|
||||
|
||||
// First run: create sensible defaults per platform
|
||||
// First run: create sensible defaults per platform (#160)
|
||||
if config.vaults.is_empty() {
|
||||
info!("First run — creating default vault configuration");
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -521,25 +576,28 @@ fn main() {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/bruno".to_string());
|
||||
|
||||
let vault_dir = std::path::PathBuf::from(&home).join("voute_obsidian");
|
||||
// Create the vault directory if it doesn't exist
|
||||
if !vault_dir.exists() {
|
||||
if let Err(e) = fs::create_dir_all(&vault_dir) {
|
||||
error!("Failed to create default vault dir {}: {}", vault_dir.display(), e);
|
||||
} else {
|
||||
info!("Created default vault dir: {}", vault_dir.display());
|
||||
// Montage du répertoire de démarrage (vault + contexte initial) sans
|
||||
// toucher aux autres champs (taille/position de fenêtre) de la config.
|
||||
let defaults = default_first_run_config(&home);
|
||||
config.dirs = defaults.dirs;
|
||||
config.vaults = defaults.vaults;
|
||||
config.vault_path = defaults.vault_path;
|
||||
|
||||
let vault_dir = std::path::PathBuf::from(config.vaults[0].path.clone());
|
||||
if let Err(e) = fs::create_dir_all(&vault_dir) {
|
||||
error!("Failed to create default vault dir {}: {}", vault_dir.display(), e);
|
||||
} else {
|
||||
info!("Created default vault dir: {}", vault_dir.display());
|
||||
}
|
||||
// Document d'accueil : écrit une seule fois, jamais écrasé (#160).
|
||||
let welcome = vault_dir.join(WELCOME_DOC_NAME);
|
||||
if !welcome.exists() {
|
||||
match fs::write(&welcome, WELCOME_DOC) {
|
||||
Ok(()) => info!("Welcome document written: {}", welcome.display()),
|
||||
Err(e) => error!("Failed to write {}: {}", welcome.display(), e),
|
||||
}
|
||||
}
|
||||
config.dirs = vec![
|
||||
DirConfig { name: "bruno".to_string(), path: home.clone() },
|
||||
];
|
||||
config.vaults = vec![
|
||||
VaultConfig {
|
||||
name: "Obsidian".to_string(),
|
||||
path: vault_dir.to_string_lossy().to_string(),
|
||||
},
|
||||
];
|
||||
config.vault_path = Some(home);
|
||||
|
||||
save_config(&config);
|
||||
info!("Default vault config saved");
|
||||
}
|
||||
@@ -589,6 +647,7 @@ fn main() {
|
||||
get_wizard_state,
|
||||
complete_wizard,
|
||||
pick_vault_folder,
|
||||
pick_folder,
|
||||
get_system_theme,
|
||||
restart_backend,
|
||||
check_backend_health,
|
||||
@@ -602,6 +661,19 @@ fn main() {
|
||||
list_dirs,
|
||||
])
|
||||
.setup(move |app| {
|
||||
// BUG-107 / #89 — HTML5 drag & drop des fichiers : Tauri installe
|
||||
// son propre IDropTarget par-dessus celui du WebView2 (wry :
|
||||
// « find the WebView2 window and override! ») et aucun événement
|
||||
// natif n'est écouté ici → les glisser-déposer depuis
|
||||
// l'Explorateur n'atteignaient jamais la page. La fenêtre est donc
|
||||
// créée manuellement (`create: false` dans tauri.conf.json) avec
|
||||
// le handler désactivé : la page reçoit les drops comme sur le web.
|
||||
let window_config = app.config().app.windows[0].clone();
|
||||
WebviewWindowBuilder::from_config(app, &window_config)
|
||||
.expect("fenêtre principale (tauri.conf.json)")
|
||||
.disable_drag_drop_handler()
|
||||
.build()?;
|
||||
|
||||
// Build native menu bar (File / Edit / Help)
|
||||
let _ = build_native_menu(app);
|
||||
|
||||
@@ -1122,4 +1194,116 @@ mod tests {
|
||||
let dbg = format!("{:?}", d);
|
||||
assert!(dbg.contains("x"));
|
||||
}
|
||||
|
||||
/// Guardrail ACL : toute commande Tauri invoquée par le frontend doit
|
||||
/// figurer dans desktop/permissions/commands.toml. La page servie par le
|
||||
/// backend (http://127.0.0.1) est une origine *remote* pour Tauri v2 :
|
||||
/// sans cette entrée, la commande est rejetée « not allowed by ACL » et
|
||||
/// desktop.js:invoke() avale l'erreur → bouton silencieusement mort
|
||||
/// (bouton « Choisir mon dossier » du wizard, BUG-104).
|
||||
#[test]
|
||||
fn test_frontend_invokes_are_acl_allowed() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let perms = fs::read_to_string(dir.join("permissions/commands.toml"))
|
||||
.expect("desktop/permissions/commands.toml manquant");
|
||||
let main_src = fs::read_to_string(dir.join("src/main.rs")).unwrap();
|
||||
|
||||
// Commandes déclarées dans le manifeste de permissions.
|
||||
let allow_list = perms
|
||||
.split("commands.allow")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split(']').next())
|
||||
.unwrap_or("");
|
||||
let allowed: Vec<String> = allow_list
|
||||
.split('"')
|
||||
.skip(1)
|
||||
.step_by(2)
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
assert!(!allowed.is_empty(), "commands.allow vide dans commands.toml");
|
||||
|
||||
// Chaque permission doit correspondre à une commande réellement
|
||||
// enregistrée (faute de frappe → permission morte).
|
||||
for cmd in &allowed {
|
||||
assert!(
|
||||
main_src.contains(&format!("fn {cmd}(")),
|
||||
"permission allow pour la commande inconnue {cmd}"
|
||||
);
|
||||
}
|
||||
|
||||
// Toute commande invoke('…') du frontend doit être autorisée.
|
||||
let js_dir = dir.join("../frontend/js");
|
||||
let mut seen = 0usize;
|
||||
for entry in fs::read_dir(&js_dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("js") { continue; }
|
||||
let src = fs::read_to_string(&path).unwrap();
|
||||
let mut rest = src.as_str();
|
||||
while let Some(pos) = rest.find("invoke('") {
|
||||
rest = &rest[pos + "invoke('".len()..];
|
||||
let name: String = rest.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
|
||||
if !name.is_empty() {
|
||||
seen += 1;
|
||||
assert!(
|
||||
allowed.contains(&name),
|
||||
"commande {name} invoquée par {} absente de permissions/commands.toml",
|
||||
path.file_name().unwrap().to_string_lossy()
|
||||
);
|
||||
}
|
||||
rest = &rest[rest.find('\'').map(|i| i + 1).unwrap_or(rest.len())..];
|
||||
}
|
||||
}
|
||||
assert!(seen > 0, "aucun invoke('…') trouvé dans frontend/js");
|
||||
}
|
||||
|
||||
/// BUG-107 : la fenêtre est créée en code (`create: false`) avec le
|
||||
/// handler de drag & drop de Tauri désactivé. Si l'un des deux manque,
|
||||
/// wry réinstalle son IDropTarget par-dessus celui du WebView2 et les
|
||||
/// glisser-déposer de fichiers depuis l'Explorateur n'atteignent plus la
|
||||
/// page (#89 fonctionnait uniquement sur le web).
|
||||
#[test]
|
||||
fn test_window_created_without_tauri_drag_drop_handler() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let conf = fs::read_to_string(dir.join("tauri.conf.json")).unwrap();
|
||||
assert!(
|
||||
conf.contains("\"create\": false"),
|
||||
"tauri.conf.json : la fenêtre doit être créée en code (create: false)"
|
||||
);
|
||||
let main_src = fs::read_to_string(dir.join("src/main.rs")).unwrap();
|
||||
assert!(
|
||||
main_src.contains("disable_drag_drop_handler()"),
|
||||
"main.rs : disable_drag_drop_handler() manquant — drag & drop bloqué"
|
||||
);
|
||||
}
|
||||
|
||||
/// #160 — premier lancement : `<home>/ObsiGate` monté comme vault et
|
||||
/// comme contexte initial, racine home nommée d'après son chemin.
|
||||
#[test]
|
||||
fn test_default_first_run_config() {
|
||||
#[cfg(target_os = "windows")]
|
||||
let home = r"C:\Users\alice";
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let home = "/home/alice";
|
||||
|
||||
let c = default_first_run_config(home);
|
||||
assert_eq!(c.vaults.len(), 1);
|
||||
assert_eq!(c.vaults[0].name, STARTER_VAULT_NAME);
|
||||
assert!(c.vaults[0].path.ends_with("ObsiGate"), "{}", c.vaults[0].path);
|
||||
assert_eq!(c.vault_path.as_deref(), Some(c.vaults[0].path.as_str()));
|
||||
assert_eq!(c.dirs.len(), 1);
|
||||
assert_eq!(c.dirs[0].name, "alice", "nom de racine dérivé du chemin");
|
||||
assert_eq!(c.dirs[0].path, home);
|
||||
assert!(!c.wizard_done);
|
||||
}
|
||||
|
||||
/// #160 — le document d'accueil est embarqué dans le binaire : non vide,
|
||||
/// Markdown, et nom de fichier stable (écrit une seule fois sur le disque).
|
||||
#[test]
|
||||
fn test_welcome_doc_embedded() {
|
||||
assert!(WELCOME_DOC.len() > 500, "document d'accueil trop court");
|
||||
assert!(WELCOME_DOC.starts_with('#'), "attendu : titre Markdown");
|
||||
assert!(WELCOME_DOC.contains("ObsiGate"));
|
||||
assert!(WELCOME_DOC.contains("Ctrl+Espace"));
|
||||
assert_eq!(WELCOME_DOC_NAME, "Prise en main.md");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# 🟢 Bienvenue sur ObsiGate — Prise en main
|
||||
|
||||
Ce dossier `%USERPROFILE%\ObsiGate` est votre **espace de départ** : il est
|
||||
monté automatiquement comme vault **ObsiGate** au premier lancement. Vous
|
||||
pouvez y écrire librement, puis brancher vos vrais vaults Obsidian quand vous
|
||||
êtes prêt.
|
||||
|
||||
## ObsiGate en 30 secondes
|
||||
|
||||
ObsiGate est une porte d'entrée locale vers vos notes : index en mémoire,
|
||||
recherche instantanée, lecture Markdown avec liens `[[wikilinks]]`, images,
|
||||
PDF, tableurs Excel, Excalidraw, Mermaid et assistant IA — sans base de
|
||||
données, sans modification de vos fichiers, tout reste sur votre machine.
|
||||
|
||||
## Premiers pas (application desktop)
|
||||
|
||||
1. **Ce document** s'affiche dans la visionneuse : c'est votre première note,
|
||||
modifiez-la ou supprimez-la quand vous n'en avez plus besoin.
|
||||
2. **Ajouter vos vaults** : Configuration → « 🖥️ Vaults & dossiers (Desktop) »
|
||||
→ *Ajouter un vault* (sélecteur de dossier natif). Depuis la sidebar,
|
||||
clic droit sur une racine → « Retirer de l'application » la désiste
|
||||
(aucun fichier n'est supprimé).
|
||||
3. **Naviguer** : la sidebar liste vos racines et leur arbre ; l'onglet
|
||||
Navigation parcourt un dossier sous forme de vues répertoire.
|
||||
4. **Rechercher** : `Ctrl+Espace` (palette de fichiers),
|
||||
`Ctrl+Alt+Espace` (palette de commandes), `Ctrl+F` dans un document.
|
||||
5. **Onglets** : `Ctrl+W` ferme l'onglet, `Ctrl+Tab` bascule ; le mode split
|
||||
divise l'écran en panneaux.
|
||||
6. **Guide complet** : bouton d'aide de l'en-tête — sommaire, raccourcis,
|
||||
architecture, et téléchargement du guide en Markdown/PDF.
|
||||
|
||||
## Le saviez-vous ?
|
||||
|
||||
- Le **glisser-déposer** de fichiers depuis l'Explorateur est pris en charge :
|
||||
déposez-les sur la fenêtre pour les importer.
|
||||
- Le **clic droit** est partout : menus contextuels de fichier, dossier et
|
||||
vault (avec renommage, création, suppression, vue graphique).
|
||||
- Le **watcher** surveille vos vaults : une note modifiée dans Obsidian
|
||||
apparaît immédiatement ici.
|
||||
|
||||
— *Ce fichier vous appartient.*
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.27.1",
|
||||
"version": "2.68.0",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
@@ -13,6 +13,7 @@
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"create": false,
|
||||
"title": "ObsiGate",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
|
||||
@@ -13,14 +13,14 @@ services:
|
||||
context: .
|
||||
image: obsigate:latest
|
||||
container_name: obsigate-test
|
||||
user: "1000:1000"
|
||||
user: "197609:197609"
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "2021:8080"
|
||||
volumes:
|
||||
- /tmp/obsigate-test-vault:/vaults/TestVault
|
||||
- /tmp/obsigate-test-dir:/vaults/TestDir
|
||||
- /tmp/obsigate-test-data:/app/data
|
||||
- ./test_vault:/vaults/TestVault
|
||||
- ./test_dir:/vaults/TestDir
|
||||
- obsigate_data:/app/data
|
||||
environment:
|
||||
- VAULT_1_NAME=TestVault
|
||||
- VAULT_1_PATH=/vaults/TestVault
|
||||
@@ -51,3 +51,4 @@ services:
|
||||
|
||||
volumes:
|
||||
ollama_data:
|
||||
obsigate_data:
|
||||
|
||||
@@ -21,12 +21,17 @@ services:
|
||||
volumes:
|
||||
- /c/dev/git/python/ObsiGate/test_vault:/vaults/TestVault
|
||||
- /c/dev/git/python/ObsiGate/test_dir:/vaults/TestDir
|
||||
# #194 : racine des dossiers persos — UN seul montage pour tous les
|
||||
# utilisateurs (dossier hôte : mkdir test_home à côté du dépôt).
|
||||
- /c/dev/git/python/ObsiGate/test_home:/vaults/Home
|
||||
- obsigate_data:/app/data
|
||||
environment:
|
||||
- VAULT_1_NAME=TestVault
|
||||
- VAULT_1_PATH=/vaults/TestVault
|
||||
- DIR_1_NAME=TestDir
|
||||
- DIR_1_PATH=/vaults/TestDir
|
||||
# #194 : dossier personnel par utilisateur = <racine>/home-<user>
|
||||
- OBSIGATE_HOME_ROOT=/vaults/Home
|
||||
- OBSIGATE_AUTH_ENABLED=true
|
||||
- OBSIGATE_ADMIN_USER=admin
|
||||
- OBSIGATE_ADMIN_PASSWORD=test123
|
||||
|
||||
+11
-1
@@ -36,6 +36,9 @@ services:
|
||||
- /NFS/OBSIDIAN_DOC/Obsidian_WORKOUT:/vaults/Obsidian_WORKOUT
|
||||
- /NFS/OBSIDIAN_DOC/SessionsManager:/vaults/SessionsManager
|
||||
- /home/bruno:/vaults/bruno
|
||||
# #194 : racine des dossiers perso — UN seul montage pour tous les
|
||||
# utilisateurs (un montage par user demanderait un redémarrage).
|
||||
- /NFS/OBSIDIAN_DOC/Home:/vaults/Home
|
||||
- /DOCKER_CONFIG/ObsiGate/data:/app/data
|
||||
environment:
|
||||
- VAULT_1_NAME=Recettes
|
||||
@@ -50,10 +53,17 @@ services:
|
||||
- VAULT_5_PATH=/vaults/SessionsManager
|
||||
- DIR_1_NAME=Bruno
|
||||
- DIR_1_PATH=/vaults/bruno
|
||||
# #194 : dossier personnel par utilisateur = <racine>/home-<user>
|
||||
- OBSIGATE_HOME_ROOT=/vaults/Home
|
||||
- OBSIGATE_AUTH_ENABLED=true
|
||||
- OBSIGATE_ADMIN_USER=admin
|
||||
# OBSIGATE_ADMIN_PASSWORD → .env
|
||||
# OBSIGATE_SECURE_COOKIES=true # si derrière reverse proxy HTTPS
|
||||
# OBSIGATE_SECURE_COOKIES : auto par défaut (Secure si https, sinon
|
||||
# pas de flag) — forcer à true uniquement si le proxy termine TLS
|
||||
# sans X-Forwarded-Proto (avec TRUST_PROXY, l'auto suffit).
|
||||
# Reverse proxy devant l'app : IPs d'audit réelles (BUG-030) et
|
||||
# X-Forwarded-Proto honoré pour les cookies Secure (auto).
|
||||
- OBSIGATE_TRUST_PROXY=true
|
||||
- OLLAMA_BASE_URL=http://ollama:11434/v1
|
||||
- OLLAMA_MODEL=qwen2.5-coder:1.5b
|
||||
env_file:
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
# Assistant V2 — ObsiGate comme second cerveau & assistant personnel
|
||||
|
||||
> **Statut :** 📐 Spécification de conception (non implémentée)
|
||||
> **Dernière mise à jour :** 2026-10-08
|
||||
> **IDs proposés :** ObsiGate **#191 → #201** (liste « Assistant V2 »)
|
||||
> **Portée :** évolution de l'assistant IA existant en assistant à mémoire long terme,
|
||||
> outillé (connecteurs externes), planifié (rappels, briefing) et proactif (insights).
|
||||
> **Hors portée :** toute intégration avec l'infrastructure externe du homelab
|
||||
> (Honcho, Hermes, Ollama partagé, ntfy homelab, cron système). ObsiGate est **autonome** :
|
||||
> un seul conteneur, ses propres stores, ses propres clés.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision
|
||||
|
||||
Transformer ObsiGate d'un portail de notes avec assistant vers un **second cerveau
|
||||
conversationnel et autonome** :
|
||||
|
||||
1. **Il se souvient** — mémoire long terme structurée (faits, préférences, décisions,
|
||||
entités), consolidée chaque nuit, **visible et éditable en markdown dans le vault**
|
||||
(pas de boîte noire).
|
||||
2. **Il agit** — lit/écrit le vault, mais aussi les services externes connectés :
|
||||
Gmail, Google Agenda, Teams, Discord, Notion, Drive… (cadre de connecteurs unifié).
|
||||
3. **Il anticipe** — rappels conversationnels, briefing quotidien généré par l'agent,
|
||||
détection de boucles ouvertes et d'engagements non tenus.
|
||||
4. **Il centralise** — ObsiGate devient le point d'entrée unique prise de notes +
|
||||
assistant ; tout le reste (desktop Tauri, MCP externe, PWA mobile) n'est qu'un
|
||||
accès à *ce même* cerveau.
|
||||
|
||||
Le modèle économique de la conception : **Muse/Snow/ChatGPT-Memory rendent l'effet
|
||||
« l'assistant me connaît » ; ici la mémoire est auditable (markdown dans le vault,
|
||||
audit log, dédupli, réversibilité) et les données ne quittent jamais le stack ObsiGate
|
||||
sauf appel explicite aux API des services que Bruno a connectés lui-même.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Principes directeurs (non négociables)
|
||||
|
||||
| # | Principe | Conséquence technique |
|
||||
|---|---|---|
|
||||
| P1 | **Autonomie du stack** | Tout vit dans le conteneur `obsigate` + son volume `data/`. Aucun nouveau conteneur runtime (Ollama est retiré — voir §9). |
|
||||
| P2 | **Zéro framework d'agent externe** | Décision déjà prise en #92 : boucle maison `backend/agent/loop.py`. L'Assistant V2 l'étend, ne la remplace pas. |
|
||||
| P3 | **Un outil = un `@tool`** | Toute nouvelle capacité passe par le registre typé (`backend/tools/registry.py`) : risque, scope, rate-limit, confirmation, audit, labels + i18n. |
|
||||
| P4 | **Mémoire = markdown d'abord** | Les artefacts durables (profil, insights, briefings, rappels) sont des notes du vault ; la base SQLite n'est que l'index reconstructible. |
|
||||
| P5 | **Humain dans la boucle** | `WRITE`/`DANGEROUS` exigent confirmation (#91/#92) ; un fait de confiance faible reste `pending` tant que l'utilisateur ne le valide pas. |
|
||||
| P6 | **Secrets jamais committés** | Pattern existant : `data/api_keys.json` / `data/connector_secrets.json` (0600) ou variables `OBSIGATE_*` dans `.env` (`env_file`, jamais dans le compose). |
|
||||
| P7 | **i18n FR/EN systématique** | Toute clé d'interface (`ai.step.*`, config connecteurs, cartes mémoire) existe dans les deux locales. |
|
||||
| P8 | **Le desktop Tauri reste fonctionnel** | Les connecteurs se dégradent silencieusement hors ligne (capability = absente si pas de token) ; aucune dépendance à un service réseau au démarrage. |
|
||||
| P9 | **Sécurité existante réutilisée** | `_resolve_safe_path()`, `require_auth`/`require_vault_access`, SSRF guards (`validate_webhook_url`, `is_safe_target`), `secret_redactor` avant tout appel LLM. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Inventaire de l'existant (socle sur lequel on bâtit)
|
||||
|
||||
Constat clé : **l'ossature « agent » existe déjà**. Ce qui manque, c'est la mémoire
|
||||
long terme, les connecteurs, la couche proactive et l'ordonnancement de haut niveau.
|
||||
|
||||
| Brique | Fichier(s) | État | Rôle dans la V2 |
|
||||
|---|---|---|---|
|
||||
| Boucle agent multi-étapes + tool calling natif | `backend/agent/loop.py` (`run_agent`), `backend/ai_chat.py` (payload `tools`/`tool_choice`, conversion Gemini, fallback si refus) | ✅ livré (#91/#92) | Moteur de l'assistant V2 — inchangé |
|
||||
| Registre d'outils typé | `backend/tools/registry.py` (`@tool`, `ToolRisk` READ/WRITE/DANGEROUS, `ToolScope` IN_APP/MCP, confirmation auto si non-READ, rate-limit, audit, schéma OpenAI) | ✅ | Cadre d'insertion de **tous** les nouveaux outils |
|
||||
| Outils vault (~26) | `backend/tools/service.py`, `backend/services/*` (files, mutations, search, vaults, duplicates…) | ✅ | Outils natifs de l'assistant |
|
||||
| Recherche TF-IDF + sémantique embeddings (RRF) | `backend/search.py`, `backend/semantic_search.py`, `requirements-semantic.txt` (sentence-transformers **local, optionnel**, FAISS) | ✅ (#70) | Retrieval de la mémoire épisodique ; remplace le besoin Ollama embeddings |
|
||||
| Historique de conversation persistant | `backend/ai_history.py` (`data/sessions`, par utilisateur) | ✅ (#95) | **Mémoire épisodique** brute — source de la consolidation |
|
||||
| Serveur MCP externe | `backend/mcp/server.py` (Streamable HTTP `/mcp`, propose/apply) | ✅ (#79) | Même registre exposé — les outils V2 en profitent gratis |
|
||||
| Scheduler type cron | `backend/scheduler.py` (tick asyncio 60 s, `data/scheduled_tasks.json`, `interval_hours`/`daily_time`/`once_at`, actions `create_file`/`append_to_file`/`notify`) + outils `create/list/delete/run_scheduled_task` (`backend/tools/scheduled.py`) | ✅ (#170) | Socle des rappels & briefing — à étendre (§8) |
|
||||
| Notifications externes | `backend/notify.py` (canaux **Discord webhook, Telegram, SMTP, webhook générique**, triggers, secrets 0600) + outil `notify_external` | ✅ (#168) | Bras de sortie des rappels/briefing |
|
||||
| Web Push PWA | `backend/push.py` (VAPID) + `frontend/sw.js` | ✅ (#67) | Deuxième canal de rappel, sans dépendance externe |
|
||||
| Sources connectées (premier précédent) | `backend/tools/connected.py` (Gitea/GitHub, READ), page Configurations #103, `backend/tools/secrets.py` | ✅ (#92/#103) | Patron à généraliser en cadre connecteurs (§7) |
|
||||
| Web : recherche + fetch + crawl | `backend/tools/web.py` (Tavily/Brave/SerpAPI/Exa/SearXNG, `fetch_url`), `backend/tools/crawler.py` (`crawl_site`) | ✅ | Outils « connaissances hors vault » |
|
||||
| Production de documents | `backend/tools/documents.py` (`create_xlsx/docx/csv/pdf`) | ✅ | Actions de l'assistant |
|
||||
| Doublons | `backend/tools/duplicates.py` (`find_duplicates`, `merge_duplicate_notes` DANGEROUS) | ✅ (#166) | Hygiène de la mémoire |
|
||||
| Skills / slash-commandes | `backend/skills.py` (`/cmd`, `data/skills.json`) | ✅ | Vecteur naturel des prompts de consolidation & briefing |
|
||||
| Cache SQLite | `backend/tools/webcache.py` (précédent `sqlite3` stdlib dans `data/`) | ✅ | Patron du store mémoire (§5) |
|
||||
| Fournisseurs LLM | `backend/ai.py` (`PROVIDERS` : deepseek, openrouter, gemini, nvidia, qwencloud, xiaomi, mistral, ollama), `reload_ai_config()`, clés `data/api_keys.json` + fallback env | ✅ | Garder le multi-provider ; **retirer le conteneur ollama** (§9) |
|
||||
| Audit + redaction | `backend/audit.py`, `backend/tools/audit.py`, `backend/secret_redactor.py` | ✅ | Traçabilité mémoire/connecteurs |
|
||||
|
||||
**Gaps** (→ §5–§8) : mémoire sémantique (faits) · miroir markdown · consolidation ·
|
||||
cadre connecteurs générique + OAuth2 · Gmail/Calendar/Teams/Discord(in)/Notion/Drive ·
|
||||
rappels conversationnels · agent programmé (briefing) · insights proactifs.
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture cible
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ FRONTEND (SPA + PWA + Tauri) │
|
||||
│ Assistant panel · Cartes mémoire · Config │
|
||||
│ « Sources connectées » · Briefings │
|
||||
└───────────────┬────────────────────────────┘
|
||||
│ REST + SSE (existant)
|
||||
┌──────────────────────────────────────▼─────────────────────────────────────┐
|
||||
│ BACKEND obsigate (1 conteneur) │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────────┐ ┌────────────────────────────┐ │
|
||||
│ │ agent/loop.py│◄──│ tools/registry.py│◄──│ TOUS les outils V2 (@tool) │ │
|
||||
│ │ (run_agent) │ │ risque/scope/ │ │ vault · web · docs (exist.)│ │
|
||||
│ └──────┬───────┘ │ audit/limit │ │ memory.* (nouveau §5) │ │
|
||||
│ │ └──────────────────┘ │ connector.* (§7) │ │
|
||||
│ ┌──────┴────────┐ │ reminder.* (§8) │ │
|
||||
│ │ ai_chat.py │ │ scheduled.* (étendu) │ │
|
||||
│ │ providers API │ └────────────────────────────┘ │
|
||||
│ └──────┬────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────▼───────────┐ ┌────────────────────┐ ┌───────────────────────┐ │
|
||||
│ │ scheduler.py │ │ memory/ │ │ connectors/ │ │
|
||||
│ │ tick 60 s │ │ store SQLite │ │ base OAuth2+PKCE │ │
|
||||
│ │ + agent_prompt │─►│ (faits) │ │ gmail/gcal/teams/ │ │
|
||||
│ │ (nouv. action) │ │ consolidator │ │ discord/notion/drive │ │
|
||||
│ └──────┬───────────┘ └─────────┬──────────┘ └───────────┬───────────┘ │
|
||||
│ │ notify.broadcast │ miroir markdown │ HTTPS API │
|
||||
│ │ + Web Push ▼ ▼ │
|
||||
│ ┌──────▼──────────┐ ┌────────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ Vault Obsidian │ │ Vault Obsidian │ │ Services externes │ │
|
||||
│ │ (5 volumes NFS) │ │ 99_Assistant/*.md │ │ (Google/MS/Discord) │ │
|
||||
│ └─────────────────┘ └────────────────────┘ └─────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Nouveaux modules backend (alignés sur l'organisation existante) :
|
||||
|
||||
```
|
||||
backend/
|
||||
memory/
|
||||
store.py # SQLite data/memory.sqlite3 — CRUD faits + runs
|
||||
recall.py # fusion recall: facts + sémantique (#70) + épisodes (ai_history)
|
||||
consolidator.py # job « dreaming » nocturne (prompt + parsing + dédup)
|
||||
mirror.py # rendu markdown 99_Assistant/ + parse des edits humains
|
||||
connectors/
|
||||
base.py # ConnectorInterface: id, scopes, authorize_url, exchange,
|
||||
# refresh, capabilities(), call() (retry + rate-limit + audit)
|
||||
oauth.py # flux code + PKCE, redirect /api/connectors/{id}/callback
|
||||
registry.py # registre des connecteurs installés (comme tools/registry)
|
||||
gmail.py gcal.py teams.py discord.py notion.py drive.py
|
||||
routers/
|
||||
connectors.py # CRUD admin: installation, statut, test de connexion
|
||||
memory.py # API faits (liste/édit/suppression/validation pending)
|
||||
tools/
|
||||
memory.py # @tool memory.* (§5.4)
|
||||
reminders.py # @tool reminder.* (§8.1)
|
||||
connectors.py # @tool par capability connecteur (§7.4)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Mémoire long terme — #192 (socle), #193 (miroir), #194 (consolidation)
|
||||
|
||||
### 5.1 Modèle de données (SQLite, `data/memory.sqlite3`)
|
||||
|
||||
Précédent : `webcache.py`. Une seule table applicative + une table d'audit des runs.
|
||||
|
||||
```sql
|
||||
CREATE TABLE facts (
|
||||
id TEXT PRIMARY KEY, -- f-<uuid4>
|
||||
user_id TEXT NOT NULL,
|
||||
subject TEXT NOT NULL, -- ex: "santé", "Projet FlowDeck", "bruno"
|
||||
predicate TEXT NOT NULL, -- ex: "rdv", "préférence", "décision"
|
||||
value TEXT NOT NULL, -- contenu FR court (< 500 car.)
|
||||
kind TEXT NOT NULL CHECK (kind IN
|
||||
('preference','decision','entity','event','habit','goal','open_loop')),
|
||||
confidence REAL NOT NULL DEFAULT 0.5, -- 0..1 ; < 0.7 → status pending
|
||||
status TEXT NOT NULL DEFAULT 'confirmed'
|
||||
CHECK (status IN ('pending','confirmed','rejected','expired')),
|
||||
source TEXT NOT NULL, -- 'consolidation' | 'user' | 'chat'
|
||||
source_ref TEXT, -- session id ou chemin de note
|
||||
supersedes TEXT, -- id du fait remplacé (dédup temporel)
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL, -- reinforcement par usage
|
||||
expires_at TEXT -- nullable (ex: rdv passé)
|
||||
);
|
||||
CREATE INDEX idx_facts_subject ON facts(user_id, subject);
|
||||
CREATE INDEX idx_facts_status ON facts(user_id, status);
|
||||
|
||||
CREATE TABLE consolidation_runs (
|
||||
id TEXT PRIMARY KEY, started_at TEXT, finished_at TEXT,
|
||||
sessions_consumed INTEGER, notes_changed INTEGER,
|
||||
facts_added INTEGER, facts_updated INTEGER, error TEXT
|
||||
);
|
||||
```
|
||||
|
||||
Règles :
|
||||
- **Dédup temporel** : un nouveau fait `(subject, predicate)` avec valeur différente
|
||||
crée une ligne et marque l'ancienne `superseded` (via `supersedes`, status `expired`).
|
||||
Historique conservé, recall = dernier `confirmed` non expiré.
|
||||
- **Renforcement** : `last_seen_at` bump quand le fait est réaffirmé en chat ;
|
||||
les facts non revus depuis N jours (180) et non `decision` deviennent `expired`.
|
||||
- **PII santé/famille** : `consolidator` force `status='pending'` (validation humaine
|
||||
via cartes) si la note source appartient aux dossiers sensibles configurables
|
||||
(`OBSIGATE_MEMORY_REVIEW_FOLDERS`, ex. `Santé,Famille`).
|
||||
- Backup : `data/` étant déjà dans le cycle de backups existant, rien de neuf.
|
||||
|
||||
### 5.2 Miroir markdown dans le vault — #193
|
||||
|
||||
Dossier **`99_Assistant/`** (créé à la première activation, dans le vault principal
|
||||
configurable `OBSIGATE_MEMORY_VAULT=Main`, dossier racine configurable) :
|
||||
|
||||
| Fichier | Contenu | Écrit par | Éditable par Bruno |
|
||||
|---|---|---|---|
|
||||
| `Profil.md` | Préférences/habitus/goals confirmés, groupés par sujet (H2) | mirror (consolidation) | ✅ — les edits sont respectés (§5.3 règles de merge) |
|
||||
| `Insights.md` | Détections proactes #201 (boucles ouvertes, engagements, patterns), horodatées | mirror | ✅ (suppression = `rejected` en base) |
|
||||
| `Rappels.md` | Table des rappels actifs (liens vers tâches planifiées) | mirror scheduler | lecture seule (UI dédiée) |
|
||||
| `Briefings/YYYY-MM-DD.md` | Briefing quotidien #200 | agent programmé | ✅ (archivage, jamais régénéré) |
|
||||
| `Journal.md` | Dernières consolidations (nb faits ajoutés/validés) | mirror | lecture seule |
|
||||
|
||||
Le miroir utilise des **balises HTML de section** :
|
||||
|
||||
```markdown
|
||||
<!-- obsigate:managed:preferences -->
|
||||
…bloc régénéré…
|
||||
<!-- /obsigate:managed:preferences -->
|
||||
```
|
||||
|
||||
Règle de merge : à chaque écriture, le mirror compare le contenu précédent généré
|
||||
(hash stocké en base) avec le contenu actuel du fichier. Si l'utilisateur a modifié
|
||||
l'intérieur d'une section gérée → la modification est **re-parse en faits `source='user'`**
|
||||
(confiance 1.0, status confirmed) puis la section est régénérée à partir de la base.
|
||||
Tout ce qui est hors balises est laissé intact. C'est le mécanisme qui rend la mémoire
|
||||
*auditable et contestable* — l'anti-boîte-noire.
|
||||
|
||||
### 5.3 Consolidation nocturne (« dreaming ») — #194
|
||||
|
||||
Job planifié via le scheduler existant (§8.3), action `agent_prompt` dédiée :
|
||||
|
||||
1. **Collecte incrémentale** : sessions `ai_history` créées/modifiées depuis le dernier
|
||||
run + fichiers `.md` modifiés depuis le dernier run (via l'index/watcher mtime) —
|
||||
plafonds : 200 fichiers × 30 Ko (comme bookslm), contenu `redact_file_content()` avant LLM.
|
||||
2. **Extraction** (1 appel LLM, provider par défaut = cheap model) : prompt système
|
||||
d'extraction → JSON `[{subject, predicate, value, kind, confidence}]` validé Pydantic ;
|
||||
rejet des valeurs non sourcées.
|
||||
3. **Réconciliation** (déterministe, stdlib) : dédup/supersede par `(subject,predicate)`,
|
||||
plancher de confiance, dossiers sensibles → pending.
|
||||
4. **Écriture** : `facts` + `mirror.py` → sections gérées de `Profil.md`.
|
||||
5. **Cartes de validation** : les faits `pending` deviennent des cartes dans le panneau
|
||||
Assistant (« Valider / Modifier / Oublier »), persistées jusqu'à action.
|
||||
|
||||
Coût : 1–3 appels API/nuit (deepseek-chat ≈ quelques centimes). Budget max :
|
||||
`OBSIGATE_CONSOLIDATION_MAX_TOKENS` (défaut 30 000/jour) pour bornes dures.
|
||||
|
||||
### 5.4 Outils mémoire (registés `@tool`) — #192
|
||||
|
||||
| Outil | Risque | Description (schéma) |
|
||||
|---|---|---|
|
||||
| `memory_recall` | READ | `{query, subject?, kind?}` → faits confirmed pertinents + fusion RRF avec sémantique #70 sur le vault. Injecté **automatiquement** dans le prompt système de chaque chat (voir §5.5). |
|
||||
| `memory_save` | WRITE | `{subject, predicate, value, kind, confidence=0.9}` — le modèle propose, la carte de confirmation affiche le fait ; Bruno valide → `source='chat'`. |
|
||||
| `memory_update` | WRITE | `{fact_id, value | status}` — correction/revalidation. |
|
||||
| `memory_forget` | DANGEROUS | `{fact_id}` — status `rejected`, supprimé du miroir, tracé à l'audit. |
|
||||
| `profile_get` | READ | `{section?}` → contenu courant de `Profil.md` (plus riche que le recall brut). |
|
||||
|
||||
### 5.5 Injection dans le contexte (le « ça me connaît » quotidien)
|
||||
|
||||
Au démarrage de chaque session agent (`bookslm_routes` → futur endpoint `/agent`),
|
||||
construction d'un bloc mémoire coiffant le prompt système, budget ≤ 2 500 tokens :
|
||||
|
||||
```
|
||||
<memoried-context>
|
||||
Profil (résumé géré) : préférences, habitudes, objectifs actifs…
|
||||
Faits saillants liés au sujet : <memory_recall(query=requête utilisateur)>
|
||||
Rappels actifs : <n lignes de Rappels.md>
|
||||
Dernier briefing : <3 puces>
|
||||
</memoried-context>
|
||||
```
|
||||
|
||||
Ce bloc est **visible** dans la section « étapes » du panneau (label « Mémoire »),
|
||||
comme les autres étapes d'outils — pas de magie cachée.
|
||||
|
||||
---
|
||||
|
||||
## 6. Rattachement MCP / Desktop / Skills
|
||||
|
||||
- Tous les nouveaux outils `@tool` sont automatiquement exposés au **serveur MCP**
|
||||
(`/mcp`, scopes IN_APP+MCP par défaut, propose/apply déjà gérés) → Claude Desktop /
|
||||
Cursor bénéficient gratis de `memory.*`, `reminder.*`, `connector.*`.
|
||||
- **Skills `/`** : nouvelles built-in `/profil` (montre Profil.md), `/rappel <texte>`
|
||||
(crée un rappel), `/brief` (régénère le briefing du jour), `/consolider`
|
||||
(déclenche le run de consolidation à la demande), `/connecteurs` (état des sources).
|
||||
- **Desktop Tauri** : les connecteurs sans token enregistré n'apparaissent pas dans le
|
||||
registre de schémas envoyés au LLM (`list_tools` filtré par capabilities actives) ;
|
||||
zéro appel réseau au démarrage.
|
||||
|
||||
---
|
||||
|
||||
## 7. Connecteurs externes — #195 (cadre), #196–#198 (implémentations)
|
||||
|
||||
### 7.1 Cadre (`backend/connectors/`)
|
||||
|
||||
Décision : **remplacer la logique ad hoc de `connected.py` (Gitea/GitHub par variables
|
||||
d'env) par un cadre général** — tout en gardant ces deux sources fonctionnelles
|
||||
(migration interne, même UX « Sources connectées » #103 étendue).
|
||||
|
||||
Concepts :
|
||||
|
||||
- **Connector** = classe (id, label, docs URL, scopes OAuth, capabilities nommées).
|
||||
- **Installation** = entrée `data/connectors.json` `{id, type, status, config (public),
|
||||
created_at}` ; secrets dans `data/connector_secrets.json` (0600) ou
|
||||
`OBSIGATE_CONNECTOR_SECRET_<ID>` — **même pattern strict que #168/#103**.
|
||||
- **Auth** : soit *token statique* (Discord webhook, Notion key, GitHub), soit
|
||||
**OAuth2 authorization-code + PKCE** (Google, Microsoft) :
|
||||
`GET /api/connectors/{id}/authorize` → redirection → `GET /api/connectors/{id}/callback`
|
||||
(échange + stockage `refresh_token` chiffré avec `data/secret.key` existant) →
|
||||
`POST /api/connectors/{id}/test` (validation par un appel READ bon marché).
|
||||
- **Refresh** transparent dans `call()` (anticipation du expiry, 1 retry sur 401).
|
||||
- **Capabilities → tools** : chaque capability expose un `@tool` (dynamiquement
|
||||
enregistré si le connecteur est installé), préfixé :
|
||||
`gmail.search_messages`, `gcal.list_events`, `teams.list_chats`…
|
||||
Risque par capability (lecture READ / écriture WRITE / envoi DANGEROUS).
|
||||
- **Budget & limites** : rate-limit par connecteur via `tools/ratelimit.py`,
|
||||
timeout httpx 15 s, retry backoff (pattern #92), cache SQLite des réponses GET
|
||||
bornées (pattern `webcache.py`, TTL 5 min pour le contexte de chat).
|
||||
- **Audit** : chaque `call()` journalisé (connecteur, capability, args tronqués,
|
||||
latence, statut) via `tools/audit.py`.
|
||||
|
||||
### 7.2 Matrice des connecteurs cibles
|
||||
|
||||
| Connecteur | Auth | Capabilités (→ outil) | Risque | Phase |
|
||||
|---|---|---|---|---|
|
||||
| **Google — Calendar** | OAuth2 PKCE | `gcal.list_events` (période, requête), `gcal.get_event`, `gcal.create_event`, `gcal.update_event` | READ, READ, WRITE, WRITE | #196 |
|
||||
| **Google — Gmail** | OAuth2 PKCE | `gmail.search_messages` (query `is:unread…`), `gmail.read_message`, `gmail.create_draft`, ~~`gmail.send`~~ | READ, READ, WRITE, 🔴 non exposé v1 | #196 |
|
||||
| **Google — Drive** | OAuth2 PKCE | `drive.search`, `drive.read_file` (import md/docx → vault) | READ | #198 (rattache backlog #167) |
|
||||
| **Microsoft — Teams / Graph** | Entra ID client, OAuth2 | `teams.list_chats`, `teams.read_messages`, `teams.send_message` | READ, READ, WRITE | #197 |
|
||||
| **Microsoft — Outlook** | idem Graph | `outlook.search_messages`, `outlook.list_events` (calendriers persos MS) | READ | #197 (optionnel, même jeton) |
|
||||
| **Discord** | webhook (sortie, ✅ #168) + **bot token lecture** (entrée, scopes `read`) | `discord.send` (existant via notify), `discord.list_messages`, `discord.search` (REST, pas de gateway websocket en v1) | WRITE, READ, READ | #198 |
|
||||
| **Notion** | API key (user token) | `notion.search`, `notion.read_page`, `notion.append_blocks` | READ, READ, WRITE | #198 |
|
||||
| **Slack** | webhook sortie + bot token lecture REST | `slack.send`, `slack.search_messages` | WRITE, READ | option future |
|
||||
| **Gitea / GitHub** | token (existant) | migrés sous le cadre | READ | #195 (migration) |
|
||||
| **Todoist / tasks externes** | token | — | — | hors v1 ; les tâches restent **dans le vault** (checklists) |
|
||||
|
||||
Règle transversale : **la prise de notes reste le vault**. Les connecteurs *nourrissent*
|
||||
l'assistant (contexte) et reçoivent des *actions* (événement, brouillon, message) ; ils
|
||||
ne remplacent jamais la note. C'est l'inverse des apps chat généralistes.
|
||||
|
||||
### 7.3 Utilisation des connecteurs par l'assistant
|
||||
|
||||
- L'agent choisit les outils selon les demandes (tool-calling existant, `tool_choice=auto`).
|
||||
- **Contexte briefing** (§8.2) : agrège `gcal.list_events(jour)` + rappels + mails
|
||||
non lus (si Gmail connecté) + notes modifiées → note de briefing.
|
||||
- **Demande type** : « est-ce que j'ai une réponse du dentiste ? » →
|
||||
`gmail.search_messages(from:...)` → cite le mail → propose la note.
|
||||
- **Confidentialité** : le contenu des connecteurs entre dans la conversation mais
|
||||
**jamais** dans la consolidation mémoire par défaut (seuls les faits *explicitement*
|
||||
confirmés par l'utilisateur via `memory_save` entrent en base).
|
||||
|
||||
### 7.4 UI « Sources connectées » (#103 étendue)
|
||||
|
||||
Page Configurations : carte par connecteur (statut, scopes, bouton Connect/Test/
|
||||
Déconnecter), journal des derniers appels (audit filtré), budget tokens. Textes FR/EN.
|
||||
|
||||
---
|
||||
|
||||
## 8. Rappels, briefing, agent programmé — #199 (rappels), #200 (agent+briefing)
|
||||
|
||||
### 8.1 Rappels conversationnels — #199
|
||||
|
||||
Le scheduler #170 sait déjà planifier `notify`/`create_file`. Les rappels = **action
|
||||
typée de haut niveau**, pour que l'assistant puisse les gérer naturellement :
|
||||
|
||||
- Nouvelle action scheduler `reminder` : `{user_id, text, deliver: [channels…],
|
||||
recurring: none|daily|weekly|cron}` → au due : `notify.broadcast` + Web Push +
|
||||
append à `99_Assistant/Rappels.md` (historique `Rappels.md` → section « tenus »).
|
||||
- Outils : `reminder_create` (WRITE, `{texte, quand (ISO ou relatif « in 3 weeks »
|
||||
résolu côté serveur par la LLM→date), récurrence, canaux}`), `reminder_list` (READ),
|
||||
`reminder_snooze` (WRITE, +`{task_id, delta}` — le snooze bump `last_snoozed_count`,
|
||||
signal pour #201), `reminder_cancel` (WRITE).
|
||||
- UX : panneau Assistant « Cloche » + section latérale dédiée ; les rappels dus sont
|
||||
injectés en contexte mémoire (§5.5) pour que l'assistant puisse en parler de lui-même.
|
||||
|
||||
### 8.2 Agent programmé — nouveau type d'action `agent_prompt` — #200
|
||||
|
||||
Brique générique manquante : le scheduler doit pouvoir **lancer le run_agent** avec un
|
||||
prompt, pas seulement des actions primitives.
|
||||
|
||||
- Sécurité de la tâche non interactive (pas de confirmation humaine possible) :
|
||||
**whitelist de capacités** — READ sans limite ; WRITE restreint aux chemins
|
||||
`99_Assistant/**` (le prompt d'un scheduled-agent ne peut écrire ailleurs) ;
|
||||
DANGEROUS et `connectors.*` en écriture **interdits**. Paramètre
|
||||
`allowed_extra_tools: []` pour élargir explicitement à la création.
|
||||
- Plafonds : max 8 itérations d'outils, budget tokens par tâche, `last_error` +
|
||||
broadcast `schedule_failure` (déjà en place), `last_run_at` persisté.
|
||||
- Le **briefing du matin** = built-in : skill `/brief` + tâche `daily_time 07:00`
|
||||
(fuseau `OBSIGATE_TZ`) → prompt agrégateur (§7.3) → écrit `Briefings/YYYY-MM-DD.md`
|
||||
+ notification (canaux de l'utilisateur + Web Push).
|
||||
- Autres prompts programmatifs naturels : « résumé hebdo des notes modifiées »,
|
||||
« veille » (crawl d'une page + diff dans une note) — mais chacun reste une tâche
|
||||
créée par l'utilisateur ou l'assistant, pas de magie par défaut.
|
||||
|
||||
### 8.3 Le job de consolidation passe par `agent_prompt`
|
||||
|
||||
#194 s'implémente comme tâche built-in `daily_time 23:30` + whitelist
|
||||
`memory.*`/`profile_get` — l'infrastructure d'ordonnancement de #200 sert la mémoire.
|
||||
(Ordre de livraison : #200 avant #194, cf. §10.)
|
||||
|
||||
---
|
||||
|
||||
## 9. Retrait d'Ollama — #191
|
||||
|
||||
Objectif utilisateur : ObsiGate ne doit pas hériter de l'infra (ni Ollama, ni Honcho) ;
|
||||
et le conteneur Ollama (1,4 Go+ de poids, 2 modèles, RAM) ne sert plus.
|
||||
|
||||
1. `docker-compose.yml` : supprimer le service `ollama`, le volume `ollama_data`,
|
||||
les vars `OLLAMA_BASE_URL`/`OLLAMA_MODEL`, et la mention « port 11434 » de l'en-tête.
|
||||
Idem `docker-compose.test-linux.yml` / `docker-compose.test-win.yml`.
|
||||
2. `backend/ai.py` / `model_capabilities.py` : **conserver** le provider `ollama`
|
||||
(option de configuration, utile au desktop hors-ligne qui pointe vers un ollama
|
||||
local de l'utilisateur) mais retirer tout défaut pointant vers le réseau interne.
|
||||
Les tests d'isolation : `OBSIGATE_*` ne référence plus `http://ollama:11434`.
|
||||
3. Embeddings : par **sentence-transformers local optionnel** (déjà #70) — le mode
|
||||
par défaut de l'Assistant V2 fonctionne **sans embeddings** (TF-IDF + RRF + faits
|
||||
SQLite), les embeddings étant un raffinement auto-détecté (`SemanticIndex` existe,
|
||||
avec fallback API provider si configurée).
|
||||
4. Migration : `docker compose down && up -d` (règle .env connue P17/P29) ; supprimer
|
||||
le volume `ollama_data` (procédure documentée dans le CHANGELOG + ROADMAP de la tache #191).
|
||||
|
||||
---
|
||||
|
||||
## 10. Phasage & IDs
|
||||
|
||||
> Chaque tranche est livrable indépendamment, CI verte + DoD `DELIVERY_WORKFLOW.md`,
|
||||
> i18n FR/EN, tests, guide utilisateur `docs/GUIDES/`.
|
||||
|
||||
| ID | Tranche | Contenu | Effort | Dépend | Critères d'acceptation |
|
||||
|---|---|---|---|---|---|
|
||||
| #191 | **Purge Ollama** | compose ×3, docs, notes migration | 0,5 j | — | plus aucun conteneur ollama ; AI editor + agent OK sur deepseek/openrouter/gemini |
|
||||
| #192 | **Mémoire socle** | `memory/store.py`, `recall.py`, 5 outils `@tool`, tests | 3–4 j | #191 | recall/save/update/forget fonctionnent en chat ; audit + confirmation OK |
|
||||
| #193 | **Miroir markdown** | `mirror.py`, `99_Assistant/`, balises gérées, re-parse edits | 2–3 j | #192 | édition manuelle de Profil.md → faits `source='user'` ; réécritures idempotentes |
|
||||
| #200 | **Agent programmé + briefing** | action `agent_prompt` (whitelist), skill `/brief`, tâches built-in | 3–4 j | #192 | briefing 07:00 → note + notif ; WRITE limité `99_Assistant/**` prouvé par tests |
|
||||
| #194 | **Consolidation nocturne** | `consolidator.py`, incrémental, pending/cartes, budget tokens | 3–4 j | #200, #193 | 1 nuit test → ≥1 fait utile extrait ; aucun secret extrait (redaction test) ; budget respecté |
|
||||
| #199 | **Rappels** | action `reminder`, 4 outils, UI cloche, snooze-count | 2–3 j | #200 | « rappelle-moi dans 3 semaines » → rappel réel ; snooze ≥3 → signal visible #201 |
|
||||
| #195 | **Cadre connecteurs** | `connectors/` base+oauth+registry, UI Sources, migration Gitea/GitHub | 4–5 j | — | OAuth2 PKCE de bout en bout avec un provider de test ; secrets 0600 |
|
||||
| #196 | **Google Calendar + Gmail** | capabilities §7.2, context briefing étendu | 3–4 j | #195 | agenda du jour cité dans briefing ; recherche mail via chat avec confirmation WRITE nulle |
|
||||
| #197 | **Microsoft Teams/Outlook** | Graph client min, capabilities READ + send | 3–4 j | #195 | lire un chat Teams et y répondre via carte de confirmation |
|
||||
| #198 | **Discord read + Notion + Drive** | capacités restantes §7.2 | 3–5 j | #195 | import page Notion → note vault ; `drive.read_file` OK |
|
||||
| #201 | **Proactivité / insights** | détecteurs stdlib (open loops, snoozes, pourrissement TODO, récurrences) → `Insights.md` + suggestions en contexte | 3–4 j | #194, #199 | un scenario « note à faire > 21 jours » et « snooze ×3 » génèrent des insights réels et acceptables/refusables |
|
||||
|
||||
Ordre recommandé : `#191 → #192 → #193 → #200 → #194 → #199` (la mémoire et le
|
||||
briefing rendent l'assistant utile **sans** connecteur) puis connecteurs `#195 → #196…`
|
||||
et enfin `#201` (le coaching a besoin de la mémoire garnie).
|
||||
|
||||
---
|
||||
|
||||
## 11. Sécurité & confidentialité (rappel explicite)
|
||||
|
||||
- **Secrets** : uniquement `data/connector_secrets.json` (0600, chiffrés `secret.key`)
|
||||
ou env `OBSIGATE_CONNECTOR_SECRET_*` — jamais le compose, jamais git (#168/#103).
|
||||
- **SSRF** : toutes les URLs configurées par l'utilisateur passent
|
||||
`validate_webhook_url`/`is_safe_target` ; les endpoints des providers OAuth sont
|
||||
une allowlist codée (accounts.google.com, login.microsoftonline.com…).
|
||||
- **Chiffrement au repos** optionnel : même pattern que #172 (backlog existant) —
|
||||
si activé, s'applique à `connector_secrets.json` + `memory.sqlite3`.
|
||||
- **Redaction** : `redact_file_content()` systématique avant LLM pour toute note
|
||||
consolidée ; jamais le contenu brut des connecteurs dans les prompts hors
|
||||
appel d'outil explicite.
|
||||
- **Consentement par tâche planifiée** : les tâches `agent_prompt` listent leurs
|
||||
capacités autorisées dans l'UI, avec journal `last_run` dépliable.
|
||||
- **Effacement** : `memory_forget` + purge des sessions #95 + export de la base
|
||||
(`GET /api/memory/export` JSON) → réversibilité complète RGPD-like.
|
||||
|
||||
---
|
||||
|
||||
## 12. Risques & arbitrages assumés
|
||||
|
||||
| Risque | Mitigation |
|
||||
|---|---|
|
||||
| Qualité variable de l'extraction de faits → mémoire « bruitée » | seuil confiance + pending/cartes ; dossiers sensibles → validation manuelle ; `last_seen` decay ; effacement facile |
|
||||
| Coût LLM (consolidation + briefing quotidiens) | cheap model dédié par var `OBSIGATE_AGENT_BACKGROUND_MODEL` ; budgets durs en tokens ; aggregation déterministe (stdlib) avant LLM |
|
||||
| Dérive du scheduler qui exécute des prompts non surveillés | whitelist capacités + budget itérations + plafond chemins ; journal d'audit par tâche |
|
||||
| Élargissement du scope backend (main.py déjà volumineux) | nouveaux modules dédiés (`memory/`, `connectors/`) ; aucune logique dans `main.py` ; routers séparés |
|
||||
| OAuth app Google/Microsoft à créer manuellement (client id) | guide `docs/GUIDES/CONNECTEURS_GOOGLE_MS.md` pas-à-pas FR/EN ; possible de rester sur tokens statiques Notion/GitHub sans projet cloud |
|
||||
| Éditions humaines du miroir non re-parsables (markdown libre) | balises `managed` strictes ; sections hors-balises intouchables ; re-parse = faits, pas diff sémantique |
|
||||
| Tauri desktop + connecteurs = surface réseau | capabilities absentes du registre tant que non installées ; offline-first global inchangé |
|
||||
|
||||
---
|
||||
|
||||
## 13. Définition de terminé (par tranche, conforme DoD dépôt)
|
||||
|
||||
Pour chaque ID : tests unitaires backend (+tests de non-régression pour tout bug),
|
||||
tests JSDOM si UI, E2E Playwright si flux UI touché, ruff/mypy 0 erreur, i18n FR/EN,
|
||||
labels `ai.step.*` pour tout nouvel outil, mise à jour `CHANGELOG.md` `[Unreleased]`,
|
||||
`docs/ROADMAP.md` (statut + fiche `docs/features/assistant-v2-<slug>.md` par tranche),
|
||||
guide utilisateur `docs/GUIDES/` si exposé à l'utilisateur, CI verte, commit conventionnel
|
||||
référençant l'ID.
|
||||
|
||||
---
|
||||
|
||||
## Annexe A — Variables d'environnement nouvelles
|
||||
|
||||
```
|
||||
# Mémoire
|
||||
OBSIGATE_MEMORY_ENABLED=true
|
||||
OBSIGATE_MEMORY_VAULT=Main # vault où vit 99_Assistant/
|
||||
OBSIGATE_MEMORY_REVIEW_FOLDERS=Santé,Famille # forcent status=pending
|
||||
OBSIGATE_CONSOLIDATION_MODEL= # cheap model, défaut = provider courant
|
||||
OBSIGATE_CONSOLIDATION_MAX_TOKENS=30000
|
||||
# Briefing / agent programmé
|
||||
OBSIGATE_TZ=America/Toronto
|
||||
OBSIGATE_BRIEFING_TIME=07:00
|
||||
# Connecteurs (ou UI Sources connectées — stockage prioritaire)
|
||||
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET # app Google Cloud (consent screen)
|
||||
AZURE_CLIENT_ID / AZURE_CLIENT_SECRET # app Entra ID
|
||||
OBSIGATE_NOTION_API_KEY=
|
||||
OBSIGATE_DISCORD_BOT_TOKEN= # lecture REST uniquement
|
||||
```
|
||||
|
||||
## Annexe B — Prompt de consolidation (extrait)
|
||||
|
||||
```
|
||||
Tu extrais des faits durables à partir de notes et conversations récentes d'un
|
||||
journal personnel. Pour chaque fait : subject, predicate, value (une phrase,
|
||||
français), kind ∈ preference|decision|entity|event|habit|goal|open_loop,
|
||||
confidence 0..1. Règles :
|
||||
- Uniquement ce qui est explicitement affirmé ou fortement implicite ;
|
||||
- Rien de sensible sans appui textuel direct (santé, finances, familles) ;
|
||||
- Jamais de mot de passe, token, numéro personnel (signalés comme refusés) ;
|
||||
- Si un fait contredit un fait existant, produit le nouveau avec note de supersede.
|
||||
Sortie : JSON strict {facts: [...]} — aucun commentaire.
|
||||
```
|
||||
|
||||
## Annexe C — Prompt du briefing (extrait)
|
||||
|
||||
```
|
||||
Contexte fourni : agenda du jour (gcal), rappels dus, mails non lus importants
|
||||
(gmail si connecté), notes modifiées hier, faits pending à valider.
|
||||
Produis une note markdown Briefings/YYYY-MM-DD.md :
|
||||
- Aujourd'hui : événements + rappels (liens vault si applicable)
|
||||
- À trancher : 2–4 questions ouvertes détectées dans les notes récentes
|
||||
- Mémoire : 1–3 faits nouveaux à valider (citez la source note)
|
||||
Pas d'invention. Chaque affirmation cite sa source (chemin ou sujet connecteur).
|
||||
```
|
||||
@@ -0,0 +1,239 @@
|
||||
Je vais analyser la solution Assistant V2. Commençons par explorer la structure du projet et localiser les références à "Assistant V2".
|
||||
|
||||
|
||||
|
||||
Il y a un dossier `docs/Assistant-V2/` avec plusieurs documents de spécification. Explorons-le en détail.
|
||||
|
||||
|
||||
|
||||
Let me lire le document principal et les documents de phase.
|
||||
|
||||
|
||||
|
||||
Continuons la lecture du document principal puis des phases.
|
||||
|
||||
|
||||
|
||||
Maintenant, lisons les documents de phase V2.1. Vérifions d'abord leur taille.
|
||||
|
||||
|
||||
|
||||
Ces documents sont volumineux. Je vais déléguer le résumé des phases 2 à 5 à des sous-agents en parallèle, pendant que je lis moi-même la phase 0 (fondations) et la phase 1, et que je vérifie l'état réel du code.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
The sub-agents reference a parent document `ASSISTANT-V2.1.md`. Let me check whether it exists, read phases 0 and 1 myself, and inspect the actual backend state.
|
||||
|
||||
|
||||
|
||||
Now let me read phase 1 and verify the actual codebase state — whether any of this exists (memory store, runs, policies) and how it's tracked in the roadmap.
|
||||
|
||||
|
||||
|
||||
Excellent picture forming. Let me verify the missing parent document references, and how (if at all) this is registered in the roadmap/todolist.
|
||||
|
||||
|
||||
|
||||
Key finding: IDs may collide with existing roadmap items. Let me inspect the roadmap's current/upcoming sections and the highest IDs in use.
|
||||
|
||||
|
||||
|
||||
Let me do a final quick check on dependencies and existing data stores to ground the component list.
|
||||
|
||||
|
||||
|
||||
J'ai lu l'intégralité de la documentation `docs/Assistant-V2/` (7 documents), vérifié l'état réel du code, et croisé le tout avec `ROADMAP.md`, `ISSUES_TODOLIST.md` et les conventions `AGENTS.md`. Voici l'analyse complète.
|
||||
|
||||
---
|
||||
|
||||
## 0. Verdict en une phrase
|
||||
|
||||
**Assistant V2 (et sa suite V2.1) est aujourd'hui une conception de très bonne qualité, mais 100 % non implémentée, non enregistrée dans le Roadmap, et dont le plan d'IDs est cassé par des collisions avec des features déjà livrées.** Le contenu technique est solide et réutilise finement l'existant ; la dette est d'abord méthodologique et documentaire, pas architecturale.
|
||||
|
||||
---
|
||||
|
||||
## 1. Cartographie des documents
|
||||
|
||||
| Fichier | Génération | Statut déclaré | IDs couverts |
|
||||
|---|---|---|---|
|
||||
| `docs/Assistant-V2/ASSISTANT-V2.md` (528 l.) | **V2** — 2026-10-08 | « Spécification (non implémentée) » | #191 → #201 |
|
||||
| `assistant-v21-phase-0-socle-sur.md` (279 l.) | **V2.1** — 2026-10-09 | « Prêt à implémenter » | #191,#192,#193,#206,#213 + amorce #212 |
|
||||
| `assistant-v21-phase-1-le-travail.md` (276 l.) | V2.1 | Prêt à implémenter | #202,#203,#204,#205 |
|
||||
| `assistant-v21-phase-2-temps-reel.md` | V2.1 | Prêt à implémenter | #200,#194,#199,#208,#214 |
|
||||
| `assistant-v21-phase-3-le-monde.md` | V2.1 | Prêt à implémenter | #195,#196,#197,#198,#207 |
|
||||
| `assistant-v21-phase-4-les-gens.md` | V2.1 | Prêt à implémenter | #211,#209,#201 |
|
||||
| `assistant-v21-phase-5-extension.md` (160 l.) | V2.1 | Cadre prêt | #210,#212 |
|
||||
|
||||
**Fait saillant n°1 — document parent manquant.** Les 5 phases V2.1 s'appuient toutes sur un `ASSISTANT-V2.1.md` (§2 P1–P16, §6.1, §8.2, §9.2/9.3, §10.2, §13.2, §15, §16, §19, §21). **Ce fichier n'existe nulle part dans le dépôt.** Conséquence directe : toutes les références croisées sont pendantes — notamment les schémas exacts des tables `tasks`/`inbox_items`/`facts`/`entities`/`agent_runs`, les règles de politique §15.2, et surtout **les définitions des golden tasks G1→G14** (§16.1) qui sont pourtant le critère d'acceptation central de chaque phase. On ne peut pas implémenter les phases « telles quelles » sans ce parent.
|
||||
|
||||
**Fait saillant n°2 — collision d'IDs.** Le Roadmap (v2.63.0) a déjà consommé ces ID :
|
||||
|
||||
- `#191` = « Chat — suppression de post, DM, link preview OG » (livré 2.60.0)
|
||||
- `#192` = « Chat — boîte auto-agrandissante, accusé ✓✓ » (livré 2.61.0)
|
||||
- `#194` = « Dossier personnel par utilisateur » (🔵 **en cours**, fiche `docs/features/user-home-194.md`)
|
||||
|
||||
Or V2.1 assigne #191 = purge Ollama, #192 = mémoire socle, #194 = consolidation nocturne. **Ce sont des collisions avec du code déjà publié**, ce qui viole la règle n°1 d'`AGENTS.md` (« ID stable, jamais réutilisé »).
|
||||
|
||||
**Fait saillant n°3 — plan non enregistré.** Aucune des 7 docs n'est référencée dans `ROADMAP.md`, dans la cartographie documentaire d'`AGENTS.md`, ni dans `docs/features/`. Le programme entier vit dans une bulle documentaire non branchée sur le processus de livraison.
|
||||
|
||||
---
|
||||
|
||||
## 2. Résumé de la solution
|
||||
|
||||
### Vision (4 piliers)
|
||||
Transformer ObsiGate d'un « portail de notes avec assistant » en **second cerveau conversationnel autonome** :
|
||||
1. **Il se souvient** — mémoire long terme structurée (faits, préférences, entités, décisions), consolidée la nuit, **auditable en markdown dans le vault** (anti-boîte-noire).
|
||||
2. **Il agit** — sur le vault **et** les services externes connectés (Gmail, Google Agenda, Teams, Discord, Notion, Drive…).
|
||||
3. **Il anticipe** — rappels conversationnels, briefing quotidien, détection de boucles ouvertes et d'engagements non tenus.
|
||||
4. **Il centralise** — ObsiGate devient le point d'entrée unique (desktop Tauri, MCP externe, PWA mobile → *ce même* cerveau).
|
||||
|
||||
### Principes directeurs
|
||||
- **V2 (P1–P9)** : autonomie du stack (1 conteneur, Ollama retiré) · zéro framework d'agent externe · un outil = un `@tool` typé · mémoire = markdown d'abord · humain dans la boucle · secrets jamais committés · i18n FR/EN · desktop Tauri offline-first · réutilisation de la sécurité existante.
|
||||
- **V2.1 (ajout majeur)** : insère **dès la phase 0** deux mécanismes qui rendent tout le reste délégable :
|
||||
- **Moteur de politiques** (#213) — décision **déterministe** `autorisé | approbation_run | approbation_renforcée | refusé` = f(risque × niveau d'autonomie × provenance × sensibilité × réversibilité). **Jamais de LLM dans le chemin d'autorisation.**
|
||||
- **Exécution fiable** (#206) — *« un run est l'unité de tout »* : plans avant écriture, approbation par run, `agent_runs`/`agent_steps`, post-conditions (`verify`), inverses (`inverse`), idempotence, reprise après crash, annulation (undo).
|
||||
|
||||
Le delta V2 → V2.1 est significatif et pertinent : la V2 donnait de la mémoire et des connecteurs **sans** le socle de sécurité/exécution. La V2.1 corrige cela en prerequisite. Mais la V2.1 introduit aussi ~2× plus de périmètre.
|
||||
|
||||
### Phasage et effort (source : docs)
|
||||
|
||||
| Phase | Thème | Tranches | Effort |
|
||||
|---|---|---|---|
|
||||
| **0** | Socle sûr (purge Ollama, mémoire, miroir, politiques, runs) | #191,#192,#193,#213,#206, amorce #212 | 15–19 j |
|
||||
| **1** | Le travail (tâches, Aujourd'hui, planification, capture) | #202,#203,#204,#205 | 14–18 j |
|
||||
| **2** | Le temps réel (agent programmé, consolidation, rappels, automations, rituels) | #200,#194,#199,#208,#214 | 14–19 j |
|
||||
| **3** | Le monde (connecteurs, sync, client MCP) | #195,#196,#197,#198,#207 | 17–23 j |
|
||||
| **4** | Les gens (personnes & engagements, voix, insights) | #211,#209,#201 | 9–13 j |
|
||||
| **5** | L'extension (packs A–G + éval continue) | #210,#212 | 3–5 j/chantier + continu |
|
||||
|
||||
**Total cœur = ≈ 69–92 jours** de développement, plus l'extension. C'est un **programme de plusieurs mois**, pas une feature.
|
||||
|
||||
---
|
||||
|
||||
## 3. Les composantes à mettre en place
|
||||
|
||||
### 3.1 Backend — nouveaux modules
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
L[agent/loop.py existant] --> P[agent/policies.py]
|
||||
L --> R[agent/runs.py]
|
||||
P --> REG[tools/registry.py étendu verify/inverse]
|
||||
M[memory/ store, entities, recall, compiler, mirror, consolidator] --> T1[tools/memory.py]
|
||||
C[connectors/ base, oauth, registry, sync, ical/caldav/imap, mcp_client] --> T2[tools/connectors.py]
|
||||
A[automations/ bus, engine, detectors] --> T3[tools/reminders.py]
|
||||
K[tasks/ model, store, rrule, planner] --> T4[tools/tasks.py + planner.py]
|
||||
I[inbox/store.py] --> T5[tools/inbox.py]
|
||||
G[people/ + voice/] --> T6[tools/people.py, commitment, voice]
|
||||
```
|
||||
|
||||
| Module | Rôle |
|
||||
|---|---|
|
||||
| `backend/memory/{store,entities,recall,compiler,mirror,consolidator}.py` | faits bi-temporels, entités/relations, recall RRF, compileur de contexte, miroir markdown, consolidation nocturne |
|
||||
| `backend/agent/policies.py` | moteur de politiques déterministe |
|
||||
| `backend/agent/runs.py` | `agent_runs`/`agent_steps`, plans, idempotence, reprise, undo |
|
||||
| `backend/connectors/{base,oauth,registry,sync,ical,caldav,imap,mcp_client}.py` | cadre connecteurs OAuth2+PKCE + replis zéro-OAuth + sync incrémentale + client MCP |
|
||||
| `backend/tasks/{model,store,rrule,planner}.py` | parseur de tâches markdown, index, RRULE, moteur de planification |
|
||||
| `backend/inbox/store.py`, `backend/automations/{bus,engine,detectors}.py` | capture, bus d'événements, automatisations, détecteurs d'insights |
|
||||
| `backend/tools/{memory,connectors,reminders,tasks,planner,inbox,people,voice}.py` | outils `@tool` |
|
||||
| `backend/routers/{memory,runs,delegations,today,tasks,plans,approvals,inbox,connectors,automations,reminders,commitments}.py` | API REST |
|
||||
|
||||
### 3.2 Stores de données (tous dans `data/`, JSON ou SQLite reconstructible)
|
||||
|
||||
- `data/memory.sqlite3` — `facts` (bi-temporel : `entity_id, valid_from, valid_to, recorded_at, sensitivity, provenance_trust`, `kind` étendu à `procedure`), `entities`, `relations`, `consolidation_runs`.
|
||||
- `data/assistant.sqlite3` — `agent_runs`, `agent_steps`, `delegations`, `approvals`, `inbox_items`, `commitments`, `plans`, `automation_runs`, `event_log`.
|
||||
- `data/tasks.sqlite3` — index des tâches (reconstructible).
|
||||
- `data/connectors_cache.sqlite3` — `sync_state`, `synced_objects` (extraits bornés, jamais le corps complet).
|
||||
- `data/connectors.json` (public) + `data/connector_secrets.json` (0600, chiffré `secret.key`).
|
||||
|
||||
### 3.3 Artefacts vault — `99_Assistant/`
|
||||
|
||||
`Profil.md` (balises `<!-- obsigate:managed:* -->`, re-parsées en faits `source='user'`), `Insights.md`, `Rappels.md`, `Personnes.md`, `Journal.md`, `Briefings/YYYY-MM-DD.md`, `Plans/YYYY-MM-DD.md`, `Automatisations/*.md`, `Revues/`.
|
||||
|
||||
### 3.4 Frontend (vanilla JS, zéro build — contrainte à respecter)
|
||||
|
||||
Vue **Aujourd'hui**, **file d'approbations unifiée**, **centre de contrôle** (runs, délégations, budgets, santé des index), page Configurations étendue **« Sources connectées »**, cartes mémoire/pending, panneau cloche rappels, vue Automatisations + dry-run, registre des packs. i18n FR **et** EN obligatoire.
|
||||
|
||||
### 3.5 Infra & config
|
||||
|
||||
- Retrait Ollama : `docker-compose.yml` + `.test-linux` + `.test-win` (service, volume `ollama_data`, `OLLAMA_BASE_URL/MODEL`, port 11434).
|
||||
- Variables : `OBSIGATE_MEMORY_*`, `OBSIGATE_CONSOLIDATION_*`, `OBSIGATE_TZ`, `OBSIGATE_BRIEFING_TIME`, `OBSIGATE_NOTIFY_DAILY_BUDGET`, `GOOGLE_CLIENT_ID/SECRET`, `AZURE_CLIENT_ID/SECRET`, `OBSIGATE_NOTION_API_KEY`, `OBSIGATE_DISCORD_BOT_TOKEN`, `OBSIGATE_RUN_MAX_*`.
|
||||
- Pas de nouvelle dépendance lourde nécessaire : `httpx` et `mcp` sont déjà présents.
|
||||
|
||||
---
|
||||
|
||||
## 4. État réel du code (vérifié, pas déduit)
|
||||
|
||||
| Élément cité par les specs | Réalité constatée |
|
||||
|---|---|
|
||||
| `backend/agent/loop.py` (`run_agent`) | ✅ existe (`DEFAULT_MAX_ITERATIONS=10`, `STOP_*`, propose/apply) |
|
||||
| `backend/tools/registry.py` (`@tool`, `ToolRisk`) | ✅ existe — mais **pas de `verify`/`inverse`/`data_classes`** (donc P0-T12 est bien un vrai travail) |
|
||||
| `backend/scheduler.py` (actions, schedules) | ✅ existe — actions `create_file`/`append_to_file`/`notify`, schedules `interval_hours`/`daily_time`/`once_at` |
|
||||
| `backend/notify.py` (`broadcast`) | ✅ existe |
|
||||
| `backend/mcp/server.py` (propose/apply) | ✅ existe |
|
||||
| `data/secret.key`, chiffrement, `0600` | ✅ pattern présent (`auth/jwt_handler.py`) |
|
||||
| **`backend/memory/`, `backend/connectors/`, `policies.py`, `runs.py`** | ❌ **inexistants** |
|
||||
| **`memory.sqlite3`, `assistant.sqlite3`, `agent_runs`** | ❌ **inexistants** |
|
||||
| Ollama dans les compose | ✅ **toujours présent** (les 3 files) |
|
||||
|
||||
Conclusion : les specs décrivent correctement le socle existant (bon signe), et tout ce qui est « nouveau » est effectivement à construire. Le statut « non implémenté » du doc V2 est exact.
|
||||
|
||||
---
|
||||
|
||||
## 5. Points forts de la conception
|
||||
|
||||
- **Réutilisation maximale de l'existant** : boucle agent, registre `@tool`, MCP, scheduler, notify, Web Push, recherche TF-IDF+sémantique — rien de tout cela n'est refait. La V2.1 branche proprement les nouvelles capacités.
|
||||
- **Anti-boîte-noire** : mémoire auditable en markdown, éditions humaines re-parsées comme faits `source='user'` confiance 1.0. C'est la vraie différenciation face à ChatGPT-Memory.
|
||||
- **Sécurité pensée tôt** : moteur de politiques déterministe, provenance/taint, whitelist des runs non interactifs, refus des envois externes sans relecture, traitement du contenu externe comme *données* (anti-injection), budgets durs.
|
||||
- **Coût maîtrisé** : consolidation déterministe avant LLM, cheap model dédié, budgets tokens, règle « lire le cache pendant que sa sync dort ».
|
||||
- **Dégradabilité** : chaque phase fonctionne sans les suivantes (la V2.1 insiste : phase 4 complète sans même un connecteur), connecteurs absents du registre si non installés (offline-first Tauri).
|
||||
- **DoD aligné sur le dépôt** : golden tasks, i18n FR/EN, fiches features, CHANGELOG/ROADMAP, ruff/mypy.
|
||||
|
||||
---
|
||||
|
||||
## 6. Pistes d'amélioration
|
||||
|
||||
### 🔴 Bloquantes (à traiter avant d'écrire une ligne de code)
|
||||
|
||||
1. **Réécrire/écrire le document parent `ASSISTANT-V2.1.md`.** Sans lui, les schémas `tasks`/`inbox_items`/`facts`/`entities`/`agent_runs`, les règles de politique §15.2 et **les définitions des golden tasks G1→G14** n'existent pas. Options : (a) reconstituer le parent à partir des 5 phases + de la V2, ou (b) fusionner les phases en un document unique auto-suffisant. **Recommandé : (b)**, car maintenir 6 documents cohérents pour ~90 jours de travail va dériver.
|
||||
2. **Renuméroter tout le bloc d'IDs.** #191, #192, #194 sont déjà pris (chat, user-home). Proposer un bloc contigu réservé (ex. `#215→#238`) et le figer, puis remapper V2 et V2.1. Décider aussi de la convention unique : les docs mélangent `#NNN` (roadmap) et `P0-Tnn`/`P2-Tnn` (tickets) sans mapping explicite — à trancher et documenter.
|
||||
3. **Enregistrer le programme dans le Roadmap et la cartographie documentaire.** Créer les entrées `ROADMAP.md` (statut 🔵/⚪) et les fiches `docs/features/`, ajouter `docs/Assistant-V2/` à la table d'`AGENTS.md`. Aujourd'hui le programme viole la règle « ID stable avant de coder ».
|
||||
4. **Décider du sort de la doc V2.** La V2 **contredit** la V2.1 : V2 dit « `gmail.send` non exposé v1 », V2.1 phase 3 l'expose en DANGEROUS. V2 propose une table `facts` sans bi-temporalité/entités que V2.1 remplace. Garder les deux crée un piège pour l'implémenteur. **Recommandé : marquer `ASSISTANT-V2.md` comme « superseded »** et pointer vers la V2.1.
|
||||
|
||||
### 🟡 Importantes (qualité d'implémentation)
|
||||
|
||||
5. **Trancher le séquençage par valeur, pas par phases.** Les phases sont bien dessinées, mais ~90 j avant le premier bénéfice visible est risqué. **Recommandé : livrer d'abord un « vertical slice » de la seule phase 0** (purge Ollama + mémoire + miroir + runs + politiques) — c'est là que vivent 80 % du risque technique et 100 % des prérequis de sécurité. Ne pas commencer les phases 1–5 avant d'avoir prouvé la phase 0.
|
||||
6. **Concurrence SQLite / writer unique.** Les specs ajoutent 4–5 bases manipulées par la boucle agent, le watcher, le scheduler (tick 60 s) et les handlers HTTP, dans **un seul process**. À cadrer explicitement : mode WAL, discipline « un seul écrivain par store », verrous, et reprise. Ce n'est évoqué nulle part et c'est une source classique de corruption.
|
||||
7. **Qualité de l'extraction de faits = le composant le plus risqué.** C'est le seul endroit où « le modèle décide ». Recommander : (a) un **dry-run de consolidation** obligatoire avant activation (existe pour automations, à généraliser), (b) validation Pydantic stricte + rejet des valeurs non sourcées, (c) métrique « faits rejetés/acceptés » dès la phase 0, (d) un mode « extracteur désactivé » par défaut la première semaine.
|
||||
8. **Fuites multi-utilisateurs via le miroir.** La mémoire est `user_id` par utilisateur, mais le miroir vit dans **un vault** `99_Assistant/`. Si le vault est partagé, `Profil.md`/`Personnes.md` exposent la vie privée d'un user à un autre. À trancher : vault mémoire **par utilisateur** (le plus sûr, cohérent avec `home-<user>`), ou sections par user + ACL. À documenter et tester.
|
||||
9. **Chiffrement au repos et `0600` sous Windows.** Le pattern `data/connector_secrets.json` en `0600` est POSIX. `os.chmod` ne pose pas d'ACL sous Windows, alors que le desktop Tauri est multi-OS. Prévoir une stratégie (ACL via `pywin32`, ou DPAPI, ou au minimum documenter la limite). #172 est cité comme dépendance mais pas défini ici.
|
||||
10. **Golden tasks non branchées au CI.** `AGENTS.md` impose les 5 jobs CI (`lint/test/security/build/e2e`). Les golden tasks (`tests/golden/`) nécessitent un job dédié + des **simulateurs HTTP par fournisseur** (prévus en phase 3). À cadrer dès la phase 0 (harnais) pour ne pas découvrir le problème en phase 3. La phase 5 **oublie complètement l'E2E** alors qu'elle crée une UI (registre des packs) — non conforme à la DoD du dépôt.
|
||||
11. **Friction OAuth.** Créer des apps Google Cloud / Entra ID est le principal frein d'installation. Bonne idée des specs : les **replis zéro-OAuth (iCal/CalDAV/IMAP)**. Recommander d'en faire le **chemin par défaut** et OAuth une option avancée, plutôt que l'inverse — cela dérisque toute la phase 3.
|
||||
|
||||
### 🟢 Confort / simplifications
|
||||
|
||||
12. **Mutualiser les stores.** `tasks.sqlite3`, `connectors_cache.sqlite3`, `memory.sqlite3` et `assistant.sqlite3` pourraient être **un seul** `assistant.sqlite3` (WAL) : moins de fichiers, moins de cycles de vie, un seul point de backup. Le « reconstructible séparément » se gère par table. À évaluer.
|
||||
13. **FTS5 natif** pour l'index des tâches et la recherche mémoire, plutôt qu'un index plein-texte maison — SQLite le fournit déjà.
|
||||
14. **Unifier rappel = tâche enrichie** (déjà proposé en V2.1 §7.2) : excellent, à confirmer comme décision figée pour éviter toute tentation de second store.
|
||||
15. **Instrumenter les métriques #212 dès la phase 0** (budget de contexte mémoire, faits acceptés/rejetés, temps de décision quotidien). Les specs le promettent ; en faire un vrai tableau de bord tôt évite les optimisations à l'aveugle.
|
||||
16. **Formaliser les critères de sortie de chaque phase** (les phases 0–4 en ont, la 5 non) et une **décision unique** sur les « décisions ouvertes » du parent (activation de l'envoi, fournisseur de transcription, gestionnaire de tâches externe) — ces 3 décisions conditionnent plusieurs tickets et ne sont pas tranchées.
|
||||
17. **Points ambigus relevés par phase** (à lever dans le parent) : schéma `consolidation_runs` jamais défini (phase 2) ; format de `allowed_extra_tools` et de `ctx: rappel` ; emplacement du réglage `candidats connecteurs` (`off|candidats`) ; modalités MCP (stdio vs HTTP) et algorithme de plafonnement de risque ; dédup multi-calendriers (tolérance fuseaux/récurrences) ; granularité Notion→index ; format de persistance des packs (`data/` ?).
|
||||
|
||||
---
|
||||
|
||||
## 7. Recommandation de prochaines étapes
|
||||
|
||||
1. **Décider du plan de renumérotation** et réserver un bloc d'IDs contigu.
|
||||
2. **Consolider la doc** : écrire le parent V2.1 (ou fusionner), marquer la V2 comme superseded, lever les ~15 ambiguïtés ci‑dessus.
|
||||
3. **Enregistrer** le programme dans `ROADMAP.md` + `docs/features/` + `AGENTS.md`.
|
||||
4. **Commencer par le vertical slice de la phase 0** (purge Ollama + mémoire + miroir + runs + politiques + harnais golden), et **ne pas** démarrer les phases 1–5 avant que la phase 0 soit verte en CI.
|
||||
|
||||
---
|
||||
|
||||
Souhaites-tu que je rédige :
|
||||
- le **document de synthèse consolidé** (parent V2.1 auto-suffisant) à partir des 5 phases et de la V2, ou
|
||||
- une **proposition de renumérotation** concrète (mapping `ancien ID → nouveau ID`) prête à coller dans le Roadmap ?
|
||||
|
||||
Je peux aussi produire un `docs/features/assistant-v2-index.md` qui sert de plan-maître et référence les 7 documents actuellement orphelins.
|
||||
@@ -0,0 +1,278 @@
|
||||
# Assistant V2.1 ObsiGate — Phase 0 : Socle sûr (purge, mémoire, politiques, exécution fiable)
|
||||
|
||||
| Champ | Valeur |
|
||||
|---|---|
|
||||
| Phase | **0 sur 5** — fondation de toute la solution |
|
||||
| Document parent | `ASSISTANT-V2.1.md` — en particulier §2 (P1–P16), §5, §9, §10, §15, §16, §19 |
|
||||
| Version | 1.0 — 9 octobre 2026 |
|
||||
| Auteur | Spark, pour Bruno |
|
||||
| Statut | Prêt à implémenter |
|
||||
| Tranches | **#191** (purge Ollama), **#192** (mémoire socle amendée), **#193** (miroir markdown), **#213** (politiques & taint), **#206** (exécution fiable), amorce de **#212** (harnais golden tasks) |
|
||||
| Effort estimé | 15–19 jours (dont 1–2 j d'amorce #212 ; le solde de #212 est continu, phases 1–5) |
|
||||
| Prérequis | ObsiGate actuel : boucle agent `backend/agent/loop.py`, registre `backend/tools/registry.py`, scheduler #170, notifications #168, Web Push #67, recherche #70 — tels que décrits dans l'inventaire V2.1 §4 |
|
||||
| Dépendances | Aucune (première phase) |
|
||||
| Débloque | Phase 1 (tâches et planification s'appuient sur les runs et la mémoire), et toutes les écritures externes ultérieures (#213/#206 sont le prérequis de sécurité des phases 3–4) |
|
||||
|
||||
> **Résultat visible à la fin de cette phase.** Le conteneur Ollama a disparu. L'assistant possède une mémoire long terme : il enregistre des faits (avec historique bi-temporel), des entités (personnes, projets, lieux) et des procédures, les montre dans `99_Assistant/Profil.md` — éditable à la main, les éditions étant reprises comme des faits de confiance maximale — et les injecte dans chaque conversation via un compileur de contexte dont chaque section cite son origine. Surtout, **tout ce que l'assistant exécute devient un run persistant** : plan affiché avant les écritures, approbation par run, étapes journalisées avec preuves de post-condition, reprise sans doublon après interruption, annulation quand l'inverse existe. Et un moteur de politiques déterministe décide ce qui est autorisé, approuvé ou refusé — jamais le modèle.
|
||||
|
||||
---
|
||||
|
||||
## Table des matières
|
||||
|
||||
1. [Objectif et principe directeur](#1-objectif-et-principe-directeur)
|
||||
2. [Périmètre](#2-périmètre)
|
||||
3. [État d'entrée — ce qui existe](#3-état-dentrée--ce-qui-existe)
|
||||
4. [Modèle de données](#4-modèle-de-données)
|
||||
5. [Tickets de travail](#5-tickets-de-travail)
|
||||
6. [API livrée par la phase](#6-api-livrée-par-la-phase)
|
||||
7. [Comportements détaillés](#7-comportements-détaillés)
|
||||
8. [Tests et golden tasks](#8-tests-et-golden-tasks)
|
||||
9. [Critères d'acceptation de la phase](#9-critères-dacceptation-de-la-phase)
|
||||
10. [Risques spécifiques et vigilance](#10-risques-spécifiques-et-vigilance)
|
||||
11. [Ordre d'exécution](#11-ordre-dexécution)
|
||||
12. [Définition de « terminé »](#12-définition-de--terminé-)
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif et principe directeur
|
||||
|
||||
Le principe : **on ne donne pas plus de pouvoir à l'assistant avant d'avoir rendu son pouvoir actuel sûr, traçable et réversible.** La V2 livrait la mémoire puis les connecteurs ; la V2.1 intercale, dès la phase 0, les deux mécanismes qui rendent toutes les phases suivantes délégables : le **moteur de politiques** (#213 — qui a le droit de faire quoi, avec quelles données, venues d'où) et l'**exécution fiable** (#206 — plan, preuves, reprise, annulation).
|
||||
|
||||
Corollaires qui guident chaque ticket :
|
||||
|
||||
- **La mémoire est un index du vault, pas un silo.** Faits confirmés → miroir markdown ; la base ne détient en propre que les états de travail (`pending`, historique de runs).
|
||||
- **Un run est l'unité de tout.** Conversation, tâche planifiée (phase 2), automatisation (phase 2), MCP : à la fin de la phase, toute exécution d'outils par un agent produit une ligne `agent_runs` et ses `agent_steps`. Les journaux épars d'aujourd'hui convergent là.
|
||||
- **La politique est déterministe.** Risque du registre × niveau d'autonomie × provenance × sensibilité × réversibilité → décision. Aucun appel LLM dans le chemin d'autorisation, jamais.
|
||||
- **Pas de succès sans preuve.** Un run n'est `succeeded` que si ses post-conditions vérifiables sont vraies (§7.3).
|
||||
|
||||
## 2. Périmètre
|
||||
|
||||
### 2.1 Inclus
|
||||
|
||||
- #191 : retrait du service/volume Ollama des trois compose, défauts provider nettoyés, notes de migration (repris tel quel de la V2 §9).
|
||||
- #192 : `backend/memory/` — store SQLite amendé (faits bi-temporels, sensibilité, provenance, procédures), entités et relations, recall et **compileur de contexte** (§9.4 du parent), outils `memory.*`, `entity_resolve`, `procedure_get`, API mémoire, cartes de validation des faits `pending` (forme simple — la file unifiée arrive en phase 1, #203).
|
||||
- #193 : `mirror.py`, dossier `99_Assistant/`, `Profil.md` à balises gérées avec re-parse des éditions humaines, `Journal.md` des consolidations, `Personnes.md` en **coquille** (le peuplement riche est en phase 4, #211).
|
||||
- #213 : provenance/taint sur tous les contenus entrant dans un contexte ou un run, moteur de politiques `backend/agent/policies.py` évalué avant chaque appel d'outil, règles du §15.2 du parent, journal des refus, tests d'injection.
|
||||
- #206 : plans d'exécution, `agent_runs`/`agent_steps` dans `data/assistant.sqlite3`, idempotence et reprise, post-conditions déclarées dans le registre (`verify`), inverses (`inverse`), niveaux d'autonomie 0–3 et délégations bornées datées, API runs + progression SSE.
|
||||
- #212 (amorce) : harnais de golden tasks (`tests/golden/`, fixtures de vault, connecteurs simulés) et les scénarios G3, G6, G7 de la phase.
|
||||
|
||||
### 2.2 Exclu (phases ultérieures)
|
||||
|
||||
- La **consolidation nocturne** (#194) : la phase 0 livre le store, le recall et la création de faits en conversation ; l'extraction automatique nocturne arrive en phase 2, quand `agent_prompt` (#200) existe.
|
||||
- La **vue Aujourd'hui**, la file d'approbations unifiée et le centre de contrôle complet (#203, phase 1) : la phase 0 livre les cartes mémoire simples et une liste de runs basique dans les Configurations.
|
||||
- Les **tâches** (#202, phase 1) et tout connecteur (#195+, phase 3).
|
||||
- La politique « contenu connecteur » est livrée comme **mécanisme** (provenances `connector:<id>` reconnues) ; son premier usage réel est en phase 3.
|
||||
|
||||
## 3. État d'entrée — ce qui existe
|
||||
|
||||
| Élément | État | Rôle en phase 0 |
|
||||
|---|---|---|
|
||||
| Boucle agent + tool calling natif | ✅ `backend/agent/loop.py`, `backend/ai_chat.py` | Moteur des runs ; étendu, pas remplacé (P2) |
|
||||
| Registre `@tool` (risque/scope/confirmation/audit/rate-limit) | ✅ `backend/tools/registry.py` | Reçoit les champs `verify` et la provenance ; la politique s'y branche |
|
||||
| Historique de conversation persistant | ✅ #95, `backend/ai_history.py` | Mémoire épisodique brute (source future de #194) |
|
||||
| Recherche TF-IDF + sémantique RRF | ✅ #70 | Recall : fusion faits + recherche |
|
||||
| Cache SQLite (patron) | ✅ `backend/tools/webcache.py` | Modèle des stores SQLite de la phase |
|
||||
| Audit + redaction des secrets | ✅ `backend/audit.py`, `secret_redactor.py` | Journal des runs et des refus ; args toujours redactés |
|
||||
| Providers LLM multi-fournisseurs | ✅ `backend/ai.py` | Routage par type de job : amorce posée (§16.3 du parent), table complète en phases suivantes |
|
||||
| Ollama comme conteneur | ✅ présent | **Retiré** par #191 ; le provider `ollama` reste une option de configuration (desktop local) |
|
||||
|
||||
## 4. Modèle de données
|
||||
|
||||
Repris du parent : `facts` amendée (§9.2 — `entity_id`, `valid_from`, `valid_to`, `recorded_at`, `sensitivity`, `provenance_trust`, `kind` étendu à `procedure`), `entities` et `relations` (§9.3), `consolidation_runs` (créée dès cette phase, alimentée en phase 2), `agent_runs` et `agent_steps` (§10.2), auxquelles s'ajoute la table des délégations :
|
||||
|
||||
```sql
|
||||
CREATE TABLE delegations (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||||
domain TEXT NOT NULL, -- 'vault'|'tasks'|'email'|'calendar'|…
|
||||
level INTEGER NOT NULL, -- niveau accordé (0–3, §10.4 du parent)
|
||||
scope_json TEXT, -- périmètre : chemins, connecteur, budgets
|
||||
granted_at TEXT NOT NULL, review_at TEXT NOT NULL, -- défaut +90 j
|
||||
status TEXT NOT NULL DEFAULT 'active' -- active|expired|revoked
|
||||
);
|
||||
```
|
||||
|
||||
Stores : `data/memory.sqlite3` (faits, entités, relations, runs de consolidation) et `data/assistant.sqlite3` (runs, steps, délégations ; recevra `inbox_items`, `commitments`, `approvals` en phases 1 et 4 — le fichier est créé ici, les tables arrivent avec leurs tranches).
|
||||
|
||||
Règles transverses : toute table porte `user_id` ; aucun secret ni contenu brut de connecteur dans ces bases ; les args d'étapes sont stockés **redactés** ; les stores sont reconstructibles ou journalisés (jamais de contenu de Bruno irremplaçable hors du vault — P4).
|
||||
|
||||
## 5. Tickets de travail
|
||||
|
||||
*Convention : chaque ticket est indépendamment testable ; « Fichiers » donne les points d'ancrage d'après la V2.1 — à confirmer contre le code réel au démarrage du ticket.*
|
||||
|
||||
### Purge Ollama — #191
|
||||
|
||||
**P0-T01 — Retrait du conteneur Ollama.**
|
||||
Fichiers : `docker-compose.yml`, `docker-compose.test-linux.yml`, `docker-compose.test-win.yml`, docs d'installation.
|
||||
Travail : supprimer service, volume `ollama_data`, variables `OLLAMA_BASE_URL`/`OLLAMA_MODEL` et mentions du port 11434 ; procédure de suppression du volume documentée dans le CHANGELOG.
|
||||
Acceptation : `docker compose config` ne connaît plus Ollama ; l'instance démarre et l'agent fonctionne sur deepseek/openrouter/gemini.
|
||||
|
||||
**P0-T02 — Défauts provider nettoyés.**
|
||||
Fichiers : `backend/ai.py`, `model_capabilities.py`, tests d'isolation.
|
||||
Travail : conserver le provider `ollama` comme option explicite (desktop), retirer tout défaut pointant vers le réseau interne ; le mode par défaut de la mémoire fonctionne sans embeddings (TF-IDF + RRF + faits).
|
||||
Acceptation : aucun test ni défaut ne référence `http://ollama:11434` ; la recherche sémantique se dégrade proprement sans sentence-transformers.
|
||||
|
||||
### Mémoire socle — #192
|
||||
|
||||
**P0-T03 — Store mémoire : faits amendés, entités, relations.**
|
||||
Fichiers : nouveau `backend/memory/store.py` (patron `webcache.py`), création/ouverture de `data/memory.sqlite3`.
|
||||
Travail : DDL du §4 ; CRUD faits avec clôture bi-temporelle (supersede = `valid_to`, jamais d'écrasement) ; planchers de confiance V2 (< 0,7 → `pending`) ; dossiers sensibles configurables → `pending` + `sensitivity='sensitive'` ; renforcement `last_seen_at` et decay par `kind` (les `procedure` et `decision` ne décroissent pas).
|
||||
Acceptation : G7 passe (changement de dentiste : ancien fait clos, nouveau actif, requête historique datée correctement répondue) ; aucun fait `sensitive` créé directement en `confirmed`.
|
||||
|
||||
**P0-T04 — Entités et résolution de noms.**
|
||||
Fichiers : `backend/memory/entities.py`.
|
||||
Travail : CRUD entités (person/place/project/org/topic, alias) ; `resolve(name)` → candidates scorées ; **ambiguïté = pas de fusion** (renvoyer les candidates, la décision est une carte, phase 2 pour la consolidation) ; relations bi-temporelles.
|
||||
Acceptation : deux « Marie » distinctes coexistent sans fusion ; une relation fermée n'apparaît plus dans le recall actuel mais reste interrogeable à date.
|
||||
|
||||
**P0-T05 — Recall et compileur de contexte.**
|
||||
Fichiers : `backend/memory/recall.py`, `backend/memory/compiler.py`.
|
||||
Travail : score explicite et journalisé (RRF × confiance × fraîcheur × boost entité, parent §9.4) ; assemblage du bloc `<assistant-context>` à budget adaptatif (défaut 2 500 tokens) avec sections profil/procédures, entités du sujet, faits liés, engagements (vide cette phase), dernier briefing (absent cette phase — section omise proprement) ; chaque section cite son origine ; le bloc est visible dans les étapes du panneau avec le détail du score au survol.
|
||||
Acceptation : un fait sous le seuil n'entre pas dans le bloc ; le même bloc est reproduit à l'identique pour la même requête et le même état (déterminisme de structure) ; aucun contenu `sensitivity='sensitive'` dans un contexte destiné à un outil externe.
|
||||
|
||||
**P0-T06 — Outils mémoire et API.**
|
||||
Fichiers : `backend/tools/memory.py`, `backend/routers/memory.py`.
|
||||
Travail : les 5 outils V2 (`memory_recall`, `memory_save`, `memory_update`, `memory_forget`, `profile_get`) + `entity_resolve` (READ) et `procedure_get` (READ) ; `memory_save` passe par la politique (approbation selon niveau, défaut 2) ; endpoints CRUD faits (liste/filtre statut, validation `pending`, édition, oubli) et export JSON complet du domaine mémoire.
|
||||
Acceptation : un fait proposé en chat apparaît en carte, validé il est au miroir (T08) et au recall suivant ; `memory_forget` le retire du recall et du miroir, avec trace d'audit.
|
||||
|
||||
### Miroir markdown — #193
|
||||
|
||||
**P0-T07 — Miroir `Profil.md` à balises gérées.**
|
||||
Fichiers : `backend/memory/mirror.py`.
|
||||
Travail : création de `99_Assistant/` dans le vault mémoire (`OBSIGATE_MEMORY_VAULT`, mode multi-utilisateur du §5.3 du parent) ; rendu des sections gérées (préférences, procédures, objectifs connus) ; hash de section stocké en base ; écritures idempotentes (rendu deux fois = fichier identique).
|
||||
Acceptation : supprimer un fait validé régénère le miroir sans toucher au hors-balises ; deux rendus consécutifs produisent un diff vide.
|
||||
|
||||
**P0-T08 — Re-parse des éditions humaines.**
|
||||
Travail : avant chaque écriture, comparer le hash stocké au contenu courant ; une section modifiée à la main est re-parsée en faits `source='user'`, confiance 1.0, `confirmed`, puis régénérée ; une suppression dans le miroir = fait `rejected` en base ; le hors-balises n'est jamais touché.
|
||||
Acceptation : éditer `Profil.md` à la main (ajout + suppression) puis déclencher un rendu donne des faits conformes aux éditions, sans perte du texte libre hors balises.
|
||||
|
||||
**P0-T09 — `Personnes.md` (coquille) et `Journal.md`.**
|
||||
Travail : `Personnes.md` rendu depuis les entités personnes (nom, type de relation, dernière interaction connue) — en lecture structurée simple cette phase ; `Journal.md` alimenté par les événements mémoire (faits ajoutés/validés/rejetés, rendus du miroir) en lecture seule.
|
||||
Acceptation : une entité personne créée en T04 apparaît au miroir ; le journal trace chaque validation de carte.
|
||||
|
||||
### Politiques & taint — #213
|
||||
|
||||
**P0-T10 — Provenance de bout en bout.**
|
||||
Fichiers : `backend/agent/loop.py`, `backend/tools/registry.py`, `backend/ai_chat.py`.
|
||||
Travail : chaque contenu entrant dans un contexte ou un résultat d'outil reçoit une provenance (`user`, `vault`, `connector:<id>`, `web`, `mcp:<serveur>`, `automation`) portée dans les messages et les steps ; les résultats d'outils sont sanitisés (redaction existante) et étiquetés avant retour au modèle ; les prompts système distinguent explicitement blocs « instructions » et blocs « données ».
|
||||
Acceptation : un test d'intégration montre la provenance présente sur chaque résultat d'outil d'un run ; aucun résultat n'est injecté sans étiquette.
|
||||
|
||||
**P0-T11 — Moteur de politiques.**
|
||||
Fichiers : nouveau `backend/agent/policies.py` ; branchement dans le registre (évaluation **avant** chaque appel d'outil).
|
||||
Travail : décision déterministe `autorisé | approbation_run | approbation_renforcée | refusé` = f(risque registre, niveau d'autonomie du domaine, provenance, sensibilité des données, réversibilité) ; règles du parent §15.2 (une instruction de contenu non-`user` ne crée/lève aucune approbation ; action externe justifiée uniquement par un contenu non-`user` → refus + carte ; données `private|sensitive` interdites vers web/connecteurs en écriture/MCP externe) ; journal des refus avec motif, lisible dans l'audit.
|
||||
Acceptation : G3 passe (courriel piégé simulé → refus, carte à Bruno, zéro appel d'envoi) ; la décision est couverte par des tests unitaires exhaustifs sur la matrice des facteurs (aucun LLM dans le chemin).
|
||||
|
||||
**P0-T12 — Registre étendu : `verify`, inverses, sensibilité des paramètres.**
|
||||
Fichiers : `backend/tools/registry.py`, outils existants (annotations).
|
||||
Travail : champs optionnels du décorateur `@tool` : `verify` (post-condition vérifiable), `inverse` (constructeur d'inverse ou `None` explicite), `data_classes` (classes de données acceptées en entrée) ; annoter les outils vault et documents existants ; un outil d'écriture sans `verify` déclaré est listé dans un rapport de couverture (pas bloquant cette phase, bloquant pour les outils externes des phases 3+).
|
||||
Acceptation : le rapport de couverture existe et est revu ; `create_file`/`edit_file` déclarent leur post-condition et leur inverse (backup).
|
||||
|
||||
### Exécution fiable — #206
|
||||
|
||||
**P0-T13 — Stores de runs.**
|
||||
Fichiers : `backend/agent/runs.py`, `data/assistant.sqlite3`.
|
||||
Travail : DDL `agent_runs`/`agent_steps` du parent §10.2 ; cycle de vie complet des statuts ; écriture des steps au fil de l'exécution par la boucle existante (toute exécution d'outils crée ou rattache un run, `origin` renseignée) ; plafonds : itérations et budget tokens existants + durée murale et nombre d'écritures (`OBSIGATE_RUN_MAX_WALL_SECONDS`, `OBSIGATE_RUN_MAX_WRITES`).
|
||||
Acceptation : tuer le processus pendant un run laisse un run `running` orphelin détectable au redémarrage (marqué `paused` reprenable, jamais `succeeded`).
|
||||
|
||||
**P0-T14 — Plans d'exécution et approbation par run.**
|
||||
Travail : pour toute demande ≥ 2 écritures ou 1 outil DANGEROUS, génération d'un plan (étapes : outil, args résumés, effet attendu, post-condition, inverse) persisté dans `plan_json` ; affichage dans le panneau avant la première écriture ; validation en un clic = approbation du run entier (pas par étape) ; un plan refusé n'exécute rien et le run passe `cancelled` avec motif.
|
||||
Acceptation : une demande multi-écritures ne produit aucun effet avant validation ; le plan affiché correspond exactement aux steps exécutés ensuite.
|
||||
|
||||
**P0-T15 — Idempotence et reprise.**
|
||||
Travail : `idempotency_key` par step d'écriture (hash run+seq+args) respectée par les outils de création (vérification d'existence par clé en métadonnée) ; reprise d'un run interrompu à la première step non `done`, re-vérification des post-conditions incertaines, steps WRITE incertaines → `awaiting_approval` plutôt que rejeu aveugle.
|
||||
Acceptation : G6 passe (run tué à mi-parcours, repris : aucun doublon créé, état final vérifié).
|
||||
|
||||
**P0-T16 — Vérification et annulation.**
|
||||
Travail : exécution des `verify` après chaque step d'écriture ; statut final `succeeded` seulement si toutes les post-conditions vérifiables sont vraies, sinon `partially_succeeded` détaillé ; « Annuler ce run » exécute les inverses sûrs en ordre décroissant comme un run lié ; absence d'inverse affichée comme telle (« non annulable »).
|
||||
Acceptation : un outil dont la post-condition échoue (fixture) produit `partially_succeeded`, jamais `succeeded` ; l'annulation d'un run de création de fichiers restaure l'état initial via les backups.
|
||||
|
||||
**P0-T17 — Niveaux d'autonomie et délégations.**
|
||||
Fichiers : réglages Configurations, `delegations` (§4).
|
||||
Travail : niveaux 0–3 par domaine (défauts : vault = 2, tout domaine externe = 0/1 selon existence) ; création de délégation bornée (périmètre + budget + `review_at` +90 j) depuis une approbation (« toujours autoriser ce périmètre ») ou les Configurations ; expiration automatique → retour au niveau inférieur ; **aucun niveau ne couvre** paiements, envois externes sans relecture, suppressions définitives, changements de sécurité (codé en politique, pas en réglage).
|
||||
Acceptation : une délégation expirée cesse de s'appliquer sans intervention ; tenter de déléguer un envoi externe est refusé par l'UI et par l'API.
|
||||
|
||||
**P0-T18 — API runs et progression.**
|
||||
Fichiers : `backend/routers/runs.py`.
|
||||
Travail : `GET /api/runs` (filtres statut/origine), `GET /api/runs/{id}` (plan, steps, preuves), actions `pause`/`resume`/`cancel`/`undo` ; progression en SSE sur le flux existant ; liste de runs basique dans les Configurations (le centre de contrôle complet est en phase 1).
|
||||
Acceptation : un run en cours est observable en direct ; pause puis reprise depuis l'API aboutit au même résultat qu'une exécution continue.
|
||||
|
||||
### Évaluation — amorce #212
|
||||
|
||||
**P0-T19 — Harnais golden tasks.**
|
||||
Fichiers : `tests/golden/` (fixtures de vault, connecteurs simulés, harnais d'exécution headless).
|
||||
Travail : infrastructure de scénarios rejouables en CI (état initial → demande → assertions sur les stores, le vault et les runs) ; livrer G3 (injection), G6 (reprise), G7 (bi-temporalité) ; gabarit documenté pour les phases suivantes (chaque phase ajoute les siens).
|
||||
Acceptation : les trois scénarios tournent en CI et échouent si le comportement régresse (démontré par mutation volontaire en développement).
|
||||
|
||||
### Finitions
|
||||
|
||||
**P0-T20 — i18n, docs, guides.**
|
||||
Travail : clés FR/EN pour tout libellé nouveau (`ai.step.*` mémoire/runs/politiques, cartes, réglages d'autonomie) ; fiches `docs/features/` pour #191, #192/#193, #213, #206 ; guide utilisateur « Mémoire et contrôle de l'assistant » ; CHANGELOG + ROADMAP.
|
||||
Acceptation : DoD du §12 vérifiée point par point.
|
||||
|
||||
## 6. API livrée par la phase
|
||||
|
||||
| Route | Rôle | Ticket |
|
||||
|---|---|---|
|
||||
| `GET/POST/PATCH /api/memory/facts` | CRUD faits, validation des `pending` | P0-T06 |
|
||||
| `GET /api/memory/entities` · `/api/memory/export` | Entités ; export du domaine mémoire | P0-T04/T06 |
|
||||
| `GET /api/runs` · `GET /api/runs/{id}` | Liste et détail (plan, steps, preuves) | P0-T18 |
|
||||
| `POST /api/runs/{id}/pause|resume|cancel|undo` | Pilotage d'un run | P0-T18 |
|
||||
| `GET/POST/DELETE /api/delegations` | Délégations bornées par domaine | P0-T17 |
|
||||
|
||||
Toutes suivent l'authentification et les permissions par vault existantes ; les nouveaux outils `@tool` sont exposés au MCP automatiquement (scopes IN_APP+MCP, propose/apply déjà gérés).
|
||||
|
||||
## 7. Comportements détaillés
|
||||
|
||||
### 7.1 Qui peut faire quoi
|
||||
|
||||
La mémoire, les runs et les délégations sont **par utilisateur** (`user_id` partout, parent §5.3). Un administrateur ne voit pas la mémoire d'un autre utilisateur ; l'audit montre les refus de politique sans le contenu sensible qui les a motivés.
|
||||
|
||||
### 7.2 Fait `pending` : durée de vie
|
||||
|
||||
Un fait `pending` reste en carte jusqu'à action (valider/éditer/oublier) — pas d'expiration silencieuse. Au-delà de 30 jours, il est regroupé dans un rappel hebdomadaire unique (pas une notification par fait ; le budget d'interruption P15 s'applique dès cette phase aux cartes).
|
||||
|
||||
### 7.3 Post-conditions : la règle du succès
|
||||
|
||||
`verify` absent ≠ succès présumé : le step est marqué `verified = NULL` et le run ne peut prétendre qu'à `succeeded` si **aucun** step d'écriture n'est simultanément `done` et non vérifié *alors qu'un `verify` existait*. Le rapport de couverture (P0-T12) rend la dette visible au lieu de la cacher.
|
||||
|
||||
### 7.4 Reprise après crash du conteneur
|
||||
|
||||
Au démarrage, tout run `running` sans heartbeat récent passe `paused` avec le motif `interrupted` ; la reprise est manuelle (API ou panneau) cette phase — la reprise automatique des runs `origin='scheduled'` arrive avec #200 en phase 2.
|
||||
|
||||
## 8. Tests et golden tasks
|
||||
|
||||
- Unitaires : store mémoire (clôture bi-temporelle, decay, planchers), résolution d'entités (ambiguïté), moteur de politiques (matrice complète des facteurs), idempotence (rejouer une step ne crée pas de doublon), miroir (idempotence du rendu, re-parse).
|
||||
- Intégration : run complet avec plan → approbation → exécution → vérification ; kill en cours de run → reprise ; édition manuelle de `Profil.md` → faits `source='user'`.
|
||||
- Golden tasks de la phase : **G3** (injection), **G6** (reprise), **G7** (bi-temporalité) — définitions au parent §16.1.
|
||||
- Non-régression : l'assistant actuel (sans mémoire activée) se comporte à l'identique ; `OBSIGATE_MEMORY_ENABLED=false` éteint la phase proprement.
|
||||
|
||||
## 9. Critères d'acceptation de la phase
|
||||
|
||||
1. Le conteneur Ollama n'existe plus et l'assistant fonctionne sur les providers API.
|
||||
2. Un fait dit en conversation, validé par carte, est dans `Profil.md`, au recall de la conversation suivante, et son remplacement ultérieur conserve l'historique interrogeable à date.
|
||||
3. Éditer `Profil.md` à la main modifie la mémoire (source `user`, confiance 1.0) ; rien hors balises n'est jamais écrasé.
|
||||
4. Un run multi-écritures affiche son plan, n'exécute rien avant approbation, prouve chaque post-condition, et un run tué reprend sans doublon.
|
||||
5. Le scénario d'injection G3 est refusé par la politique, journalisé, et présenté à Bruno en carte.
|
||||
6. Les golden tasks G3/G6/G7 sont verts en CI.
|
||||
|
||||
## 10. Risques spécifiques et vigilance
|
||||
|
||||
| Risque | Vigilance |
|
||||
|---|---|
|
||||
| Le compileur de contexte consomme le budget du modèle en silence | Le bloc est plafonné, son détail est visible (P0-T05) ; la métrique « tokens de contexte mémoire / conversation » est journalisée dès cette phase (alimente #212) |
|
||||
| Approbation par run trop grossière (tout ou rien) | Le plan affiche chaque step ; un step peut être **exclu** du plan avant validation (case par step) — l'approbation reste unique, le périmètre est ajustable |
|
||||
| Le miroir et une édition collaborative (Yjs) se marchent dessus | Le miroir écrit via le même chemin de sauvegarde que l'éditeur (backup préalable) et compare les hashs avant/après (règle durcie en phase 2 pour la consolidation) |
|
||||
| Dérive du périmètre : vouloir les tâches dès la phase 0 | Les tâches sont la phase 1 ; la phase 0 ne livre que les fondations qu'elles consommeront (runs, politique, mémoire) |
|
||||
|
||||
## 11. Ordre d'exécution
|
||||
|
||||
```
|
||||
P0-T01 → T02 (#191, indépendant, à faire en premier)
|
||||
P0-T03 → T04 → T05 → T06 (#192)
|
||||
P0-T07 → T08 → T09 (#193, après T03/T06)
|
||||
P0-T10 → T11 → T12 (#213, T10 tôt : la provenance sert les runs)
|
||||
P0-T13 → T14 → T15 → T16 → T17 → T18 (#206, après T11)
|
||||
P0-T19 (harnais tôt, scénarios au fil des tickets) → P0-T20 (clôture)
|
||||
```
|
||||
|
||||
## 12. Définition de « terminé »
|
||||
|
||||
DoD V2 §13 (tests unitaires + non-régression, JSDOM si UI, ruff/mypy 0 erreur, i18n FR/EN, CHANGELOG, ROADMAP, fiche par tranche, guide si exposé, CI verte, commit conventionnel référençant l'ID) **plus** les ajouts V2.1 (parent §21) : golden tasks du périmètre vertes ; toute écriture déclare sa post-condition et son inverse ou son absence ; toute source de contenu déclare sa provenance.
|
||||
@@ -0,0 +1,275 @@
|
||||
# Assistant V2.1 ObsiGate — Phase 1 : Le travail (tâches, Aujourd'hui, planification, capture)
|
||||
|
||||
| Champ | Valeur |
|
||||
|---|---|
|
||||
| Phase | **1 sur 5** — le premier jalon « assistant personnel » |
|
||||
| Document parent | `ASSISTANT-V2.1.md` — en particulier §6 (tâches), §7 (planification), §8 (capture), §14 (UX) |
|
||||
| Version | 1.0 — 9 octobre 2026 |
|
||||
| Auteur | Spark, pour Bruno |
|
||||
| Statut | Prêt à implémenter |
|
||||
| Tranches | **#202** (tâches, projets, objectifs), **#203** (vue Aujourd'hui, file d'approbations unifiée, centre de contrôle), **#204** (moteur de planification), **#205** (capture universelle & Inbox) |
|
||||
| Effort estimé | 14–18 jours |
|
||||
| Prérequis | Phase 0 terminée : mémoire (#192/#193), politiques (#213), runs persistants et niveaux d'autonomie (#206) |
|
||||
| Dépendances | #202 ← #193 ; #203 ← #202, #206 ; #204 ← #202 ; #205 ← #202. **Pas de dépendance à #200** : voir §7.1 pour la résolution explicite |
|
||||
| Débloque | Phase 2 (rappels liés aux tâches, revues ritualisées, briefing-plan), phase 4 (engagements rattachés aux tâches et aux personnes) |
|
||||
|
||||
> **Résultat visible à la fin de cette phase.** L'assistant planifie et agit sur le vault **sans aucun connecteur**. Les tâches vivent dans les notes (checklists enrichies), indexées et interrogeables ; un projet sans prochaine action se voit. Chaque matin, Bruno peut demander son **plan du jour** : agenda du vault + tâches dues + capacité déclarée → proposition en blocs de temps qui **signale la surcharge au lieu de la cacher**, négociable en conversation. L'écran **Aujourd'hui** montre le plan, les priorités, la file unique de tout ce qui attend sa décision, et l'activité de l'assistant. Et tout ce qui lui passe par la tête se capture en 5 secondes (panneau, mobile, voix) dans une **Inbox** que l'assistant trie en propositions validables.
|
||||
|
||||
---
|
||||
|
||||
## Table des matières
|
||||
|
||||
1. [Objectif et principe directeur](#1-objectif-et-principe-directeur)
|
||||
2. [Périmètre](#2-périmètre)
|
||||
3. [État d'entrée — ce que la phase 0 a livré](#3-état-dentrée--ce-que-la-phase-0-a-livré)
|
||||
4. [Modèle de données](#4-modèle-de-données)
|
||||
5. [Tickets de travail](#5-tickets-de-travail)
|
||||
6. [API livrée par la phase](#6-api-livrée-par-la-phase)
|
||||
7. [Comportements détaillés](#7-comportements-détaillés)
|
||||
8. [Tests et golden tasks](#8-tests-et-golden-tasks)
|
||||
9. [Critères d'acceptation de la phase](#9-critères-dacceptation-de-la-phase)
|
||||
10. [Risques spécifiques et vigilance](#10-risques-spécifiques-et-vigilance)
|
||||
11. [Ordre d'exécution](#11-ordre-dexécution)
|
||||
12. [Définition de « terminé »](#12-définition-de--terminé-)
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif et principe directeur
|
||||
|
||||
Le principe : **le travail est un objet, pas un texte (P10).** À la fin de la phase, rien d'important pour Bruno n'existe seulement « dans une conversation » ou « quelque part dans une note » : c'est une tâche datée, un projet avec une prochaine action, ou un item d'inbox en attente de tri.
|
||||
|
||||
Corollaires :
|
||||
|
||||
- **Markdown d'abord, comme la mémoire.** La tâche vit sur sa ligne, dans la note du projet ; l'index SQLite est reconstructible et tout désaccord se résout en faveur de la note.
|
||||
- **Le plan propose, il n'ordonne pas.** Le moteur calcule (déterministe), le modèle rédige et négocie ; la validation est humaine (ou déléguée bornée, phase 0).
|
||||
- **La surcharge se dit.** Si les tâches retenues dépassent la capacité, le plan le signale et propose des reports explicites. Un plan irréaliste est un défaut du système.
|
||||
- **Une seule file de décisions.** Faits `pending`, plans, triages : tout passe par la même file (#203), pour que « ce que l'assistant attend de moi » tienne en un écran et moins de 5 minutes par jour.
|
||||
|
||||
## 2. Périmètre
|
||||
|
||||
### 2.1 Inclus
|
||||
|
||||
- #202 : syntaxe de tâche (états, attributs `[due::] [pri::] [est::] [ctx::] [energy::] [every::]`), parseur et réécriture de lignes, index `data/tasks.sqlite3` maintenu par le watcher, projets et objectifs par frontmatter, récurrence **RRULE** (sous-ensemble RFC 5545), outils `task.*`/`project.*`/`goal_review`, API tâches.
|
||||
- #203 : `GET /api/today` et la vue Aujourd'hui (plan, priorités, file de décisions, inbox, activité) ; file d'approbations unifiée `approvals` (migration des cartes mémoire de la phase 0) ; centre de contrôle v1 (runs, délégations, budgets, reconstruction d'index).
|
||||
- #204 : moteur de planification déterministe (scoring explicable, capacité et créneaux protégés tirés du profil), plan du jour en note `Plans/YYYY-MM-DD.md` + objet plan, time blocking dans les trous d'agenda, négociation par `plan_update`, replanification du reste de journée, agrégats de revue (jour/semaine) — le plan automatique du matin est livré comme **tâche intégrée typée** du scheduler (§7.1), la généralisation `agent_prompt` restant en phase 2.
|
||||
- #205 : Inbox (store, API, outil `inbox_add`), canaux de capture (panneau ⚡, préfixe `+` au chat, Web Share Target PWA, API/MCP), triage assisté par cartes (tâche/note/rappel/fait candidat/événement/à jeter), file hors-ligne rejouée avec horodatage de capture.
|
||||
|
||||
### 2.2 Exclu (phases ultérieures)
|
||||
|
||||
- Les **rappels** comme objets livrés à heure dite (#199, phase 2) : le triage peut *proposer* un rappel, sa création effective attend #199 ; en phase 1, la proposition est convertie en tâche due datée (comportement documenté dans l'UI de la carte).
|
||||
- Le **briefing** (#200, phase 2) et les **rituels** complets (#214, phase 2) : la phase livre le plan et les données de revue ; leur mise en rituel automatique est en phase 2.
|
||||
- Tout **agenda externe** : l'« agenda » de la phase = événements notés dans le vault (notes datées, section Temps du profil) ; l'écriture des blocs dans Google/Outlook arrive en phase 3 avec les connecteurs. Les blocs du plan vivent dans la note du plan.
|
||||
- La transcription vocale (#209, phase 4) : la capture voix de la phase utilise la dictée du navigateur quand elle existe, sinon un texte saisi.
|
||||
|
||||
## 3. État d'entrée — ce que la phase 0 a livré
|
||||
|
||||
| Élément | Usage en phase 1 |
|
||||
|---|---|
|
||||
| Runs persistants, plans, approbations par run (#206) | Le triage, la génération de plan et ses ajustements sont des runs ; la file d'approbations consomme les demandes d'approbation des runs |
|
||||
| Moteur de politiques (#213) | Toute création de tâche/note depuis l'inbox ou un plan passe par la politique (provenance incluse) |
|
||||
| Mémoire et compileur de contexte (#192) | Le plan consomme capacité et créneaux protégés depuis le profil ; le compileur reçoit la section « Aujourd'hui » (plan résumé, tâches dues) |
|
||||
| Miroir à balises gérées (#193) | Même patron pour `Plans/`, `Taches.md` et les sections gérées des notes projets |
|
||||
| Watcher et index du vault | Source des événements de re-parse des tâches |
|
||||
|
||||
## 4. Modèle de données
|
||||
|
||||
Repris du parent : table `tasks` (§6.1 — id ancré, états, projet/objectif, échéances, priorité, estimation, contexte, énergie, `rrule`, `waiting_on`, source), table `inbox_items` (§8.2). Ajouts de la phase :
|
||||
|
||||
```sql
|
||||
CREATE TABLE plans (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||||
date TEXT NOT NULL, -- jour local du plan
|
||||
status TEXT NOT NULL DEFAULT 'proposed' -- proposed|validated|in_progress|closed
|
||||
CHECK (status IN ('proposed','validated','in_progress','closed')),
|
||||
capacity_min INTEGER NOT NULL, -- capacité retenue pour ce jour
|
||||
blocks_json TEXT, -- blocs : tâche/événement, début, fin, source
|
||||
overload_json TEXT, -- surcharge calculée + reports proposés
|
||||
note_path TEXT, -- 99_Assistant/Plans/YYYY-MM-DD.md
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE approvals (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL, -- fait|plan|envoi|action_write|action_dangerous|
|
||||
-- triage|contradiction|delegation_review
|
||||
run_id TEXT, -- run demandeur (nullable pour les cartes mémoire)
|
||||
payload_json TEXT NOT NULL, -- ce qui est à décider, affichable tel quel
|
||||
status TEXT NOT NULL DEFAULT 'pending' -- pending|approved|rejected|expired
|
||||
CHECK (status IN ('pending','approved','rejected','expired')),
|
||||
created_at TEXT NOT NULL, decided_at TEXT, expires_at TEXT
|
||||
);
|
||||
CREATE INDEX idx_approvals_pending ON approvals(user_id, status, created_at);
|
||||
```
|
||||
|
||||
`approvals` vit dans `data/assistant.sqlite3` (créé en phase 0). Une approbation **périmée ne s'exécute jamais** : à l'expiration, le run demandeur est notifié et l'action associée est abandonnée (journalisé).
|
||||
|
||||
## 5. Tickets de travail
|
||||
|
||||
*Convention : chaque ticket est indépendamment testable ; « Fichiers » donne les points d'ancrage d'après la V2.1 — à confirmer contre le code réel au démarrage du ticket.*
|
||||
|
||||
### Tâches & projets — #202
|
||||
|
||||
**P1-T01 — Syntaxe et parseur de tâches.**
|
||||
Fichiers : nouveau `backend/tasks/model.py`.
|
||||
Travail : grammaire des états (`[ ] [/] [x] [-] [!]` + motif « bloqué : » en sous-ligne) et des attributs entre crochets (tous optionnels, ordre libre, texte libre préservé) ; parse d'un fichier markdown → tâches avec position (ligne) ; réécriture d'une ligne à l'identique hors la modification demandée (aucune reformatage parasite) ; ancrage `t-<uuid4>` en commentaire HTML discret **seulement** pour les tâches référencées (rappel, run, plan).
|
||||
Acceptation : aller-retour parse → réécriture sans changement = fichier identique au caractère près ; les checklists Obsidian existantes du vault de test sont lues sans perte.
|
||||
|
||||
**P1-T02 — Index des tâches et watcher.**
|
||||
Fichiers : `backend/tasks/store.py`, `data/tasks.sqlite3`, branchement au watcher existant.
|
||||
Travail : DDL du parent §6.1 ; re-parse incrémental par fichier modifié ; reconstruction complète à la demande (bouton centre de contrôle) et au démarrage si l'index est absent/corrompu ; le désaccord se résout en faveur de la note.
|
||||
Acceptation : créer/modifier/cocher une tâche dans l'éditeur met l'index à jour en < 2 s ; supprimer l'index et redémarrer le reconstruit à l'identique.
|
||||
|
||||
**P1-T03 — Projets et objectifs.**
|
||||
Travail : détection par frontmatter (`obsigate: projet|objectif`) ou dossier projets configurable ; état du projet (actif/pause/terminé/archivé), prochaine action = première tâche ouverte liée ; calcul « projet sans prochaine action » exposé à l'index et à l'API (le détecteur d'insight est en phase 4, la donnée est ici) ; objectifs : horizon, mesure, projets liés.
|
||||
Acceptation : un projet actif sans tâche ouverte est retourné par `project_status` avec le drapeau `no_next_action = true`.
|
||||
|
||||
**P1-T04 — Récurrence RRULE.**
|
||||
Fichiers : `backend/tasks/rrule.py` (stdlib + `zoneinfo`, sous-ensemble RFC 5545 : `FREQ`, `INTERVAL`, `BYDAY`, `BYMONTHDAY`, `COUNT`, `UNTIL`).
|
||||
Travail : ancrage à l'**heure murale** dans `OBSIGATE_TZ` (jamais un timestamp UTC figé) ; à la complétion d'une occurrence, création de la suivante selon le mode (série/instance) ; dates de fin respectées.
|
||||
Acceptation : « tous les mar/jeu pendant 8 semaines » produit exactement 16 occurrences ; le passage à l'heure d'hiver ne décale aucune heure murale (tests sur `America/Toronto`).
|
||||
|
||||
**P1-T05 — Outils tâches et API.**
|
||||
Fichiers : `backend/tools/tasks.py`, `backend/routers/tasks.py`.
|
||||
Travail : `task_list` (filtres today/overdue/project/context/waiting), `task_create` (note cible : note du projet, sinon `99_Assistant/Taches.md`), `task_update`, `task_complete` (avec récurrence T04), `project_status`, `goal_review` ; endpoints REST miroirs + recherche plein texte sur les titres.
|
||||
Acceptation : une tâche créée en conversation se retrouve dans la note, dans l'index et dans la réponse de `task_list(filter=today)` si elle est due ce jour.
|
||||
|
||||
### Aujourd'hui & approbations — #203
|
||||
|
||||
**P1-T06 — Agrégat `GET /api/today`.**
|
||||
Fichiers : `backend/routers/today.py`.
|
||||
Travail : assemblage lecture seule : plan du jour + capacité restante, 3 priorités, compteurs file de décisions, inbox (nombre, plus ancien), tâches dues/en retard, activité des runs (24 h), alertes (index périmé, run en échec) ; chaque section porte ses liens d'objet ; temps de réponse cible < 300 ms sur le vault de test (agrégats indexés, aucun balayage de fichiers).
|
||||
Acceptation : l'agrégat est cohérent avec les endpoints de détail (tests croisés) ; aucune section ne fait échouer les autres (dégradation par section).
|
||||
|
||||
**P1-T07 — Vue Aujourd'hui.**
|
||||
Fichiers : frontend (SPA), patron de la maquette du parent §14.1.
|
||||
Travail : sections plan/priorités/décisions/inbox/activité ; actions sur place (valider un bloc, reporter une tâche, ouvrir la file) sans quitter l'écran ; états vides honnêtes (« pas de plan : en proposer un ») ; responsive et PWA (c'est l'écran d'accueil mobile de l'assistant).
|
||||
Acceptation : tests JSDOM sur les sections et les actions ; l'écran reste utilisable avec zéro connecteur, zéro plan et une inbox vide.
|
||||
|
||||
**P1-T08 — File d'approbations unifiée.**
|
||||
Fichiers : `backend/routers/approvals.py`, migration des cartes mémoire.
|
||||
Travail : table `approvals` (§4) ; les cartes `pending` de la phase 0 (faits) sont re-présentées comme des approvals de type `fait` sans perte d'état ; regroupement par run et par lot (dépliable) ; expiration honorée ; historique des décisions dans l'audit ; badge de compteur partagé (Aujourd'hui, panneau, Configurations).
|
||||
Acceptation : un fait `pending`, un plan à valider et un lot de triages coexistent dans une seule file triée par ancienneté ; approuver un lot de 12 items crée 12 décisions tracées, pas une.
|
||||
|
||||
**P1-T09 — Centre de contrôle v1.**
|
||||
Travail : page Configurations « Assistant » : runs (liste/détail/reprise/annulation — déjà en phase 0, ici intégrés), délégations et niveaux par domaine, budgets (tokens, et compteur d'approbations du jour), santé des index (tâches, mémoire) + bouton « reconstruire l'index des tâches », liens vers les miroirs `99_Assistant/`.
|
||||
Acceptation : chaque compteur affiché correspond à une métrique journalisée (pas d'affichage décoratif) ; la reconstruction d'index est un run visible.
|
||||
|
||||
### Planification — #204
|
||||
|
||||
**P1-T10 — Moteur de scoring et capacité.**
|
||||
Fichiers : `backend/tasks/planner.py`.
|
||||
Travail : entrées déterministes (§7.1 du parent) : événements du vault, tâches dues + en retard, capacité déclarée (réglage + section « Temps » du profil : heures de focus, créneaux protégés), rituels absents cette phase ; scoring explicable (échéance > priorité > âge > lien objectif ; pénalité d'énergie incompatible, règle issue du profil uniquement) ; chaque tâche retenue ou écartée porte sa **raison** affichable.
|
||||
Acceptation : à entrées égales, plan proposé identique (déterminisme) ; la raison d'exclusion d'une tâche est consultable et correcte.
|
||||
|
||||
**P1-T11 — Génération du plan du jour.**
|
||||
Travail : objet `plans` (§4) + note `99_Assistant/Plans/YYYY-MM-DD.md` à balises gérées ; time blocking dans les trous d'agenda (durée estimée, défaut 45 min **marqué estimé**) ; calcul de surcharge : si la somme dépasse la capacité, `overload_json` liste les reports proposés (jamais de compression silencieuse) ; rédaction LLM (1 appel, modèle de routage « planification ») avec citation de la source de chaque ligne.
|
||||
Acceptation : G2 passe (journée surchargée → surcharge signalée, reports proposés, aucun bloc inventé) ; la note du plan et l'objet plan restent synchronisés après édition des blocs gérés.
|
||||
|
||||
**P1-T12 — Négociation et replanification.**
|
||||
Fichiers : `backend/tools/planner.py` (`plan_today`, `plan_propose`, `plan_update`, `plan_review`).
|
||||
Travail : ajustements conversationnels (« déplace X à demain », « je n'ai que 2 h ») → réécriture de la proposition, revalidation ; replanification du **reste de journée** uniquement, diff lisible (« X glisse à 15 h 30, Y reporté ») ; seuil d'impact : tout report d'une tâche `pri: haute+` exige validation.
|
||||
Acceptation : une replanification ne touche jamais un bloc passé ; le diff affiché correspond exactement aux changements appliqués à la validation.
|
||||
|
||||
**P1-T13 — Revues : agrégats jour et semaine.**
|
||||
Travail : `plan_review(period)` déterministe sans LLM : fait/pas fait/reporté (avec compteur de reports par tâche — la donnée du détecteur « reportée ≥ 3 fois » de la phase 4), projets sans prochaine action, progrès objectifs ; rédaction de la note de revue de semaine (le rituel automatisé est en phase 2, la fonction est ici, appelable à la demande et par skill `/revue`).
|
||||
Acceptation : une tâche reportée 3 fois est comptée 3 fois, avec ses dates ; la revue ne contient aucun chiffre non traçable à l'index.
|
||||
|
||||
**P1-T14 — Déclenchement du plan sans #200.**
|
||||
Travail : tâche intégrée typée du scheduler existant (`action: plan_propose`, heure configurable, défaut 07:00, politique de rattrapage simple : si l'occurrence du jour manque, proposer à la première ouverture d'Aujourd'hui) ; skill `/planjour` ; **aucune** utilisation d'`agent_prompt` générique (phase 2).
|
||||
Acceptation : à 7 h, la proposition du jour existe ; conteneur arrêté à 7 h puis rouvert à 9 h → la proposition est générée à l'ouverture, datée du jour, sans doublon.
|
||||
|
||||
### Capture & Inbox — #205
|
||||
|
||||
**P1-T15 — Store Inbox et outil d'ajout.**
|
||||
Fichiers : `backend/inbox/store.py`, `backend/tools/inbox.py`, `data/assistant.sqlite3`.
|
||||
Travail : table `inbox_items` (parent §8.2) ; `inbox_add` (WRITE sans classement — la capture ne décide rien), `inbox_list` ; `POST /api/inbox` authentifié, exposé au MCP ; horodatage de capture conservé séparément de l'horodatage de synchronisation.
|
||||
Acceptation : un item ajouté par API, par chat et par MCP a la même forme et le même statut initial `new`.
|
||||
|
||||
**P1-T16 — Canaux de capture.**
|
||||
Travail : bouton « ⚡ Capture » du panneau (aucun appel LLM) ; préfixe `+` dans le chat général et le chat de fichier ; Web Share Target de la PWA (titre + URL + texte) ; bouton capture de la vue Aujourd'hui mobile ; dictée navigateur si disponible (sinon saisie texte).
|
||||
Acceptation : capture mobile de bout en bout en < 5 s (mesuré sur le parcours PWA) ; une capture ne déclenche aucun run LLM (vérifié au journal).
|
||||
|
||||
**P1-T17 — Triage assisté.**
|
||||
Fichiers : `backend/tools/inbox.py` (`inbox_triage`), prompt de triage (annexe B.1 du parent).
|
||||
Travail : proposition typée par item (tâche avec échéance **uniquement si énoncée ou déductible d'une date explicite** — aucune date inventée ; note avec dossier proposé ; rappel → tâche datée cette phase, §2.2 ; fait candidat → pipeline mémoire `pending` ; événement vault ; à jeter) ; présentation en cartes dans la file unifiée (type `triage`), validation/correction/rejet unitaire ou en lot pour les évidents ; application = un run par lot, via la politique (provenance `inbox`).
|
||||
Acceptation : G4 passe (compte rendu collé dans l'inbox → tâches datées + proposition d'engagement en cartes, rien créé sans validation) ; un item sans date explicite produit une tâche **sans** échéance.
|
||||
|
||||
**P1-T18 — Hors-ligne.**
|
||||
Travail : les captures PWA hors-ligne entrent dans la file IndexedDB existante avec leur horodatage d'origine et sont rejouées à la reconnexion ; la résolution des dates relatives (« demain ») se fait à la date de **capture** ; sur Tauri hors-ligne, même file locale ; le triage différé (serveur injoignable) ne perd ni ne duplique aucun item.
|
||||
Acceptation : capture hors-ligne puis reconnexion = un seul item, daté de la capture ; rejouer deux fois la file ne crée aucun doublon (clé d'idempotence par item).
|
||||
|
||||
### Finitions
|
||||
|
||||
**P1-T19 — Skills et surfaces conversationnelles.**
|
||||
Travail : skills `/planjour`, `/revue`, `/trier`, `/taches` ; section « Aujourd'hui » du compileur de contexte (plan résumé + tâches dues, budget borné) ; libellés `ai.step.*` pour les outils tâches/plan/inbox.
|
||||
Acceptation : « qu'est-ce que j'ai aujourd'hui ? » en conversation répond depuis l'agrégat Aujourd'hui, sources citées, sans accès fichier direct.
|
||||
|
||||
**P1-T20 — i18n, docs, guides.**
|
||||
Travail : clés FR/EN (vue Aujourd'hui, cartes, plans, inbox) ; fiches `docs/features/` pour #202–#205 ; guide utilisateur « Tâches, plan du jour et capture » ; CHANGELOG + ROADMAP.
|
||||
Acceptation : DoD du §12 vérifiée point par point.
|
||||
|
||||
## 6. API livrée par la phase
|
||||
|
||||
| Route | Rôle | Ticket |
|
||||
|---|---|---|
|
||||
| `GET /api/today` | Agrégat de la vue Aujourd'hui | P1-T06 |
|
||||
| `GET/POST/PATCH /api/tasks` · `/api/tasks/{id}/complete` | CRUD et complétion des tâches (miroir des outils) | P1-T05 |
|
||||
| `GET /api/projects` · `GET /api/projects/{id}/status` | Projets et leur état calculé | P1-T03/T05 |
|
||||
| `GET /api/plans/{date}` · `POST /api/plans/{date}/propose|update|validate` | Plan du jour | P1-T11/T12 |
|
||||
| `GET /api/approvals` · `POST /api/approvals/{id}/decide` · `POST /api/approvals/decide-batch` | File de décisions unifiée | P1-T08 |
|
||||
| `GET/POST /api/inbox` · `POST /api/inbox/{id}/triage` | Capture et triage | P1-T15/T17 |
|
||||
|
||||
## 7. Comportements détaillés
|
||||
|
||||
### 7.1 Dépendance #204 → #200 : résolution
|
||||
|
||||
Le tableau des tranches du parent donnait #204 dépendant de #200 (`agent_prompt`), alors que l'ordre des phases place #200 en phase 2. **Résolution adoptée dans les documents de phase** : le plan du matin n'a pas besoin d'un prompt libre — c'est une fonction typée. La phase 1 le livre comme tâche intégrée du scheduler (P1-T14) ; la phase 2 généralise (#200 : `agent_prompt` arbitraire, briefing comme amorce rédactionnelle du plan) sans changer le comportement du plan. Le parent est corrigé en conséquence (§19.2 : dépendance de #204 = #202, #193).
|
||||
|
||||
### 7.2 Tâche ou rappel ?
|
||||
|
||||
En phase 1, il n'existe qu'un objet daté : la tâche. La carte de triage qui propose « rappel » crée une tâche due avec `ctx: rappel` ; en phase 2, #199 rattache la livraison (push/canaux, snooze, escalade) à ces mêmes tâches — aucune migration d'objets, un enrichissement de comportement.
|
||||
|
||||
### 7.3 Édition concurrente d'une ligne de tâche
|
||||
|
||||
Si la ligne d'une tâche a changé dans la note entre la lecture et la réécriture demandée par un outil (édition humaine entre-temps), l'outil refuse avec un conflit lisible et relit l'état courant — jamais d'écrasement. Le backup automatique existant couvre les écritures.
|
||||
|
||||
### 7.4 Capacité et créneaux protégés
|
||||
|
||||
La capacité vient d'abord du réglage, puis de la section « Temps » de `Profil.md` si Bruno l'édite (source `user`, prioritaire). Le moteur n'infère **jamais** une capacité depuis l'historique sans la proposer en carte (ce serait une procédure — phase 2 via la consolidation, validation humaine).
|
||||
|
||||
## 8. Tests et golden tasks
|
||||
|
||||
- Unitaires : parseur (aller-retour sans perte), RRULE (16 occurrences, heure d'hiver), scoring (déterminisme, raisons), surcharge (aucune compression), approvals (expiration, lots).
|
||||
- Intégration : tâche créée par 3 surfaces → même objet ; plan généré → négocié → validé → revu ; capture hors-ligne → synchro → triage.
|
||||
- Golden tasks de la phase : **G2** (surcharge), **G4** (triage d'un compte rendu) — définitions au parent §16.1 ; plus deux scénarios locaux : « tâche créée en chat visible aux 3 surfaces » et « plan du matin après redémarrage » (P1-T14).
|
||||
|
||||
## 9. Critères d'acceptation de la phase
|
||||
|
||||
1. Une tâche créée en conversation, dans l'éditeur ou par triage d'inbox est le **même objet** dans la note, l'index et Aujourd'hui.
|
||||
2. Le plan d'une journée surchargée signale la surcharge et propose des reports ; aucun bloc n'est inventé et aucune tâche n'est compressée en silence.
|
||||
3. La file de décisions contient faits, plans et triages ; le temps de décision quotidien est mesuré et affiché au centre de contrôle.
|
||||
4. Une capture mobile prend moins de 5 secondes ; une capture hors-ligne n'est ni perdue ni dupliquée.
|
||||
5. Aucun connecteur n'est requis pour l'ensemble de la phase.
|
||||
|
||||
## 10. Risques spécifiques et vigilance
|
||||
|
||||
| Risque | Vigilance |
|
||||
|---|---|
|
||||
| Friction de la syntaxe d'attributs dans les notes | Tous les attributs sont optionnels ; une checklist ordinaire reste une tâche valide (échéance absente = jamais proposée comme « due ») |
|
||||
| L'index et les notes divergent (édition externe, Syncthing) | Reconstruction complète toujours disponible ; les conflits de ligne sont des refus lisibles (§7.3), pas des écrasements |
|
||||
| Le plan du jour est ignoré s'il arrive trop tard | Proposition à 7 h **et** à la première ouverture d'Aujourd'hui ; la métrique « plans validés/modifiés/ignorés » est suivie dès cette phase (#212) |
|
||||
| La file de décisions devient un deuxième travail | Lots, expiration, et métrique du temps de décision : au-delà de 5 min/jour en moyenne, c'est un signal de défaut à traiter (parent §20) |
|
||||
|
||||
## 11. Ordre d'exécution
|
||||
|
||||
```
|
||||
P1-T01 → T02 → T03 → T04 → T05 (#202 — T04 peut paralléliser après T01)
|
||||
P1-T06 → T07 ; P1-T08 ; P1-T09 (#203 — T08 après T05, T06 après T11 pour le plan réel,
|
||||
mais livrable avant avec section plan vide honnête)
|
||||
P1-T10 → T11 → T12 → T13 → T14 (#204)
|
||||
P1-T15 → T16 → T17 → T18 (#205)
|
||||
P1-T19 → T20 (finitions)
|
||||
```
|
||||
|
||||
## 12. Définition de « terminé »
|
||||
|
||||
DoD du document de phase 0 §12, inchangée, plus : les golden tasks G2 et G4 vertes en CI ; la reconstruction d'index démontre l'égalité index ↔ notes sur le vault de test.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user