Compare commits

..
Author SHA1 Message Date
bruno b42265b41e Phase 2 coffre: correctif modale (max-height viewport + scroll interne)
CI / security (pull_request) Failing after 1s
CI / lint (pull_request) Failing after 1m46s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 23:45:44 -04:00
bruno 0fc7654f6c Phase 2 coffre: cles i18n import (en) 2026-10-27 23:45:38 -04:00
bruno effad97472 Phase 2 coffre: cles i18n import (fr) 2026-10-27 23:45:33 -04:00
bruno 53d98bfffe Phase 2 coffre: variables OBSIGATE_SECRETS_ENABLED/KEK documentees (sans valeur) 2026-10-27 23:45:20 -04:00
bruno 62aa6079be Phases 3-4 coffre: contrat de distribution Spark et consommateurs 2026-10-27 23:45:16 -04:00
bruno 7d48f65ff9 Phase 2 coffre: documentation sauvegarde/restauration KEK 2026-10-27 23:45:13 -04:00
bruno 3acadf6ad7 Phase 2 coffre: bouton Importer l'existant (import puis purge confirmee) 2026-10-27 23:45:10 -04:00
bruno 5492f99f06 Phase 2 coffre: tests migration, restauration vierge, Spark, consommateurs 2026-10-27 23:45:05 -04:00
bruno 71221e055b Phase 2 coffre: migration, consommateur+octrois, contexte Spark imbrique 2026-10-27 23:44:58 -04:00
bruno 86330e8f65 Phase 2 coffre: module migration api_keys.json -> coffre d'instance (import + purge confirmee) 2026-10-27 23:44:43 -04:00
bruno f3b507b8d9 Merge pull request '[Theme] Nouveau theme Spark (dark/light, identique a la Console Spark) et defaut = Spark dark' (#28) from feat/theme-spark into main
CI / security (push) Failing after 0s
CI / lint (push) Successful in 3m6s
CI / test (push) Failing after 0s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-27 23:25:10 -04:00
bruno c01c7e862d Merge pull request '[fix] Mypy Coffre Secrets (3 erreurs) + version 2.80.2' (#27) from fix/mypy-coffre-2.80.2 into main
CI / lint (push) Failing after 0s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-27 23:24:44 -04:00
bruno 99cd765997 [fix] CHANGELOG 2.80.2 : bouton Nouveau secret + plancher msgpack
CI / lint (pull_request) Failing after 0s
CI / security (pull_request) Failing after 0s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 23:20:41 -04:00
bruno 5266506b56 [fix] Securite : plancher msgpack>=1.2.3 (CVE-2026-114613, pip-audit bloquant)
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
CI / security (pull_request) Successful in 1m56s
2026-10-27 23:20:35 -04:00
bruno 682a5030d3 [fix] Coffre Secrets : valeurField attache l'oeil a wrap avant insertBefore (NotFoundError bouton Nouveau secret)
CI / security (pull_request) Failing after 0s
CI / lint (pull_request) Successful in 3m19s
CI / test (pull_request) Failing after 1m47s
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 23:20:30 -04:00
bruno fc3d847d85 [fix] mypy Coffre + bump 2.80.2 : CHANGELOG.md
CI / security (pull_request) Failing after 2m29s
CI / lint (pull_request) Successful in 2m55s
CI / test (pull_request) Failing after 5m10s
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 23:10:51 -04:00
bruno 82756030c6 [fix] mypy Coffre + bump 2.80.2 : docs/ROADMAP.md 2026-10-27 23:10:48 -04:00
bruno 0352451ead [fix] mypy Coffre + bump 2.80.2 : README.fr.md 2026-10-27 23:10:44 -04:00
bruno 593cf492c3 [fix] mypy Coffre + bump 2.80.2 : README.md 2026-10-27 23:10:41 -04:00
bruno 0b8ad8b0f3 [fix] mypy Coffre + bump 2.80.2 : desktop/Cargo.lock 2026-10-27 23:10:39 -04:00
bruno 111d927ece [fix] mypy Coffre + bump 2.80.2 : desktop/Cargo.toml 2026-10-27 23:10:36 -04:00
bruno 2dae023ee3 [fix] mypy Coffre + bump 2.80.2 : desktop/tauri.conf.json 2026-10-27 23:10:34 -04:00
bruno b12122f78a [fix] mypy Coffre + bump 2.80.2 : package.json 2026-10-27 23:10:31 -04:00
bruno 0c7b3162c8 [fix] mypy Coffre + bump 2.80.2 : VERSION 2026-10-27 23:10:29 -04:00
bruno d4d050297b [fix] mypy Coffre + bump 2.80.2 : backend/coffre_store.py 2026-10-27 23:10:27 -04:00
bruno 8d768bf9d5 [fix] mypy Coffre + bump 2.80.2 : backend/coffre_crypto.py 2026-10-27 23:10:24 -04:00
bruno 47ac8bf6c0 Merge pull request '[fix] Version 2.80.1 synchronisee + corrections ruff (60 erreurs CI)' (#26) from fix/version-2.80.1-ruff into main
CI / security (push) Failing after 1s
CI / lint (push) Failing after 2m10s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-27 23:00:13 -04:00
bruno 01572736c0 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/webhooks.py
CI / security (pull_request) Successful in 1m57s
CI / lint (pull_request) Failing after 2m7s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 22:58:14 -04:00
bruno b6e5f01748 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/watcher.py 2026-10-27 22:58:11 -04:00
bruno 90da0ac990 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/tools/audit.py 2026-10-27 22:58:08 -04:00
bruno bc26d1506b [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/share.py 2026-10-27 22:58:05 -04:00
bruno 769e3ca559 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/services/vaults.py 2026-10-27 22:58:02 -04:00
bruno b5d789f02a [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/services/recent.py 2026-10-27 22:57:59 -04:00
bruno c8dce25e0d [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/services/backups.py 2026-10-27 22:57:56 -04:00
bruno b322e2d24a [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/scheduler.py 2026-10-27 22:57:53 -04:00
bruno 414ec69c0a [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/routers/realtime.py 2026-10-27 22:57:50 -04:00
bruno f5ca656c04 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/routers/backups.py 2026-10-27 22:57:47 -04:00
bruno e158232aba [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/push.py 2026-10-27 22:57:44 -04:00
bruno 632871ac24 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/notify.py 2026-10-27 22:57:40 -04:00
bruno 7af7b2c8c2 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/indexer.py 2026-10-27 22:57:36 -04:00
bruno 97132770e4 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/guide_export.py 2026-10-27 22:57:32 -04:00
bruno 683ad3ef94 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/export.py 2026-10-27 22:57:29 -04:00
bruno 604383f51c [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/coffre_store.py 2026-10-27 22:57:25 -04:00
bruno bdeb2f00b3 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/coffre_crypto.py 2026-10-27 22:57:21 -04:00
bruno 8688875fae [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/bookslm_routes.py 2026-10-27 22:57:17 -04:00
bruno a42271c7de [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/auth/user_store.py 2026-10-27 22:57:13 -04:00
bruno cf4685eaf1 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/auth/router.py 2026-10-27 22:57:10 -04:00
bruno 5023fb6774 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/audit.py 2026-10-27 22:57:06 -04:00
bruno 04eb70cbe4 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/admin.py 2026-10-27 22:57:01 -04:00
bruno 4dc010f54d [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : CHANGELOG.md 2026-10-27 22:56:57 -04:00
bruno f7e36bc44e [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : docs/ROADMAP.md 2026-10-27 22:56:53 -04:00
bruno a9ac1c06a7 [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : README.fr.md 2026-10-27 22:56:48 -04:00
bruno 9bf6e7492f [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : README.md 2026-10-27 22:56:43 -04:00
bruno 14070cef43 [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : desktop/Cargo.lock 2026-10-27 22:56:37 -04:00
bruno 6b1abbb50e [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : desktop/Cargo.toml 2026-10-27 22:56:33 -04:00
bruno 46c641ebc5 [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : desktop/tauri.conf.json 2026-10-27 22:56:30 -04:00
bruno 2d036f5126 [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : package.json 2026-10-27 22:56:27 -04:00
bruno 2c69e357b8 [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : VERSION 2026-10-27 22:56:25 -04:00
bruno ba81f22d40 Merge pull request '[Coffre Secrets] Phase 1 complète : chiffrement, API et UI (1a + 1b + 1c)' (#25) from feat/coffre-secrets-phase1 into main
CI / lint (push) Failing after 1m47s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 1m58s
2026-10-27 22:29:09 -04:00
bruno 070add4e7b [Thème Spark] Anti-FOUC admin aligné sur fond/texte Spark
CI / security (pull_request) Failing after 0s
CI / lint (pull_request) Successful in 3m10s
CI / test (pull_request) Failing after 5m22s
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 22:10:14 -04:00
bruno 847b3225b2 [Thème Spark] Splash et meta theme-color alignés sur Spark dark 2026-10-27 22:10:11 -04:00
bruno 3ae5c7efac [Thème Spark] Bloc :root de secours aligné sur la palette dark Spark 2026-10-27 22:10:07 -04:00
bruno 4966c27548 [Thème Spark] Ajout du thème 'spark' (dark/light, 39 variables) et défaut spark/dark 2026-10-27 22:10:04 -04:00
bruno 06c12d0383 [Coffre Secrets] Phase 1c UI : cles i18n coffres.* (en)
CI / security (pull_request) Failing after 2s
CI / lint (pull_request) Failing after 1m55s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 22:08:23 -04:00
bruno 69f3968d4a [Coffre Secrets] Phase 1c UI : cles i18n coffres.* (fr) 2026-10-27 22:08:20 -04:00
bruno d5ae66466a [Coffre Secrets] Phase 1c UI : styles vue Secrets et menu contextuel 2026-10-27 22:08:16 -04:00
bruno 2dc8be3127 [Coffre Secrets] Phase 1c UI : noeud virtuel Secrets sous Home-user dans sidebar.js (patron Partage #196) 2026-10-27 22:08:05 -04:00
bruno 040e80a36d [Coffre Secrets] Phase 1c UI : nouveau module frontend/js/coffres.js (vue Secrets, modales, copie manuelle) 2026-10-27 22:08:00 -04:00
bruno bd6b79fb41 [Coffre Secrets] Phase 1a/1b : tests crypto, store, autorisations, distribution 2026-10-27 21:45:58 -04:00
bruno 581a31161c [Coffre Secrets] Phase 1a : dependance cryptography pour AES-GCM 2026-10-27 21:45:56 -04:00
bruno 5f8f93ba6c [Coffre Secrets] Phase 1b : montage du routeur coffres a cote de config_router 2026-10-27 21:45:53 -04:00
bruno 40b3e9c5dd [Coffre Secrets] Phase 1b : routeur /api/coffres, autorisations, octrois, distribution 2026-10-27 21:45:40 -04:00
bruno 2ffc219f47 [Coffre Secrets] Phase 1b : log_coffre_event dans audit.log (sans valeur) 2026-10-27 21:45:38 -04:00
bruno 02394d3278 [Coffre Secrets] Phase 1a : store coffres/valeurs/octrois/consommateurs (0600, atomique, RLock) 2026-10-27 21:45:35 -04:00
bruno ff1ccca3cd [Coffre Secrets] Phase 1a : chiffrement par enveloppe AES-256-GCM (KEK/DEK) 2026-10-27 21:45:32 -04:00
Spark fddbaec82e feat: panneau Spark — version du service d'agents visible en mode sidebar #225
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-26 16:56:28 +00:00
Spark 8e073438a3 fix: URLs sœurs dérivées de l'environnement de l'agent — le menu inter-apps ne pointe plus la prod depuis la dev BUG-120
CI / security (push) Successful in 1m55s
CI / lint (push) Successful in 3m5s
CI / test (push) Successful in 5m23s
CI / build (push) Successful in 1m46s
CI / e2e (push) Successful in 18m9s
2026-10-26 00:06:16 +00:00
Spark 6972e9ec87 fix: planchers de sécurité pip-audit — brotli, filelock, opentelemetry-api, pypdf #224
CI / security (push) Successful in 2m42s
CI / lint (push) Successful in 3m22s
CI / test (push) Successful in 5m34s
CI / build (push) Successful in 2m48s
CI / e2e (push) Successful in 19m15s
2026-10-25 21:02:57 +00:00
Spark f29a22886a feat: URLs publiques de l'écosystème par configuration — dev/prod sans noms en dur #223
CI / lint (push) Successful in 3m11s
CI / security (push) Failing after 1m55s
CI / test (push) Successful in 5m23s
CI / build (push) Successful in 2m35s
CI / e2e (push) Successful in 18m24s
2026-10-25 18:10:59 +00:00
Spark 287afa1537 fix: CSP — frame-src autorise la console du service d'agents (mode console du panneau Spark) BUG-119
CI / lint (push) Successful in 3m5s
CI / security (push) Failing after 1m49s
CI / test (push) Successful in 5m19s
CI / build (push) Successful in 1m36s
CI / e2e (push) Successful in 18m9s
2026-10-24 03:10:34 +00:00
Spark b0ac6dfdb9 docs: #222 livré (2.77.0) — statut Roadmap
CI / lint (push) Successful in 2m58s
CI / security (push) Failing after 1m46s
CI / test (push) Successful in 5m10s
CI / build (push) Successful in 1m39s
CI / e2e (push) Successful in 18m2s
2026-10-23 04:13:51 +00:00
Spark 7f06606300 feat: panneau Spark V3 — la console en surimpression pleine page #222 2026-10-23 04:13:37 +00:00
Spark 985af3a294 docs: #221 livré (2.76.1) — statut Roadmap
CI / lint (push) Successful in 2m54s
CI / security (push) Failing after 1m40s
CI / test (push) Successful in 4m59s
CI / build (push) Failing after 11m14s
CI / e2e (push) Skipped
2026-10-22 21:21:27 +00:00
Spark 440cfdcc31 fix: bouton « Console ↗ » dans le panneau Spark #221
CI / lint (push) Successful in 2m53s
CI / security (push) Failing after 1m39s
CI / test (push) Successful in 5m3s
CI / build (push) Successful in 1m39s
CI / e2e (push) Successful in 18m1s
2026-10-22 21:20:51 +00:00
Spark 0ca92c3723 docs: #220 livré (2.76.0) — statut Roadmap
CI / lint (push) Successful in 2m52s
CI / security (push) Failing after 1m38s
CI / test (push) Successful in 5m3s
CI / build (push) Successful in 1m29s
CI / e2e (push) Successful in 17m47s
2026-10-22 19:59:24 +00:00
Spark f434f30286 feat: émission d'événements vers le journal du service d'agents #220
CI / lint (push) Successful in 2m53s
CI / security (push) Failing after 1m46s
CI / test (push) Successful in 4m57s
CI / build (push) Successful in 1m36s
CI / e2e (push) Successful in 17m40s
2026-10-22 19:58:25 +00:00
Spark f4b8935189 docs: #219 livré (2.75.0) — statut Roadmap
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-10-22 12:07:49 -04:00
Spark 014df322e7 feat: panneau Spark v2 — Markdown, largeur, thème, discussions #219
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-10-22 12:06:12 -04:00
bruno 39f9cc16e4 fix(deploy): retirer DIR_1 Bruno — doublon avec le vault par défaut home-user (#194), surconsommation mémoire
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-10-22 10:10:18 -04:00
Spark b4b3032456 docs: #218 livré (2.74.0) — statut Roadmap
CI / lint (push) Successful in 2m49s
CI / security (push) Failing after 1m40s
CI / test (push) Successful in 5m4s
CI / build (push) Successful in 1m29s
CI / e2e (push) Successful in 18m6s
2026-10-22 03:43:24 +00:00
Spark edef1ea708 feat: panneau Spark intégré à la barre commune (Fusion) #218 2026-10-22 03:43:17 +00:00
Spark 7523670949 fix: barre commune v0.1.4, palier mobile élargi à 900 px (suite BUG-118, #215)
CI / lint (push) Successful in 2m54s
CI / security (push) Failing after 1m34s
CI / test (push) Successful in 4m54s
CI / build (push) Successful in 1m28s
CI / e2e (push) Successful in 18m28s
2026-10-21 12:47:16 -04:00
Spark d81f328859 feat: menu des applications par la marque ✦ de la barre commune (commons v0.1.3, #215)
CI / lint (push) Successful in 2m47s
CI / security (push) Successful in 1m34s
CI / test (push) Successful in 4m55s
CI / build (push) Successful in 1m28s
CI / e2e (push) Successful in 18m20s
2026-10-21 12:27:12 -04:00
Spark 1a39d601de fix: barre d'apps mobile — CSS commune v0.1.2 et rail contextuel (BUG-118, #215)
CI / lint (push) Successful in 2m51s
CI / security (push) Successful in 1m33s
CI / test (push) Successful in 4m58s
CI / build (push) Successful in 1m27s
CI / e2e (push) Successful in 18m3s
2026-10-21 11:36:35 -04:00
Spark 68d3dd85a9 feat: validation locale du jeton draco_session — repli SSO dans get_current_user (#217)
CI / lint (push) Successful in 3m34s
CI / security (push) Successful in 2m17s
CI / test (push) Successful in 5m38s
CI / build (push) Successful in 2m15s
CI / e2e (push) Successful in 18m31s
2026-10-20 23:47:45 -04:00
Spark cb80b08f77 feat: émettrice SSO draco_session — cookie partagé .dracodev.net, renouvellement et révocation (#216)
CI / lint (push) Successful in 3m32s
CI / security (push) Successful in 2m18s
CI / test (push) Successful in 5m38s
CI / build (push) Successful in 3m52s
CI / e2e (push) Successful in 19m0s
2026-10-20 23:33:52 -04:00
Spark e5577b9996 fix: planchers de sécurité des dépendances — pip-audit vierge (aiohttp, mcp, pillow, certifi)
CI / lint (push) Successful in 3m40s
CI / security (push) Successful in 2m23s
CI / test (push) Successful in 5m38s
CI / build (push) Successful in 3m16s
CI / e2e (push) Successful in 21m7s
2026-10-20 23:10:34 -04:00
Spark 9bc31d069b feat: barre d'apps commune et références croisées des notes (#215)
CI / lint (push) Successful in 3m32s
CI / security (push) Failing after 2m27s
CI / test (push) Successful in 5m36s
CI / build (push) Successful in 2m12s
CI / e2e (push) Successful in 18m18s
2026-10-20 22:20:48 -04:00
bruno 411667fd88 feat: menu contextuel Déplacer/Dupliquer sidebar + réparation drag & drop interne #201 #BUG-117
CI / lint (push) Successful in 3m13s
CI / security (push) Successful in 1m59s
CI / test (push) Successful in 5m16s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 18m2s
2026-10-15 10:02:48 -04:00
bruno ed9029d760 fix: éditeur Forge — textarea qui ne grandit pas après insertion programmatique (tableau/code) #BUG-116
CI / lint (push) Successful in 3m17s
CI / security (push) Successful in 2m0s
CI / test (push) Successful in 5m14s
CI / build (push) Successful in 1m49s
CI / e2e (push) Successful in 17m57s
2026-10-14 22:09:28 -04:00
bruno 1892062d88 fix: éditeur Forge — espace checkbox/texte aperçu, flash du preview, menus table/langue coupés #BUG-115
CI / lint (push) Successful in 3m11s
CI / security (push) Successful in 2m0s
CI / test (push) Successful in 5m16s
CI / build (push) Successful in 1m49s
CI / e2e (push) Successful in 17m47s
2026-10-14 21:56:37 -04:00
bruno cbe3d9bc75 fix: popup currentPath is not defined après suppression/renommage de répertoire #BUG-114
CI / lint (push) Successful in 3m9s
CI / security (push) Successful in 1m59s
CI / test (push) Successful in 5m12s
CI / build (push) Successful in 1m49s
CI / e2e (push) Successful in 17m57s
2026-10-14 21:20:08 -04:00
bruno 2ebfe74459 perf: suppression de répertoire incrémentale au lieu du re-scan complet #BUG-113
CI / lint (push) Successful in 3m9s
CI / security (push) Successful in 1m55s
CI / test (push) Successful in 5m25s
CI / build (push) Successful in 1m48s
CI / e2e (push) Successful in 17m56s
2026-10-14 16:56:56 -04:00
bruno 6257adb46b fix: CI security — bandit B112 sur search_shared_notes (nosec, continue délibéré)
CI / lint (push) Successful in 3m9s
CI / security (push) Successful in 1m56s
CI / test (push) Successful in 5m13s
CI / build (push) Successful in 1m47s
CI / e2e (push) Successful in 18m17s
2026-10-14 13:43:26 -04:00
bruno 31a0ab5b70 fix: config Clés API & MCP — wrap des champs de création au lieu du débordement desktop
CI / lint (push) Successful in 3m10s
CI / security (push) Failing after 1m51s
CI / test (push) Successful in 5m12s
CI / build (push) Successful in 1m48s
CI / e2e (push) Successful in 18m15s
2026-10-14 12:09:08 -04:00
bruno f783394f09 feat: intégration TikTak #197-200 + scoped tokens, webhooks filtrés, listes mtime/size ; fix e2e-server BUG-112
CI / lint (push) Successful in 3m17s
CI / security (push) Failing after 1m51s
CI / test (push) Successful in 5m14s
CI / build (push) Successful in 1m50s
CI / e2e (push) Successful in 18m18s
2026-10-14 11:42:53 -04:00
bruno 2be0b15bcf fix: partage dirigé — ACL court-circuitée après résolution Partage/ + chemin canonique au token #196
CI / lint (push) Successful in 3m0s
CI / security (push) Failing after 1m38s
CI / test (push) Successful in 4m58s
CI / build (push) Successful in 1m36s
CI / e2e (push) Successful in 19m55s
- le partage EST l'autorisation: plus de 403 'Accès refusé à la vault' pour un
  destinataire sans accès au vault émetteur (api_file, raw, download)
- chemin virtuel canonique Partage/<token>/<nom> — désambiguïse les homonymes,
  fallback par nom conservé
- 'reçu' = partagé_avec contient l'utilisateur: un admin ne voit plus les
  partages des autres comme reçus (fix File not found: Partage/…)
- +3 tests (14 total)
2026-10-10 23:11:12 -04:00
bruno de6bde1613 fix: partage dirigé — dossier Partage virtuel + ouverture en onglet #196
CI / lint (push) Successful in 3m1s
CI / security (push) Failing after 1m58s
CI / test (push) Successful in 4m43s
CI / build (push) Successful in 1m39s
CI / e2e (push) Successful in 20m28s
- _resolve_shared_file: home-<user>/Partage/<f> -> fichier source (/api/file, raw, download)
- clic dossier Partage: dépliage sans navigation (fix Directory not found)
- fichiers reçus ouverts en onglet applicatif (arbre, recherche, dashboard)
- POST /api/share: path vide/null rejeté (400) — un share path:null cassait /api/shares
- +2 tests (13 total)
2026-10-10 21:48:46 -04:00
bruno fdc9f47a0d feat: visibilité & recherche des partages dirigés #196
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
- icône share-2 bleue sur les fichiers partagés dans l'arborescence
- dossier virtuel Partage/ à la racine du home du destinataire (lecture seule /s/{token})
- recherche plein texte couvre les documents reçus (merge search_vaults, share_token)
- cache front invalidé à la création/révocation ; +2 tests non-fuite
2026-10-10 21:23:00 -04:00
bruno 49c715199d feat: partage dirigé entre utilisateurs #196
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
CI / lint (push) Canceled after 1m29s
- create_share/shared_with + validation des destinataires
- gate auth sur /s/{token} (+pdf, raw) : 404 opaque hors créateur/admin/destinataires
- GET /api/shares scopé (non-admin = créés + reçus), révocation créateur/admin
- UI : dialogue dirigé/public + destinataires, dashboard « partagé par X »
- i18n FR/EN, tests test_directed_shares.py (9), fiche feature
2026-10-10 20:14:29 -04:00
bruno fd192df036 fix: admin — icône fermeture modale utilisateurs + sections du dashboard affichées une à la fois BUG-111
CI / lint (push) Successful in 2m54s
CI / security (push) Successful in 1m41s
CI / test (push) Successful in 4m36s
CI / build (push) Successful in 1m34s
CI / e2e (push) Successful in 17m56s
2026-10-10 17:17:14 -04:00
bruno 8662d23ec8 fix: un dossier perso n'est plus visible par les autres comptes #194
CI / lint (push) Successful in 2m57s
CI / security (push) Successful in 1m40s
CI / test (push) Successful in 4m39s
CI / build (push) Successful in 1m34s
CI / e2e (push) Successful in 17m44s
L'admin (vaults: ["*"]) voyait le home de chaque utilisateur dans sa
barre latérale : "*" ouvrait tous les vaults, home-* compris.

- backend/auth/middleware.py : check_vault_access exige un octroi
  explicite pour tout vault home-* (nouveau is_home_vault()).
- Filtres « * » en dur remplacés par check_vault_access : dashboard,
  conflits, liens retour, favoris, abonnements push.
- /api/search : search_vaults(is_allowed=…) filtre les bruts avant
  pagination (total et page restent justes).
- backend/user_home.py : _grant n'écarte plus les comptes « * » —
  l'admin reçoit son propre home-admin (auto-réparé au démarrage).
- Tests : test_user_home.py +2, assertion API inversée dans
  test_auth_api.py (admin ne voit plus home-alice).
2026-10-10 16:42:00 -04:00
bruno 10453d8dfe feat: dossier perso (#194) — vaults home-* en tête de l'arborescence, icône maison, compose de test
CI / lint (push) Successful in 3m1s
CI / security (push) Successful in 1m41s
CI / test (push) Successful in 4m37s
CI / build (push) Successful in 1m34s
CI / e2e (push) Successful in 17m40s
- frontend/js/sidebar.js : tri stable _homeFirst appliqué à la source des
  deux listes de vaults (loadVaults + soft refresh) → les vaults home-<user>
  passent en premier, arborescence et sélecteurs cohérents.
- frontend/js/viewer.js : getVaultIcon renvoie l'icône lucide "home" pour
  home-* (même safeCreateIcons / stroke currentColor que les autres).
- docker-compose.test-win.yml : montage test_home:/vaults/Home +
  OBSIGATE_HOME_ROOT=/vaults/Home pour tester la feature en local ;
  test_home/ ajouté au .gitignore.
- Tests : sidebar-filters.test.mjs +2 (icône maison, gemme violette
  inchangée), vérifié live sur l'instance 2020 (ordre + svg.lucide-home).
2026-10-10 16:09:59 -04:00
bruno 8235d632b8 feat: dossier personnel par utilisateur — vault home-<user> + registre de vaults persistant #194
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
- backend/user_home.py : ensure_user_home()/release_user_home() idempotents
  (mkdir, vault, watcher, octroi dans user.vaults), rappelés à la création
  d'un compte et au démarrage (migration + réparation + sweep des vaults
  home-* orphelins), dossier conservé à la suppression.
- backend/indexer.py : data/vaults.json, registre persistant des vaults
  ajoutés à la volée, fusionné par load_vault_config() — ils disparaissaient
  au premier reindex complet et au redémarrage. reload_single_vault remet
  vault_config[name] après remove_vault_from_index.
- backend/auth/router.py + routers/vaults.py : appels ensure/release +
  persistance à l'ajout/retrait d'un vault.
- docker-compose.yml : montage unique /NFS/OBSIDIAN_DOC/Home:/vaults/Home +
  OBSIGATE_HOME_ROOT (absente = fonctionnalité inactive).
- Tests : tests/test_user_home.py (12) + tests/test_auth_api.py::TestUserHome (2).
2026-10-10 00:40:35 -04:00
bruno f4c8504c8d feat: chat — les posts affichent le markdown rendu comme un document, code coloré #193
CI / lint (push) Successful in 2m50s
CI / security (push) Successful in 1m37s
CI / test (push) Successful in 4m34s
CI / build (push) Successful in 1m31s
CI / e2e (push) Successful in 17m36s
2026-10-09 15:48:04 -04:00
bruno 4fb7c43e06 feat: chat — boutons discrets sous l'image, image en onglet d'app, point de non-lus, notification OS, suppression annulable BUG-110
CI / lint (push) Successful in 2m50s
CI / security (push) Successful in 1m36s
CI / test (push) Successful in 4m28s
CI / build (push) Successful in 1m31s
CI / e2e (push) Successful in 17m30s
2026-10-09 11:53:26 -04:00
bruno 54caa48d1f feat: chat — saisie auto-agrandissante, accusé de réception ✓✓, vignette de tuile #192 (BUG-109)
CI / lint (push) Successful in 2m52s
CI / security (push) Successful in 1m35s
CI / test (push) Successful in 4m27s
CI / build (push) Successful in 1m29s
CI / e2e (push) Successful in 17m1s
2026-10-08 23:28:08 -04:00
bruno f00a8bea8f feat: chat — suppression de post, messages privés, boîte compacte, link preview #191
CI / lint (push) Successful in 2m50s
CI / security (push) Successful in 1m35s
CI / test (push) Successful in 4m28s
CI / build (push) Successful in 1m28s
CI / e2e (push) Successful in 17m11s
2026-10-08 22:33:29 -04:00
bruno 55a9fcc0f4 fix: retirer -f de logs du build.sh (bloquait le lock de deploiement)
CI / lint (push) Successful in 2m48s
CI / security (push) Successful in 1m34s
CI / test (push) Successful in 4m31s
CI / build (push) Successful in 1m26s
CI / e2e (push) Successful in 17m16s
2026-10-08 21:06:18 -04:00
bruno 4cdea956d7 docs: spec Assistant V2 — second cerveau (mémoire, connecteurs, proactivité)
CI / lint (push) Successful in 2m48s
CI / security (push) Successful in 1m35s
CI / test (push) Successful in 4m27s
CI / build (push) Successful in 1m26s
CI / e2e (push) Successful in 16m41s
2026-10-08 21:01:45 -04:00
bruno ae06436f91 feat: chat — suivi du document, chat général en onglet sidebar, onglets au-dessus du filtre #190
CI / lint (push) Successful in 2m48s
CI / security (push) Successful in 1m34s
CI / test (push) Successful in 4m33s
CI / build (push) Successful in 1m30s
CI / e2e (push) Successful in 17m5s
- **A — Suivi du document** : l'en-tête du panneau chat affiche le document
  ciblé (titre + chemin) et `followFileChat()` appelé par `renderFile()`
  re-cible le panneau ouvert à chaque changement de document.
- **B — Chat général** : onglet « Chat » (dernier) dans la sidebar,
  conversation centrale stockée dans le store #169 via les sentinelles
  `__global__/general` (`GET/POST /api/chat`) ; pastille de messages non
  lus sur l'onglet ; pièces jointes image/vidéo (`POST /api/chat/upload` :
  allow-list d'extensions, 25 MB, nom UUID ; `GET /api/chat/attachment/{name}`
  résolu contre l'allow-list) ; URL cliquables au rendu ; date/heure d'envoi.
- **C — Ordre des onglets** : la barre de filtre passe sous les onglets et
  sert de recherche dans le chat (texte + auteur) quand l'onglet Chat est
  actif (`switchSidebarTab` + `routeFilter`).
- Transport : broadcast SSE `chat_message` réutilisé (`vault __global__`
  route vers la sidebar, sinon le panneau fichier) — pas de 2ᵉ WebSocket.
- i18n FR/EN (6 clés), CSS bloc #190, `initSidebarChat()` dans l'orchestrateur.
- Tests : pytest 27 chat (12 nouveaux) ; JSDOM `filechat.test.mjs` 11 (5
  nouveaux) ; suite 1655 passed, ruff/mypy 0, validate-imports 42 modules.
- Docs : CHANGELOG [Unreleased], ROADMAP #190 → index, fiche
  `file-chat-169.md` §#190, guide « Chat général », journal des interventions.
2026-10-08 20:40:54 -04:00
bruno d0e10d4cc6 docs: journal des interventions AI #189/#169 + versions d'index resynchronisées
CI / lint (push) Successful in 2m47s
CI / security (push) Successful in 1m34s
CI / test (push) Successful in 4m24s
CI / build (push) Successful in 1m27s
CI / e2e (push) Successful in 17m37s
- `docs/ISSUES_TODOLIST.md` : deux lignes ajoutées au journal (features
  #189 mobile tableur et #169 chat par fichier, avec vérifications et
  résultats CI), exigées par le DoD pour tout passage d'IA modifiant du code.
- `docs/ROADMAP.md` : colonne version de l'index corrigée — #189 → 2.56.0,
  #169 → 2.57.0 (le hook de bump ne resynchronise pas ces rangées).
2026-10-08 18:42:31 -04:00
bruno c94f065f80 feat: chat intégré par fichier — panneau latéral, historique, temps réel SSE #169
CI / lint (push) Successful in 2m46s
CI / security (push) Successful in 1m35s
CI / test (push) Successful in 4m28s
CI / build (push) Successful in 1m27s
CI / e2e (push) Successful in 17m11s
- Store backend `backend/file_chat.py` : messages JSON par (vault, path)
  sous `data/chats/` (nom hashé SHA-256 → traversal impossible), plafond
  500 messages, texte tronqué à 4000 caractères, écriture atomique.
- Routes `GET/POST /api/file/{vault}/chat` : auth + accès vault +
  `resolve_safe_path`, schémas Pydantic (`response_model`), broadcast SSE
  `chat_message` sur le transport existant (#62) — pas de second WebSocket.
- Panneau latéral `frontend/js/filechat.js` : bouton 💬 dans la toolbar
  fichier, historique chronologique, envoi optimiste + dédoublonnage par id,
  toast « Nouveau message » si le panneau est fermé/autre fichier.
- Relais SSE dans `sync.js` (import dynamique), CSS bloc #169 (plein écran
  ≤ 768 px, input 16 px anti-zoom), i18n FR/EN (10 clés `chat.*`).
- Tests : `tests/test_file_chat.py` (15) + `tests/frontend/filechat.test.mjs`
  (6, ajouté au pipeline CI), regex toolbar-order mise à jour.
- Docs : CHANGELOG [Unreleased], ROADMAP #169 → livré + index, fiche
  `docs/features/file-chat-169.md`, guide « Discuter d'un fichier ».
2026-10-08 17:03:29 -04:00
bruno 36b4962efb feat: tableur mobile — barres repliées par défaut, bascule unique #189
CI / lint (push) Successful in 2m42s
CI / security (push) Successful in 1m38s
CI / test (push) Successful in 4m19s
CI / build (push) Successful in 1m27s
CI / e2e (push) Successful in 17m12s
Sous 768 px, la barre de menus, le ruban et le groupe Recherche sont
repliés par défaut : la barre de formule (fx), les onglets et Enregistrer
restent visibles et la grille récupère la hauteur d'écran. Un bouton
unique (#xlsx-tools-toggle) dans la barre des feuilles déplie/replie
l'ensemble (aria-expanded, état actif) ; desktop inchangé.

Ergonomie tactile : cibles ≥ 44 px, onglets en défilement horizontal,
champs de la barre de formule en 16 px (anti-zoom iOS), barre de formule
en 2 lignes quand les outils sont ouverts.

Tests : JSDOM xlsx-viewer.test.mjs (+2, 165/165), E2E mobile (repli par
défaut, dépliage, no-overflow ruban, cibles 44 px) ; openXlsx() déplie
automatiquement ≤ 768 px pour les tests desktop.
2026-10-08 15:45:39 -04:00
bruno e01e837a2a feat: secrets masqués — couverture universelle clés API/mots de passe + clic pour copier (#188)
CI / lint (push) Successful in 2m40s
CI / security (push) Successful in 1m33s
CI / test (push) Successful in 4m19s
CI / build (push) Successful in 1m31s
CI / e2e (push) Successful in 16m56s
2026-10-08 13:22:27 -04:00
bruno 634ba8a272 feat: éditeur tableur — grille A → Z par blocs, barre de menus Sheets, panneau de couleurs, création .xlsx (#180 → #186)
CI / lint (push) Successful in 2m43s
CI / security (push) Successful in 1m33s
CI / test (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
Restaure et termine le lot tableur resté non committé. Il n'existait que dans un
`git stash` (24 fichiers suivis) et en fichiers non suivis (menus.js, formula.js,
color-picker.js, tests, fiches) : la 2.53.3 livrée ne le contenait donc pas. Le
stash, créé avant le commit BUG-108, n'a jamais été restauré.

#180 Redimensionnement au curseur (en-tête de bord miroité + classe de repaint),
grille thémée sur les 15 thèmes et les modes contraste élevé / sépia, menu
Fichier au niveau des onglets, barre épinglée pleine largeur.

#181 Modèle de saisie Google Sheets : sélection ≠ édition (caret masqué sans
quitter contenteditable, double-clic / F2 / première frappe qui remplace,
Entrée contextuelle, Échap qui restaure) + inventaire priorisé des écarts.

#182 Désélection fiable (clic simple sans dépendre du focus), contours de plage
non empilés (sélecteur de classes sans point), couleurs texte/fond sur une
plage, clic droit qui préserve la sélection multiple.

#183 Panneau de couleurs façon Google Sheets : palette 8 × 10, STANDARD,
PERSONNALISÉ, coche selon la luminance, sortie `#rrggbb` (une valeur HSL était
rejetée par normHex).

#184 Peinture de format complète (toutes propriétés, source sans format =
réinitialisation), sélection multi-lignes/colonnes depuis les marges, grille
étendue : colonnes A → Z d'emblée, lignes ajoutées PAR BLOCS DE 100 au
défilement jusqu'à 1000 — matérialiser 1000 lignes d'un coup = ~26 000 cellules
câblées par feuille, ce qui épuisait le tas de la suite JSDOM ; index de
cellules `ref → td`, court-circuits formule/styles, marqueur data-wired.

#185 Barre de menus : les 10 menus Google Sheets (161 entrées, 125 câblées, 36
annoncées indisponibles), ruban façon Sheets, grille unie 1 px dérivée du thème.

#186 « Créer un fichier » propose .xlsx et construit un vrai classeur OPC
(openpyxl) au lieu d'une charge utile texte illisible.

Corrections trouvées en restaurant et en exerçant le lot :
- fuite mémoire : écouteur `click` anonyme posé sur #content-area à chaque
  rendu, jamais retiré — sa fermeture retenait la grille précédente en entier ;
- sorties Markdown / HTML / Imprimer de la barre de menus inertes
  (`data-xlsx-export` jamais réparti, seul `data-xlsx-action` l'était) ;
- collision de classe `.xlsx-structure-menu` entre la barre de menus et le menu
  Structure de la barre d'outils (toute requête tombait sur un nœud masqué) ;
- curseur col/row-resize absent quand le pointeur est sur la table elle-même ;
- l'export emportait les lignes et colonnes vides du quadrillage (CSV, Markdown,
  HTML, impression) ;
- clic extérieur avalé par la grâce de 250 ms du menu contextuel (destinée au
  seul appui long tactile) ;
- une entrée indisponible laissait la barre de menus ouverte.

Tests : pytest 1595 passés / 2 ignorés ; ruff et mypy 0 erreur ; bandit 0 ;
33 suites frontend vertes (xlsx-viewer 163/163, xlsx-menus 19/19,
xlsx-formula 14/14) ; E2E complet 132 passés / 12 ignorés ; E2E xlsx-viewer
19/19.
2026-10-08 12:00:15 -04:00
bruno ce1944d437 fix: coller du menu contextuel tableur muet + planchers sécurité CI (BUG-108)
CI / lint (push) Successful in 3m6s
CI / security (push) Successful in 2m18s
CI / test (push) Successful in 4m55s
CI / build (push) Successful in 3m1s
CI / e2e (push) Successful in 19m27s
BUG-108 — closeContextMenu() retirait le nœud du menu AVANT de déférencer
_menu : le retrait émet focusout en synchrone (le menu tient le focus), qui
rappelle closeContextMenu() ; le second remove() sur un nœud démonté lève
NotFoundError et avorte le handler de l'action « Coller » du viewer — le
coller par menu contextuel était silencieusement mort (régression de la
fermeture au focus #179, reproduite par l'E2E « coller une plage », CI
#850→#854). Déférencement avant retrait.

Sécurité CI (job security, run #853) :
- plancher multidict>=6.9.1 (CVE-2026-104874 ; 6.7.x préinstallée dans la
  toolcache de l'image du runner, même piège « already satisfied » que pypdf
  BUG-093) + entrée dans le garde-fou TestDependencySecurityFloors ;
- exception documentée CVE-2026-85394 (python-jose, AUCUN correctif upstream) :
  non atteignable ici, jwt.decode passe toujours algorithms=["HS256"] avec un
  secret symétrique serveur — jamais de clé publique comme clé HMAC.

Vérifié : E2E -g « coller une plage » 1/1 ; xlsx-menus 11/11,
xlsx-formula 14/14, ai 100/100, ai-quick-actions 13/13 ; pytest 1586
passés ; ruff/mypy 0 erreur ; ISSUES_TODOLIST + CHANGELOG à jour.
2026-10-07 16:49:31 -04:00
bruno 39bad990c0 test: ai-quick-actions suit #187 - plus de flag agent sur les actions
CI / lint (push) Successful in 3m1s
CI / security (push) Failing after 2m14s
CI / test (push) Successful in 4m58s
CI / build (push) Successful in 3m17s
CI / e2e (push) Failing after 16m56s
Le test assertait encore `agent: true` sur les quick actions frontmatter,
depuis la suppression du toggle mode agent (#187, v2.53.2). Job lint CI
(run 853) en échec. Commit rattaché à la livraison 2.53.2 déjà publiée :
pas de nouvel incrément (SKIP_VERSION_BUMP).
2026-10-07 08:29:57 -04:00
bruno 431d6e9338 docs: i18n et suivi #187 - assistant agent toujours actif
CI / lint (push) Failing after 2m51s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 2m14s
2026-10-07 08:18:45 -04:00
bruno d453969708 docs: i18n et suivi #187 - assistant agent toujours actif 2026-10-07 08:17:59 -04:00
bruno 778fa65b4c fix: assistant IA toujours en mode agent, retrait du bouton toggle #187
- Bouton « mode agent » du panneau supprimé : l'assistant est toujours
  agent (toute requête texte part sur /api/ai/bookslm/agent, les images
  restent sur /chat multimodal). Les mutations gardent la confirmation
  two-step. Deep Research et les quick actions `agent: true` basculaient
  déjà le mode en silence : le toggle ne protégeait plus rien.
- /agent résout le provider comme /chat (req.provider brut transmis à
  l'adapter pouvait désigner un fournisseur indisponible et retomber
  silencieusement sur un autre que l'étiquette SSE affichée).
- Schémas des outils mis en cache par (scope, taille du registre) avec
  copies fraîches par appelant (~27 ms de pydantic économisées par
  requête agent/MCP).
- Aide in-app réécrite, i18n FR/EN (retrait de ai.agent_mode_*),
  tests frontend adaptés (ai.test.mjs 100/100) + non-régression pytest
  (provider résolu, cache sûr).
2026-10-07 08:16:56 -04:00
bruno 69c3817579 docs: diagramme de flux de l'agent AI (boucle run_agent + pipeline call_tool + 50 outils)
CI / lint (push) Successful in 3m1s
CI / security (push) Successful in 2m12s
CI / test (push) Successful in 4m56s
CI / build (push) Successful in 2m5s
CI / e2e (push) Failing after 17m3s
2026-10-05 12:59:51 -04:00
bruno b89af917b1 fix: ordre du flag --no-cache dans build.ps1 (procedure Test refonctionne)
CI / lint (push) Successful in 2m54s
CI / security (push) Successful in 2m10s
CI / test (push) Successful in 4m48s
CI / build (push) Successful in 1m59s
CI / e2e (push) Failing after 17m13s
2026-10-04 19:58:38 -04:00
bruno d79202e698 feat: menus tableur a icones, fermeture au focus et polish mobile #179
CI / lint (push) Successful in 2m52s
CI / security (push) Successful in 2m11s
CI / test (push) Successful in 4m48s
CI / build (push) Successful in 2m0s
CI / e2e (push) Failing after 16m50s
2026-10-04 19:27:50 -04:00
bruno 4ce677902a feat: agent IA phase 4 — doublons #166, notifications externes #168 (Discord/Telegram/SMTP/webhook), taches planifiees #170
CI / lint (push) Successful in 2m53s
CI / security (push) Successful in 2m5s
CI / test (push) Successful in 4m52s
CI / build (push) Successful in 2m5s
CI / e2e (push) Successful in 16m14s
2026-10-04 13:11:25 -04:00
bruno fe9f7b49f7 feat: premier lancement ObsiGate + section Configuration harmonisée (#160)
CI / lint (push) Successful in 2m50s
CI / security (push) Successful in 2m1s
CI / test (push) Successful in 4m44s
CI / build (push) Successful in 1m54s
CI / e2e (push) Successful in 16m25s
- Premier lancement : %USERPROFILE%\ObsiGate créé et monté comme vault de
  démarrage « ObsiGate » (remplace voute_obsidian), vault_path dessus,
  racine home nommée d'après son dernier segment (fin du « bruno » codé en
  dur), champs de fenêtre préservés.
- Document « Prise en main.md » embarqué dans le binaire
  (include_str!) et écrit dans ce répertoire si absent — jamais écrasé.
- Section « 🖥️ Vaults & dossiers (Desktop) » refondue : markup à classes
  (zéro style inline), bloc CSS desktop-roots-* sur variables (motif
  webauthn-key-item), boutons .config-btn-sm primaire/secondaire,
  cibles tactiles 44px en mobile.
- Tests : test_default_first_run_config, test_welcome_doc_embedded
  (cargo test 28 passed), suites frontend vertes, E2E 123/123.
2026-10-03 10:02:16 -04:00
bruno 649f965c52 fix: drag & drop de fichiers — fenêtre créée sans le handler Tauri (BUG-107)
CI / lint (push) Successful in 2m46s
CI / security (push) Successful in 2m0s
CI / test (push) Successful in 4m40s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 16m25s
2026-10-02 23:42:01 -04:00
bruno d6fa8c7bb1 fix: injecte le nonce CSP dans les pages /docs et /redoc générées par FastAPI (BUG-106)
CI / lint (push) Successful in 2m46s
CI / security (push) Successful in 2m1s
CI / test (push) Successful in 4m40s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 15m57s
2026-10-02 22:04:25 -04:00
bruno d2734dc8ce feat: gestion desktop des vaults & dossiers — retrait par menu contextuel et ajout en configuration #159
CI / lint (push) Successful in 3m2s
CI / security (push) Successful in 2m2s
CI / test (push) Successful in 4m42s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 15m54s
2026-10-02 21:04:17 -04:00
bruno bab272bb5c fix: desktop — commandes Tauri bloquées par l'ACL et crash heap à l'ouverture (BUG-104, BUG-105)
CI / lint (push) Successful in 2m45s
CI / security (push) Successful in 1m59s
CI / test (push) Successful in 4m44s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 16m16s
2026-10-02 20:17:20 -04:00
bruno 34428ea7b3 test: mobileBlockContaining ignore les commentaires CSS (CI #842)
CI / lint (push) Successful in 2m45s
CI / security (push) Successful in 1m58s
CI / test (push) Successful in 4m41s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 16m32s
- le helper choisissait le premier bloc media contenant la chaine 'mobile-toolbar' ; un commentaire citant ce selecteur (correctif BUG-103) le faisait retomber sur le mauvais bloc, sans la clearance .main-body (BUG-073)
- les commentaires sont maintenant retires avant la recherche de bloc
2026-10-02 15:46:05 -04:00
bruno 83e6a951dd fix: sidebar mobile passe au-dessus de la barre d outils du bas (BUG-103)
CI / lint (push) Failing after 2m11s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 1m58s
- en mobile le sidebar de navigation (z-index 200) passait sous .mobile-toolbar (900, 64 px) : les dernieres entrees de l'arbre etaient masquees
- regle mobile .sidebar : z-index 200 -> 950 ; test de non-regression 'mobile sidebar over toolbar' dans unit.test.mjs
2026-10-02 15:34:35 -04:00
bruno 3d618eb660 ci: relance du workflow apres annulation des runs 839 et 840
CI / lint (push) Successful in 2m44s
CI / security (push) Successful in 1m57s
CI / test (push) Successful in 4m37s
CI / build (push) Successful in 1m51s
CI / e2e (push) Successful in 15m58s
2026-10-02 14:42:18 -04:00
bruno 2add24a9c1 feat: onglet Accueil, menus contextuels de navigation et racine vault #158
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
- clic sur le titre: la page d'accueil s'ouvre dans un onglet Accueil (icone Maison) epingle en tete de barre, reactivable d'un clic (onglet propre au panneau actif en split)
- page de navigation: menus contextuels des repertoires et des fichiers identiques a ceux de la sidebar, clic sur la racine d'un vault qui ouvre la vue, icone dossier a la couleur de l'arbre de navigation
- BUG-101: bloc « Keyboard shortcuts for tabs » duplique dans ui.js (un seul Ctrl+W fermait deux onglets)
- BUG-102: le menu contextuel se refermait 10 ms apres son ouverture (scroll de reflow des icones lucide)
2026-10-02 14:05:01 -04:00
bruno a50227849d feat: onglet de navigation avec facettes, tris et retour Home complet #158
CI / lint (push) Successful in 2m46s
CI / security (push) Successful in 1m57s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
CI / test (push) Canceled after 1h50m30s
- clic sur un repertoire de l'arbre (modes focus vault et All) ouvre un onglet de navigation dedie qui coexiste avec les onglets de fichiers ouverts
- vue: sous-repertoires cliquables, facettes Vaults/Tags/Extensions (meme mecanisme que la recherche), tris Pertinence/Date, bouton Sauver; tags indexes exposes par GET /api/vault/{v}/files
- BUG-100: showWelcome re-injecte la section Raccourcis & Astuces et goHome efface la recherche globale + le filtre de la sidebar
2026-10-02 12:27:27 -04:00
bruno 5ffc9d851a fix: bouton de repli des facettes compact et icone seule qui pivote sur mobile #157
CI / lint (push) Successful in 2m44s
CI / security (push) Successful in 2m1s
CI / test (push) Successful in 4m36s
CI / build (push) Successful in 1m51s
CI / e2e (push) Successful in 15m47s
2026-10-02 08:14:48 -04:00
bruno 1e0e419210 feat: panneau de facettes repliable + facette Extensions exposee par l'API #157
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
CI / lint (push) Canceled after 9s
2026-10-01 23:07:16 -04:00
bruno 2813d546a7 feat: facette Extensions dans les facettes de recherche #157
CI / lint (push) Successful in 2m41s
CI / security (push) Successful in 2m0s
CI / test (push) Successful in 4m45s
CI / build (push) Successful in 3m4s
CI / e2e (push) Successful in 15m36s
2026-10-01 20:35:21 -04:00
bruno 19e94dfa51 chore: retire fichier test webhook
CI / lint (push) Successful in 2m40s
CI / security (push) Successful in 1m53s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
CI / test (push) Canceled after 1s
2026-10-01 14:36:39 -04:00
bruno 49f97fc26e test: webhook apres ALLOWED_HOST_LIST
CI / lint (push) Successful in 2m43s
CI / security (push) Successful in 1m53s
CI / test (push) Successful in 4m35s
CI / e2e (push) Canceled after 0s
CI / build (push) Canceled after 22s
2026-10-01 14:30:55 -04:00
bruno 562290d922 fix: plancher urllib3>=2.8.0 pour CVE-2026-97687/97688/97689 + garde-fou CI
CI / lint (push) Successful in 2m39s
CI / security (push) Successful in 2m5s
CI / test (push) Successful in 4m35s
CI / build (push) Successful in 2m43s
CI / e2e (push) Successful in 15m26s
- backend/requirements.txt : plancher urllib3>=2.8.0 (3 CVE corrigées)
- .gitea/workflows/ci.yml : documentation des nouveaux planchers
- tests/test_ci_workflow.py : garde-fou urllib3 ajouté dans TestDependencySecurityFloors

Corrige l'échec du job security (pip-audit bloquant sur urllib3 2.7.0)
2026-10-01 12:20:46 -04:00
bruno 66a5505965 fix: correction raccourci palette + filtrage ext/vault/tag dans palette fichiers
CI / lint (push) Successful in 2m40s
CI / security (push) Failing after 1m53s
CI / test (push) Successful in 4m36s
CI / build (push) Successful in 1m48s
CI / e2e (push) Successful in 17m29s
- Correction du raccourci clavier inversé dans l'aide :
  Ctrl+Shift+Space → Palette de fichiers (navigation rapide)
  Ctrl+Alt+Space → Palette de commandes
- Ajout du support des filtres ext:xxx, vault:nom, tag:monTag dans la palette de fichiers
- parseQuery étendu pour gérer les opérateurs de filtrage
- Filtrage client-side par extension et tag dans searchFiles et searchBrowseGlobal
- Backend : suggest_titles retourne les tags pour chaque suggestion
- Schema TitleSuggestion : ajout du champ tags
- Placeholders mis à jour pour indiquer les nouveaux filtres disponibles

Fixes : raccourci inversé, filtrage par extension/vault/tag dans palette fichiers
2026-10-01 10:29:42 -04:00
bruno c5c225a68e feat: completude de l'editeur Excel - mise en forme, sortie, concurrence, cache, outils IA #156
CI / lint (push) Successful in 2m36s
CI / security (push) Successful in 1m49s
CI / test (push) Successful in 4m33s
CI / build (push) Successful in 2m39s
CI / e2e (push) Successful in 15m45s
L'editeur lisait les styles mais ne les ecrivait pas, exportait la feuille
entiere et laissait le dernier-ecrivain gagner entre processus.

- A8 : bouton Mise en forme (gras/italique/souligne, alignements, couleurs via
  selecteur natif, formats de nombre, fusion, volets figes, largeur/hauteur) et
  nouvelle route PUT .../xlsx/style (verrou, backup, swap atomique, garde de
  perte, If-Match) ; A9 : decision "pas de moteur de formule" annoncee dans
  l'UI ; A10 : undo/redo unifie, piles par fichier conservees au re-rendu
- A11 : export de la selection + Markdown/HTML/impression et recherche sur
  toutes les feuilles ; A12 : concurrence optimiste (If-Match -> 409 reparable,
  retry qui relit) ; A13 : cache des metadonnees par (chemin, mtime, taille)
- A14 : outils IA .xlsm/.csv + search_workbook, analyze_range,
  edit_xlsx_structure
- tests : test_xlsx_styles.py (17), test_spreadsheet_tools.py (34),
  TestOptimisticConcurrency/TestMetaCache, JSDOM xlsx-viewer 107/107
2026-09-30 07:04:47 -04:00
bruno 1bacfd69d9 fix: plancher pyjwt >=2.14.0 - CVE-2026-102274 corrige le job security BUG-095 2026-09-29 17:57:35 -04:00
bruno 383ffa6a65 feat: menu contextuel et selection type Excel dans la grille XLSX #155
CI / lint (push) Successful in 2m31s
CI / security (push) Failing after 1m46s
CI / test (push) Successful in 4m20s
CI / build (push) Successful in 1m41s
CI / e2e (push) Successful in 15m30s
2026-09-29 16:43:39 -04:00
bruno 69927176df fix: feuille xlsx vide editable avec quadrillage vierge BUG-094
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-09-29 16:34:33 -04:00
bruno 5c2ae26a74 docs: corrige la plage de versions de #154 dans la roadmap
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-09-29 15:26:10 -04:00
bruno 3f52b56251 refactor: extraction des modules xlsx, lien dashboard-grille et inspecteur redimensionnable #154 2026-09-29 15:25:38 -04:00
bruno 72da123a51 feat: undo/redo, chargement IntersectionObserver et ARIA pour la visionneuse XLSX #154
CI / lint (push) Successful in 2m30s
CI / security (push) Failing after 1m45s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
CI / test (push) Canceled after 19s
2026-09-29 15:01:00 -04:00
bruno e94af0369b feat: inspecteur droit pour le tableau de bord XLSX #154
CI / lint (push) Successful in 2m30s
CI / security (push) Failing after 1m46s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
CI / test (push) Canceled after 1h32m51s
2026-09-29 14:56:08 -04:00
bruno 8da65611cb feat: dialogues themes et conflits non bloquants pour la visionneuse XLSX #154
CI / lint (push) Successful in 2m31s
CI / security (push) Failing after 1m46s
CI / test (push) Successful in 4m17s
CI / build (push) Successful in 1m40s
CI / e2e (push) Canceled after 42s
2026-09-29 14:50:47 -04:00
bruno 605060c51d feat: visionneuse XLSX - ruban groupe, onglets permanents et badges d'etat #154
CI / lint (push) Successful in 2m30s
CI / security (push) Failing after 1m45s
CI / test (push) Successful in 4m17s
CI / build (push) Successful in 1m40s
CI / e2e (push) Successful in 15m10s
2026-09-29 14:30:36 -04:00
bruno 856e654306 fix: plancher pypdf >= 6.16.1 - deux DoS de ressources bloques le job security BUG-093
CI / lint (push) Successful in 2m30s
CI / security (push) Successful in 1m49s
CI / test (push) Successful in 4m17s
CI / build (push) Successful in 3m2s
CI / e2e (push) Successful in 15m9s
pip-audit bloquait sur PYSEC-2026-3910 (outlines) et PYSEC-2026-3911 (XForm),
toutes deux atteignables via backend/pdf_reader.py. Le plancher pypdf>=4.0 ne
protégeait rien : l'image Act du runner embarque 6.16.0 dans sa toolcache
Python, donc pip répondait « already satisfied » sans jamais aligner.

Au passage, le garde-fou TestSemgrepStep était en régression depuis la
désactivation de semgrep (v2.39.9) et aurait rougi le job `test` : il vérifie
désormais que l'étape n'exécute que son avertissement et que bandit et
pip-audit restent bloquants. Nouveau TestDependencySecurityFloors pour
verrouiller les planchers de sécurité (contre-preuve : pypdf remis à >=4.0).

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-29 13:06:53 -04:00
bruno 4de9ee038c fix: desactive l'etape semgrep du job security - core natif inexecutable sur ce runner BUG-091
CI / lint (push) Successful in 2m29s
CI / security (push) Failing after 1m45s
CI / test (push) Failing after 4m21s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-09-29 12:54:55 -04:00
bruno 290d62da4e fix: continue-on-error sur l'etape semgrep - le core natif ne doit plus bloquer la CI BUG-091
CI / lint (push) Successful in 2m29s
CI / security (push) Failing after 2m16s
CI / test (push) Failing after 4m17s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-09-29 12:43:21 -04:00
bruno 140e9a679d fix: semgrep ne bloque le job security que si le core s'execute (bandit et pip-audit restent bloquants) BUG-091
CI / lint (push) Successful in 2m32s
CI / security (push) Failing after 2m17s
CI / test (push) Failing after 4m20s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-09-29 12:19:38 -04:00
bruno 267a33d43b fix: deplace le venv semgrep hors de /tmp (fs noexec du runner) BUG-091
CI / lint (push) Successful in 2m27s
CI / security (push) Failing after 2m15s
CI / test (push) Successful in 4m21s
CI / build (push) Successful in 1m40s
CI / e2e (push) Successful in 15m33s
2026-09-29 10:37:01 -04:00
bruno 435a0687d7 test: isole le garde SSRF dans les tests fetch_url - plus de dependance au DNS reel BUG-092
CI / lint (push) Successful in 2m28s
CI / security (push) Failing after 2m16s
CI / test (push) Successful in 4m16s
CI / build (push) Successful in 1m40s
CI / e2e (push) Successful in 14m53s
2026-09-29 10:05:24 -04:00
bruno dbf935bec0 fix: diagnostic semgrep-core dans le job security - CPU, disque et exec brute traces BUG-091
CI / lint (push) Successful in 2m31s
CI / security (push) Failing after 2m16s
CI / test (push) Failing after 4m21s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-09-29 09:21:25 -04:00
bruno d6d081c0e9 fix: semgrep 1.157.0 dans un venv isole du job security et plancher pyjwt 2.13 BUG-091
CI / lint (push) Successful in 2m27s
CI / security (push) Failing after 2m21s
CI / test (push) Successful in 4m19s
CI / build (push) Successful in 2m31s
CI / e2e (push) Successful in 15m1s
2026-09-28 21:54:12 -04:00
bruno c72f852a55 fix: semgrep epingle 1.174.0 dans le job security - CPU runner refuse les builds x86-64-v2 BUG-091
CI / lint (push) Successful in 2m30s
CI / security (push) Failing after 1m45s
CI / test (push) Successful in 4m14s
CI / build (push) Successful in 2m37s
CI / e2e (push) Successful in 14m58s
2026-09-28 19:14:08 -04:00
bruno 99779ecc08 docs: cloture documentaire #153 - changelog A6-A17, fiche, guide utilisateur et README
CI / lint (push) Successful in 2m33s
CI / security (push) Failing after 1m41s
CI / test (push) Failing after 3h11m57s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-09-28 17:48:43 -04:00
bruno c4b8e66206 feat: tableau de bord classeur - plages nommees, TCD/graphiques et KPI par feuille #153
A17 — nouveau endpoint GET /api/file/{vault}/xlsx/dashboard (read_workbook_
dashboard : plages nommees avec portee depuis defined_names read_only,
comptage graphiques/TCD par parts OPC, stats par feuille bornées 500x40 :
cellules/lignes/colonnes/formules/numerique + 8 premieres valeurs en cartes
KPI) et panneau frontend toggled depuis la toolbar (table des plages,
cartes KPI par feuille, hint actions IA). Bouton absent pour .csv et
formats en lecture seule ; le menu structure est saute quand le bouton
n'existe pas. i18n FR/EN (xlsx.dashboard_*), 8 tests backend + 2 tests
JSDOM + contre-preuve (5 echecs sur neutralisation), ruff/mypy 0.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 16:40:09 -04:00
bruno ca6407e0c0 feat: formats tableur additionnels - xlsm editable, xls/ods lecture seule, csv editable #153
A16 — la visionneuse tableur accepte quatre formats de plus : .xlsm est
servi et sauvegarde comme un .xlsx avec keep_vba=True (les macros
survivent, la porte lossy est levée pour ce format) ; .xls (xlrd) et .ods
(odfpy) sont rendus en lecture seule (xlsx_readonly, wiring d'édition
désactivé) ; .csv devient éditable via render_csv_table (grille A1
identique au viewer) et PUT /api/file/{vault}/csv/save (réécriture csv
RFC 4180, extension de grille, valeurs stockées telles quelles). 12 tests
backend + contre-preuve (4 échecs sur neutralisation du service CSV),
JSDOM 33/33, ruff/mypy 0.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 16:27:10 -04:00
bruno dff32a97ee feat: styles minimaux, fusions et volets figes dans la visionneuse xlsx #153
A15 — le rendu tableur lit les metadonnees du classeur (une charge en mode
normal par feuille) et les restitue : styles inline data-driven (couleur de
police en premier, fond, gras/italique, format numerique signale en mono),
alignements non-defaut, plages fusionnees (rowSpan/colSpan cote client) et
ancrage des volets figes (sticky rows/cols). Le chargement lazy replie les
metadonnees de chaque fenetre. Contrat API : styles/aligns = {ref: css},
merges = [ranges], freeze = ancre. 9 tests backend + contre-preuve (5 echecs
sur neutralisation), JSDOM 33/33.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 16:07:58 -04:00
bruno d5c528fead feat: structure des classeurs Excel editable depuis la visionneuse #153
A14 : service mutate_xlsx_structure (batch ordonné d'actions
sheet_add/sheet_rename/sheet_delete/sheet_duplicate et
row/col_insert/delete, une seule réécriture verrouillée et atomique,
409 lossy sans force, backup, dernier sheet protégé) ; endpoint
PUT /api/file/{vault}/xlsx/structure (1-50 actions/requête) ;
menu « structure » dans la visionneuse : ajout/renommage/duplication/
suppression de feuilles et insertion/suppression de ligne/colonne à
partir de la cellule active, confirmations explicites pour les
destructions, re-rendu serveur après succès, reprise force après
confirmation 409.

Vérifié : test_xlsx_structure.py 11 (ops, gardes, atomicité du batch,
409 lossy + force) + contre-preuve (garde dernier sheet neutralisée ->
1 échec), xlsx-viewer.test.mjs 33/33 (3 nouveaux), ruff 0, mypy 0,
i18n parity, validate-imports.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 12:53:15 -04:00
bruno 38f39a10ae feat: tri, filtre, recherche et export CSV dans la visionneuse XLSX #153
A13 (affichage seul, le classeur n'est jamais réécrit) : clic sur un
en-tête pour trier la colonne (asc/desc, numérique si toutes les valeurs
le sont, localeCompare fr sinon — les cellules modifiées suivent leur
ligne) ; l'unique champ de recherche filtre les lignes ET surligne les
occurrences (marque <mark>, navigation ↑/↓/Entrée, compteur i18n,
insensible à la casse par défaut, bouton Aa) ; bouton « Réinitialiser »
qui re-rend le fichier (vérité serveur) ; export CSV de la feuille
visible (BOM UTF-8, échappement ;/" et retrait des ombres de valeurs
calculées).

Vérifié : xlsx-viewer.test.mjs 30/30 (5 nouveaux) + contre-preuves
(tri neutralisé -> 1 échec, filtre neutralisé -> 1 échec), validate-imports,
i18n parity (xlsx.find_*, xlsx.sort_*, xlsx.csv_export).

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 12:28:09 -04:00
bruno 48e023ba25 feat: navigation clavier et barre de formule dans la visionneuse XLSX #153
A7 : barre de formule sous la barre d'outils (nom de cellule active A1 +
miroir du contenu, édition live, Entrée valide, Échap annule puis
refocalise) ; flèches et Tab déplacent la cellule active dans les 4
directions (Shift inverse Tab), outline persistant sur la cellule active
quand le focus passe à la barre ; Maj+Entrée ne valide plus (réservé au
multiligne A7+). td.tabIndex=0 pour la focalisation clavier.

Vérifié : xlsx-viewer.test.mjs 25/25 (6 nouveaux), E2E 9/9 (barre +
flèches + Échap sur fixture réelle), validate-imports, i18n parity
(xlsx.formula_bar_placeholder, xlsx.active_cell).

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 12:18:24 -04:00
bruno 011ec84f23 feat: outils IA de lecture et d'edition des classeurs existants #153
A6 : list_xlsx_sheets (noms + dimensions + truncated), xlsx_to_markdown
(table bornee 100x20 pour le contexte LLM), update_xlsx_cells et
append_xlsx_rows (WRITE + confirmation, via edit_xlsx_cells : verrou,
ecriture atomique, neutralisation des formules, 409 lossy). Les lignes
ajoutees sont coercees comme dans le viewer (A10). Labels de step
ai.step.xlsx_* FR/EN ; update_xlsx_cells/append_xlsx_rows branches sur
MUTATING_TOOLS et FILE_WRITE_TOOLS (refresh viewer via obsigate:file-written).

Tests : test_spreadsheet_tools.py 17 (risque, confirmation, persistance,
garde formule heritee, coercion) + contre-preuve (cells vide -> 2 echecs).
ruff 0, mypy 0, i18n parity, validate-imports.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 12:05:16 -04:00
bruno 472ea9d309 feat: chargement à la demande des lignes cachées des tableurs #153
CI / lint (push) Successful in 2m24s
CI / security (push) Failing after 1m39s
CI / test (push) Successful in 4m0s
CI / build (push) Successful in 1m37s
CI / e2e (push) Successful in 15m34s
A9bis : sous une feuille tronquée, un pied de page « N lignes affichées
sur M · Charger la suite » fetch la fenêtre suivante (limit=500) au clic
ou à l'approche du bas du tableau (sentinelle de défilement, marge 120px).
Les lignes ajoutées passent par le même pipeline d'édition que le rendu
initial (setupCell factorisé) : éditables et sauvegardables immédiatement.
Fetch échoué → bouton restauré (retry) + toast ; feuille complète → pied
de page masqué (class done).

Vérifié : xlsx-viewer.test.mjs 19/19 (5 nouveaux, contre-preuve
wireLazyRows désactivé → 5 échecs), E2E 8/8 (A520 visible et éditable
après clic), validate-imports 40 modules, unit.test.mjs 12/12, i18n parity.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 11:52:42 -04:00
bruno 6ba04c4381 feat: troncature annoncee et lecture par fenetres des tableurs #153
CI / lint (push) Successful in 2m29s
CI / security (push) Failing after 1m39s
CI / test (push) Successful in 4m1s
CI / build (push) Successful in 1m37s
CI / e2e (push) Successful in 14m47s
- BUG-090 (#153 A8) : render_sheets() expose total_rows/total_cols,
  max_rows/max_cols et truncated ; la visionneuse affiche un bandeau
  « Feuille tronquée » (i18n FR/EN) au lieu de couper en silence, et la
  ligne d'en-têtes devient sticky (top:auto sur les numéros de ligne).
- #153 A9 : GET /api/file/{vault}/xlsx/sheet?sheet&offset&limit sert une
  fenêtre de 1 à 1000 lignes avec les vraies coordonnées A1, has_more de
  pagination et valeurs calculées A12 ; 404 feuille inconnue, 415 non-xlsx.
- Tests : TestXlsxTruncationNotice (4) + TestXlsxSheetWindow (11) avec
  contre-preuves, xlsx-viewer.test.mjs 14/14, E2E 7/7 (fixture
  sample-xlsx-large.xlsx 520 lignes), suite 1417 passed / 6 skipped,
  ruff/mypy 0, i18n parity.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 10:38:24 -04:00
bruno 06f8e63d06 feat: tableurs indexables, saisie typée et valeurs calculées #153
Les tableurs étaient invisibles à la recherche, chaque saisie devenait
du texte, et une formule n'affichait que sa formule.

- A5 : extract_indexable_text() indexe les noms de feuilles et les 20
  premières lignes (plafond 5 k caractères) pour le TF-IDF et la
  recherche sémantique. Un mot tapé dans une cellule rend le fichier
  trouvable ; un classeur chiffré s'indexe par son seul nom.
- A10 : la saisie est typée comme dans Excel — booléens
  (TRUE/FAUX/OUI/NON) et dates FR JJ/MM/AAAA en ordre jour-first, donc
  01/02/2026 est le 1er février. Une saisie ressemblant à une formule
  n'est jamais convertie (BUG-088 préservé).
- A12 : la valeur calculée par Excel s'affiche sous la formule, via une
  2e lecture data_only=True faite seulement si l'archive contient un
  <v>. Info-bulle traduite FR/EN, aucun texte d'interface côté backend.
- BUG-089 : un reindex manuel ne reconstruisait pas l'index inversé, et
  backend/search.py lisait l'index via un import par valeur — après un
  rechargement du module, la recherche écrivait dans un dict périmé.

Contre-preuves vérifiées pour A5, A10 et A12 (neutralisation de chaque
fonction → échec des tests concernés). Tests : 1402 pytest, 10 JSDOM,
4 E2E, suite E2E complète verte, ruff/mypy 0.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-27 22:43:24 -04:00
bruno 31d4616baf feat: garde-fous d'écriture des classeurs Excel #153 (P0)
L'édition d'un .xlsx pouvait détruire une partie du classeur, le
concurrencer en silence, ou diffuser une injection de formule.

- BUG-085 : inspect_workbook() détecte ce qu'un round-trip openpyxl perd
  (valeurs calculées en cache, slicers, contrôles, connexions, custom
  XML, signature, commentaires enrichis, macros) → xlsx_lossy_features
  exposé en lecture, bandeau FR/EN, et 409 xlsx_lossy_content sans
  `force` (confirmation explicite puis reprise). Périmètre réel
  revalidé : graphiques, images et TCD survivent au round-trip.
- BUG-086 : écriture atomique (fichier .tmp + os.replace) : un plantage
  ne peut plus tronquer le classeur, le backup reste intact.
- BUG-087 : verrou par fichier autour du read-modify-write (timeout 15 s,
  409 conflict) ; endpoint xlsx/save devenu synchrone pour que
  l'attente s'exécute dans le threadpool.
- BUG-088 : une saisie en '=' ou '@' est stockée en texte, sauf opt-in
  `allow_formula` ou le bouton f(x) de la visionneuse. Le handler
  ServiceError expose désormais code + details, que api() propage.
- BUG-084 : la suppression d'une vault purge enfin l'index inversé
  (documents fantômes qui continuaient de matcher) et is_stale() devient
  is_ready(), le nom étant trompeur (la staleness n'existe plus).

Tests : 1390 pytest, 10 JSDOM (xlsx-viewer.test.mjs, branché au CI),
3 E2E Playwright, suite E2E complète verte, ruff/mypy 0.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-27 20:39:12 -04:00
bruno 4c4b1222d5 chore: ignore les diagrammes E2E générés (excalidraw)
CI / lint (push) Successful in 2m23s
CI / security (push) Failing after 1m48s
CI / test (push) Successful in 3m49s
CI / build (push) Successful in 2m28s
CI / e2e (push) Successful in 14m5s
2026-09-27 14:46:09 -04:00
bruno a3973b981c securite: #87 T6-T8 fin dette — deps qualifiées, semgrep, Secure auto, CORS 2026-09-27 12:43:31 -04:00
bruno b6e2029770 fix: E2E node-direct, timeouts réalistes 25/30 min (complément BUG-080)
CI / lint (push) Successful in 2m22s
CI / security (push) Successful in 1m37s
CI / test (push) Successful in 4m48s
CI / build (push) Successful in 1m33s
CI / e2e (push) Successful in 14m3s
2026-09-27 11:28:35 -04:00
268 changed files with 50878 additions and 1188 deletions
+44 -5
View File
@@ -12,11 +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)
# false par défaut : les navigateurs ignorent les cookies `Secure` en HTTP,
# ce qui casserait les logins en local. En production (TLS + bind réseau),
# posez true — un avertissement est loggé au démarrage sinon (#87).
# 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
@@ -103,3 +104,41 @@ DEEPSEEK_MODEL=deepseek-chat
# OBSIGATE_GITEA_URL=https://git.example.net
# OBSIGATE_GITEA_TOKEN=
# OBSIGATE_GITHUB_TOKEN=
# ── Fusion / dev-prod — URL publiques de l'écosystème (#223) ──
# Aucun nom public n'est figé dans le code : ces variables portent les
# URL des autres applications. Valeurs par défaut (si absentes) = noms
# OFFICIELS de la production. Depuis BUG-120, les URL sœurs se dérivent
# de l'hôte de l'agent (agent<SUFFIXE>.dracodev.net, lu via
# AGENT_EVENEMENTS_URL / DRACO_AGENT_URL) : en dev, rien d'autre à
# déclarer pour obtenir les noms en -dev. Une variable explicite
# reste prioritaire sur la dérivation, par exemple :
# DRACO_TIKTAK_URL=https://tiktak-dev.dracodev.net
# DRACO_AGENT_URL=https://agent.dracodev.net
# DRACO_OBSIGATE_URL=https://og.dracodev.net
# DRACO_FLOWDECK_URL=https://flowdeck.dracodev.net
# DRACO_TIKTAK_URL=https://tiktak.dracodev.net
# DRACO_KANBANTE_URL=https://kanbante.dracodev.net
# DRACO_GITEA_URL=https://git.dracodev.net
# ── Fusion — émission d'événements vers le service d'agents (#220) ──
# URL complète de réception ; à défaut, dérivée de DRACO_AGENT_URL.
# AGENT_EVENEMENTS_URL=https://agent.dracodev.net/api/v1/evenements
# AGENT_EVENEMENTS_CLE=<clé propre à ObsiGate, jamais commitée>
# AGENT_EVENEMENTS_DOMAINES={"NomDuVault": "famille"}
# ── Fusion — émettrice SSO draco_session (#216/#217) ──
# DRACO_SESSION_SECRET=<secret partagé, jamais commité>
# DRACO_SESSION_SECRET_PREV=<ancien secret, fenêtre de rotation>
# DRACO_SESSION_COOKIE_DOMAIN=.dracodev.net
# DRACO_SSO_MACHINE_KEY=<clé machine des révocations, jamais commitée>
# ── Coffre Secrets ObsiGate (Phases 1–2, voir docs/COFFRE-sauvegarde-restauration.md) ──
# Activation (défaut : désactivé — toutes les routes répondent 503 sinon) :
# OBSIGATE_SECRETS_ENABLED=true
# Clé maître 256 bits, base64 de 32 octets, rendue une seule fois par
# POST /api/coffres/admin/generer-kek ; à sauvegarder HORS dépôt, HORS note :
# OBSIGATE_KEK=<kek-base64-32-octets>
# … ou fichier 0600, hors dépôt et hors arborescence des vaults :
# OBSIGATE_KEK_FILE=/chemin/hors/depot/kek.txt
# Aucune autre variable ne doit contenir une valeur de secret du coffre.
+67 -9
View File
@@ -38,6 +38,8 @@ 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
@@ -48,7 +50,7 @@ jobs:
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 + Upload)
- 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
@@ -58,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
@@ -68,6 +71,8 @@ jobs:
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
@@ -77,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
@@ -87,6 +93,8 @@ jobs:
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 ─────────────────────────────────────────────────────────
@@ -129,7 +137,12 @@ 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
@@ -139,14 +152,59 @@ jobs:
# vrais positifs restants portent un `# nosec` justifié inline.
run: bandit -r backend/ --skip B101,B105,B110,B310
- name: Pip-audit (consultatif — #87)
# Reste non bloquant tant que les montées de version requises
# (starlette via fastapi, weasyprint) ne sont pas qualifiées :
# upgrade FastAPI = chantier de régression dédié, hors périmètre.
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`
# ci-dessous (tronqué au premier `#`, même entre guillemets, ce qui
# cassait la citation de l'echo) — la réf #87 ne vit qu'ici.
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), relevé à pypdf>=6.21.2 par #224 (11 CVE
# sur la 6.19.0 de la toolcache, corrigées au plus tard en 6.21.2).
# #224 ajoute les planchers brotli>=1.2.1 (PYSEC-2026-4322,
# GHSA-9v8h-qqgm-4m35), filelock>=4.2.1 (CVE-2026-112940,
# CVE-2026-112913, CVE-2026-112907) et opentelemetry-api>=1.45.1
# (CVE-2026-114603) : paquets transitifs ou préinstallés dans la
# toolcache, audités mais jamais alignés sans plancher explicite.
# 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:
+15
View File
@@ -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
+2146 -7
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.28.13-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.80.2-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -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.28.13).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.80.2).
---
*Projet : ObsiGate | Version : 2.28.13 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.80.2 | Dernière mise à jour : Septembre 2026*
+9 -8
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.28.13-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.80.2-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -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.28.13).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.80.2).
---
*Project: ObsiGate | Version: 2.28.13 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.80.2 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.28.13
2.80.2
+2 -2
View File
@@ -10,7 +10,7 @@ import logging
import os
import shutil
import time
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -244,7 +244,7 @@ def _build_stats_payload() -> dict:
"disk_used_gb": disk_used_gb,
"disk_total_gb": disk_total_gb,
"uptime_seconds": int(_server_uptime_seconds()),
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
}
+184
View File
@@ -0,0 +1,184 @@
"""Émission d'événements vers le journal du service d'agents
(Fusion, Lot 2 vague 5 — contrat ACC-0.2, Roadmap #220).
ObsiGate émet ses mutations de notes **explicites** vers
``POST /api/v1/evenements`` du service d'agents, qui les expose dans
le journal de sa console. L'enveloppe ne contient qu'une référence,
un libellé et un lien — jamais le contenu de la note :
``{id, type, occurred_at, actor, domain, source_app,
object{ref, label, url}}``.
**Garde-fou (conception validée)** : ce module n'est appelé que
depuis les points d'écriture explicites de l'API web —
:mod:`backend.routers.files_write` (session utilisateur, acteur
``user``) et la couche outils :mod:`backend.tools.service`
(serveur MCP d'ObsiGate, acteur ``agent:mcp`` ; assistant intégré,
acteur ``user``). Le **watcher** (:mod:`backend.watcher`) et
l'indexeur ne l'appellent **jamais** : le vault principal compte
plus de 220 000 fichiers, un branchement sur les événements du
système de fichiers inonderait le journal. Une modification faite
hors API (éditeur externe, synchro disque…) reste donc invisible
du journal — c'est voulu.
Authentification : signature HMAC-SHA256 du corps brut, en-tête
``X-Draco-Signature``, clé propre à ObsiGate
(``AGENT_EVENEMENTS_CLE``, jamais en dur). Sans clé, le module est
**inactif** : aucun appel, aucun bruit. URL surchargée par
``AGENT_EVENEMENTS_URL``.
Discipline « au mieux » (même esprit que TikTak et Kanbante) :
l'envoi part dans un fil d'arrière-plan démon, délai maximal 2 s,
et toute erreur est avalée avec une ligne de journal — une panne
du récepteur ne doit jamais casser ni ralentir le métier.
Domaine : ObsiGate ne modélise pas encore le domaine d'un vault
(question Q-A du programme Fusion toujours ouverte). La
correspondance peut être déclarée par ``AGENT_EVENEMENTS_DOMAINES``
(JSON ``{"NomDuVault": "famille"}``) ; repli ``pro``, qui est aussi
le défaut du journal du service.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import os
import threading
import uuid
from datetime import UTC, datetime
from urllib.parse import quote
import httpx
logger = logging.getLogger("obsigate.agent_journal")
SOURCE_APP = "obsigate"
# Défaut officiel, conservé pour référence : depuis #223, ``_url()``
# passe par la config centralisée (``backend.draco_config``) — jamais
# un nom d'environnement particulier.
URL_DEFAUT = "https://agent.dracodev.net/api/v1/evenements"
DELAI_S = 2.0
TYPE_CREE = "obsigate.note.created"
TYPE_EDITE = "obsigate.note.edited"
TYPE_DEPLACE = "obsigate.note.moved"
TYPE_SUPPRIME = "obsigate.note.deleted"
ACTEUR_USER = "user"
ACTEUR_MCP = "agent:mcp"
def _cle() -> str:
return os.environ.get("AGENT_EVENEMENTS_CLE", "").strip()
def _url() -> str:
# #223 : AGENT_EVENEMENTS_URL d'abord ; à défaut, l'URL du service
# configurée (DRACO_AGENT_URL) complétée du chemin d'événements ;
# à défaut, le défaut officiel.
explicite = os.environ.get("AGENT_EVENEMENTS_URL", "").strip()
if explicite:
return explicite
from backend.draco_config import agent_url
return f"{agent_url()}/api/v1/evenements"
def actif() -> bool:
"""L'émission n'existe que si la clé d'ObsiGate est configurée."""
return bool(_cle())
def domaine_du_vault(vault: str) -> str:
"""Domaine journal du vault : correspondance déclarée dans
``AGENT_EVENEMENTS_DOMAINES`` (JSON), repli ``pro``."""
brut = os.environ.get("AGENT_EVENEMENTS_DOMAINES", "").strip()
if brut:
try:
correspondance = json.loads(brut)
except ValueError:
correspondance = {}
if isinstance(correspondance, dict):
domaine = correspondance.get(vault)
if isinstance(domaine, str) and domaine:
return domaine
return "pro"
def lien_fichier(vault: str, chemin: str) -> str:
"""Lien profond ObsiGate vers un fichier (format ``#file=`` lu
par le frontend : ``#file=<vault>:<chemin>``, décodé en URI)."""
return f"/#file={quote(vault, safe='')}:{quote(chemin, safe='/')}"
def construire_enveloppe(
type_: str,
vault: str,
chemin: str,
actor: str = ACTEUR_USER,
) -> tuple[dict, bytes]:
"""L'enveloppe ACC-0.2 et son corps JSON exact (celui qui est
signé puis envoyé — la signature porte sur ces octets-là).
``chemin`` est le chemin relatif au vault ; pour un déplacement,
c'est le **nouveau** chemin (la référence pointe sur l'objet tel
qu'il existe après l'opération)."""
env = {
"id": uuid.uuid4().hex,
"type": type_,
"occurred_at": datetime.now(UTC).isoformat(),
"actor": actor or ACTEUR_USER,
"domain": domaine_du_vault(vault),
"source_app": SOURCE_APP,
"object": {
"ref": f"fichier:{vault}/{chemin}",
"label": chemin.rsplit("/", 1)[-1] if chemin else vault,
"url": lien_fichier(vault, chemin),
},
}
corps = json.dumps(env, ensure_ascii=False).encode("utf-8")
return env, corps
def _signer(corps: bytes) -> str:
return hmac.new(_cle().encode("utf-8"), corps, hashlib.sha256).hexdigest()
def _envoyer(corps: bytes) -> None:
"""Le POST signé, dans le fil d'arrière-plan. Avale tout : le
journal distant est un confort, jamais une dépendance."""
try:
with httpx.Client(timeout=DELAI_S, trust_env=False) as client:
client.post(
_url(),
content=corps,
headers={
"Content-Type": "application/json",
"X-Draco-Signature": _signer(corps),
},
)
except Exception as exc: # panne, délai, DNS… : on log et on passe
logger.warning("événement non livré au journal du service (%s)", exc)
def emettre(
type_: str,
vault: str,
chemin: str,
actor: str = ACTEUR_USER,
) -> bool:
"""Émet un événement en tâche de fond. Retourne True si l'envoi
a été déclenché, False si le module est inactif (pas de clé) ou
si l'enveloppe n'a pas pu être construite. Ne lève jamais."""
if not actif():
return False
try:
_env, corps = construire_enveloppe(type_, vault, chemin, actor)
except Exception:
logger.warning("événement %s non construit", type_, exc_info=True)
return False
threading.Thread(
target=_envoyer, args=(corps,), daemon=True, name="obsigate-evenements"
).start()
return True
+41 -6
View File
@@ -10,7 +10,7 @@ Format: JSON lines (one JSON object per line) for easy parsing.
import json
import logging
import os
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
logger = logging.getLogger("obsigate.audit")
@@ -54,7 +54,7 @@ def log_file_save(
):
"""Log a file save (PUT) operation."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"action": "file_save",
"username": username,
"ip": ip or "unknown",
@@ -72,7 +72,7 @@ def log_file_delete(
):
"""Log a file delete operation."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"action": "file_delete",
"username": username,
"ip": ip or "unknown",
@@ -88,7 +88,7 @@ def log_config_change(
):
"""Log a configuration change."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"action": "config_change",
"username": username,
"ip": ip or "unknown",
@@ -99,7 +99,7 @@ def log_config_change(
def log_vault_add(username: str, vault_name: str, vault_path: str, ip: str | None = None):
"""Log a vault addition."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"action": "vault_add",
"username": username,
"ip": ip or "unknown",
@@ -111,7 +111,7 @@ def log_vault_add(username: str, vault_name: str, vault_path: str, ip: str | Non
def log_vault_remove(username: str, vault_name: str, ip: str | None = None):
"""Log a vault removal."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"action": "vault_remove",
"username": username,
"ip": ip or "unknown",
@@ -119,6 +119,41 @@ def log_vault_remove(username: str, vault_name: str, ip: str | None = None):
})
def log_coffre_event(
username: str,
action: str,
coffre: str | None = None,
secret_ref: str | None = None,
consommateur: str | None = None,
resultat: str | None = None,
contexte_id: str | None = None,
ip: str | None = None,
):
"""Journal d'un événement du coffre Secrets (Phase 1b).
Règle absolue : aucun champ ne contient jamais une valeur de secret,
un texte chiffré, un nonce ou un jeton. Seules des références
(coffre:<slug>/<nom>), des identifiants et des codes de résultat.
"""
entry = {
"timestamp": datetime.now(UTC).isoformat(),
"action": action,
"username": username,
"ip": ip or "unknown",
}
if coffre is not None:
entry["coffre"] = coffre
if secret_ref is not None:
entry["secret_ref"] = secret_ref
if consommateur is not None:
entry["consommateur"] = consommateur
if resultat is not None:
entry["resultat"] = resultat
if contexte_id is not None:
entry["contexte_id"] = contexte_id
_write_entry(entry)
def get_recent_entries(limit: int = 100, action: str | None = None) -> list:
"""Read the most recent audit log entries.
+37 -7
View File
@@ -215,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"]
@@ -235,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
@@ -252,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)
@@ -262,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)
+33 -6
View File
@@ -9,6 +9,7 @@ import sys
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from backend import draco_session
from backend.services.net import get_client_ip
from .jwt_handler import decode_token, is_token_revoked, maybe_touch_api_token
@@ -86,20 +87,22 @@ def get_current_user(
token = request.cookies["access_token"]
if not token:
return None
return _draco_fallback(request)
payload = decode_token(token)
if not payload or payload.get("type") != "access":
return None
return _draco_fallback(request)
# BUG-027: access tokens revoked at logout must be rejected immediately.
# (Le repli draco reste sûr : la déconnexion globale révoque aussi
# le jti draco et efface son cookie.)
jti = payload.get("jti")
if jti and is_token_revoked(jti):
return None
return _draco_fallback(request)
user = get_user(payload["sub"])
if not user or not user.get("active"):
return None
return _draco_fallback(request)
# BUG-028: a password change invalidates every token issued before it.
pca = user.get("password_changed_at")
@@ -107,14 +110,17 @@ def get_current_user(
if pca is not None and iat is not None:
try:
if int(iat) < int(float(pca)):
return None
return _draco_fallback(request)
except (TypeError, ValueError):
return None
return _draco_fallback(request)
# Attach vault permissions from the token (snapshot at login time)
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"):
@@ -124,6 +130,17 @@ def get_current_user(
return user
def _draco_fallback(request: Request) -> dict | None:
"""Fusion ACC-3.2 (#9) : à défaut de jeton natif valide, le cookie
partagé draco_session authentifie l'utilisateur (sub = id
ObsiGate, correspondance directe — voir draco_session.user_for_draco).
"""
user = draco_session.user_for_draco(request)
if user is not None:
user["_request_ip"] = get_client_ip(request)
return user
def require_auth(current_user=Depends(get_current_user)):
"""Dependency: require a valid authenticated user."""
if not current_user:
@@ -142,6 +159,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 +171,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
+102 -22
View File
@@ -7,16 +7,18 @@ import binascii
import logging
import os
import re
from datetime import UTC
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
from pydantic import BaseModel, validator
from backend import draco_session
from backend.ratelimit import is_account_rate_limited, is_rate_limited
from backend.ratelimit import record_account_failure as rl_record_account_failure
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,
@@ -57,15 +59,32 @@ logger = logging.getLogger("obsigate.auth.router")
router = APIRouter(prefix="/api/auth", tags=["auth"])
def is_secure_cookies() -> bool:
"""True when auth cookies must carry the ``Secure`` flag (#87 T3).
def is_secure_cookies(request: Request | None = None) -> bool:
"""True when auth cookies must carry the ``Secure`` flag (#87 T3/T8).
Opt-in via ``OBSIGATE_SECURE_COOKIES=true`` (required behind TLS).
Default stays ``false`` so logins keep working over plain HTTP on
trusted loopback deployments — browsers drop ``Secure`` cookies sent
over HTTP, which would silently break localhost logins.
``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).
"""
return os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
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 ──────────────────────────────────────────
@@ -230,10 +249,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)
@@ -242,7 +262,7 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
refresh_token, refresh_jti = create_refresh_token(username, remember=remember_me)
max_age = 2592000 if remember_me else 604800 # 30d or 7d
secure = is_secure_cookies()
secure = is_secure_cookies(request)
response.set_cookie(
key="refresh_token",
value=refresh_token,
@@ -262,6 +282,11 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
secure=secure,
path="/",
)
# Fusion ACC-3.1 (#216) : ObsiGate est l'émettrice SSO — chaque
# émission de jetons natifs s'accompagne du cookie partagé
# draco_session (inerte tant que DRACO_SESSION_SECRET n'est pas
# configuré).
draco_session.set_cookie(response, user, secure)
return {
"access_token": access_token,
# OAuth2 token_type, pas un mot de passe (B105) :
@@ -311,7 +336,7 @@ async def refresh_token_endpoint(request: Request, response: Response):
if stale:
raise HTTPException(401, "Session expirée, veuillez vous reconnecter")
secure = is_secure_cookies()
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.
@@ -378,6 +403,19 @@ async def logout(
except Exception:
pass
# Fusion ACC-3.1 (#216) : déconnexion globale — révoquer le jti du
# cookie partagé et effacer le cookie sur le domaine parent.
draco_tok = request.cookies.get(draco_session.COOKIE_NAME)
if draco_tok:
draco_claims = draco_session.decode_claims(draco_tok)
if draco_claims and draco_claims.get("jti"):
try:
draco_session.revoke(str(draco_claims["jti"]),
int(draco_claims.get("exp", 0)))
except Exception:
pass
draco_session.clear_cookie(response)
response.delete_cookie("refresh_token", path="/api/auth/refresh")
response.delete_cookie("access_token", path="/")
response.delete_cookie("access_token", path="/api") # just in case
@@ -437,6 +475,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.
@@ -452,7 +491,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
@@ -658,7 +697,7 @@ async def mfa_webauthn_register(
Returns recovery codes when MFA is newly enabled (they were never issued).
"""
from datetime import datetime, timezone
from datetime import datetime
from .user_store import get_user, update_user
from .webauthn_mfa import complete_registration, resolve_relying_party
@@ -678,7 +717,7 @@ async def mfa_webauthn_register(
logger.warning(f"WebAuthn registration failed for {current_user['username']}: {e}")
raise HTTPException(400, "Validation du credential WebAuthn échouée")
record["registered_at"] = datetime.now(timezone.utc).isoformat()
record["registered_at"] = datetime.now(UTC).isoformat()
creds = list(user.get("webauthn_credentials", []))
creds = [c for c in creds if c.get("credential_id") != record["credential_id"]]
creds.append(record)
@@ -815,7 +854,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")
@@ -868,7 +907,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")
@@ -906,7 +945,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 ───────────────────────────────────────────────────
@@ -927,9 +966,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}")
@@ -956,9 +1006,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) ──────────────────────────────────
@@ -969,6 +1023,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")
@@ -986,9 +1042,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
+141
View File
@@ -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
+7 -7
View File
@@ -8,7 +8,7 @@ import logging
import shutil
import threading
import uuid
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from pathlib import Path
from .password import hash_password
@@ -97,8 +97,8 @@ def create_user(
"active": True,
"language": "fr", # default UI language
"avatar": None, # profile picture data-URL (#113)
"created_at": datetime.now(timezone.utc).isoformat(),
"password_changed_at": datetime.now(timezone.utc).timestamp(),
"created_at": datetime.now(UTC).isoformat(),
"password_changed_at": datetime.now(UTC).timestamp(),
"last_login": None,
"failed_attempts": 0,
"locked_until": None,
@@ -126,7 +126,7 @@ def update_user(username: str, updates: dict) -> dict:
if "password" in safe_updates:
safe_updates["password_hash"] = hash_password(safe_updates.pop("password"))
# BUG-028: invalidate every token issued before this change.
safe_updates["password_changed_at"] = datetime.now(timezone.utc).timestamp()
safe_updates["password_changed_at"] = datetime.now(UTC).timestamp()
data["users"][username].update(safe_updates)
_write(data)
@@ -148,7 +148,7 @@ def delete_user(username: str):
def record_login_success(username: str):
"""Record a successful login: update last_login, reset failed attempts."""
update_user(username, {
"last_login": datetime.now(timezone.utc).isoformat(),
"last_login": datetime.now(UTC).isoformat(),
"failed_attempts": 0,
"locked_until": None,
})
@@ -171,7 +171,7 @@ def record_login_failure(username: str) -> int:
# Lock after 5 failed attempts (15 minutes)
if attempts >= 5:
locked_until = (
datetime.now(timezone.utc) + timedelta(minutes=15)
datetime.now(UTC) + timedelta(minutes=15)
).isoformat()
updates["locked_until"] = locked_until
logger.warning(f"Account '{username}' locked after {attempts} failed attempts")
@@ -189,7 +189,7 @@ def is_locked(username: str) -> bool:
if not user or not user.get("locked_until"):
return False
locked_until = datetime.fromisoformat(user["locked_until"])
if datetime.now(timezone.utc) > locked_until:
if datetime.now(UTC) > locked_until:
# Lock expired — unlock
update_user(username, {"locked_until": None, "failed_attempts": 0})
return False
+18 -13
View File
@@ -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.
@@ -586,7 +591,7 @@ async def api_bookslm_agent(
while True:
try:
kind, item = await asyncio.wait_for(queue.get(), timeout=0.25)
except asyncio.TimeoutError:
except TimeoutError:
if run_task.done():
break
continue
+215
View File
@@ -0,0 +1,215 @@
"""Chiffrement par enveloppe du coffre Secrets ObsiGate (Phase 1a).
Principe (spec §5) : deux niveaux de clés, jamais de valeur en clair au repos.
- KEK : clé maître 256 bits, fournie par l'opérateur via ``OBSIGATE_KEK``
(base64, 32 octets) ou ``OBSIGATE_KEK_FILE`` (fichier 0600, hors dépôt,
hors arborescence des vaults). Elle n'est JAMAIS dérivée du secret
d'application, jamais journalisée, jamais renvoyée par API.
- DEK : une clé aléatoire par coffre, stockée uniquement chiffrée par la KEK.
- Version de secret : ciphertext AES-256-GCM + nonce unique + tag GCM
(le tag est inclus dans le ciphertext renvoyé par ``AESGCM.encrypt``).
Un tag invalide ou un fichier altéré = refus, jamais de valeur fausse.
Le module reste importable sans ``cryptography`` installé : les opérations
échouent alors avec ``CryptoIndisponibleError`` au lieu de casser le
démarrage de l'application (le routeur répond 503 dans ce cas).
"""
from __future__ import annotations
import base64
import binascii
import logging
import os
import stat
from pathlib import Path
from typing import Any
logger = logging.getLogger("obsigate.coffre_crypto")
# Annotation explicite : sans elle, mypy refuse l'assignation de deux types
# (la classe AESGCM importée, puis None dans le repli sans cryptography)
# et le commentaire `type: ignore[assignment]` ne couvre pas ce code [misc].
AESGCM: type[Any] | None
try: # cryptography est une dépendance de la Phase 1a (requirements.txt)
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
except Exception: # pragma: no cover - environnement sans cryptography
AESGCM = None
InvalidTag = Exception # type: ignore[assignment,misc]
class CoffreCryptoError(Exception):
"""Erreur générique du chiffrement du coffre."""
class KEKAbsenteError(CoffreCryptoError):
"""Aucune KEK configurée (ni OBSIGATE_KEK, ni OBSIGATE_KEK_FILE)."""
class KEKInvalideError(CoffreCryptoError):
"""KEK mal formée (ni base64 32 octets, ni hex 64 caractères)."""
class TagInvalideError(CoffreCryptoError):
"""Authentification GCM échouée : données altérées ou mauvaise clé."""
class CryptoIndisponibleError(CoffreCryptoError):
"""La bibliothèque ``cryptography`` n'est pas installée."""
def _require_aesgcm() -> type[Any]:
"""Retourne la classe AESGCM, ou lève si cryptography est absent.
Retourner la classe (plutôt qu'un simple garde) permet à mypy de
savoir que l'appelant détient bien un constructible, et non None.
"""
if AESGCM is None:
raise CryptoIndisponibleError(
"cryptography n'est pas installé (dépendance Phase 1a)"
)
return AESGCM
def parse_kek(raw: str) -> bytes:
"""Parse une KEK : base64 (32 octets) ou hex (64 caractères).
Accepte le base64 standard et URL-safe, avec ou sans padding.
"""
value = (raw or "").strip()
if not value:
raise KEKInvalideError("KEK vide")
# Hex d'abord si la forme correspond (64 caractères hex).
if len(value) == 64:
try:
decoded = bytes.fromhex(value)
except ValueError:
decoded = b""
if len(decoded) == 32:
return decoded
padded = value + "=" * (-len(value) % 4)
for decoder in (base64.urlsafe_b64decode, base64.b64decode):
try:
decoded = decoder(padded)
except (binascii.Error, ValueError):
continue
if len(decoded) == 32:
return decoded
raise KEKInvalideError("KEK invalide : attendu base64 de 32 octets")
def generate_kek_b64() -> str:
"""Génère une KEK 256 bits, encodée base64 (à sauvegarder hors dépôt)."""
return base64.b64encode(os.urandom(32)).decode("ascii")
def load_kek() -> bytes:
"""Charge la KEK depuis l'environnement ou le fichier dédié.
Priorité : ``OBSIGATE_KEK`` puis ``OBSIGATE_KEK_FILE``. Le fichier doit
être en 0600 (aucun droit groupe/autre) ; sinon, refus explicite.
"""
env_kek = os.environ.get("OBSIGATE_KEK", "").strip()
if env_kek:
return parse_kek(env_kek)
kek_file = os.environ.get("OBSIGATE_KEK_FILE", "").strip()
if kek_file:
path = Path(kek_file)
try:
mode = path.stat().st_mode
except OSError as exc:
raise KEKAbsenteError(f"Fichier KEK illisible : {kek_file}") from exc
if mode & (stat.S_IRWXG | stat.S_IRWXO):
raise KEKInvalideError(
"Fichier KEK trop permissif : attendu 0600, "
f"droits groupe/autre détectés sur {kek_file}"
)
try:
return parse_kek(path.read_text(encoding="utf-8"))
except OSError as exc:
raise KEKAbsenteError(f"Fichier KEK illisible : {kek_file}") from exc
raise KEKAbsenteError("Aucune KEK configurée (OBSIGATE_KEK / OBSIGATE_KEK_FILE)")
def kek_presente() -> bool:
"""True si une KEK valide est chargeable, False sinon (jamais d'exception)."""
try:
load_kek()
except CoffreCryptoError:
return False
return True
def generate_dek() -> bytes:
"""Génère une DEK aléatoire 256 bits pour un coffre."""
return os.urandom(32)
def _b64(data: bytes) -> str:
return base64.b64encode(data).decode("ascii")
def _unb64(value: str) -> bytes:
return base64.b64decode(value)
def encrypt_with_key(key: bytes, plaintext: bytes, aad: bytes) -> dict:
"""Chiffre avec AES-256-GCM. Retourne {nonce, ciphertext} en base64."""
aesgcm = _require_aesgcm()
nonce = os.urandom(12)
ciphertext = aesgcm(key).encrypt(nonce, plaintext, aad)
return {"nonce": _b64(nonce), "ciphertext": _b64(ciphertext)}
def decrypt_with_key(key: bytes, nonce_b64: str, ciphertext_b64: str, aad: bytes) -> bytes:
"""Déchiffre avec AES-256-GCM. Tag invalide -> TagInvalideError."""
aesgcm = _require_aesgcm()
try:
return aesgcm(key).decrypt(_unb64(nonce_b64), _unb64(ciphertext_b64), aad)
except InvalidTag as exc:
raise TagInvalideError("Tag GCM invalide : données altérées ou mauvaise clé") from exc
except (binascii.Error, ValueError) as exc:
raise TagInvalideError("Données chiffrées mal formées") from exc
def wrap_dek(dek: bytes, kek: bytes, coffre_id: str) -> dict:
"""Chiffre une DEK avec la KEK (AAD = identifiant du coffre)."""
return encrypt_with_key(kek, dek, coffre_id.encode("utf-8"))
def unwrap_dek(wrapped: dict, kek: bytes, coffre_id: str) -> bytes:
"""Déchiffre une DEK avec la KEK."""
return decrypt_with_key(
kek, wrapped["nonce"], wrapped["ciphertext"], coffre_id.encode("utf-8")
)
def encrypt_secret_value(dek: bytes, secret_id: str, version: int, valeur: str) -> dict:
"""Chiffre une valeur de secret (AAD = secret_id:version)."""
aad = f"{secret_id}:{version}".encode()
return encrypt_with_key(dek, valeur.encode("utf-8"), aad)
def decrypt_secret_value(
dek: bytes, secret_id: str, version: int, nonce_b64: str, ciphertext_b64: str
) -> str:
"""Déchiffre une valeur de secret."""
aad = f"{secret_id}:{version}".encode()
return decrypt_with_key(dek, nonce_b64, ciphertext_b64, aad).decode("utf-8")
def reencrypt_secret_value(
ancienne_dek: bytes,
nouvelle_dek: bytes,
secret_id: str,
version: int,
nonce_b64: str,
ciphertext_b64: str,
) -> dict:
"""Re-chiffre une version avec une nouvelle DEK (rotation DEK)."""
valeur = decrypt_secret_value(
ancienne_dek, secret_id, version, nonce_b64, ciphertext_b64
)
return encrypt_secret_value(nouvelle_dek, secret_id, version, valeur)
+306
View File
@@ -0,0 +1,306 @@
"""Migration Phase 2 du coffre Secrets ObsiGate (issue #20).
Bascule des clés aujourd'hui en clair dans ``data/api_keys.json`` vers le
coffre d'instance « ObsiGate interne » (slug ``obsigate-interne``).
Format source (constaté dans ``backend/ai.py`` et
``backend/tools/secrets.py``) : un dictionnaire plat ``{NOM: valeur}``.
Seules les entrées dont le nom ressemble à un secret sont migrables :
clés IA et clés d'outils. Les valeurs non secrètes (par exemple
``OBSIGATE_GITEA_URL``) ne sont JAMAIS migrées et ne sont jamais
touchées par la purge.
Règles de sécurité, non négociables :
- Aucune fonction de ce module ne renvoie une valeur, ne la journalise,
ni ne l'écrit ailleurs que dans le coffre (chiffrée, via le store).
Les aperçus ne contiennent que des noms, des types et ``has_value``.
- L'import ne supprime rien : il crée des secrets dans le coffre et
laisse ``api_keys.json`` intact. La purge est une étape séparée, qui
exige ``confirmer=True`` et ne supprime que les noms listés.
- Un secret déjà présent dans le coffre n'est JAMAIS écrasé : il est
signalé dans ``deja_existants`` et la valeur du coffre est conservée.
Limite connue, documentée dans ``docs/COFFRE-sauvegarde-restauration.md`` :
les lecteurs actuels (``backend/ai.py``, ``backend/tools/secrets.py``)
lisent encore ``api_keys.json`` (chemin relatif ``data/``, sans passer
par ``OBSIGATE_DATA_DIR``). Purger le clair sans avoir branché le
lecteur concerné sur le coffre rend donc la clé inaccessible à ce
lecteur : la purge reste un choix explicite de l'opérateur, confirmé
deux fois côté interface (import, puis purge).
"""
from __future__ import annotations
import json
import logging
import os
import threading
from pathlib import Path
from typing import Any
from backend.coffre_store import (
ValidationError,
create_coffre,
create_secret,
get_coffre,
get_secret,
resolve_data_dir,
)
logger = logging.getLogger("obsigate.coffre_migration")
_lock = threading.RLock()
COFFRE_INTERNE_SLUG = "obsigate-interne"
COFFRE_INTERNE_NOM = "ObsiGate interne"
# (nom dans api_keys.json, type de secret, service d'affichage).
# Miroir des clés lues par backend/ai.py (clés IA) et par
# backend/tools/secrets.py TOOL_KEY_NAMES (clés d'outils). L'URL Gitea
# est volontairement absente : ce n'est pas un secret.
CLES_MIGRABLES: tuple[tuple[str, str, str], ...] = (
("DEEPSEEK_API_KEY", "api_key", "DeepSeek"),
("OPENROUTER_API_KEY", "api_key", "OpenRouter"),
("GEMINI_API_KEY", "api_key", "Gemini"),
("NVIDIA_API_KEY", "api_key", "NVIDIA"),
("QWENCLOUD_API_KEY", "api_key", "QwenCloud"),
("XIAOMI_API_KEY", "api_key", "Xiaomi"),
("MISTRAL_API_KEY", "api_key", "Mistral"),
("OBSIGATE_TAVILY_API_KEY", "api_key", "Tavily"),
("OBSIGATE_BRAVE_API_KEY", "api_key", "Brave"),
("OBSIGATE_SERPAPI_API_KEY", "api_key", "SerpAPI"),
("OBSIGATE_EXA_API_KEY", "api_key", "Exa"),
("OBSIGATE_GITEA_TOKEN", "token", "Gitea"),
("OBSIGATE_GITHUB_TOKEN", "token", "GitHub"),
)
_NOMS_MIGRABLES = {nom for nom, _, _ in CLES_MIGRABLES}
def _nom_secret(nom_source: str) -> str:
"""Nom de secret dérivé du nom source (minuscules, valide NOM_SECRET_RE)."""
return nom_source.lower()
def _api_keys_path(base: Path) -> Path:
return base / "api_keys.json"
def _read_api_keys(base: Path) -> dict:
path = _api_keys_path(base)
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
logger.warning("api_keys.json illisible (%s) : %s", path, exc)
return {}
return data if isinstance(data, dict) else {}
def _write_api_keys(base: Path, data: dict) -> None:
"""Écriture atomique tmp+rename, fichier final en 0600 (best-effort)."""
path = _api_keys_path(base)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
try:
os.chmod(tmp, 0o600)
except OSError:
pass
os.replace(tmp, path)
try:
os.chmod(path, 0o600)
except OSError:
pass
def lister_candidats(data_dir: str | Path | None = None) -> list:
"""Aperçu de migration : métadonnées seules, jamais de valeur.
Chaque candidat porte ``nom`` (nom dans le coffre), ``source`` (nom
dans api_keys.json), ``type``, ``service``, ``has_value`` (une valeur
non vide est présente dans le fichier) et ``deja_existant`` (un
secret de ce nom existe déjà dans le coffre d'instance).
"""
base = resolve_data_dir(data_dir)
with _lock:
cles = _read_api_keys(base)
coffre = get_coffre(COFFRE_INTERNE_SLUG, base)
candidats = []
for source, type_secret, service in CLES_MIGRABLES:
nom = _nom_secret(source)
valeur = cles.get(source)
candidats.append(
{
"nom": nom,
"source": source,
"type": type_secret,
"service": service,
"has_value": isinstance(valeur, str) and bool(valeur.strip()),
"deja_existant": (
get_secret(coffre["id"], nom, base) is not None
if coffre is not None
else False
),
}
)
return candidats
def importer_api_keys(
data_dir: str | Path | None = None, cree_par: str = ""
) -> dict:
"""Importe les entrées avec valeur vers le coffre d'instance.
Ne supprime rien dans ``api_keys.json``. Retourne uniquement des
noms et des comptes : ``migres``, ``deja_existants``, ``sans_valeur``.
Lève ``KEKAbsenteError`` / ``CryptoIndisponibleError`` si le coffre
ne peut pas chiffrer (le routeur les traduit en 503).
"""
base = resolve_data_dir(data_dir)
with _lock:
cles = _read_api_keys(base)
coffre = get_coffre(COFFRE_INTERNE_SLUG, base)
if coffre is None:
coffre = create_coffre(
slug=COFFRE_INTERNE_SLUG,
nom=COFFRE_INTERNE_NOM,
proprietaire=(cree_par or "admin").strip(),
perimetre="instance",
description="Coffre d'instance : clés IA et clés d'outils migrées depuis api_keys.json (Phase 2).",
data_dir=base,
)
migres: list[str] = []
deja_existants: list[str] = []
sans_valeur: list[str] = []
for source, type_secret, service in CLES_MIGRABLES:
nom = _nom_secret(source)
valeur = cles.get(source)
if not (isinstance(valeur, str) and valeur.strip()):
sans_valeur.append(nom)
continue
if get_secret(coffre["id"], nom, base) is not None:
deja_existants.append(nom)
continue
create_secret(
coffre,
nom,
type_secret,
valeur,
service=service,
note="Migré depuis api_keys.json (Phase 2).",
data_dir=base,
)
migres.append(nom)
return {
"coffre": COFFRE_INTERNE_SLUG,
"migres": migres,
"deja_existants": deja_existants,
"sans_valeur": sans_valeur,
}
def purger_api_keys(
noms: list[str] | None,
data_dir: str | Path | None = None,
confirmer: bool = False,
) -> dict:
"""Supprime du clair les noms listés, après confirmation explicite.
``confirmer`` doit valoir True, sinon refus (jamais automatique).
Seuls les noms migrables sont acceptés ; un nom inconnu refuse
l'opération entière, pour ne jamais supprimer une clé par erreur.
Retourne ``supprimes`` et ``absents`` (noms, jamais de valeurs).
"""
if not confirmer:
raise ValidationError(
"Suppression du clair refusée : confirmation explicite requise"
)
demandes = [(n or "").strip().lower() for n in (noms or [])]
# Accepte indifféremment le nom source (DEEPSEEK_API_KEY) ou le nom
# de secret (deepseek_api_key) ; tout le reste refuse l'opération.
noms_secrets = {_nom_secret(source) for source in _NOMS_MIGRABLES}
inconnus = [
n
for n in demandes
if n.upper() not in _NOMS_MIGRABLES and n not in noms_secrets
]
if inconnus:
raise ValidationError(f"Noms inconnus, purge annulée : {', '.join(inconnus)}")
sources_a_supprimer = set()
for demande in demandes:
for source, _, _ in CLES_MIGRABLES:
if demande in (source.lower(), _nom_secret(source)):
sources_a_supprimer.add(source)
base = resolve_data_dir(data_dir)
with _lock:
cles = _read_api_keys(base)
supprimes: list[str] = []
absents: list[str] = []
for source in sorted(sources_a_supprimer):
if source in cles:
del cles[source]
supprimes.append(_nom_secret(source))
else:
absents.append(_nom_secret(source))
_write_api_keys(base, cles)
return {"supprimes": supprimes, "absents": absents}
def creer_consommateur_avec_octrois(
nom: str,
octrois_demandes: list[dict[str, Any]] | None,
cree_par: str,
data_dir: str | Path | None = None,
) -> tuple[dict, str, list, list]:
"""Enregistre un consommateur et ses octrois en une seule opération.
Retourne (consommateur public, jeton à usage unique, octrois créés,
erreurs). Une erreur sur un octroi (coffre ou secret introuvable)
n'annule pas le consommateur ni les autres octrois : elle est
signalée dans ``erreurs`` (noms seuls, jamais de valeurs). Le coffre
lui-même n'est jamais modifié par cette opération.
"""
from backend.coffre_store import ( # import local : évite un cycle
IntrouvableError,
create_consommateur,
create_octroi,
)
base = resolve_data_dir(data_dir)
consommateur, jeton = create_consommateur(nom, cree_par, base)
crees: list[dict] = []
erreurs: list[dict] = []
for demande in octrois_demandes or []:
slug = str(demande.get("coffre_slug", "") or "")
secret_nom = str(demande.get("secret_nom", "") or "") or None
try:
coffre = get_coffre(slug, base)
if coffre is None:
raise IntrouvableError(f"Coffre introuvable : {slug}")
secret_id = None
if secret_nom is not None:
secret = get_secret(coffre["id"], secret_nom, base)
if secret is None:
raise IntrouvableError(f"Secret introuvable : {secret_nom}")
secret_id = secret["id"]
octroi = create_octroi(
coffre=coffre,
cible_type="consommateur",
cible_id=consommateur["id"],
secret_id=secret_id,
cree_par=cree_par,
expiration=demande.get("expiration"),
data_dir=base,
)
crees.append(octroi)
except (IntrouvableError, ValidationError) as exc:
erreurs.append(
{
"coffre_slug": slug,
"secret_nom": secret_nom,
"erreur": str(exc),
}
)
return consommateur, jeton, crees, erreurs
+860
View File
@@ -0,0 +1,860 @@
"""Store du coffre Secrets ObsiGate (Phase 1a).
Patron repris de ``backend/tools/secrets.py`` : JSON dans ``data/``,
écriture atomique (tmp + rename), verrou ``RLock`` sur les cycles
lecture-modification-écriture, répertoire honorant ``OBSIGATE_DATA_DIR``.
Fichiers (permissions 0600) :
- ``data/coffres.json`` : coffres (métadonnées + DEK chiffrées),
consommateurs (jetons hachés SHA-256 seulement, jamais en clair),
octrois (références, statuts, expirations).
- ``data/coffres_valeurs.json`` : versions chiffrées des secrets.
Aucune valeur en clair n'est jamais persistée ici, et aucune fonction de
ce module ne journalise de valeur ni de texte chiffré.
Modèle :
- Coffre : id, slug, nom, description, proprietaire, perimetre
(personnel | partage | instance), dek_chiffree (ou None en mode
dégradé sans KEK), statut (actif | archive), dates.
- Secret : id, coffre_id, nom, type, service, note, expiration,
version_courante, cree_le, modifie_le, derniere_utilisation.
- Version : numero, nonce, ciphertext, cree_le, remplacee_le.
Seule la version courante est distribuable.
- Consommateur : id (sert de jti), nom, jeton_hash, jeton_prefixe,
statut (actif | revoque), cree_par, cree_le.
- Octroi : id, coffre_id, secret_id (None = tout le coffre),
cible_type (consommateur | utilisateur | executeur | workspace),
cible_id, expiration, statut (actif | revoque | expire),
cree_par, cree_le, revoque_le.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import os
import re
import secrets as _secrets
import threading
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from backend.coffre_crypto import (
decrypt_secret_value,
encrypt_secret_value,
generate_dek,
load_kek,
parse_kek,
reencrypt_secret_value,
unwrap_dek,
wrap_dek,
)
logger = logging.getLogger("obsigate.coffre_store")
_lock = threading.RLock()
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{2,63}$")
NOM_SECRET_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{2,127}$")
TYPES_SECRET = {"api_key", "token", "password", "certificate", "autre"}
PERIMETRES = {"personnel", "partage", "instance"}
CIBLES_OCTROI = {"consommateur", "utilisateur", "executeur", "workspace"}
MAX_VALEUR = 65536
MAX_TEXTE = 2000
DUREE_OCTROI_DEFAUT_JOURS = 90
# Motifs refusés dans les champs de métadonnées (description, note) :
# une valeur ressemblant à un secret ne doit jamais transiter par un champ
# qui, lui, est renvoyé en clair dans les métadonnées.
_SECRET_LIKE_PATTERNS = (
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
re.compile(r"\bsk-[A-Za-z0-9]{8,}\b"),
re.compile(r"\bgh[opsu]_[A-Za-z0-9]{8,}\b"),
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
re.compile(
r"(?i)\b(token|secret|mot_de_passe|password|api[-_]?key)\s*[:=]\s*\S+"
),
)
class StoreError(Exception):
"""Erreur générique du store."""
class CoffreExisteError(StoreError):
pass
class SecretExisteError(StoreError):
pass
class IntrouvableError(StoreError):
pass
class ValidationError(StoreError):
pass
class SecretExpireError(StoreError):
pass
def resolve_data_dir(data_dir: str | Path | None = None) -> Path:
"""Répertoire de persistance (OBSIGATE_DATA_DIR, défaut ``data``)."""
if data_dir is not None:
return Path(data_dir)
return Path(os.environ.get("OBSIGATE_DATA_DIR", "data"))
def _coffres_path(base: Path) -> Path:
return base / "coffres.json"
def _valeurs_path(base: Path) -> Path:
return base / "coffres_valeurs.json"
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
def _parse_date(value: str | None) -> datetime | None:
"""Parse une date ISO (datetime) ou une date seule (fin de journée UTC)."""
if value is None:
return None
text = str(value).strip()
if not text:
return None
if len(text) == 10:
text = text + "T23:59:59+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError as exc:
raise ValidationError(f"Date invalide : {value!r}") from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed
def est_expire(expiration: str | None, maintenant: datetime | None = None) -> bool:
"""True si la date d'expiration est passée (None = jamais)."""
parsed = _parse_date(expiration)
if parsed is None:
return False
ref = maintenant or datetime.now(UTC)
return parsed <= ref
def _read_json(path: Path, default: dict) -> dict:
if not path.exists():
return json.loads(json.dumps(default))
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
logger.warning("Store coffre illisible (%s) : %s", path, exc)
return json.loads(json.dumps(default))
return data if isinstance(data, dict) else json.loads(json.dumps(default))
def _write_json(path: Path, data: dict) -> None:
"""Écriture atomique tmp+rename, fichier final en 0600."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
try:
os.chmod(tmp, 0o600)
except OSError:
pass
os.replace(tmp, path)
try:
os.chmod(path, 0o600)
except OSError:
pass
def _load_coffres(base: Path) -> dict:
return _read_json(
_coffres_path(base),
{"coffres": [], "consommateurs": [], "octrois": []},
)
def _load_valeurs(base: Path) -> dict:
return _read_json(_valeurs_path(base), {"versions": {}})
def _save_coffres(base: Path, data: dict) -> None:
_write_json(_coffres_path(base), data)
def _save_valeurs(base: Path, data: dict) -> None:
_write_json(_valeurs_path(base), data)
def valider_slug(slug: str) -> str:
value = (slug or "").strip().lower()
if not SLUG_RE.match(value):
raise ValidationError(
"Slug invalide : 3 à 64 caractères, minuscules, chiffres, - ou _"
)
return value
def valider_nom_secret(nom: str) -> str:
value = (nom or "").strip().lower()
if not NOM_SECRET_RE.match(value):
raise ValidationError(
"Nom de secret invalide : 3 à 128 caractères, minuscules, chiffres, - ou _"
)
return value
def refuser_valeur_dans_metadonnees(texte: str | None, champ: str) -> None:
"""Refuse une valeur ressemblant à un secret dans un champ métadonnée."""
if not texte:
return
for motif in _SECRET_LIKE_PATTERNS:
if motif.search(texte):
raise ValidationError(
f"Le champ {champ} ressemble à une valeur de secret ; "
"utilisez le champ valeur du secret, jamais une métadonnée"
)
def _valider_texte(texte: str | None, champ: str) -> str:
value = (texte or "").strip()
if len(value) > MAX_TEXTE:
raise ValidationError(f"Le champ {champ} dépasse {MAX_TEXTE} caractères")
refuser_valeur_dans_metadonnees(value, champ)
return value
# ---------------------------------------------------------------------------
# Coffres
# ---------------------------------------------------------------------------
def list_coffres(data_dir: str | Path | None = None) -> list:
base = resolve_data_dir(data_dir)
with _lock:
return list(_load_coffres(base)["coffres"])
def get_coffre(slug: str, data_dir: str | Path | None = None) -> dict | None:
base = resolve_data_dir(data_dir)
slug = valider_slug(slug)
with _lock:
for coffre in _load_coffres(base)["coffres"]:
if coffre["slug"] == slug:
return coffre
return None
def get_coffre_or_404(slug: str, data_dir: str | Path | None = None) -> dict:
coffre = get_coffre(slug, data_dir)
if coffre is None:
raise IntrouvableError(f"Coffre introuvable : {slug}")
return coffre
def get_coffre_by_id(coffre_id: str, data_dir: str | Path | None = None) -> dict | None:
base = resolve_data_dir(data_dir)
with _lock:
for coffre in _load_coffres(base)["coffres"]:
if coffre["id"] == coffre_id:
return coffre
return None
def get_octroi(octroi_id: str, data_dir: str | Path | None = None) -> dict | None:
base = resolve_data_dir(data_dir)
with _lock:
for octroi in _load_coffres(base)["octrois"]:
if octroi["id"] == octroi_id:
return octroi
return None
def create_coffre(
slug: str,
nom: str,
proprietaire: str,
perimetre: str = "personnel",
description: str = "",
data_dir: str | Path | None = None,
) -> dict:
"""Crée un coffre. Sans KEK, la DEK reste None (mode dégradé) : les
métadonnées sont créées, mais aucun secret ne pourra être ajouté tant
que la KEK n'est pas configurée."""
base = resolve_data_dir(data_dir)
slug = valider_slug(slug)
if perimetre not in PERIMETRES:
raise ValidationError(f"Périmètre invalide : {perimetre}")
if not (proprietaire or "").strip():
raise ValidationError("Propriétaire requis")
description = _valider_texte(description, "description")
nom = (nom or "").strip()
if not nom or len(nom) > 128:
raise ValidationError("Nom de coffre requis (128 caractères max)")
with _lock:
data = _load_coffres(base)
if any(c["slug"] == slug for c in data["coffres"]):
raise CoffreExisteError(f"Coffre déjà existant : {slug}")
coffre: dict[str, Any] = {
"id": str(uuid.uuid4()),
"slug": slug,
"nom": nom,
"description": description,
"proprietaire": proprietaire.strip(),
"perimetre": perimetre,
"dek_chiffree": None,
"statut": "actif",
"cree_le": _now_iso(),
}
try:
kek = load_kek()
except Exception:
kek = None
if kek is not None:
coffre["dek_chiffree"] = wrap_dek(generate_dek(), kek, coffre["id"])
data["coffres"].append(coffre)
_save_coffres(base, data)
return coffre
def ensure_dek(coffre: dict, data_dir: str | Path | None = None) -> bytes:
"""Retourne la DEK en clair (mémoire seulement). Génère et persiste la
DEK si le coffre a été créé en mode dégradé et que la KEK est là."""
base = resolve_data_dir(data_dir)
kek = load_kek() # lève KEKAbsenteError si aucune KEK
if coffre.get("dek_chiffree"):
return unwrap_dek(coffre["dek_chiffree"], kek, coffre["id"])
with _lock:
data = _load_coffres(base)
for item in data["coffres"]:
if item["id"] == coffre["id"]:
if item.get("dek_chiffree"):
return unwrap_dek(item["dek_chiffree"], kek, item["id"])
dek = generate_dek()
item["dek_chiffree"] = wrap_dek(dek, kek, item["id"])
_save_coffres(base, data)
return dek
raise IntrouvableError("Coffre introuvable pour génération de DEK")
# Les secrets vivent dans coffres.json (métadonnées), leurs versions dans
# coffres_valeurs.json (chiffrées). Les deux chargements sont faits ici
# pour garder une seule source de vérité dans ce module.
def _load_tout(base: Path) -> tuple[dict, dict]:
data = _load_coffres(base)
if "secrets" not in data:
data["secrets"] = []
return data, _load_valeurs(base)
def compter_secrets(coffre_id: str, data_dir: str | Path | None = None) -> int:
base = resolve_data_dir(data_dir)
with _lock:
data, _ = _load_tout(base)
return sum(1 for s in data["secrets"] if s["coffre_id"] == coffre_id)
def list_secrets(coffre_id: str, data_dir: str | Path | None = None) -> list:
base = resolve_data_dir(data_dir)
with _lock:
data, _ = _load_tout(base)
return [s for s in data["secrets"] if s["coffre_id"] == coffre_id]
def get_secret(
coffre_id: str, nom: str, data_dir: str | Path | None = None
) -> dict | None:
base = resolve_data_dir(data_dir)
nom = valider_nom_secret(nom)
with _lock:
data, _ = _load_tout(base)
for secret in data["secrets"]:
if secret["coffre_id"] == coffre_id and secret["nom"] == nom:
return secret
return None
def secret_metadata(secret: dict, octrois_actifs: int = 0) -> dict:
"""Vue métadonnées seules d'un secret : jamais de valeur, jamais de
texte chiffré, jamais de nonce."""
return {
"id": secret["id"],
"nom": secret["nom"],
"type": secret["type"],
"service": secret.get("service", ""),
"note": secret.get("note", ""),
"expiration": secret.get("expiration"),
"expire": est_expire(secret.get("expiration")),
"version": secret["version_courante"],
"has_value": True,
"cree_le": secret["cree_le"],
"modifie_le": secret["modifie_le"],
"derniere_utilisation": secret.get("derniere_utilisation"),
"octrois_actifs": octrois_actifs,
"reference": f"coffre:{secret['coffre_slug']}/{secret['nom']}",
}
def create_secret(
coffre: dict,
nom: str,
type_secret: str,
valeur: str,
service: str = "",
expiration: str | None = None,
note: str = "",
data_dir: str | Path | None = None,
) -> dict:
"""Chiffre et stocke un secret (nouvelle version 1)."""
base = resolve_data_dir(data_dir)
nom = valider_nom_secret(nom)
if type_secret not in TYPES_SECRET:
raise ValidationError(f"Type de secret invalide : {type_secret}")
if not valeur or len(valeur) > MAX_VALEUR:
raise ValidationError("Valeur requise (64 KiB max)")
service = (service or "").strip()
if len(service) > 128:
raise ValidationError("Service trop long (128 caractères max)")
note = _valider_texte(note, "note")
if expiration is not None:
_parse_date(expiration)
dek = ensure_dek(coffre, base) # KEKAbsenteError si pas de KEK
with _lock:
data, valeurs = _load_tout(base)
if any(
s["coffre_id"] == coffre["id"] and s["nom"] == nom
for s in data["secrets"]
):
raise SecretExisteError(f"Secret déjà existant : {nom}")
secret_id = str(uuid.uuid4())
chiffre = encrypt_secret_value(dek, secret_id, 1, valeur)
secret = {
"id": secret_id,
"coffre_id": coffre["id"],
"coffre_slug": coffre["slug"],
"nom": nom,
"type": type_secret,
"service": service,
"note": note,
"expiration": expiration,
"version_courante": 1,
"cree_le": _now_iso(),
"modifie_le": _now_iso(),
"derniere_utilisation": None,
}
data["secrets"].append(secret)
valeurs["versions"][secret_id] = [
{
"numero": 1,
"nonce": chiffre["nonce"],
"ciphertext": chiffre["ciphertext"],
"cree_le": secret["cree_le"],
"remplacee_le": None,
}
]
_save_coffres(base, data)
_save_valeurs(base, valeurs)
return secret
def rotate_secret(
coffre: dict, nom: str, valeur: str, data_dir: str | Path | None = None
) -> dict:
"""Rotation : nouvelle version active immédiatement, l'ancienne n'est
plus distribuable (seule la version courante est lue à la distribution)."""
base = resolve_data_dir(data_dir)
nom = valider_nom_secret(nom)
if not valeur or len(valeur) > MAX_VALEUR:
raise ValidationError("Valeur requise (64 KiB max)")
dek = ensure_dek(coffre, base)
with _lock:
data, valeurs = _load_tout(base)
secret = None
for item in data["secrets"]:
if item["coffre_id"] == coffre["id"] and item["nom"] == nom:
secret = item
break
if secret is None:
raise IntrouvableError(f"Secret introuvable : {nom}")
nouvelle_version = secret["version_courante"] + 1
chiffre = encrypt_secret_value(dek, secret["id"], nouvelle_version, valeur)
versions = valeurs["versions"].setdefault(secret["id"], [])
for version in versions:
if version["remplacee_le"] is None:
version["remplacee_le"] = _now_iso()
versions.append(
{
"numero": nouvelle_version,
"nonce": chiffre["nonce"],
"ciphertext": chiffre["ciphertext"],
"cree_le": _now_iso(),
"remplacee_le": None,
}
)
secret["version_courante"] = nouvelle_version
secret["modifie_le"] = _now_iso()
_save_coffres(base, data)
_save_valeurs(base, valeurs)
return secret
def delete_secret(
coffre: dict, nom: str, data_dir: str | Path | None = None
) -> tuple[dict, list]:
"""Supprime un secret, ses versions et ses octrois (cascade)."""
base = resolve_data_dir(data_dir)
nom = valider_nom_secret(nom)
with _lock:
data, valeurs = _load_tout(base)
secret = None
for item in data["secrets"]:
if item["coffre_id"] == coffre["id"] and item["nom"] == nom:
secret = item
break
if secret is None:
raise IntrouvableError(f"Secret introuvable : {nom}")
data["secrets"] = [s for s in data["secrets"] if s["id"] != secret["id"]]
valeurs["versions"].pop(secret["id"], None)
octrois_supprimes = [
o for o in data["octrois"] if o.get("secret_id") == secret["id"]
]
data["octrois"] = [
o for o in data["octrois"] if o.get("secret_id") != secret["id"]
]
_save_coffres(base, data)
_save_valeurs(base, valeurs)
return secret, octrois_supprimes
def distribuer_valeur(
coffre: dict, secret: dict, data_dir: str | Path | None = None
) -> str:
"""Déchiffre la version courante d'un secret (après contrôles d'octroi
faits par le routeur). Refuse un secret expiré (410 côté routeur)."""
base = resolve_data_dir(data_dir)
if est_expire(secret.get("expiration")):
raise SecretExpireError("Secret expiré")
dek = ensure_dek(coffre, base)
with _lock:
_, valeurs = _load_tout(base)
versions = valeurs["versions"].get(secret["id"], [])
courante = None
for version in versions:
if version["numero"] == secret["version_courante"]:
courante = version
break
if courante is None:
raise IntrouvableError("Version courante introuvable")
return decrypt_secret_value(
dek,
secret["id"],
secret["version_courante"],
courante["nonce"],
courante["ciphertext"],
)
def toucher_utilisation(secret_id: str, data_dir: str | Path | None = None) -> None:
base = resolve_data_dir(data_dir)
with _lock:
data, _ = _load_tout(base)
for secret in data["secrets"]:
if secret["id"] == secret_id:
secret["derniere_utilisation"] = _now_iso()
_save_coffres(base, data)
return
def rotate_dek(coffre_slug: str, data_dir: str | Path | None = None) -> dict:
"""Rotation DEK : re-chiffre toutes les versions du coffre avec une
nouvelle DEK, puis remplace la DEK chiffrée du coffre."""
base = resolve_data_dir(data_dir)
coffre = get_coffre_or_404(coffre_slug, base)
kek = load_kek()
ancienne_dek = ensure_dek(coffre, base)
nouvelle_dek = generate_dek()
with _lock:
data, valeurs = _load_tout(base)
secrets_coffre = [s for s in data["secrets"] if s["coffre_id"] == coffre["id"]]
for secret in secrets_coffre:
nouvelles_versions = []
for version in valeurs["versions"].get(secret["id"], []):
nouvelles_versions.append(
{
**reencrypt_secret_value(
ancienne_dek,
nouvelle_dek,
secret["id"],
version["numero"],
version["nonce"],
version["ciphertext"],
),
"numero": version["numero"],
"cree_le": version["cree_le"],
"remplacee_le": version["remplacee_le"],
}
)
valeurs["versions"][secret["id"]] = nouvelles_versions
for item in data["coffres"]:
if item["id"] == coffre["id"]:
item["dek_chiffree"] = wrap_dek(nouvelle_dek, kek, item["id"])
coffre = item
break
_save_coffres(base, data)
_save_valeurs(base, valeurs)
return coffre
def rotate_kek(nouvelle_kek_b64: str, data_dir: str | Path | None = None) -> int:
"""Rotation KEK : re-chiffre uniquement les DEK avec la nouvelle KEK.
L'opérateur doit ensuite remplacer OBSIGATE_KEK / OBSIGATE_KEK_FILE
par la nouvelle valeur (cette fonction ne touche pas à l'environnement).
Retourne le nombre de coffres re-chiffrés."""
base = resolve_data_dir(data_dir)
ancienne_kek = load_kek()
nouvelle_kek = parse_kek(nouvelle_kek_b64)
count = 0
with _lock:
data = _load_coffres(base)
for coffre in data["coffres"]:
if not coffre.get("dek_chiffree"):
continue
dek = unwrap_dek(coffre["dek_chiffree"], ancienne_kek, coffre["id"])
coffre["dek_chiffree"] = wrap_dek(dek, nouvelle_kek, coffre["id"])
count += 1
_save_coffres(base, data)
return count
# ---------------------------------------------------------------------------
# Consommateurs (jetons serveur-à-serveur, hachés, révocables par jti)
# ---------------------------------------------------------------------------
def _hash_jeton(jeton: str) -> str:
return hashlib.sha256(jeton.encode("utf-8")).hexdigest()
def list_consommateurs(data_dir: str | Path | None = None) -> list:
base = resolve_data_dir(data_dir)
with _lock:
return [
{k: v for k, v in c.items() if k != "jeton_hash"}
for c in _load_coffres(base)["consommateurs"]
]
def create_consommateur(
nom: str, cree_par: str, data_dir: str | Path | None = None
) -> tuple[dict, str]:
"""Crée un consommateur et retourne (métadonnées, jeton à usage unique).
Le jeton n'est JAMAIS persisté : seul son hash SHA-256 est stocké."""
base = resolve_data_dir(data_dir)
nom = (nom or "").strip()
if not nom or len(nom) > 128:
raise ValidationError("Nom de consommateur requis (128 caractères max)")
jeton = "csk_" + _secrets.token_urlsafe(32)
with _lock:
data = _load_coffres(base)
consommateur = {
"id": str(uuid.uuid4()),
"nom": nom,
"jeton_hash": _hash_jeton(jeton),
"jeton_prefixe": jeton[:12],
"statut": "actif",
"cree_par": cree_par,
"cree_le": _now_iso(),
}
data["consommateurs"].append(consommateur)
_save_coffres(base, data)
public = {k: v for k, v in consommateur.items() if k != "jeton_hash"}
return public, jeton
def verifier_jeton_consommateur(
jeton: str, data_dir: str | Path | None = None
) -> dict | None:
"""Retourne le consommateur (sans hash) si le jeton correspond, sinon None."""
if not jeton:
return None
base = resolve_data_dir(data_dir)
cible = _hash_jeton(jeton)
with _lock:
for consommateur in _load_coffres(base)["consommateurs"]:
if hmac.compare_digest(consommateur.get("jeton_hash", ""), cible):
return {
k: v for k, v in consommateur.items() if k != "jeton_hash"
}
return None
def revoquer_consommateur(
consommateur_id: str, data_dir: str | Path | None = None
) -> dict:
base = resolve_data_dir(data_dir)
with _lock:
data = _load_coffres(base)
for consommateur in data["consommateurs"]:
if consommateur["id"] == consommateur_id:
consommateur["statut"] = "revoque"
_save_coffres(base, data)
return {
k: v
for k, v in consommateur.items()
if k != "jeton_hash"
}
raise IntrouvableError("Consommateur introuvable")
# ---------------------------------------------------------------------------
# Octrois
# ---------------------------------------------------------------------------
def list_octrois(coffre_id: str, data_dir: str | Path | None = None) -> list:
base = resolve_data_dir(data_dir)
with _lock:
return [
o for o in _load_coffres(base)["octrois"] if o["coffre_id"] == coffre_id
]
def compter_octrois_actifs(
secret_id: str, data_dir: str | Path | None = None
) -> int:
base = resolve_data_dir(data_dir)
with _lock:
return sum(
1
for o in _load_coffres(base)["octrois"]
if o.get("secret_id") == secret_id
and o["statut"] == "actif"
and not est_expire(o.get("expiration"))
)
def create_octroi(
coffre: dict,
cible_type: str,
cible_id: str,
secret_id: str | None,
cree_par: str,
expiration: str | None = None,
data_dir: str | Path | None = None,
) -> dict:
base = resolve_data_dir(data_dir)
if cible_type not in CIBLES_OCTROI:
raise ValidationError(f"Type de cible invalide : {cible_type}")
if not (cible_id or "").strip():
raise ValidationError("Cible d'octroi requise")
if secret_id is not None:
with _lock:
data = _load_coffres(base)
if not any(
s["id"] == secret_id and s["coffre_id"] == coffre["id"]
for s in data["secrets"]
):
raise IntrouvableError("Secret introuvable dans ce coffre")
if expiration is None:
expiration = (datetime.now(UTC) + timedelta(days=DUREE_OCTROI_DEFAUT_JOURS)).isoformat()
else:
_parse_date(expiration)
with _lock:
data = _load_coffres(base)
octroi = {
"id": str(uuid.uuid4()),
"coffre_id": coffre["id"],
"secret_id": secret_id,
"cible_type": cible_type,
"cible_id": cible_id.strip(),
"expiration": expiration,
"statut": "actif",
"cree_par": cree_par,
"cree_le": _now_iso(),
"revoque_le": None,
}
data["octrois"].append(octroi)
_save_coffres(base, data)
return octroi
def revoquer_octroi(octroi_id: str, data_dir: str | Path | None = None) -> dict:
"""Révocation : statut -> revoque (conservé pour distinguer
octroi_revoque de octroi_absent à la distribution)."""
base = resolve_data_dir(data_dir)
with _lock:
data = _load_coffres(base)
for octroi in data["octrois"]:
if octroi["id"] == octroi_id:
octroi["statut"] = "revoque"
octroi["revoque_le"] = _now_iso()
_save_coffres(base, data)
return octroi
raise IntrouvableError("Octroi introuvable")
def statut_octroi_consommateur(
consommateur_id: str,
coffre_id: str,
secret_id: str,
data_dir: str | Path | None = None,
) -> tuple[str, dict | None]:
"""Statut d'accès d'un consommateur à un secret.
Retourne ("ok" | "revoque" | "expire" | "absent", octroi). Un octroi
coffre-wide (secret_id None) couvre tous les secrets du coffre.
Priorité : ok > revoque > expire > absent (un octroi révoqué ne doit
jamais être masqué par l'absence d'un autre octroi)."""
base = resolve_data_dir(data_dir)
with _lock:
candidats = [
o
for o in _load_coffres(base)["octrois"]
if o["cible_type"] == "consommateur"
and o["cible_id"] == consommateur_id
and o["coffre_id"] == coffre_id
and (o.get("secret_id") is None or o["secret_id"] == secret_id)
]
for octroi in candidats:
if octroi["statut"] == "actif" and not est_expire(octroi.get("expiration")):
return "ok", octroi
for octroi in candidats:
if octroi["statut"] == "revoque":
return "revoque", octroi
for octroi in candidats:
if octroi["statut"] == "actif" and est_expire(octroi.get("expiration")):
return "expire", octroi
return "absent", None
def secrets_accessibles_utilisateur(
username: str, coffre: dict, data_dir: str | Path | None = None
) -> set | None:
"""Secrets d'un coffre visibles par un utilisateur via octroi.
Retourne None si un octroi coffre-wide actif existe (tout le coffre),
sinon l'ensemble des ids de secrets octroyés, sinon l'ensemble vide."""
base = resolve_data_dir(data_dir)
with _lock:
octrois = [
o
for o in _load_coffres(base)["octrois"]
if o["cible_type"] == "utilisateur"
and o["cible_id"] == username
and o["coffre_id"] == coffre["id"]
and o["statut"] == "actif"
and not est_expire(o.get("expiration"))
]
if any(o.get("secret_id") is None for o in octrois):
return None
return {o["secret_id"] for o in octrois if o.get("secret_id")}
+197
View File
@@ -0,0 +1,197 @@
"""URLs publiques de l'écosystème dracodev — par configuration (#223).
Règle dev/prod (Bruno, 2026-10-25) : **aucun nom public de
l'écosystème n'est figé dans le code vivant**. Chaque URL publique
vient de l'environnement ; les valeurs par défaut sont les noms
**officiels** (sans suffixe) de la future production. L'environnement
dev déclare ses noms en ``-dev`` dans son seul ``.env`` — le code
n'en connaît aucun.
Variables (voir ``.env.example``) :
- ``DRACO_AGENT_URL`` — base publique du service d'agents ;
à défaut, l'origine est **dérivée** de ``AGENT_EVENEMENTS_URL``
(la configuration réelle de l'émission d'événements, #220), puis
le défaut officiel.
- ``DRACO_OBSIGATE_URL`` — base publique d'ObsiGate (liens profonds).
- ``DRACO_FLOWDECK_URL`` — base publique de Flowdeck.
- ``DRACO_TIKTAK_URL`` — base publique de TikTak.
- ``DRACO_KANBANTE_URL`` — base publique de Kanbante.
- ``DRACO_GITEA_URL`` — base publique de Gitea (les cartes
Kanbante *sont* des issues Gitea, contrat liens-profonds) ;
distincte de ``OBSIGATE_GITEA_URL`` (source connectée de
l'assistant, réglable depuis l'UI, pas une URL d'affichage).
Résolution d'une URL sœur (BUG-120) : la variable explicite prime ;
à défaut, l'**environnement est dérivé de l'hôte de l'agent** tel
qu'ObsiGate le connaît déjà (:func:`agent_url`) — si cet hôte est de
la forme ``agent<SUFFIXE>.dracodev.net`` (``-dev`` en dev, vide en
prod), l'URL sœur est ``https://<slug><SUFFIXE>.dracodev.net``. Un
hôte hors motif (autre domaine, LAN, IP, port) ne dérive **rien** :
on ne fabrique jamais un lien deviné, le défaut officiel s'applique.
L'installation pointe donc les bons liens selon son environnement
sans déclarer les quatre ``DRACO_<APP>_URL``.
Ce module ne lit l'environnement qu'à l'appel (jamais à l'import) :
les tests et un changement de ``.env`` suivi d'un redémarrage
prennent effet sans état figé.
"""
from __future__ import annotations
import os
import re
from urllib.parse import urlparse
# Défauts officiels (production future) — jamais de nom en ``-dev`` ici.
DEFAUTS: dict[str, str] = {
"agent": "https://agent.dracodev.net",
"obsigate": "https://og.dracodev.net",
"flowdeck": "https://flowdeck.dracodev.net",
"tiktak": "https://tiktak.dracodev.net",
"kanbante": "https://kanbante.dracodev.net",
"gitea": "https://git.dracodev.net",
}
_VARIABLES: dict[str, str] = {
"agent": "DRACO_AGENT_URL",
"obsigate": "DRACO_OBSIGATE_URL",
"flowdeck": "DRACO_FLOWDECK_URL",
"tiktak": "DRACO_TIKTAK_URL",
"kanbante": "DRACO_KANBANTE_URL",
"gitea": "DRACO_GITEA_URL",
}
# Slugs d'hôte des applications sœurs pour la dérivation
# d'environnement (BUG-120) : premier label de ``<slug>.dracodev.net``
# (et de ``<slug>-dev.dracodev.net`` en dev). Gitea est **exclue** :
# son URL ne se dérive jamais.
_SLUGS_HOTE: dict[str, str] = {
"obsigate": "og",
"flowdeck": "flowdeck",
"tiktak": "tiktak",
"kanbante": "kanbante",
}
# Premier label de l'hôte de l'agent : ``agent`` suivi d'un suffixe
# d'environnement éventuel (``-dev`` en dev, vide en prod).
_MOTIF_AGENT = re.compile(r"^agent(-[a-z0-9-]*)?$")
def _nettoyer(valeur: str) -> str:
return valeur.strip().rstrip("/")
def origine(url: str) -> str:
"""Origine ``schéma://hôte[:port]`` d'une URL (``""`` si illisible)."""
u = urlparse(url or "")
if u.scheme and u.netloc:
return f"{u.scheme}://{u.netloc}"
return ""
def agent_url() -> str:
"""Base publique du service d'agents.
Priorité : ``DRACO_AGENT_URL``, puis l'origine de
``AGENT_EVENEMENTS_URL`` (déjà la configuration réelle du poste),
puis le défaut officiel.
"""
explicite = _nettoyer(os.environ.get(_VARIABLES["agent"], ""))
if explicite:
return explicite
evenements = _nettoyer(os.environ.get("AGENT_EVENEMENTS_URL", ""))
if evenements:
derivee = origine(evenements)
if derivee:
return derivee
return DEFAUTS["agent"]
def agent_origine() -> str:
"""Origine du service d'agents, pour la CSP (``connect-src``/``frame-src``)."""
return origine(agent_url()) or DEFAUTS["agent"]
def _suffixe_environnement() -> str | None:
"""Suffixe d'environnement déduit de l'hôte de l'agent (BUG-120).
Lit l'hôte de :func:`agent_url` — l'origine de l'agent telle
qu'ObsiGate la connaît déjà (``DRACO_AGENT_URL``, à défaut
``AGENT_EVENEMENTS_URL``). Si cet hôte est exactement de la forme
``agent<SUFFIXE>.dracodev.net`` sans port, retourne ``SUFFIXE``
(``-dev`` en dev, ``""`` en prod). Sinon (autre domaine, LAN, IP,
port exotique) retourne ``None`` : aucune dérivation, on ne
fabrique jamais un lien deviné.
"""
u = urlparse(agent_url())
hote = (u.hostname or "").lower()
labels = hote.split(".")
if len(labels) != 3 or labels[1:] != ["dracodev", "net"]:
return None
try:
if u.port is not None:
return None
except ValueError:
return None
motif = _MOTIF_AGENT.match(labels[0])
if motif is None:
return None
return motif.group(1) or ""
def app_url(app: str) -> str:
"""Base publique d'une application de l'écosystème (sans ``/`` final).
Résolution d'une URL sœur (BUG-120) : ``DRACO_<APP>_URL``
explicite d'abord ; à défaut, dérivation d'environnement depuis
l'hôte de l'agent (:func:`_suffixe_environnement`) ; à défaut,
le défaut officiel. L'URL Gitea ne se dérive jamais.
"""
if app == "agent":
return agent_url()
variable = _VARIABLES[app]
explicite = _nettoyer(os.environ.get(variable, ""))
if explicite:
return explicite
if app in _SLUGS_HOTE:
suffixe = _suffixe_environnement()
if suffixe is not None:
return f"https://{_SLUGS_HOTE[app]}{suffixe}.dracodev.net"
return DEFAUTS[app]
def app_hote(app: str) -> str:
"""Nom d'hôte (minuscules) de l'URL publique configurée de *app*."""
return (urlparse(app_url(app)).hostname or "").lower()
def hotes_reconnus() -> dict[str, str]:
"""``{hôte: app}`` pour le parseur de liens profonds.
Contient **toujours** les hôtes officiels (un lien collé depuis la
production reste reconnaissable en dev, et réciproquement) **plus**
l'hôte résolu de chaque application (les noms en ``-dev`` du poste,
déclarés dans son ``.env`` ou dérivés de l'hôte de l'agent).
"""
hotes: dict[str, str] = {}
for app, defaut in DEFAUTS.items():
hote = (urlparse(defaut).hostname or "").lower()
if hote:
hotes[hote] = app
for app in DEFAUTS:
hote = app_hote(app)
if hote:
hotes[hote] = app
return hotes
def urls_publiques() -> dict[str, str]:
"""Carte des URL publiques injectée à l'hôte web (``window.DRACO_URLS``).
C'est le contrat d'injection de dracodev-commons : l'application
hôte (ici ObsiGate, qui sert ``index.html``) fournit les URL aux
assets partagés (panneau Spark, barre, liens) — les assets ne
portent que les défauts officiels en repli.
"""
return {app: app_url(app) for app in DEFAUTS}
+307
View File
@@ -0,0 +1,307 @@
# backend/draco_session.py
# Fusion ACC-3.1 (#216) — Émettrice SSO « draco_session » (option 2a, C2).
#
# ObsiGate est l'unique émettrice du cookie de session partagé
# ``draco_session`` (Domain=.dracodev.net) que les autres applications
# de l'écosystème (Flowdeck, TikTak, Kanbante) valideront localement
# (ACC-3.2, leurs propres tickets). Design : architecture Fusion §5.2 —
# JWS compact HS256, claims minimaux (sub, name, iat, exp 12 h, jti),
# aucun rôle dans le jeton, renouvellement glissant par l'émettrice,
# révocation par jti exposée aux autres apps.
#
# Configuration (environnement) :
# DRACO_SESSION_SECRET secret de signature partagé (obligatoire
# pour activer la fonction ; sans lui,
# aucun cookie n'est émis et rien ne change)
# DRACO_SESSION_SECRET_PREV ancien secret accepté en vérification
# pendant la fenêtre de rotation (24 h)
# DRACO_SESSION_COOKIE_DOMAIN domaine du cookie (défaut .dracodev.net ;
# vide = cookie lié à l'hôte, utile en dev)
# DRACO_SSO_MACHINE_KEY clé machine protégeant la lecture de la
# liste de révocation (GET /api/sso/revocations)
from __future__ import annotations
import json
import logging
import os
import threading
import time
import uuid
from pathlib import Path
from urllib.parse import urlparse
from jose import JWTError, jwt
logger = logging.getLogger("obsigate.draco_session")
COOKIE_NAME = "draco_session"
ALGORITHM = "HS256"
TOKEN_TTL_SECONDS = 12 * 3600 # durée de vie du jeton (design §5.2)
RENEWAL_WINDOW_SECONDS = 2 * 3600 # réémettre si exp - now < 2 h
REVOKED_FILE = Path("data/draco_revoked.json")
ALLOWED_RETURN_SUFFIX = "dracodev.net"
_lock = threading.Lock()
_revoked: dict[str, int] | None = None # jti -> exp (chargé paresseusement)
def _secret() -> str:
return os.environ.get("DRACO_SESSION_SECRET", "")
def _secret_prev() -> str:
return os.environ.get("DRACO_SESSION_SECRET_PREV", "")
def _cookie_domain() -> str:
return os.environ.get("DRACO_SESSION_COOKIE_DOMAIN", ".dracodev.net")
def enabled() -> bool:
"""La fonction est active seulement si le secret partagé est configuré."""
return bool(_secret())
# ── Révocation (jti) ────────────────────────────────────────────────────
def _load_revoked() -> dict[str, int]:
global _revoked
if _revoked is None:
try:
data = json.loads(REVOKED_FILE.read_text(encoding="utf-8"))
_revoked = {str(k): int(v) for k, v in data.items()} \
if isinstance(data, dict) else {}
except (OSError, ValueError):
_revoked = {}
return _revoked
def _save_revoked() -> None:
REVOKED_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = REVOKED_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(_revoked or {}), encoding="utf-8")
tmp.replace(REVOKED_FILE)
def revoke(jti: str, exp: int) -> None:
"""Révoque un jeton par son jti (déconnexion globale)."""
with _lock:
store = _load_revoked()
store[jti] = int(exp)
now = int(time.time())
for k in [k for k, v in store.items() if v <= now]:
del store[k] # purge des expirés
_save_revoked()
def is_revoked(jti: str) -> bool:
with _lock:
return jti in _load_revoked()
def revoked_list() -> list[dict]:
"""Liste des révocations actives (pour les autres apps, cache 60 s)."""
with _lock:
now = int(time.time())
return [{"jti": jti, "exp": exp}
for jti, exp in _load_revoked().items() if exp > now]
# ── Émission / vérification ─────────────────────────────────────────────
def issue_token(user: dict) -> tuple[str, str, int]:
"""Émet un JWS pour l'utilisateur. Retourne (jeton, jti, exp)."""
now = int(time.time())
exp = now + TOKEN_TTL_SECONDS
jti = uuid.uuid4().hex
claims = {
"sub": str(user.get("id") or user.get("username") or ""),
"name": str(user.get("display_name") or user.get("username") or ""),
"iat": now,
"exp": exp,
"jti": jti,
}
return jwt.encode(claims, _secret(), algorithm=ALGORITHM), jti, exp
def decode_claims(token: str) -> dict | None:
"""Vérifie signature et expiration (pas la révocation)."""
if not token or not enabled():
return None
for secret in (_secret(), _secret_prev()):
if not secret:
continue
try:
return jwt.decode(token, secret, algorithms=[ALGORITHM])
except JWTError:
continue
return None
def verify_token(token: str) -> dict | None:
"""decode_claims + contrôle de révocation du jti."""
claims = decode_claims(token)
if claims is None:
return None
if claims.get("jti") and is_revoked(str(claims["jti"])):
return None
return claims
def needs_renewal(claims: dict) -> bool:
"""Vrai si le jeton entre dans la fenêtre de renouvellement (< 2 h)."""
try:
return int(claims["exp"]) - int(time.time()) < RENEWAL_WINDOW_SECONDS
except (KeyError, TypeError, ValueError):
return True
def set_cookie(response, user: dict, secure: bool) -> None:
"""Pose le cookie draco_session sur une réponse (émission initiale)."""
if not enabled():
return
token, _jti, _exp = issue_token(user)
domain = _cookie_domain()
extra = {"domain": domain} if domain else {}
response.set_cookie(COOKIE_NAME, token, max_age=TOKEN_TTL_SECONDS,
httponly=True, samesite="lax", secure=secure,
path="/", **extra)
def clear_cookie(response) -> None:
domain = _cookie_domain()
extra = {"domain": domain} if domain else {}
response.delete_cookie(COOKIE_NAME, path="/", **extra)
# ── Validation locale (ACC-3.2, #9) ─────────────────────────────────────
def user_for_draco(request) -> dict | None:
"""Utilisateur ObsiGate prouvé par le cookie draco_session.
ObsiGate est à la fois émettrice et consommatrice : le `sub` du
jeton EST son identifiant d'utilisateur — la correspondance est
directe, par id. Le garde-fou « jeton antérieur au dernier
changement de mot de passe » (BUG-028 côté natif) est appliqué
à l'identique via le claim `iat`.
"""
claims = verify_token(request.cookies.get(COOKIE_NAME, ""))
if not claims:
return None
sub = str(claims.get("sub", ""))
if not sub:
return None
from backend.auth.user_store import get_all_users
for candidate in get_all_users():
if str(candidate.get("id")) != sub:
continue
if not candidate.get("active"):
return None
pca = candidate.get("password_changed_at")
iat = claims.get("iat")
if pca is not None and iat is not None:
try:
if int(iat) < int(float(pca)):
return None
except (TypeError, ValueError):
return None
user = dict(candidate)
user["_token_vaults"] = candidate.get("vaults", [])
user["_token_scope"] = "full"
user["_draco_sub"] = sub
return user
return None
# ── Renouvellement glissant (middleware) ────────────────────────────────
class DracoRenewalMiddleware:
"""Réémet le cookie quand l'utilisateur est actif chez l'émettrice.
Middleware ASGI pur (pas de BaseHTTPMiddleware : la pile d'ObsiGate
streame des réponses SSE/WS que BaseHTTPMiddleware mettrait en
tampon). On enveloppe l'envoi de la réponse : si la requête porte
un cookie d'accès natif valide et que le cookie draco est absent,
invalide ou dans sa fenêtre de renouvellement (< 2 h), on ajoute
un Set-Cookie frais aux en-têtes de la réponse. La révocation
ciblée d'un jti draco est ainsi « guérie » par la prochaine
activité authentifiée locale — la déconnexion globale, elle,
révoque aussi les jetons natifs, donc plus rien n'est réémis.
"""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope.get("type") != "http" or not enabled():
await self.app(scope, receive, send)
return
cookie_header = ""
for name, value in scope.get("headers") or []:
if name == b"cookie":
cookie_header = value.decode("latin-1")
break
cookies = {}
for part in cookie_header.split(";"):
if "=" in part:
k, v = part.strip().split("=", 1)
cookies[k] = v
user = self._user_from_native(cookies.get("access_token", ""))
if user is None:
await self.app(scope, receive, send)
return
claims = verify_token(cookies.get(COOKIE_NAME, "")) \
if cookies.get(COOKIE_NAME) else None
if claims is not None and not needs_renewal(claims):
await self.app(scope, receive, send)
return
token, _jti, _exp = issue_token(user)
domain = _cookie_domain()
cookie = (f"{COOKIE_NAME}={token}; Max-Age={TOKEN_TTL_SECONDS}; "
f"Path=/; HttpOnly; SameSite=lax")
if domain:
cookie += f"; Domain={domain}"
if scope.get("scheme") == "https":
cookie += "; Secure"
async def send_with_cookie(message):
if message.get("type") == "http.response.start":
headers = list(message.get("headers") or [])
headers.append((b"set-cookie", cookie.encode("latin-1")))
message = {**message, "headers": headers}
await send(message)
await self.app(scope, receive, send_with_cookie)
@staticmethod
def _user_from_native(access_token: str):
"""Utilisateur local prouvé par le cookie d'accès natif, ou None."""
if not access_token:
return None
try:
from backend.auth.jwt_handler import decode_token
from backend.auth.user_store import get_user
payload = decode_token(access_token)
if not payload or payload.get("type") != "access":
return None
return get_user(str(payload.get("sub", "")))
except Exception: # le renouvellement ne casse jamais une requête
return None
# ── return_to (anti open-redirect) ──────────────────────────────────────
def validate_return_to(url: str) -> str | None:
"""Accepte uniquement https vers dracodev.net ou un sous-domaine."""
if not url:
return None
parsed = urlparse(url)
host = (parsed.hostname or "").lower()
if parsed.scheme != "https" or not host:
return None
if host == ALLOWED_RETURN_SUFFIX or host.endswith("." + ALLOWED_RETURN_SUFFIX):
return url
return None
+6 -3
View File
@@ -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:
@@ -356,7 +359,7 @@ def export_epub(vault_path: Path, file_path: Path) -> bytes:
rendered = _render_body(body, path.parent, vault_path, path)
uid = f"obsigate-{_slugify(title)}-{int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp())}"
uid = f"obsigate-{_slugify(title)}-{int(datetime.datetime.now(tz=datetime.UTC).timestamp())}"
xhtml = f"""<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr" lang="fr">
@@ -380,7 +383,7 @@ def export_epub(vault_path: Path, file_path: Path) -> bytes:
<dc:title>{html_mod.escape(title)}</dc:title>
<dc:creator>{html_mod.escape(author)}</dc:creator>
<dc:language>fr</dc:language>
<meta property="dcterms:modified">{datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}</meta>
<meta property="dcterms:modified">{datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%dT%H:%M:%SZ')}</meta>
</metadata>
<manifest>
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
+238
View File
@@ -0,0 +1,238 @@
"""Références croisées ``external_ref`` des notes — Fusion #215 (ACC-1.3).
Modèle figé par le programme Fusion dans ``dracodev-commons``
(``contrats/external_ref.schema.json``, v0.1.0) : la référence vit chez
le propriétaire de l'objet source — ici la note ObsiGate, identifiée
par ``(vault, path)`` — et pointe vers un objet d'une autre application
(projet ou page Flowdeck, tâche ou liste TikTak, carte Kanbante, note
ObsiGate) par son URL profonde (``contrats/liens-profonds.md``). Une
seule source de vérité par objet : on stocke des références, jamais
des copies du contenu cible.
Stockage : JSON par utilisateur dans ``data/external_refs/``, même
patron que :mod:`backend.history` (pas de base de données, cf.
``AGENTS.md``). Le répertoire est surchargeable par la variable
d'environnement ``OBSIGATE_EXTERNAL_REFS_DIR`` (tests).
"""
import json
import logging
import os
import re
import shutil
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, quote, unquote, urlparse
logger = logging.getLogger("obsigate.external_refs")
def _refs_dir() -> Path:
override = os.environ.get("OBSIGATE_EXTERNAL_REFS_DIR")
directory = Path(override) if override else Path("data/external_refs")
directory.mkdir(parents=True, exist_ok=True)
return directory
def _refs_file(username: str) -> Path:
return _refs_dir() / f"{username}_refs.json"
def _read_refs(username: str) -> list[dict[str, Any]]:
file = _refs_file(username)
if not file.exists():
return []
try:
data = json.loads(file.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except Exception as e:
logger.error(f"Failed to read external refs from {file.name}: {e}")
return []
def _write_refs(username: str, refs: list[dict[str, Any]]) -> None:
file = _refs_file(username)
try:
tmp = file.with_suffix(".tmp")
tmp.write_text(json.dumps(refs, indent=2, ensure_ascii=False), encoding="utf-8")
shutil.move(str(tmp), str(file))
except Exception as e:
logger.error(f"Failed to write external refs to {file.name}: {e}")
# ------------------------------------------------------------ parseur d'URL
def build_deep_link(target_app: str, target_type: str, target_id: str) -> str | None:
"""Construit l'URL profonde d'affichage depuis la **configuration**
courante (#223), ou ``None`` si la forme est inconnue.
Les références stockées portent une URL absolue figée au moment du
collage (voir ``add_ref``) : elle peut nommer l'autre environnement
(un lien collé en prod, lu en dev). L'affichage ne l'utilise donc
jamais telle quelle — il reconstruit l'URL depuis
``(target_app, target_type, target_id)`` et les bases publiques
configurées (``backend.draco_config``), au moment de la lecture.
Aucune donnée stockée n'est migrée ni réécrite.
"""
from backend.draco_config import app_url
if target_app == "tiktak":
if target_type == "task":
return f"{app_url('tiktak')}/?task={quote(target_id, safe='')}"
if target_type == "list":
return f"{app_url('tiktak')}/?list={quote(target_id, safe='')}"
return None
if target_app == "obsigate" and target_type == "note":
return f"{app_url('obsigate')}/#file={quote(target_id, safe=':')}"
if target_app == "flowdeck":
if target_type == "page":
return f"{app_url('flowdeck')}/pages/{quote(target_id, safe='')}"
if target_type == "project":
return f"{app_url('flowdeck')}/collections/{quote(target_id, safe='')}"
return None
if target_app == "kanbante" and target_type == "card":
m = re.fullmatch(r"([^/]+)/([^/#]+)#(\d+)", target_id or "")
if m:
owner, repo, num = m.groups()
return f"{app_url('gitea')}/{owner}/{repo}/issues/{num}"
return None
return None
def _avec_url_affichee(ref: dict[str, Any]) -> dict[str, Any]:
"""Copie de la référence dont ``target_url`` est reconstruite
depuis la config courante (#223) — le stockage n'est pas touché."""
construite = build_deep_link(
str(ref.get("target_app") or ""),
str(ref.get("target_type") or ""),
str(ref.get("target_id") or ""),
)
if construite is None:
return ref
copie = dict(ref)
copie["target_url"] = construite
return copie
def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
"""Reconnaît une URL profonde des applications dracodev.
Formes figées par ``dracodev-commons/contrats/liens-profonds.md``.
Retourne ``(target_app, target_type, target_id, label_par_défaut)``
ou ``None`` si l'URL ne correspond à aucune forme du contrat.
Hôtes reconnus (#223) : les hôtes **officiels** et les hôtes
**configurés** de chaque application (``backend.draco_config``) —
un lien collé depuis l'environnement courant (noms en ``-dev``
déclarés dans son ``.env``) comme depuis l'autre environnement
est donc reconnu ; aucun nom d'environnement n'est figé ici.
"""
from backend.draco_config import hotes_reconnus
try:
u = urlparse((url or "").strip())
except ValueError:
return None
host = (u.hostname or "").lower()
app = hotes_reconnus().get(host)
if app == "tiktak":
q = parse_qs(u.query)
if q.get("task"):
return ("tiktak", "task", q["task"][0], f"TikTak · tâche {q['task'][0]}")
if q.get("list"):
return ("tiktak", "list", q["list"][0], f"TikTak · liste {q['list'][0]}")
return None
if app == "obsigate":
frag = u.fragment or ""
if frag.startswith("file="):
cible = unquote(frag[5:])
if ":" in cible:
nom = cible.rsplit("/", 1)[-1]
return ("obsigate", "note", cible, f"ObsiGate · {nom}")
return None
if app == "flowdeck":
m = re.fullmatch(r"/pages/(\d+)", u.path or "")
if m:
return ("flowdeck", "page", m.group(1), f"Flowdeck · page {m.group(1)}")
m = re.fullmatch(r"/collections/(\d+)", u.path or "")
if m:
return ("flowdeck", "project", m.group(1), f"Flowdeck · projet {m.group(1)}")
return None
if app == "gitea":
m = re.fullmatch(r"/([^/]+)/([^/]+)/issues/(\d+)", u.path or "")
if m:
owner, repo, num = m.groups()
return ("kanbante", "card", f"{owner}/{repo}#{num}", f"Kanbante · {repo} #{num}")
return None
return None
# ------------------------------------------------------------ CRUD
def list_refs(username: str, vault: str, path: str) -> list[dict[str, Any]]:
"""Références posées sur une note, dans l'ordre de création.
``target_url`` de chaque référence retournée est **reconstruite
depuis la config courante** (#223, ``_avec_url_affichee``) : la
valeur stockée, figée au collage, n'est jamais servie telle quelle.
"""
if not username:
return []
return [
_avec_url_affichee(r) for r in _read_refs(username)
if r.get("owner_vault") == vault and r.get("owner_path") == path
]
def get_ref(username: str, ref_id: str) -> dict[str, Any] | None:
"""Une référence par son identifiant, ou ``None`` (URL reconstruite, #223)."""
for r in _read_refs(username):
if r.get("id") == ref_id:
return _avec_url_affichee(r)
return None
def add_ref(
username: str,
vault: str,
path: str,
target_app: str,
target_type: str,
target_id: str,
target_url: str,
label: str,
) -> dict[str, Any]:
"""Ajoute une référence à une note et la retourne (avec son ``id``)."""
refs = _read_refs(username)
ref = {
"id": uuid.uuid4().hex,
"owner_type": "obsigate.note",
"owner_id": f"{vault}:{path}",
"owner_vault": vault,
"owner_path": path,
"target_app": target_app,
"target_type": target_type,
"target_id": target_id,
"target_url": target_url,
"label": label,
"domain": None,
"status": "ok",
"created_by": "user",
"created_at": datetime.now(UTC).isoformat(),
}
refs.append(ref)
_write_refs(username, refs)
return ref
def delete_ref(username: str, ref_id: str) -> dict[str, Any] | None:
"""Retire une référence ; retourne la référence retirée ou ``None``."""
refs = _read_refs(username)
kept = [r for r in refs if r.get("id") != ref_id]
if len(kept) == len(refs):
return None
removed = next(r for r in refs if r.get("id") == ref_id)
_write_refs(username, kept)
return _avec_url_affichee(removed)
+430
View File
@@ -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",
}
+1 -1
View File
@@ -355,7 +355,7 @@ def _export_footer(lang: str) -> str:
template = loc.get("guide105.export_footer", "")
if "%s" not in template and "{" not in template:
template = "ObsiGate {version}"
today = datetime.datetime.now(tz=datetime.timezone.utc).date().isoformat()
today = datetime.datetime.now(tz=datetime.UTC).date().isoformat()
return _collapse(template).format(version=_app_version(), date=today)
+174 -12
View File
@@ -5,7 +5,7 @@ import os
import re
import threading
from collections.abc import Callable
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -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.
@@ -501,7 +581,7 @@ def _scan_vault(
try:
relative = fpath.relative_to(vault_root)
stat = fpath.stat()
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
modified = datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat()
# #86 differential scan: reuse the previous entry when neither
# size nor mtime changed — skips the disk read + parse below.
@@ -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("_", " ")
@@ -785,7 +866,7 @@ async def build_index(progress_callback=None) -> None:
# Record timestamp of full index rebuild
global _last_full_index_ts
_last_full_index_ts = datetime.now(timezone.utc).isoformat()
_last_full_index_ts = datetime.now(UTC).isoformat()
# Build attachment index
from backend.attachment_indexer import build_attachment_index
@@ -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
@@ -934,7 +1033,7 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
return None
stat = fpath.stat()
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
modified = datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat()
# PDF handling — binary, must go through pdf_reader (same as _scan_vault)
tags: list[str] = []
@@ -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:
@@ -1202,6 +1312,52 @@ async def handle_file_move(vault_name: str, src_abs: str, dest_abs: str) -> dict
return await update_single_file(vault_name, dest_abs)
async def remove_directory_from_index(vault_name: str, dir_rel_path: str) -> int:
"""Remove a directory subtree from the index without a full vault re-scan.
Incremental equivalent of ``reload_single_vault`` for deletions : every
indexed file under *dir_rel_path* is dropped via
``_remove_file_from_structures`` (which notifies the inverted and semantic
indexes through the change hook), and the subtree's directory entries are
purged from ``path_index``. Returns the number of files removed.
Used by :func:`api_directory_delete` — previously a ``rmtree`` was followed
by a full vault re-scan + global inverted-index rebuild, which is linear in
the whole vault size on every single directory deletion.
Args:
vault_name: Name of the vault.
dir_rel_path: Directory path relative to the vault root.
Returns:
Number of indexed files removed.
"""
vault_data = index.get(vault_name)
if not vault_data:
return 0
prefix = dir_rel_path.rstrip("/") + "/"
targets = [f["path"] for f in vault_data.get("files", []) if f["path"].startswith(prefix)]
lock = _get_async_lock()
async with lock:
removed = 0
for rel_path in targets:
if _remove_file_from_structures(vault_name, rel_path):
removed += 1
# Purge the subtree's directory entries too, otherwise empty dirs stay
# in path_index as ghosts (file entries were removed per-file above).
if vault_name in path_index:
path_index[vault_name] = [
p for p in path_index[vault_name]
if not (p["path"] == dir_rel_path or p["path"].startswith(prefix))
]
if removed:
logger.info(f"Directory removed from index: {vault_name}/{dir_rel_path} ({removed} files)")
return removed
async def remove_vault_from_index(vault_name: str):
"""Remove an entire vault from the index.
@@ -1226,6 +1382,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)
+156 -9
View File
@@ -167,7 +167,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Add security headers to all HTTP responses."""
async def dispatch(self, request, call_next):
from backend.csp import new_nonce
from backend.csp import inject_csp_nonce, new_nonce
# Nonce CSP frais par réponse (#87 T5b) : injecté dans script-src et
# dans le balisage HTML par les routes (backend.csp.inject_csp_nonce).
@@ -186,20 +186,50 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
# avec un nonce frais (`backend.csp`) ou servis par 'self'/CDN
# listés s'exécutent. `style-src` garde 'unsafe-inline' (attributs
# `style=` et `el.style` omniprésents — chantier séparé).
# #223 : l'origine du service d'agents n'est pas figée — elle
# est dérivée de la configuration (DRACO_AGENT_URL, sinon
# l'origine d'AGENT_EVENEMENTS_URL, sinon le défaut officiel).
from backend.draco_config import agent_origine
agent = agent_origine()
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
"img-src 'self' data: blob:; "
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
f"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net {agent}; "
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
"worker-src 'self' blob:; "
"frame-src 'self' blob:; "
f"frame-src 'self' blob: {agent}; "
"object-src 'none'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'self';"
)
# BUG-106 : /docs et /redoc sont générés par FastAPI, hors de nos
# routes qui appellent inject_csp_nonce — leur balisage inline
# restait sans nonce et était bloqué par script-src (page blanche).
# On injecte ici avec le même helper, seul le HTML est retouché.
if request.url.path.startswith(("/docs", "/redoc")) and "text/html" in (
response.headers.get("Content-Type") or ""
):
import gzip
# La compression (GZipMiddleware) est plus proche de la route :
# le corps arrive déjà gunzippé, on recomprime à l'identique.
raw = b"".join([chunk async for chunk in response.body_iterator])
is_gz = "gzip" in (response.headers.get("Content-Encoding") or "")
if is_gz:
raw = gzip.decompress(raw)
patched = inject_csp_nonce(raw.decode("utf-8", "replace"), nonce).encode("utf-8")
if is_gz:
patched = gzip.compress(patched)
response.headers["Content-Length"] = str(len(patched))
async def _docs_body():
yield patched
response.body_iterator = _docs_body()
# Static assets are NOT content-hashed, so they must revalidate:
# ``immutable``/long max-age made Cloudflare and mobile browsers serve
# a stale build for a year (the service worker cache compounded it).
@@ -260,12 +290,19 @@ async def lifespan(app: FastAPI):
# BUG-037: refuse to expose an unauthenticated instance on a public bind.
_guard_insecure_auth()
# #87 T3 : avertir quand les cookies d'auth circulent sans flag Secure
# sur un bind non-loopback (transactions observables en clair).
# #87 T3/T8 : avertir quand les cookies d'auth circulent sans flag Secure
# sur un bind non-loopback (transactions observables en clair). Avec
# `OBSIGATE_SECURE_COOKIES=auto` (défaut) + `OBSIGATE_TRUST_PROXY=true`,
# le flag suit `X-Forwarded-Proto` : pas d'avertissement, le https du
# reverse proxy est honoré.
from backend.auth.middleware import bind_host_from_argv, is_auth_enabled, is_loopback_host
from backend.auth.router import is_secure_cookies
from backend.services.net import is_trusted_proxy
if is_auth_enabled() and not is_secure_cookies() and not is_loopback_host(bind_host_from_argv()):
secure_mode = os.environ.get("OBSIGATE_SECURE_COOKIES", "auto").lower()
proxy_secure = secure_mode == "auto" and is_trusted_proxy()
if (is_auth_enabled() and not is_secure_cookies()
and not is_loopback_host(bind_host_from_argv()) and not proxy_secure):
logger.warning(
"Cookies d'authentification sans flag `Secure` sur un bind non-loopback : "
"activez TLS et posez OBSIGATE_SECURE_COOKIES=true en production."
@@ -333,10 +370,34 @@ async def lifespan(app: FastAPI):
else:
logger.info("File watcher disabled by configuration.")
# #194 : dossier personnel par utilisateur — réparation/migration
# (mkdir manquant, registre perdu, user créé hors API). Après le
# watcher pour que les vaults ajoutés soient surveillés.
from backend.user_home import ensure_all_user_homes
await ensure_all_user_homes()
logger.info("Background startup complete.")
asyncio.create_task(_background_startup())
async def _scheduler_loop():
"""Background tick for scheduled tasks (#170) — every 60 s, best effort."""
from backend.scheduler import tick
await asyncio.sleep(60)
while True:
try:
outcomes = await asyncio.to_thread(tick)
if outcomes:
logger.info(f"Scheduler tick: {len(outcomes)} task(s) executed")
except Exception as e:
logger.warning(f"Scheduler tick failed: {e}")
await asyncio.sleep(60)
if os.environ.get("OBSIGATE_SCHEDULER", "1") != "0":
asyncio.create_task(_scheduler_loop())
logger.info("Scheduler loop started (#170, 60 s tick).")
logger.info("ObsiGate ready (listening for requests while indexing).")
yield
@@ -382,12 +443,25 @@ app.openapi = _custom_openapi # type: ignore[method-assign]
@app.exception_handler(ServiceError)
async def _service_error_handler(request: Request, exc: ServiceError):
"""Map shared-layer domain errors to HTTP responses (``{"detail": ...}``)."""
return JSONResponse(status_code=exc.status, content={"detail": exc.message})
"""Map shared-layer domain errors to HTTP responses (``{"detail": ...}``).
``code`` and ``details`` travel with the message so the client can react to
a specific case instead of parsing prose (#153 A1 : ``xlsx_lossy_content``
asks the viewer to confirm before forcing a lossy write).
"""
return JSONResponse(
status_code=exc.status,
content={
"detail": exc.message,
"code": exc.code,
"details": exc.details,
},
)
# GZip compression — reduces bandwidth by ~70% for text responses
# Custom wrapper: skip compression for SSE streams (/api/events)
from fastapi.middleware.gzip import GZipMiddleware
from starlette.middleware.cors import CORSMiddleware
from starlette.types import Receive, Scope, Send
@@ -418,6 +492,35 @@ app.add_middleware(SSESafeGZipMiddleware, minimum_size=1000)
# Security headers on all responses
app.add_middleware(SecurityHeadersMiddleware)
# Scoped API tokens (#199) : les clés à portée "files" sont confinées à la
# surface fichiers côté REST (403 hors périmètre). Placé avant CORS pour que
# le refus s'applique aussi aux requêtes out-of-scope.
from backend.auth.scope import TokenScopeMiddleware
app.add_middleware(TokenScopeMiddleware)
# Fusion ACC-3.1 (#216) : renouvellement glissant du cookie SSO
# draco_session (inerte tant que DRACO_SESSION_SECRET n'est pas défini).
from backend.draco_session import DracoRenewalMiddleware
app.add_middleware(DracoRenewalMiddleware)
# Explicit same-origin CORS policy (#87 T8 — finit BUG-034).
# `allow_origins=[]` : le navigateur n'émet aucun `Access-Control-Allow-*`,
# donc toute lecture cross-origin est refusée (défense explicite, plus
# seulement l'absence de middleware). Sûr pour tous les clients : web
# (same-origin), desktop Tauri (la webview est redirigée same-origin sur
# http://127.0.0.1:<port>, voir frontend/js/desktop.js) et API directe
# (curl/scripts, CORS non appliqué hors navigateur). Ajouté en dernier :
# le plus externe, les preflights court-circuitent avant tout le reste.
app.add_middleware(
CORSMiddleware,
allow_origins=[],
allow_credentials=False,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"],
)
# Auth router
# Multi-format export (HTML / MD bundle / ePub) — voir backend.routers.files_media (#85 T6c).
from backend.ai_routes import router as ai_router
@@ -427,17 +530,24 @@ from backend.auth.middleware import (
from backend.auth.router import router as auth_router
from backend.bookslm_routes import router as bookslm_router
from backend.routers.backups import router as backups_router
from backend.routers.coffres import router as coffres_router
from backend.routers.config import _load_config
from backend.routers.config import router as config_router
from backend.routers.conflicts import router as conflicts_router
from backend.routers.duplicates import router as duplicates_router
from backend.routers.external_refs import router as external_refs_router
from backend.routers.file_chat import router as file_chat_router
from backend.routers.files_media import router as files_media_router
from backend.routers.files_read import router as files_read_router
from backend.routers.files_write import router as files_write_router
from backend.routers.health import router as health_router
from backend.routers.history import router as history_router
from backend.routers.notify import router as notify_router
from backend.routers.realtime import router as realtime_router
from backend.routers.scheduler import router as scheduler_router
from backend.routers.search import router as search_router
from backend.routers.sharing import router as sharing_router
from backend.routers.sso import router as sso_router
from backend.routers.vaults import router as vaults_router
from backend.routers.webhooks import router as webhooks_router
from backend.skills_routes import router as skills_router
@@ -448,17 +558,24 @@ app.include_router(bookslm_router)
app.include_router(skills_router)
app.include_router(health_router) # ROADMAP #85 T1 — System / health
app.include_router(history_router) # ROADMAP #85 T8 — History
app.include_router(external_refs_router) # Fusion #215 — Références croisées (ACC-1.3)
app.include_router(sso_router) # Fusion #216 — Émettrice SSO draco_session (ACC-3.1)
app.include_router(realtime_router) # ROADMAP #85 T9 — SSE + collab WS
app.include_router(search_router) # ROADMAP #85 T5 — Search
app.include_router(backups_router) # ROADMAP #85 T4 — Backups
app.include_router(conflicts_router) # ROADMAP #85 T8 — Conflicts
app.include_router(config_router) # ROADMAP #85 T7 — Config
app.include_router(coffres_router) # Coffre Secrets Phase 1b — JAMAIS dans vaults.py
app.include_router(file_chat_router) # ROADMAP #169 — Chat par fichier
app.include_router(files_read_router) # ROADMAP #85 T6a — Files read
app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export
app.include_router(files_write_router) # ROADMAP #85 T6b — Files write
app.include_router(webhooks_router) # ROADMAP #85 T2 — Webhooks
app.include_router(sharing_router) # ROADMAP #85 T3 — Sharing
app.include_router(vaults_router) # ROADMAP #85 T8 — Vaults
app.include_router(duplicates_router) # ROADMAP #166 — Doublons
app.include_router(notify_router) # ROADMAP #168 — Notifications externes
app.include_router(scheduler_router) # ROADMAP #170 — Tâches planifiées
# Admin Dashboard endpoints (system stats, audit logs, backups, stream)
try:
@@ -720,6 +837,36 @@ def _html_with_nonce(request: Request, name: str) -> str:
request.state.csp_nonce,
)
def _index_html(request: Request) -> str:
"""``index.html`` + injection des URL publiques de l'écosystème (#223).
Contrat d'injection de dracodev-commons : l'hôte (ObsiGate, qui
sert la page) fournit les URL aux assets partagés — panneau Spark,
barre d'apps, liens inter-apps lisent ``window.DRACO_URLS`` et ne
portent que les défauts officiels en repli. Les valeurs viennent
de :mod:`backend.draco_config` (environnement ; défauts officiels,
noms en ``-dev`` déclarés par le seul ``.env`` du poste). Le script
injecté passe par le même mécanisme de nonce CSP que les autres
scripts inline de la page.
"""
import json as _json
from backend.csp import inject_csp_nonce
from backend.draco_config import urls_publiques
html = (FRONTEND_DIR / "index.html").read_text(encoding="utf-8")
script = (
"<script>window.DRACO_URLS = "
+ _json.dumps(urls_publiques()).replace("<", "\\u003c")
+ ";</script>"
)
if "</head>" in html:
html = html.replace("</head>", script + "</head>", 1)
else: # repli défensif : tout en tête de document
html = script + html
return inject_csp_nonce(html, request.state.csp_nonce)
if FRONTEND_DIR.exists():
# ``Cache-Control`` for /static is set by SecurityHeadersMiddleware (no-cache).
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
@@ -798,5 +945,5 @@ if FRONTEND_DIR.exists():
"""Serve the SPA index.html for all non-API routes."""
index_file = FRONTEND_DIR / "index.html"
if index_file.exists():
return HTMLResponse(content=_html_with_nonce(request, "index.html"), headers={"Cache-Control": "no-cache"})
return HTMLResponse(content=_index_html(request), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Frontend not found")
+20
View File
@@ -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:
+350
View File
@@ -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 UTC, datetime
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(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(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(UTC).isoformat()
channel["last_error"] = error
_write_channels(channels)
+47 -3
View File
@@ -28,6 +28,8 @@ TAGS_METADATA: list[dict[str, str]] = [
{"name": "Vaults", "description": "List, add and remove vaults; per-vault display settings; attachment indexing."},
{"name": "Search", "description": "Full-text and advanced search, tags, title suggestions and the link graph."},
{"name": "Bookmarks", "description": "Recently opened files, bookmarks and saved searches."},
{"name": "ExternalRefs", "description": "Cross-application references from a note to Flowdeck, TikTak, Kanbante or ObsiGate objects (Fusion program, ACC-1.3)."},
{"name": "SSO", "description": "draco_session single sign-on emitter: authorize entry point and jti revocation list for the other dracodev apps (Fusion program, ACC-3.1)."},
{"name": "Backups", "description": "Automatic file backups, diffs, restore, compression and purge."},
{"name": "Export", "description": "Export notes or whole vaults to HTML, Markdown bundle or ePub."},
{"name": "Guide", "description": "Download the in-app user guide as Markdown or PDF (mirrors the help modal, FR/EN)."},
@@ -41,6 +43,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 +100,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"),
@@ -108,6 +116,8 @@ _TAG_RULES: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"^/api/graph"), "Search"),
(re.compile(r"^/api/recent"), "Bookmarks"),
(re.compile(r"^/api/bookmarks"), "Bookmarks"),
(re.compile(r"^/api/refs"), "ExternalRefs"),
(re.compile(r"^/api/sso"), "SSO"),
(re.compile(r"^/api/saved-searches"), "Bookmarks"),
(re.compile(r"^/api/vaults"), "Vaults"),
(re.compile(r"^/api/vault/"), "Vaults"),
@@ -148,6 +158,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 +195,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 +246,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"},
},
}
+6 -6
View File
@@ -6,14 +6,14 @@ import base64
import json
import logging
import os
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
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
@@ -185,7 +185,7 @@ async def subscribe_push(
"keys": request.subscription.keys,
"vault": request.vault,
"username": username,
"created_at": datetime.now(timezone.utc).isoformat()
"created_at": datetime.now(UTC).isoformat()
}
_push_subscriptions.append(subscription)
+27 -5
View File
@@ -15,12 +15,13 @@ 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_file_content
from backend.secret_redactor import redact_with_placeholders, restore_masks
from backend.services.sanitizer import sanitize_html
@@ -165,7 +166,13 @@ def _normalize_line_breaks(text: str) -> str:
return "".join(parts)
def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | None = None) -> str:
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.
@@ -174,6 +181,10 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | Non
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.
@@ -183,8 +194,13 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | Non
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)
raw_md = redact_file_content(raw_md, str(current_file_path) if current_file_path else "")
# 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:
@@ -196,7 +212,13 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | Non
# Normalize line breaks to match Obsidian behavior (single \n → hard break)
converted = _normalize_line_breaks(converted)
rendered = _markdown_renderer(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)
+63 -9
View File
@@ -1,27 +1,81 @@
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
# Plancher de sécurité : 3.14.3 est vulnérable à 8 CVE (dont
# CVE-2026-111063/111064 et GHSA-2g87-pp6c-29x5), toutes corrigées en
# 3.14.4 — pip-audit (CI, bloquant) exige de rester au-dessus.
aiohttp>=3.14.4
watchdog>=4.0.0
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, relevé par #224) : 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). #224 : la version préinstallée dans la toolcache du
# runner (6.19.0) est frappée par 11 CVE supplémentaires (dont
# CVE-2026-113786, CVE-2026-115572, CVE-2026-115585, CVE-2026-115580),
# corrigées au plus tard en 6.21.2 — le plancher doit être >= 6.21.2 :
# en dessous, pip répond « already satisfied » pour la toolcache et
# n'aligne jamais (même piège que BUG-093).
pypdf>=6.21.2
pyotp>=2.10.0
segno>=1.5.0
webauthn==2.6.0
psutil>=5.9
pywebpush>=2.3.0
mcp==1.9.4
# 1.28.1 vulnérable (CVE-2026-59951, fix 1.30.0) — épinglé sur la
# première version corrigée de la même ligne 1.x.
mcp==1.30.0
# 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 de sécurité : 12.3.0 est vulnérable à 8 CVE (dont
# CVE-2026-112895 et CVE-2026-112400), toutes corrigées en 13.0.0.
pillow>=13.0.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
# Plancher de sécurité (PYSEC-2026-4320, fix 2026.10.19) : certifi est
# transitive (httpx/requests) — sans plancher explicite, pip-audit
# signale la version héritée de l'environnement.
certifi>=2026.10.19
# Planchers de sécurité (#224) : paquets transitifs ou préinstallés dans
# la toolcache du runner, audités par pip-audit (CI, bloquant) mais jamais
# alignés sans plancher explicite — « already satisfied » (même piège que
# BUG-093). Versions relevées par le job security du run sur 2.78.0 :
# brotli 1.2.0 (transitive fonttools[woff] via weasyprint) — PYSEC-2026-4322
# et GHSA-9v8h-qqgm-4m35, corrigées en 1.2.1.
brotli>=1.2.1
# filelock 3.32.3 (transitive de CacheControl, outil pip-audit, et
# préinstallée dans la toolcache) — CVE-2026-112940, CVE-2026-112913 et
# CVE-2026-112907, corrigées en 4.2.1.
filelock>=4.2.1
# opentelemetry-api 1.37.0 (préinstallée dans la toolcache du runner,
# aucune dépendance directe d'ObsiGate) — CVE-2026-114603, corrigée
# en 1.45.1.
opentelemetry-api>=1.45.1
# Coffre Secrets (Phase 1a) : chiffrement par enveloppe AES-256-GCM.
cryptography>=44.0
# Plancher de securite : msgpack est transitif, version 1.2.1 relevee par le job
# security (pip-audit, bloquant) — CVE-2026-114613, corrigee en 1.2.3.
msgpack>=1.2.3
+2 -2
View File
@@ -19,7 +19,7 @@ Adaptations strictement équivalentes :
import logging
import os
import time
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -207,7 +207,7 @@ async def api_backups_list(
"file": rel_file,
"backup_file": fpath.name,
"timestamp": ts,
"datetime": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(),
"datetime": datetime.fromtimestamp(ts, tz=UTC).isoformat(),
"size": fsize,
"full_path": str(fpath),
})
+731
View File
@@ -0,0 +1,731 @@
"""API du coffre Secrets ObsiGate (Phase 1b, spec §7, §10, §11).
Monté dans ``backend/main.py`` à côté de ``config_router`` — JAMAIS dans
``backend/routers/vaults.py`` : un coffre n'est pas un vault de notes, il
ne passe ni par l'indexeur, ni par la recherche, ni par le graphe, et
aucun outil MCP de secret n'existe.
Règles d'or, appliquées côté serveur à chaque requête :
- Toutes les lectures renvoient des métadonnées seules (``has_value``,
version, dates, octrois actifs) : jamais de valeur, jamais de texte
chiffré, jamais de nonce.
- Seul ``POST /api/coffres/distribuer`` rend une valeur, et uniquement à
un consommateur authentifié par jeton (hash + jti, jamais la session
utilisateur), avec un octroi actif et un contexte obligatoire.
- Autorisations (spec §6) : le propriétaire seul sur son coffre
personnel ; l'admin gère les coffres d'instance, les consommateurs et
la KEK, jamais le coffre personnel d'autrui ; l'ACL « * » des vaults
ne donne AUCUN droit sur un coffre (``check_vault_access`` n'est pas
utilisé ici, volontairement).
- Feature flag ``OBSIGATE_SECRETS_ENABLED`` (défaut désactivé) : tant
qu'il n'est pas activé, toutes les routes répondent 503.
- Phase 2 (issues #20) : migration ``api_keys.json`` -> coffre
d'instance, en deux temps (import, puis purge confirmée), et
enregistrement d'un consommateur avec ses octrois en un appel.
- Phase 3 (issue #21) : ``/distribuer`` accepte le contexte Spark à
plat ou imbriqué (``contexte: {type: "mission", id}``) ; les codes
d'erreur restent ceux de la Phase 1b (voir
``docs/COFFRE-contrat-distribution.md`` pour la correspondance avec
l'état ``acces_requis`` côté Spark).
"""
from __future__ import annotations
import logging
import os
from backend.auth.middleware import require_admin, require_auth
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from backend.audit import get_recent_entries, log_coffre_event
from backend.coffre_crypto import (
CryptoIndisponibleError,
KEKAbsenteError,
generate_kek_b64,
)
from backend.coffre_migration import (
COFFRE_INTERNE_SLUG,
creer_consommateur_avec_octrois,
importer_api_keys,
lister_candidats,
purger_api_keys,
)
from backend.coffre_store import (
CoffreExisteError,
IntrouvableError,
SecretExisteError,
SecretExpireError,
ValidationError,
compter_octrois_actifs,
compter_secrets,
create_coffre,
create_consommateur,
create_octroi,
create_secret,
delete_secret,
distribuer_valeur,
get_coffre,
get_coffre_by_id,
get_coffre_or_404,
get_octroi,
get_secret,
list_coffres,
list_consommateurs,
list_octrois,
list_secrets,
revoquer_consommateur,
revoquer_octroi,
rotate_dek,
rotate_kek,
rotate_secret,
secret_metadata,
secrets_accessibles_utilisateur,
statut_octroi_consommateur,
toucher_utilisation,
verifier_jeton_consommateur,
)
logger = logging.getLogger("obsigate.coffres")
router = APIRouter(tags=["Coffres Secrets"])
CODES_ERREUR = {
"octroi_absent",
"octroi_revoque",
"octroi_expire",
"secret_expire",
"kek_absente",
"contexte_manquant",
"consommateur_invalide",
"consommateur_revoque",
"crypto_indisponible",
"secrets_desactives",
}
def secrets_actives() -> bool:
"""Feature flag : défaut désactivé, activation explicite par l'opérateur."""
return os.environ.get("OBSIGATE_SECRETS_ENABLED", "false").lower() in (
"1",
"true",
"yes",
"on",
)
def _exiger_actives() -> None:
if not secrets_actives():
raise HTTPException(status_code=503, detail="secrets_desactives")
def _username(user: dict) -> str:
return str(user.get("username", ""))
def _est_admin(user: dict) -> bool:
return user.get("role") == "admin"
def _peut_voir_coffre(user: dict, coffre: dict) -> bool:
"""Voir les métadonnées d'un coffre."""
if coffre["proprietaire"] == _username(user):
return True
if _est_admin(user) and coffre["perimetre"] == "instance":
return True
# Octroi utilisateur (coffre-wide ou par secret) : accès en lecture.
accessibles = secrets_accessibles_utilisateur(_username(user), coffre)
if accessibles is None:
return True
return len(accessibles) > 0
def _peut_gerer_coffre(user: dict, coffre: dict) -> bool:
"""Créer / roter / supprimer des secrets et gérer les octrois."""
if coffre["proprietaire"] == _username(user):
return True
return _est_admin(user) and coffre["perimetre"] == "instance"
def _exiger_voir(user: dict, coffre: dict) -> None:
if not _peut_voir_coffre(user, coffre):
raise HTTPException(status_code=403, detail="Accès refusé à ce coffre")
def _exiger_gerer(user: dict, coffre: dict) -> None:
if not _peut_gerer_coffre(user, coffre):
raise HTTPException(status_code=403, detail="Accès refusé à ce coffre")
def _coffre_or_404(slug: str) -> dict:
try:
return get_coffre_or_404(slug)
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
def _secret_or_404(coffre: dict, nom: str) -> dict:
try:
secret = get_secret(coffre["id"], nom)
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if secret is None:
raise HTTPException(status_code=404, detail=f"Secret introuvable : {nom}")
return secret
def _metadata_coffre(coffre: dict) -> dict:
return {
"slug": coffre["slug"],
"nom": coffre["nom"],
"description": coffre.get("description", ""),
"proprietaire": coffre["proprietaire"],
"perimetre": coffre["perimetre"],
"statut": coffre["statut"],
"kek_configuree": coffre.get("dek_chiffree") is not None,
"nb_secrets": compter_secrets(coffre["id"]),
"cree_le": coffre["cree_le"],
}
def _metadata_secret(coffre: dict, secret: dict) -> dict:
return secret_metadata(secret, compter_octrois_actifs(secret["id"]))
def _secrets_visibles(user: dict, coffre: dict) -> list:
"""Secrets visibles par l'utilisateur (métadonnées seules)."""
if coffre["proprietaire"] == _username(user) or (
_est_admin(user) and coffre["perimetre"] == "instance"
):
return [_metadata_secret(coffre, s) for s in list_secrets(coffre["id"])]
accessibles = secrets_accessibles_utilisateur(_username(user), coffre)
if accessibles is None:
return [_metadata_secret(coffre, s) for s in list_secrets(coffre["id"])]
return [
_metadata_secret(coffre, s)
for s in list_secrets(coffre["id"])
if s["id"] in accessibles
]
def _audit(user: str, action: str, **kwargs) -> None:
try:
log_coffre_event(user, action, **kwargs)
except Exception as exc: # l'audit ne doit jamais casser l'opération
logger.warning("Échec audit coffre (%s) : %s", action, exc)
# ---------------------------------------------------------------------------
# Coffres
# ---------------------------------------------------------------------------
@router.get("/api/coffres")
async def api_list_coffres(current_user=Depends(require_auth)):
_exiger_actives()
visibles = [c for c in list_coffres() if _peut_voir_coffre(current_user, c)]
return [_metadata_coffre(c) for c in visibles]
@router.post("/api/coffres")
async def api_create_coffre(body: dict = Body(...), current_user=Depends(require_auth)):
_exiger_actives()
slug = body.get("slug", "")
perimetre = body.get("perimetre", "personnel")
if perimetre == "instance" and not _est_admin(current_user):
raise HTTPException(
status_code=403, detail="Seul un admin peut créer un coffre d'instance"
)
try:
coffre = create_coffre(
slug=slug,
nom=body.get("nom", ""),
proprietaire=_username(current_user),
perimetre=perimetre,
description=body.get("description", ""),
)
except CoffreExisteError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_audit(
_username(current_user),
"coffre_cree",
coffre=coffre["slug"],
resultat="ok",
)
return _metadata_coffre(coffre)
# ---------------------------------------------------------------------------
# Consommateurs (admin seulement, jeton rendu une seule fois)
# ---------------------------------------------------------------------------
@router.get("/api/coffres/consommateurs")
async def api_list_consommateurs(current_user=Depends(require_admin)):
_exiger_actives()
return list_consommateurs()
@router.post("/api/coffres/consommateurs")
async def api_create_consommateur(
body: dict = Body(...), current_user=Depends(require_admin)
):
_exiger_actives()
try:
consommateur, jeton = create_consommateur(
nom=body.get("nom", ""), cree_par=_username(current_user)
)
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_audit(
_username(current_user),
"consommateur_cree",
consommateur=consommateur["nom"],
resultat="ok",
)
# Le jeton n'est rendu qu'ici, jamais stocké en clair, jamais relisté.
return {**consommateur, "jeton": jeton}
@router.post("/api/coffres/consommateurs/avec-octrois")
async def api_create_consommateur_avec_octrois(
body: dict = Body(...), current_user=Depends(require_admin)
):
"""Phase 4 : enregistrer une app = un consommateur + ses octrois.
Le coffre n'est jamais modifié par cette opération. Le jeton n'est
rendu qu'ici, jamais stocké en clair, jamais relisté. Un octroi en
erreur (coffre ou secret introuvable) est signalé dans ``erreurs``
sans annuler le consommateur ni les autres octrois.
"""
_exiger_actives()
try:
consommateur, jeton, octrois, erreurs = creer_consommateur_avec_octrois(
nom=body.get("nom", ""),
octrois_demandes=body.get("octrois") or [],
cree_par=_username(current_user),
)
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_audit(
_username(current_user),
"consommateur_cree",
consommateur=consommateur["nom"],
resultat="ok",
)
for octroi in octrois:
coffre_octroi = get_coffre_by_id(octroi["coffre_id"])
_audit(
_username(current_user),
"octroi_cree",
coffre=coffre_octroi["slug"] if coffre_octroi else None,
resultat="ok",
)
return {**consommateur, "jeton": jeton, "octrois": octrois, "erreurs": erreurs}
@router.delete("/api/coffres/consommateurs/{consommateur_id}")
async def api_revoke_consommateur(
consommateur_id: str, current_user=Depends(require_admin)
):
_exiger_actives()
try:
consommateur = revoquer_consommateur(consommateur_id)
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
_audit(
_username(current_user),
"consommateur_revoque",
consommateur=consommateur["nom"],
resultat="ok",
)
return consommateur
# ---------------------------------------------------------------------------
# Migration Phase 2 (admin seulement) : api_keys.json -> coffre d'instance.
# L'import ne supprime rien ; la purge exige une confirmation explicite
# et ne supprime que les noms listés. Aucune réponse ne contient jamais
# une valeur, seulement des noms et des comptes.
# Ces routes sont déclarées AVANT /api/coffres/{slug} pour ne pas être
# capturées par le paramètre slug.
# ---------------------------------------------------------------------------
@router.get("/api/coffres/migration/api-keys")
async def api_migration_apercu(current_user=Depends(require_admin)):
_exiger_actives()
return {"coffre": COFFRE_INTERNE_SLUG, "candidats": lister_candidats()}
@router.post("/api/coffres/migration/api-keys/import")
async def api_migration_import(current_user=Depends(require_admin)):
_exiger_actives()
try:
resultat = importer_api_keys(cree_par=_username(current_user))
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except KEKAbsenteError:
raise HTTPException(status_code=503, detail="kek_absente") from None
except CryptoIndisponibleError:
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
for nom in resultat["migres"]:
_audit(
_username(current_user),
"migration_api_keys",
coffre=COFFRE_INTERNE_SLUG,
secret_ref=f"coffre:{COFFRE_INTERNE_SLUG}/{nom}",
resultat="ok",
)
return resultat
@router.post("/api/coffres/migration/api-keys/purger")
async def api_migration_purger(
body: dict = Body(...), current_user=Depends(require_admin)
):
_exiger_actives()
if body.get("confirmer") is not True:
raise HTTPException(status_code=400, detail="confirmation_requise")
try:
resultat = purger_api_keys(
noms=body.get("noms") or [], confirmer=True
)
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
for nom in resultat["supprimes"]:
_audit(
_username(current_user),
"migration_api_keys_purge",
coffre=COFFRE_INTERNE_SLUG,
secret_ref=f"coffre:{COFFRE_INTERNE_SLUG}/{nom}",
resultat="ok",
)
return resultat
# ---------------------------------------------------------------------------
# Distribution (serveur-à-serveur, hors session utilisateur, hors LLM)
# ---------------------------------------------------------------------------
@router.post("/api/coffres/distribuer")
async def api_distribuer(request: Request, body: dict = Body(...)):
_exiger_actives()
reference = (body.get("reference") or "").strip()
# Phase 3 (Spark) : le contexte peut arriver à plat
# (contexte_type / contexte_id) ou imbriqué (contexte: {type, id},
# forme envoyée par le client Spark). Les deux formes sont acceptées.
contexte_type = str(body.get("contexte_type") or "").strip()
contexte_id_raw = body.get("contexte_id")
contexte_id = "" if contexte_id_raw is None else str(contexte_id_raw).strip()
if not contexte_type or not contexte_id:
contexte = body.get("contexte")
if isinstance(contexte, dict):
contexte_type = contexte_type or str(contexte.get("type") or "").strip()
contexte_id_raw = contexte.get("id")
contexte_id = contexte_id or (
"" if contexte_id_raw is None else str(contexte_id_raw).strip()
)
if not contexte_type or not contexte_id:
raise HTTPException(status_code=400, detail="contexte_manquant")
if not reference.startswith("coffre:") or "/" not in reference[7:]:
raise HTTPException(status_code=400, detail="reference_invalide")
slug, nom = reference[7:].split("/", 1)
# Authentification consommateur : Bearer, hash + statut, jamais un JWT
# de session utilisateur.
auth = request.headers.get("Authorization", "")
jeton = auth[7:] if auth.startswith("Bearer ") else ""
consommateur = verifier_jeton_consommateur(jeton)
if consommateur is None:
_audit("consommateur", "distribution_refusee", secret_ref=reference,
resultat="consommateur_invalide", contexte_id=contexte_id)
raise HTTPException(status_code=401, detail="consommateur_invalide")
if consommateur["statut"] != "actif":
_audit("consommateur", "distribution_refusee", secret_ref=reference,
consommateur=consommateur["nom"],
resultat="consommateur_revoque", contexte_id=contexte_id)
raise HTTPException(status_code=403, detail="consommateur_revoque")
try:
coffre = get_coffre(slug)
secret = get_secret(coffre["id"], nom) if coffre else None
except ValidationError:
raise HTTPException(status_code=400, detail="reference_invalide") from None
if coffre is None or secret is None or coffre["statut"] != "actif":
# Anti-énumération : un secret inexistant se comporte comme un
# octroi absent, il ne révèle pas l'existence du coffre/secret.
_audit("consommateur", "distribution_refusee", secret_ref=reference,
consommateur=consommateur["nom"],
resultat="octroi_absent", contexte_id=contexte_id)
raise HTTPException(status_code=403, detail="octroi_absent")
statut, _octroi = statut_octroi_consommateur(
consommateur["id"], coffre["id"], secret["id"]
)
if statut != "ok":
code = {
"revoque": "octroi_revoque",
"expire": "octroi_expire",
"absent": "octroi_absent",
}[statut]
_audit("consommateur", "distribution_refusee", coffre=coffre["slug"],
secret_ref=reference, consommateur=consommateur["nom"],
resultat=code, contexte_id=contexte_id)
raise HTTPException(status_code=403, detail=code)
try:
valeur = distribuer_valeur(coffre, secret)
except SecretExpireError:
_audit("consommateur", "distribution_refusee", coffre=coffre["slug"],
secret_ref=reference, consommateur=consommateur["nom"],
resultat="secret_expire", contexte_id=contexte_id)
raise HTTPException(status_code=410, detail="secret_expire") from None
except KEKAbsenteError:
_audit("consommateur", "distribution_refusee", coffre=coffre["slug"],
secret_ref=reference, consommateur=consommateur["nom"],
resultat="kek_absente", contexte_id=contexte_id)
raise HTTPException(status_code=503, detail="kek_absente") from None
except CryptoIndisponibleError:
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
toucher_utilisation(secret["id"])
_audit("consommateur", "distribution_ok", coffre=coffre["slug"],
secret_ref=reference, consommateur=consommateur["nom"],
resultat="ok", contexte_id=contexte_id)
return {
"reference": reference,
"valeur": valeur,
"version": secret["version_courante"],
}
# ---------------------------------------------------------------------------
# Un coffre, ses secrets, ses octrois, son journal
# ---------------------------------------------------------------------------
@router.get("/api/coffres/{slug}")
async def api_get_coffre(slug: str, current_user=Depends(require_auth)):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_voir(current_user, coffre)
return _metadata_coffre(coffre)
@router.get("/api/coffres/{slug}/secrets")
async def api_list_secrets(slug: str, current_user=Depends(require_auth)):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_voir(current_user, coffre)
return _secrets_visibles(current_user, coffre)
@router.post("/api/coffres/{slug}/secrets")
async def api_create_secret(
slug: str, body: dict = Body(...), current_user=Depends(require_auth)
):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_gerer(current_user, coffre)
try:
secret = create_secret(
coffre=coffre,
nom=body.get("nom", ""),
type_secret=body.get("type", "autre"),
valeur=body.get("valeur", ""),
service=body.get("service", ""),
expiration=body.get("expiration"),
note=body.get("note", ""),
)
except SecretExisteError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except KEKAbsenteError:
raise HTTPException(status_code=503, detail="kek_absente") from None
except CryptoIndisponibleError:
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
_audit(
_username(current_user),
"secret_cree",
coffre=coffre["slug"],
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
resultat="ok",
)
return _metadata_secret(coffre, secret)
@router.post("/api/coffres/{slug}/secrets/{nom}/rotation")
async def api_rotate_secret(
slug: str, nom: str, body: dict = Body(...), current_user=Depends(require_auth)
):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_gerer(current_user, coffre)
try:
secret = rotate_secret(coffre, nom, body.get("valeur", ""))
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except KEKAbsenteError:
raise HTTPException(status_code=503, detail="kek_absente") from None
except CryptoIndisponibleError:
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
_audit(
_username(current_user),
"secret_rotation",
coffre=coffre["slug"],
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
resultat="ok",
)
return _metadata_secret(coffre, secret)
@router.delete("/api/coffres/{slug}/secrets/{nom}")
async def api_delete_secret(
slug: str, nom: str, current_user=Depends(require_auth)
):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_gerer(current_user, coffre)
try:
secret, _octrois = delete_secret(coffre, nom)
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
_audit(
_username(current_user),
"secret_supprime",
coffre=coffre["slug"],
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
resultat="ok",
)
return {"status": "deleted", "nom": secret["nom"]}
@router.get("/api/coffres/{slug}/octrois")
async def api_list_octrois(slug: str, current_user=Depends(require_auth)):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_voir(current_user, coffre)
return list_octrois(coffre["id"])
@router.post("/api/coffres/{slug}/octrois")
async def api_create_octroi(
slug: str, body: dict = Body(...), current_user=Depends(require_auth)
):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_gerer(current_user, coffre)
secret_id = None
if body.get("secret_nom"):
secret = _secret_or_404(coffre, body["secret_nom"])
secret_id = secret["id"]
try:
octroi = create_octroi(
coffre=coffre,
cible_type=body.get("cible_type", ""),
cible_id=body.get("cible_id", ""),
secret_id=secret_id,
cree_par=_username(current_user),
expiration=body.get("expiration"),
)
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_audit(
_username(current_user),
"octroi_cree",
coffre=coffre["slug"],
secret_ref=(
f"coffre:{coffre['slug']}/{body.get('secret_nom', '*')}"
),
resultat="ok",
)
return octroi
@router.delete("/api/coffres/octrois/{octroi_id}")
async def api_revoke_octroi(octroi_id: str, current_user=Depends(require_auth)):
_exiger_actives()
# Le gestionnaire du coffre concerné (ou l'admin d'instance) seul peut
# révoquer : on résout le coffre depuis l'octroi avant toute action.
octroi = get_octroi(octroi_id)
if octroi is None:
raise HTTPException(status_code=404, detail="Octroi introuvable")
coffre = get_coffre_by_id(octroi["coffre_id"])
if coffre is None:
raise HTTPException(status_code=404, detail="Coffre introuvable")
_exiger_gerer(current_user, coffre)
try:
octroi = revoquer_octroi(octroi_id)
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
_audit(
_username(current_user),
"octroi_revoque",
coffre=coffre["slug"],
resultat="ok",
)
return octroi
@router.get("/api/coffres/{slug}/journal")
async def api_journal(slug: str, current_user=Depends(require_auth)):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_voir(current_user, coffre)
# Défense en profondeur : même si une entrée contenait un jour un champ
# sensible, il ne sort jamais par cette route.
nettoyees = []
for entry in get_recent_entries(limit=100):
if entry.get("coffre") != slug:
continue
nettoyees.append(
{
k: v
for k, v in entry.items()
if k not in ("valeur", "ciphertext", "nonce", "jeton", "dek_chiffree")
}
)
return nettoyees
# ---------------------------------------------------------------------------
# Administration KEK / rotations (admin seulement)
# ---------------------------------------------------------------------------
@router.post("/api/coffres/admin/generer-kek")
async def api_generer_kek(current_user=Depends(require_admin)):
_exiger_actives()
# La KEK générée n'est rendue qu'une fois, ici, et jamais stockée par
# ObsiGate : l'opérateur doit la sauvegarder hors dépôt immédiatement.
return {"kek": generate_kek_b64()}
@router.post("/api/coffres/{slug}/rotation-dek")
async def api_rotate_dek(slug: str, current_user=Depends(require_auth)):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_gerer(current_user, coffre)
try:
coffre = rotate_dek(slug)
except KEKAbsenteError:
raise HTTPException(status_code=503, detail="kek_absente") from None
except CryptoIndisponibleError:
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
_audit(_username(current_user), "dek_rotation", coffre=coffre["slug"],
resultat="ok")
return _metadata_coffre(coffre)
@router.post("/api/coffres/admin/rotation-kek")
async def api_rotate_kek(
body: dict = Body(...), current_user=Depends(require_admin)
):
_exiger_actives()
try:
count = rotate_kek(body.get("nouvelle_kek", ""))
except KEKAbsenteError:
raise HTTPException(status_code=503, detail="kek_absente") from None
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_audit(_username(current_user), "kek_rotation", resultat="ok")
return {"status": "ok", "coffres_rechiffres": count}
+3 -4
View File
@@ -24,7 +24,7 @@ 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 require_admin, require_auth
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 (
@@ -481,7 +481,7 @@ async def api_diagnostics(current_user=Depends(require_admin)):
"total_postings": word_index_entries,
"documents": inv.doc_count,
"sorted_tokens": len(inv._sorted_tokens),
"is_stale": inv.is_stale(),
"is_ready": inv.is_ready(),
"memory_estimate_mb": mem_estimate_mb,
},
"config": _load_config(),
@@ -495,14 +495,13 @@ async def api_diagnostics(current_user=Depends(require_admin)):
@router.get("/api/dashboard", response_model=DashboardResponse)
async def api_dashboard(current_user=Depends(require_auth)):
"""Aggregated dashboard statistics across all accessible vaults."""
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
vault_stats = []
total_files = 0
total_tags = set()
total_size = 0
total_images = 0
for vname, vdata in index.items():
if "*" not in user_vaults and vname not in user_vaults:
if not check_vault_access(vname, current_user):
continue
files = vdata.get("files", [])
fc = len(files)
+2 -3
View File
@@ -31,10 +31,9 @@ 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."""
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
all_conflicts = get_conflicts()
if "*" not in user_vaults:
all_conflicts = [c for c in all_conflicts if c["vault"] in user_vaults]
# #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)}
+88
View File
@@ -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
+84
View File
@@ -0,0 +1,84 @@
"""Endpoints des références croisées des notes — Fusion #215 (ACC-1.3).
Création par collage d'une URL profonde d'une autre application
(Flowdeck, TikTak, Kanbante, ObsiGate) — zéro couplage serveur :
l'URL est reconnue par les formes figées du contrat
``dracodev-commons/contrats/liens-profonds.md`` (voir
:mod:`backend.external_refs`), la référence est stockée chez la note
propriétaire et affichée dans sa section « Liens ».
"""
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from backend.auth.middleware import check_vault_access, require_auth
from backend.external_refs import add_ref, delete_ref, get_ref, list_refs, parse_deep_link
from backend.schemas import ExternalRef, ExternalRefCreateRequest, ExternalRefsResponse
logger = logging.getLogger("obsigate")
router = APIRouter(tags=["ExternalRefs"])
_FORMES_ACCEPTEES = (
"URL non reconnue — formes acceptées : TikTak ?task= ou ?list=, "
"ObsiGate #file=, Flowdeck /pages/ ou /collections/, "
"issue Gitea (carte Kanbante)"
)
@router.get("/api/refs", response_model=ExternalRefsResponse)
async def api_list_refs(
vault: str = Query(...),
path: str = Query(...),
current_user=Depends(require_auth),
):
"""Références croisées posées sur une note."""
if not check_vault_access(vault, current_user):
raise HTTPException(status_code=403, detail="Accès au vault refusé")
username = current_user.get("username") or ""
return {"refs": list_refs(username, vault, path)}
@router.post("/api/refs", response_model=ExternalRef, status_code=201)
async def api_add_ref(
payload: ExternalRefCreateRequest,
current_user=Depends(require_auth),
):
"""Crée une référence sur une note à partir d'une URL profonde collée."""
if not check_vault_access(payload.vault, current_user):
raise HTTPException(status_code=403, detail="Accès au vault refusé")
parsed = parse_deep_link(payload.url)
if parsed is None:
raise HTTPException(status_code=422, detail=_FORMES_ACCEPTEES)
target_app, target_type, target_id, default_label = parsed
label = (payload.label or "").strip() or default_label
username = current_user.get("username") or ""
return add_ref(
username,
payload.vault,
payload.path,
target_app,
target_type,
target_id,
payload.url.strip(),
label,
)
@router.delete("/api/refs/{ref_id}", response_model=ExternalRef)
async def api_delete_ref(
ref_id: str,
current_user=Depends(require_auth),
):
"""Retire une référence d'une note."""
username = current_user.get("username") or ""
ref = get_ref(username, ref_id)
if ref is None:
raise HTTPException(status_code=404, detail="Lien introuvable")
if not check_vault_access(ref["owner_vault"], current_user):
raise HTTPException(status_code=403, detail="Accès au vault refusé")
removed = delete_ref(username, ref_id)
if removed is None: # course improbable : disparue entre get et delete
raise HTTPException(status_code=404, detail="Lien introuvable")
return removed
+290
View File
@@ -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"}
+250 -30
View File
@@ -38,13 +38,53 @@ from backend.schemas import (
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
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",
@@ -101,7 +141,11 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela
Returns:
``FileRawResponse`` with vault, path, and raw text content.
"""
if not check_vault_access(vault_name, current_user):
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)
@@ -117,7 +161,11 @@ async def api_file_download(vault_name: str, path: str = Query(..., description=
Returns:
``FileResponse`` with ``application/octet-stream`` content-type.
"""
if not check_vault_access(vault_name, current_user):
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:
@@ -163,12 +211,11 @@ async def api_file_backlinks(
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
backlinks = get_backlinks(vault_name, path)
# Filter by user-accessible vaults
if "*" not in user_vaults:
backlinks = [b for b in backlinks if b["vault"] in user_vaults]
# 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,
@@ -178,6 +225,116 @@ async def api_file_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.
@@ -193,8 +350,18 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
Returns:
``FileContentResponse`` with HTML, metadata, and tags.
"""
if not check_vault_access(vault_name, current_user):
# #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")
@@ -241,8 +408,11 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
# === Excel .xlsx: render sheets as HTML tables (binary, before read_text) ===
if ext == ".xlsx":
try:
from backend.xlsx_reader import render_sheets
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 {
@@ -257,6 +427,13 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
"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,
}
@@ -374,32 +551,75 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
logger.error(f"Unexpected error reading file {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}")
# === CSV: render as HTML table ===
# === 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":
import csv
import io as csv_io
reader = csv.reader(csv_io.StringIO(raw))
rows = list(reader)
if not rows:
html = "<p><em>Fichier CSV vide</em></p>"
else:
headers = rows[0]
data_rows = rows[1:]
html = '<div class="csv-table-wrapper"><table class="csv-table"><thead><tr>'
for h in headers:
html += f"<th>{h}</th>"
html += "</tr></thead><tbody>"
for row in data_rows:
html += "<tr>"
for cell in row:
html += f"<td>{cell}</td>"
html += "</tr>"
html += "</tbody></table></div>"
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 ===
@@ -482,7 +702,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
tags = _extract_tags(post)
title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " "))
html_content = _render_markdown(post.content, vault_name, file_path)
html_content = _render_markdown(post.content, vault_name, file_path, click_to_copy=True)
return {
"vault": vault_name,
+313 -9
View File
@@ -15,6 +15,7 @@ from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from backend import agent_journal
from backend.audit import log_file_delete, log_file_save
from backend.auth.middleware import check_vault_access, require_auth
from backend.history import (
@@ -34,6 +35,8 @@ from backend.schemas import (
FileCreateRequest,
FileCreateResponse,
FileDeleteResponse,
FileDuplicateRequest,
FileDuplicateResponse,
FileMoveRequest,
FileMoveResponse,
FileRenameRequest,
@@ -55,6 +58,9 @@ from backend.services.mutations import (
from backend.services.mutations import (
delete_file as service_delete_file,
)
from backend.services.mutations import (
duplicate_path as service_duplicate_path,
)
from backend.services.mutations import (
edit_file as service_edit_file,
)
@@ -64,12 +70,21 @@ from backend.services.mutations import (
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
@@ -106,25 +121,59 @@ async def api_file_save(
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)
# Journal du service d'agents (#220) — émission « au mieux », API seulement.
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
@router.put("/api/file/{vault_name}/xlsx/save", response_model=FileSaveResponse)
async def api_file_xlsx_save(
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}}'),
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). A backup is created before the
workbook is rewritten.
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}'")
@@ -138,14 +187,212 @@ async def api_file_xlsx_save(
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)
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"]}
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
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"),
)
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
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"),
)
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
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"),
)
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
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)
@@ -166,6 +413,9 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
result = service_delete_file(vault_name, path)
# Journal du service d'agents (#220) — émission « au mieux », API seulement.
agent_journal.emettre(agent_journal.TYPE_SUPPRIME, vault_name, result["path"])
# Audit log
client_ip = current_user.get("_request_ip", "unknown")
log_file_delete(current_user["username"], vault_name, path, client_ip)
@@ -298,9 +548,10 @@ async def api_directory_delete(
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)
# Update index — incremental subtree removal (no full vault re-scan, no
# global inverted-index rebuild).
from backend.indexer import remove_directory_from_index
await remove_directory_from_index(vault_name, result["path"])
# Broadcast SSE event
await sse_manager.broadcast("directory_deleted", {
@@ -327,12 +578,19 @@ async def api_file_create(
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)
# Journal du service d'agents (#220) — émission « au mieux », API seulement.
agent_journal.emettre(agent_journal.TYPE_CREE, vault_name, result["path"])
# Update index
await update_single_file(vault_name, result["path"])
@@ -442,6 +700,9 @@ async def api_file_rename(
old_path_str = result["old_path"]
new_path_str = result["new_path"]
# Journal du service d'agents (#220) — la référence pointe sur le nouveau chemin.
agent_journal.emettre(agent_journal.TYPE_DEPLACE, vault_name, new_path_str)
# Update index
await handle_file_move(vault_name, old_path_str, new_path_str)
@@ -487,6 +748,11 @@ async def api_file_move(
new_path_str = result["new_path"]
item_type = result["item_type"]
# Journal du service d'agents (#220) — fichiers seulement (le catalogue
# est note.*) ; les déplacements de dossiers ne sont pas émis.
if item_type == "file":
agent_journal.emettre(agent_journal.TYPE_DEPLACE, vault_name, new_path_str)
# Update index
if item_type == "directory":
from backend.indexer import reload_single_vault
@@ -504,3 +770,41 @@ async def api_file_move(
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}
@router.post("/api/duplicate/{vault_name}", response_model=FileDuplicateResponse)
async def api_file_duplicate(
vault_name: str,
body: FileDuplicateRequest,
current_user=Depends(require_auth),
):
"""Duplicate (copy) a file or directory within the same vault."""
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_duplicate_path(vault_name, body.source_path, body.destination_dir)
new_path_str = result["new_path"]
item_type = result["item_type"]
# Index: incrémental pour chaque fichier copié (évite un reload complet).
from backend.services.vaults import get_vault_root
root = get_vault_root(vault_name)
target = root / new_path_str
if item_type == "directory":
for fp in sorted(target.rglob("*")):
if fp.is_file():
await update_single_file(vault_name, str(fp.relative_to(root)).replace("\\", "/"))
else:
await update_single_file(vault_name, new_path_str)
await sse_manager.broadcast("item_duplicated", {
"vault": vault_name,
"source_path": result["source_path"],
"new_path": new_path_str,
"item_type": item_type,
})
await dispatch_webhooks(
"file_created" if item_type == "file" else "directory_created",
{"vault": vault_name, "path": new_path_str},
)
return {"success": True, "source_path": result["source_path"], "new_path": new_path_str, "item_type": item_type}
+2 -2
View File
@@ -59,7 +59,6 @@ async def api_recent(limit: int | None = Query(None), vault: str | None = Query(
@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")
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
if not username:
return {"files": []}
@@ -68,7 +67,8 @@ async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(re
files_resp = []
for item in history:
v_name = item["vault"]
if "*" not in user_vaults and v_name not in user_vaults:
# #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
+96
View File
@@ -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}
+1 -1
View File
@@ -49,7 +49,7 @@ async def api_events(current_user=Depends(require_auth)):
try:
msg = await asyncio.wait_for(queue.get(), timeout=30.0)
yield f"event: {msg['event']}\ndata: {msg['data']}\n\n"
except asyncio.TimeoutError:
except TimeoutError:
# Keepalive comment
yield ": keepalive\n\n"
except asyncio.CancelledError:
+118
View File
@@ -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
+9 -1
View File
@@ -82,7 +82,15 @@ async def api_search(
# paginate in the shared service so routes and tools share the same logic.
return await loop.run_in_executor(
get_search_executor(),
partial(search_vaults, q, vault, tag, limit, offset),
# #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"],
),
)
+54 -15
View File
@@ -24,7 +24,8 @@ 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, require_auth
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
@@ -53,6 +54,27 @@ except Exception: # pragma: no cover - WeasyPrint/GTK missing
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,
@@ -66,9 +88,18 @@ async def api_share_create(
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
path = body.get("path", "")
path = body.get("path") or ""
if not path:
raise HTTPException(400, "Chemin de fichier requis")
expires = body.get("expires_in_hours")
share = create_share(vault_name, path, current_user["username"], expires)
# #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
@@ -94,8 +125,13 @@ async def api_share_create(
@router.get("/api/shares", response_model=list[ShareModel])
async def api_shares_list(vault: str | None = Query(None), current_user=Depends(require_auth)):
"""List all shares (optionally filtered by vault)."""
shares = list_shares(vault)
"""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
@@ -103,6 +139,12 @@ async def api_shares_list(vault: str | None = Query(None), current_user=Depends(
@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"}
@@ -113,13 +155,12 @@ async def api_share_revoke(share_id: str, current_user=Depends(require_auth)):
response_class=Response,
responses={200: {"content": {"application/pdf": {}}, "description": "Shared document as PDF"}},
)
async def public_share_pdf_download(token: str):
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)
if not share:
raise HTTPException(404, "Share not found or expired")
share = _gate_share(share, current_user)
vault_data = get_vault_data(share["vault"])
if not vault_data:
raise HTTPException(404, "Vault not found")
@@ -147,11 +188,10 @@ async def public_share_pdf_download(token: str):
@router.get("/s/{token}/raw", response_class=FileResponse)
async def public_share_raw(token: str):
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)
if not share:
raise HTTPException(404, "Share not found or expired")
share = _gate_share(share, current_user)
vault_data = get_vault_data(share["vault"])
if not vault_data:
raise HTTPException(404, "Vault not found")
@@ -164,13 +204,12 @@ async def public_share_raw(token: str):
@router.get("/s/{token}", response_class=HTMLResponse)
async def public_share_view(request: Request, token: str):
"""Public share view — no authentication required."""
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)
if not share:
raise HTTPException(404, "Share not found or expired")
share = _gate_share(share, current_user)
vault_data = get_vault_data(share["vault"])
if not vault_data:
raise HTTPException(404, "Vault not found")
+44
View File
@@ -0,0 +1,44 @@
# backend/routers/sso.py
# Fusion ACC-3.1 (#216) — Points d'entrée SSO de l'émettrice.
#
# GET /sso/authorize?return_to=<url> : point d'entrée des autres
# applications — valide le return_to (liste blanche *.dracodev.net,
# anti open-redirect) puis redirige vers la page de connexion
# d'ObsiGate en conservant le paramètre ; après connexion, le
# frontend repart vers l'application demanderesse (le cookie
# draco_session posé à la connexion couvre tout le domaine parent).
#
# GET /api/sso/revocations : liste des jti révoqués, réservée aux
# autres applications (clé machine DRACO_SSO_MACHINE_KEY, en-tête
# X-Draco-Machine-Key) qui la mettent en cache 60 s (design §5.2).
from __future__ import annotations
import os
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
from backend import draco_session
router = APIRouter(tags=["SSO"])
@router.get("/sso/authorize")
async def sso_authorize(return_to: str = ""):
target = draco_session.validate_return_to(return_to)
if target:
return RedirectResponse(
url=f"/?return_to={quote(target, safe='')}", status_code=302)
return RedirectResponse(url="/", status_code=302)
@router.get("/api/sso/revocations")
async def sso_revocations(request: Request):
machine_key = os.environ.get("DRACO_SSO_MACHINE_KEY", "")
if not machine_key:
raise HTTPException(404, "Non configuré")
if request.headers.get("X-Draco-Machine-Key") != machine_key:
raise HTTPException(401, "Clé machine invalide")
return {"revoked": draco_session.revoked_list()}
+9
View File
@@ -55,6 +55,11 @@ async def api_add_vault(body: dict = Body(...), current_user=Depends(require_adm
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:
@@ -80,6 +85,10 @@ async def api_remove_vault(vault_name: str, current_user=Depends(require_admin))
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}
+3 -1
View File
@@ -34,7 +34,9 @@ async def api_webhooks_create(body: dict = Body(...), current_user=Depends(requi
secret = body.get("secret")
if not url:
raise HTTPException(400, "URL is required")
return create_webhook(name, url, events, secret)
# #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)
+331
View File
@@ -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 UTC, datetime, timedelta
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=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(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(UTC)
if now.tzinfo is None:
now = now.replace(tzinfo=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=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=UTC)
except ValueError:
return now
if task.get("last_run_at"):
return datetime.max.replace(tzinfo=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(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(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(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=UTC)
except ValueError:
next_run = compute_next_run(task, now)
if next_run <= now:
outcomes.append(run_task(task["id"]))
return outcomes
+214 -3
View File
@@ -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``."""
@@ -252,6 +300,8 @@ class BrowseItem(BaseModel):
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):
@@ -285,8 +335,37 @@ class FileContentResponse(BaseModel):
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}]"
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")
@@ -297,6 +376,69 @@ class FileContentResponse(BaseModel):
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."""
@@ -317,6 +459,12 @@ class FileSaveResponse(BaseModel):
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):
@@ -432,6 +580,20 @@ class FileMoveResponse(BaseModel):
success: bool = Field(description="Whether move succeeded")
old_path: str = Field(description="Original path")
new_path: str = Field(description="New path after move")
class FileDuplicateRequest(BaseModel):
"""Request to duplicate (copy) a file or directory."""
source_path: str = Field(description="Relative path of the item to duplicate")
destination_dir: str = Field(default="", description="Target directory relative path (empty = source parent)")
class FileDuplicateResponse(BaseModel):
"""Response after duplicating a file or directory."""
success: bool = Field(description="Whether duplication succeeded")
source_path: str = Field(description="Original path")
new_path: str = Field(description="Path of the created copy")
item_type: str = Field(description="Type of item moved: 'file' or 'directory'")
@@ -457,6 +619,44 @@ class BookmarkToggleRequest(BaseModel):
title: str | None = None
class ExternalRef(BaseModel):
"""Référence croisée d'une note vers un objet d'une autre application.
Modèle figé par dracodev-commons (contrats/external_ref.schema.json,
v0.1.0) — Fusion #215 (ACC-1.3).
"""
id: str
owner_type: str = Field(description="Toujours 'obsigate.note' côté ObsiGate")
owner_id: str = Field(description="'<vault>:<chemin>' de la note propriétaire")
owner_vault: str
owner_path: str
target_app: str = Field(description="obsigate | flowdeck | tiktak | kanbante")
target_type: str = Field(description="note | project | page | task | list | card")
target_id: str
target_url: str = Field(description="URL profonde selon contrats/liens-profonds.md")
label: str = Field(description="Instantané d'affichage du titre de la cible")
domain: str | None = None
status: str = Field(default="ok", description="ok | target_missing")
created_by: str = Field(default="user")
created_at: str
class ExternalRefCreateRequest(BaseModel):
"""Création d'une référence par collage d'URL profonde."""
vault: str
path: str
url: str
label: str | None = None
class ExternalRefsResponse(BaseModel):
"""Liste des références d'une note."""
refs: list[ExternalRef]
# ---------------------------------------------------------------------------
# Search / suggest / graph (#85 — extrait de backend.main, inchangé)
# ---------------------------------------------------------------------------
@@ -465,13 +665,15 @@ class BookmarkToggleRequest(BaseModel):
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 = Field(description="ISO 8601 modification timestamp")
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):
@@ -519,6 +721,9 @@ class VaultPathEntry(BaseModel):
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):
@@ -548,6 +753,7 @@ class SearchFacets(BaseModel):
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):
@@ -557,7 +763,7 @@ class AdvancedSearchResponse(BaseModel):
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 and vault")
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")
@@ -568,6 +774,7 @@ class TitleSuggestion(BaseModel):
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):
@@ -763,6 +970,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):
@@ -891,6 +1099,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):
@@ -907,6 +1117,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
View File
@@ -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():
+191 -38
View File
@@ -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.
-4
View File
@@ -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()
+3 -3
View File
@@ -14,7 +14,7 @@ import logging
import os
import shutil
import time
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -145,7 +145,7 @@ def list_backup_files(vault_name: str, relative_path: str) -> list[dict[str, Any
except (ValueError, TypeError):
continue
try:
dt = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
dt = datetime.fromtimestamp(ts, tz=UTC).isoformat()
except (OSError, OverflowError, ValueError) as ts_err:
logger.warning(f"Skipping backup with invalid timestamp {ts}: {ts_err}")
continue
@@ -219,7 +219,7 @@ def diff_backup(
status=500,
details={"vault": vault_name, "path": path, "version": ts},
) from e
dt = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
dt = datetime.fromtimestamp(ts, tz=UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
return content, f"{path}@{dt}"
try:
+215
View File
@@ -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}
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -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,7 +117,7 @@ def list_recent(
for v_name, f in recent:
iso_modified = f.get("modified", "")
try:
mtime_dt = datetime.fromisoformat(iso_modified.replace("Z", "+00:00"))
mtime_dt = datetime.fromisoformat(iso_modified)
mtime_val = mtime_dt.timestamp()
except Exception:
mtime_val = time.time()
+112 -8
View File
@@ -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: # nosec B112 — partage illisible : ignoré, on continue
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}
+33 -3
View File
@@ -7,6 +7,7 @@ Single source of truth consumed by both the REST routes (``/api/vaults``,
from __future__ import annotations
from datetime import UTC
from pathlib import Path
from typing import Any
@@ -49,6 +50,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
from backend.indexer import SUPPORTED_EXTENSIONS
from backend.vault_settings import get_vault_setting
@@ -89,11 +92,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=UTC).isoformat(),
"extension": entry.suffix.lower(),
})
except PermissionError:
@@ -102,6 +108,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 = "",
@@ -128,7 +157,7 @@ def list_all_files(
ServiceError: ``not_found`` (404) if the directory does not exist,
``permission_denied`` (403) if it cannot be read.
"""
from datetime import datetime, timezone
from datetime import datetime
from backend.indexer import IGNORED_DIRS
@@ -187,8 +216,9 @@ def list_all_files(
"vault": vault_name,
"size": stat.st_size,
"modified": stat.st_mtime,
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=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
+22 -8
View File
@@ -11,7 +11,7 @@ import json
import logging
import secrets
import threading
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from pathlib import Path
logger = logging.getLogger("obsigate.share")
@@ -44,15 +44,21 @@ 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."""
"""Create a new share token for a document.
``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
expires_at = None
if expires_in_hours:
expires_at = (datetime.now(timezone.utc) + timedelta(hours=expires_in_hours)).isoformat()
expires_at = (datetime.now(UTC) + timedelta(hours=expires_in_hours)).isoformat()
share = {
"id": token,
@@ -60,10 +66,11 @@ def create_share(
"vault": vault,
"path": path,
"created_by": created_by,
"created_at": datetime.now(timezone.utc).isoformat(),
"created_at": datetime.now(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)
@@ -79,7 +86,7 @@ def get_share_by_token(token: str) -> dict | None:
return None
if share.get("expires_at"):
expires = datetime.fromisoformat(share["expires_at"])
if datetime.now(timezone.utc) > expires:
if datetime.now(UTC) > expires:
return None
return share
@@ -91,7 +98,7 @@ def record_access(token: str):
share = data["shares"].get(token)
if share:
share["access_count"] = share.get("access_count", 0) + 1
share["last_accessed"] = datetime.now(timezone.utc).isoformat()
share["last_accessed"] = datetime.now(UTC).isoformat()
_write(data)
@@ -107,10 +114,17 @@ def revoke_share(share_id: str) -> bool:
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
+6
View File
@@ -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",
+2 -2
View File
@@ -7,7 +7,7 @@ Reuses the application audit log (``data/audit.log``, JSON lines) and adds an
from __future__ import annotations
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
from backend.audit import _write_entry
@@ -41,7 +41,7 @@ def log_tool_call(
) -> None:
"""Append an ``ai_tool_call`` entry to the audit log."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"action": "ai_tool_call",
"username": username,
"ip": ip or "unknown",
+49
View File
@@ -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.
+4 -2
View File
@@ -18,7 +18,7 @@ import csv as csv_lib
import io
import logging
import re
from typing import Any
from typing import Any, cast
# saxutils.escape uniquement (échappement de chaînes, aucun parsing XML).
from xml.sax import saxutils # nosec B406
@@ -173,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
+59
View File
@@ -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
+14
View File
@@ -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"
+42
View File
@@ -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
+23 -2
View File
@@ -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)
+78
View File
@@ -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
+151
View File
@@ -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`."""
+57 -11
View File
@@ -14,6 +14,7 @@ import logging
import os
from typing import Any
from backend import agent_journal
from backend.indexer import get_backlinks as _get_backlinks
from backend.indexer import get_vault_names
from backend.services.backups import diff_backup as _diff_backup
@@ -58,7 +59,7 @@ from backend.services.search import advanced_search_vaults, search_vaults
from backend.services.search import list_tags as _list_tags
from backend.services.search import search_paths as _search_paths
from backend.services.vaults import browse_directory, list_accessible_vaults, list_all_files
from backend.tools.context import ToolContext, ToolRisk
from backend.tools.context import ToolContext, ToolMode, ToolRisk
from backend.tools.registry import tool
from backend.tools.schemas import (
AppendToFileInput,
@@ -123,10 +124,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(
@@ -353,6 +361,12 @@ def list_recent(ctx: ToolContext, params: ListRecentInput) -> dict[str, Any]:
# ── D. Mutations ───────────────────────────────────────────────────────────
def _acteur_journal(ctx: ToolContext) -> str:
"""Acteur journal (#220) : ``agent:mcp`` quand l'appel vient du
serveur MCP d'ObsiGate, ``user`` pour l'assistant intégré."""
return agent_journal.ACTEUR_MCP if ctx.mode == ToolMode.MCP else agent_journal.ACTEUR_USER
@tool(
name="create_file",
description=(
@@ -367,7 +381,12 @@ def list_recent(ctx: ToolContext, params: ListRecentInput) -> dict[str, Any]:
)
def create_file(ctx: ToolContext, params: CreateFileInput) -> dict[str, Any]:
"""Create a vault file (fails if it already exists)."""
return _create_file(params.vault, params.path, params.content)
result = _create_file(params.vault, params.path, params.content)
agent_journal.emettre(
agent_journal.TYPE_CREE, params.vault, result.get("path", params.path),
actor=_acteur_journal(ctx),
)
return result
@tool(
@@ -395,7 +414,12 @@ def create_directory(ctx: ToolContext, params: CreateDirectoryInput) -> dict[str
)
def edit_file(ctx: ToolContext, params: EditFileInput) -> dict[str, Any]:
"""Replace a vault file's content."""
return _edit_file(params.vault, params.path, params.content)
result = _edit_file(params.vault, params.path, params.content)
agent_journal.emettre(
agent_journal.TYPE_EDITE, params.vault, result.get("path", params.path),
actor=_acteur_journal(ctx),
)
return result
@tool(
@@ -407,7 +431,12 @@ def edit_file(ctx: ToolContext, params: EditFileInput) -> dict[str, Any]:
)
def append_to_file(ctx: ToolContext, params: AppendToFileInput) -> dict[str, Any]:
"""Append content to a vault file."""
return _append_to_file(params.vault, params.path, params.content)
result = _append_to_file(params.vault, params.path, params.content)
chemin = result.get("path", params.path) if isinstance(result, dict) else params.path
agent_journal.emettre(
agent_journal.TYPE_EDITE, params.vault, chemin, actor=_acteur_journal(ctx),
)
return result
@tool(
@@ -419,7 +448,12 @@ def append_to_file(ctx: ToolContext, params: AppendToFileInput) -> dict[str, Any
)
def rename_file(ctx: ToolContext, params: RenameFileInput) -> dict[str, Any]:
"""Rename a vault file."""
return _rename_file(params.vault, params.path, params.new_name)
result = _rename_file(params.vault, params.path, params.new_name)
agent_journal.emettre(
agent_journal.TYPE_DEPLACE, params.vault,
result.get("new_path", params.path), actor=_acteur_journal(ctx),
)
return result
@tool(
@@ -443,7 +477,14 @@ def rename_directory(ctx: ToolContext, params: RenameDirectoryInput) -> dict[str
)
def move_path(ctx: ToolContext, params: MovePathInput) -> dict[str, Any]:
"""Move a vault file or directory."""
return _move_path(params.vault, params.source_path, params.destination_dir)
result = _move_path(params.vault, params.source_path, params.destination_dir)
# Journal (#220) : fichiers seulement — les dossiers sont hors catalogue.
if result.get("item_type") == "file":
agent_journal.emettre(
agent_journal.TYPE_DEPLACE, params.vault,
result.get("new_path", params.source_path), actor=_acteur_journal(ctx),
)
return result
@tool(
@@ -490,7 +531,12 @@ def replace_in_files(ctx: ToolContext, params: ReplaceInFilesInput) -> dict[str,
)
def delete_file(ctx: ToolContext, params: DeleteFileInput) -> dict[str, Any]:
"""Delete a vault file."""
return _delete_file(params.vault, params.path)
result = _delete_file(params.vault, params.path)
chemin = result.get("path", params.path) if isinstance(result, dict) else params.path
agent_journal.emettre(
agent_journal.TYPE_SUPPRIME, params.vault, chemin, actor=_acteur_journal(ctx),
)
return result
@tool(
+573
View File
@@ -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),
}
+169
View File
@@ -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
+1 -1
View File
@@ -244,7 +244,7 @@ class VaultWatcher:
key = event.get('dest') or event['src']
pending[key] = event
except asyncio.TimeoutError:
except TimeoutError:
if pending:
events_to_process = list(pending.values())
pending.clear()
+93 -7
View File
@@ -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
@@ -28,7 +31,7 @@ import os
import socket
import threading
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import urlparse
@@ -120,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 []
@@ -195,7 +257,14 @@ 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)
with _lock:
webhooks = _read()
@@ -206,8 +275,11 @@ def create_webhook(name: str, url: str, events: list[str], secret: str | None =
"url": url,
"events": [e for e in events if e in VALID_EVENTS],
"enabled": True,
"created_at": datetime.now(timezone.utc).isoformat(),
"created_at": datetime.now(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)
@@ -226,6 +298,13 @@ def update_webhook(wh_id: str, updates: dict) -> dict | None:
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")
@@ -250,15 +329,22 @@ def delete_webhook(wh_id: str) -> bool:
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
payload = {
"event": event_type,
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"data": data,
}
body = json.dumps(payload, default=str)
@@ -283,7 +369,7 @@ async def dispatch_webhooks(event_type: str, data: dict):
logger.debug(f"Webhook '{wh['name']}' OK ({resp.status})")
else:
logger.warning(f"Webhook '{wh['name']}' failed ({resp.status})")
update_webhook(wh["id"], {"last_fired_at": datetime.now(timezone.utc).isoformat()})
update_webhook(wh["id"], {"last_fired_at": datetime.now(UTC).isoformat()})
except Exception as e:
logger.warning(f"Webhook '{wh['name']}' error: {e}")
+1099 -15
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.28.13"
version = "2.80.2"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.28.13"
version = "2.80.2"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+2
View File
@@ -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()
}
+1
View File
@@ -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
View File
@@ -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"]}}
+6
View File
@@ -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",
+6
View File
@@ -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",
+36
View File
@@ -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
View File
@@ -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
View File
@@ -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");
}
}
+41
View File
@@ -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.*
+2 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.28.13",
"version": "2.80.2",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
@@ -13,6 +13,7 @@
"withGlobalTauri": true,
"windows": [
{
"create": false,
"title": "ObsiGate",
"width": 1200,
"height": 800,
+5 -4
View File
@@ -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:
+5
View File
@@ -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 -3
View File
@@ -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
@@ -48,12 +51,17 @@ services:
- VAULT_4_PATH=/vaults/Obsidian_WORKOUT
- VAULT_5_NAME=Sessions
- 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:
+527
View 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).
```
+239
View File
@@ -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.

Some files were not shown because too many files have changed in this diff Show More