Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bee4a237d | ||
|
|
d6cca2b1af | ||
|
|
58312e64da | ||
|
|
3b0927a8c9 | ||
|
|
6e527c371d | ||
|
|
6cccdc1f34 | ||
|
|
0abc17e9f2 | ||
|
|
b83d8dacdf | ||
|
|
dadc055429 | ||
|
|
750114a923 | ||
|
|
83a81da319 | ||
|
|
3eb0256127 | ||
|
|
9d8b3cc854 | ||
|
|
0ab402aa73 | ||
|
|
c36c299466 | ||
|
|
8611416670 | ||
|
|
e20fd6bf97 | ||
|
|
943005328c | ||
|
|
e1842043d8 | ||
|
|
9fb094f505 | ||
|
|
d142049216 | ||
|
|
33fe1a3439 | ||
|
|
a726ad8511 | ||
|
|
b926f01b85 | ||
|
|
8264e7ffae | ||
|
|
80852374a8 | ||
|
|
e3c6789776 | ||
|
|
e2417cb5ab | ||
|
|
eccbf7474e | ||
|
|
69cee4d93a | ||
|
|
705f755b6b | ||
|
|
8ad8eaac71 | ||
|
|
dd9224e685 | ||
|
|
aeb7516445 | ||
|
|
bca0fdd941 | ||
|
|
60da957f13 | ||
|
|
f621620593 | ||
|
|
eff74cabe0 | ||
|
|
6f0a6f7fd8 | ||
|
|
ab7c227b97 | ||
|
|
b8054665bc | ||
|
|
99a5b735c8 |
@@ -16,13 +16,15 @@ OBSIGATE_ADMIN_PASSWORD=chab30
|
||||
# OBSIGATE_SECURE_COOKIES=false
|
||||
|
||||
# Tokens TTL en secondes
|
||||
# OBSIGATE_ACCESS_TOKEN_TTL=900
|
||||
# OBSIGATE_ACCESS_TOKEN_TTL=31536000000 # 1000 ans
|
||||
# OBSIGATE_REFRESH_TOKEN_TTL=604800
|
||||
|
||||
# Rate limiting
|
||||
# OBSIGATE_LOGIN_MAX_ATTEMPTS=10
|
||||
# OBSIGATE_ACCOUNT_MAX_ATTEMPTS=10
|
||||
# OBSIGATE_LOGIN_WINDOW_SECONDS=900
|
||||
# Compteurs partagés/persistants (SQLite WAL, multi-workers) — défaut : mémoire.
|
||||
# OBSIGATE_RATELIMIT_DB=data/ratelimit.db
|
||||
|
||||
# IP client derrière un reverse proxy (fait confiance à X-Forwarded-For)
|
||||
# OBSIGATE_TRUST_PROXY=false
|
||||
@@ -51,7 +53,10 @@ OBSIGATE_ADMIN_PASSWORD=chab30
|
||||
# OBSIGATE_PDF_MAX_SIZE_MB=50 # PDFs plus volumineux = texte non indexé
|
||||
# OBSIGATE_PDF_EXTRACT_TIMEOUT=30 # secondes avant abandon de l'extraction
|
||||
|
||||
# WebAuthn / MFA (ROADMAP #64) — nécessaire hors localhost
|
||||
# WebAuthn / MFA (ROADMAP #64) — par défaut rp_id/origines sont dérivés de la
|
||||
# requête (hôte exact, port inclus) : rien à configurer en accès direct.
|
||||
# À renseigner uniquement pour un accès via reverse-proxy sous un autre nom
|
||||
# (avec OBSIGATE_TRUST_PROXY=true pour X-Forwarded-Host/Proto) :
|
||||
# OBSIGATE_WEBAUTHN_RP_ID=obsigate.example.com
|
||||
# OBSIGATE_WEBAUTHN_RP_NAME=ObsiGate
|
||||
# OBSIGATE_WEBAUTHN_ORIGINS=https://obsigate.example.com
|
||||
|
||||
@@ -38,8 +38,17 @@ jobs:
|
||||
- name: Frontend unit tests
|
||||
run: |
|
||||
node tests/frontend/unit.test.mjs
|
||||
node tests/frontend/image-viewer.test.mjs
|
||||
node tests/frontend/pdf-viewer.test.mjs
|
||||
node tests/frontend/forge-completion.test.mjs
|
||||
node tests/frontend/config-mobile.test.mjs
|
||||
node tests/frontend/settings-order-avatar.test.mjs
|
||||
node tests/frontend/mobile-toolbar.test.mjs
|
||||
node tests/frontend/upload.test.mjs
|
||||
node tests/frontend/pretty.test.mjs
|
||||
node tests/frontend/media-viewer.test.mjs
|
||||
node tests/frontend/mfa-settings.test.mjs
|
||||
node tests/frontend/config-ai-keys.test.mjs
|
||||
|
||||
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition)
|
||||
run: |
|
||||
@@ -58,6 +67,7 @@ jobs:
|
||||
node desktop.test.mjs
|
||||
node toolbar-order.test.mjs
|
||||
node editor-inline.test.mjs
|
||||
node ai-quick-actions.test.mjs
|
||||
else
|
||||
echo "tests/frontend/node_modules missing - installing jsdom"
|
||||
npm install --no-audit --no-fund --silent
|
||||
@@ -74,6 +84,7 @@ jobs:
|
||||
node desktop.test.mjs
|
||||
node toolbar-order.test.mjs
|
||||
node editor-inline.test.mjs
|
||||
node ai-quick-actions.test.mjs
|
||||
fi
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────
|
||||
@@ -120,11 +131,17 @@ jobs:
|
||||
pip install bandit pip-audit
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Bandit (SAST)
|
||||
run: bandit -r backend/ --skip B101,B110,B310 || echo "bandit found issues (non-blocking)"
|
||||
- name: Bandit (SAST, bloquant — #87)
|
||||
# B105 est exclu (aligné avec [tool.bandit] de pyproject.toml :
|
||||
# faux positifs systématiques sur les noms de variables) ; les rares
|
||||
# vrais positifs restants portent un `# nosec` justifié inline.
|
||||
run: bandit -r backend/ --skip B101,B105,B110,B310
|
||||
|
||||
- name: Pip-audit (dependency vulnerabilities)
|
||||
run: pip-audit || echo "pip-audit found vulnerabilities (non-blocking)"
|
||||
- name: 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.
|
||||
run: pip-audit || echo "pip-audit found vulnerabilities (non-blocking, see #87)"
|
||||
|
||||
# ── Docker build ──────────────────────────────────────────────────
|
||||
build:
|
||||
@@ -186,6 +203,9 @@ jobs:
|
||||
npm ci
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
- name: Npm audit (bloquant — #87, 0 dépendance prod hors Playwright)
|
||||
run: npm audit --omit=dev
|
||||
|
||||
- name: Start ObsiGate
|
||||
run: |
|
||||
docker rm -f obsigate-e2e 2>/dev/null || true
|
||||
|
||||
@@ -44,9 +44,13 @@ node tests/frontend/unit.test.mjs
|
||||
# Tests JSDOM : node_modules dans tests/frontend/ (npm install là-bas si absent), ex :
|
||||
node tests/frontend/pane-manager.test.mjs
|
||||
|
||||
# E2E (si UI touchée, ~5 min) : reproduit le job CI e2e (port 2029, auth désactivée)
|
||||
# E2E (si UI touchée, ~10 min) : reproduit le job CI e2e (port 2029, auth désactivée)
|
||||
npm run test:e2e # prérequis : uv, Node >= 20, npx playwright install chromium
|
||||
bash scripts/run-e2e-local.sh -g "nom du test" # filtre / --headed
|
||||
|
||||
# Windows sans bash exploitable (WSL HS, git-bash bloqué par App Control) :
|
||||
npm run test:e2e:ps # équivalent PowerShell, mêmes conditions que le CI
|
||||
pwsh -File scripts/run-e2e-local.ps1 -PlaywrightArgs @('-g','nom du test')
|
||||
```
|
||||
|
||||
- Un seul test backend : `.\.venv\Scripts\python.exe -m pytest tests/test_search.py -q`.
|
||||
@@ -91,6 +95,7 @@ bash scripts/run-e2e-local.sh -g "nom du test" # filtre / --headed
|
||||
| Travail à venir + index | `docs/ROADMAP.md` |
|
||||
| Historique des versions | `CHANGELOG.md` |
|
||||
| Conception par feature | `docs/features/<slug>.md` |
|
||||
| Guides d'utilisation | `docs/GUIDES/` |
|
||||
| Archive du complété | `docs/archive/COMPLETED_v1-v2.md` |
|
||||
| Bugs / TODO | `docs/ISSUES_TODOLIST.md` |
|
||||
| Build & releases | `docs/DEVELOPMENT_AND_RELEASES.md` |
|
||||
|
||||
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.13.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.28.1**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,683 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.28.1] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#87 (T1) — CI sécurité durcie.**
|
||||
`bandit` passe en bloquant (`# nosec` justifiés : SHA1 non-crypto,
|
||||
subprocess git à argv fixe, `saxutils.escape` sans parsing ; B105 exclu
|
||||
comme `pyproject.toml`) ; `npm audit --omit=dev` bloquant (0 faille) ;
|
||||
les 5 suites frontend hors CI (`upload`, `pretty`, `media-viewer`,
|
||||
`mfa-settings`, `config-ai-keys`, vertes en local) rejoignent le job
|
||||
`lint`. `pip-audit` reste consultatif (upgrades starlette/weasyprint à
|
||||
qualifier, chantier dédié).
|
||||
|
||||
---
|
||||
|
||||
## [2.28.0] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.27.12] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.27.11] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.27.10] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.27.9] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.27.8] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.27.7] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.27.6] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.27.5] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.27.4] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.27.3] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.27.2] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T10) — persistance d'état et clôture de la refonte architecturale.**
|
||||
Verrous `RLock` sur les stores JSON sans protection (`revoked_tokens`,
|
||||
`shares`, `webhooks` + secrets, clés d'outils) avec tests de concurrence
|
||||
(`tests/test_store_locks.py` — pertes prouvées sans verrou) ; rate-limit
|
||||
auth persisté en option (`OBSIGATE_RATELIMIT_DB`, SQLite WAL, sémantique
|
||||
identique, défaut mémoire inchangé, `tests/test_ratelimit_store.py`).
|
||||
Contrat `tools/registry.py` audité (permissions/quotas/redaction déjà
|
||||
câblés, rien à coder). Index non persisté : rebuild différentiel #86
|
||||
suffisant (décision documentée). Fiche `docs/features/archi-refonte-85.md`,
|
||||
#85 sorti du backlog (index roadmap).
|
||||
- **#85 (T9) — extraction realtime + render hors du monolithe `backend/main.py`.**
|
||||
Le stream SSE `/api/events` et le WebSocket `/ws/collab/*` sont servis par
|
||||
`backend/routers/realtime.py`, le pipeline markdown (mistune, wikilinks,
|
||||
slugs, sanitizer) par `backend/render.py` (imports directs, plus de
|
||||
couplage différé). `main.py` (4 827 → ~760 lignes) ne contient plus que
|
||||
l'assemblage : lifespan, middlewares, montage des 16 routers, racine
|
||||
`/api`, statique/SPA et cales de compatibilité testées.
|
||||
- **#85 (T8) — extraction vaults/history/conflicts hors du monolithe `backend/main.py`.**
|
||||
13 routes servies par `backend/routers/vaults.py`, `history.py` et
|
||||
`conflicts.py` ; `VaultInfo`/`BookmarkToggleRequest` dans `schemas.py`,
|
||||
handle watcher partagé dans `backend/watcher_state.py`.
|
||||
`tests/test_api_main.py` importe `humanize_mtime` depuis son module
|
||||
canonique (`services.recent`).
|
||||
- **#85 (T7) — extraction du domaine `config` hors du monolithe `backend/main.py`.**
|
||||
`/api/config`, ai-keys (get/post/delete/test), tool-keys (×3), ai-models,
|
||||
diagnostics et dashboard sont servis par `backend/routers/config.py`
|
||||
(`_FALLBACK_MODELS`, store clés et config déplacés ; `main` réimporte
|
||||
`_load_config` pour son lifespan, les fixtures de tests inchangées).
|
||||
`tests/test_ai_models.py` patch désormais la référence du router.
|
||||
- **#85 (T6c) — extraction media/pdf/export/guide hors du monolithe `backend/main.py`.**
|
||||
file/pdf, exports (html/md-bundle/epub), guide/download, pdf/stream|info,
|
||||
image, media+thumb, attachments (rescan/stats), vault settings (get/post/all)
|
||||
et vault files sont servis par `backend/routers/files_media.py` ; le helper
|
||||
Range partagé vit dans `backend/routers/helpers.py` (tags OpenAPI inchangés,
|
||||
tests statiques frontend `media-viewer`/`image-viewer` réalignés).
|
||||
- **#85 (T6b) — extraction mutations fichiers/dossiers hors du monolithe `backend/main.py`.**
|
||||
`PUT .../save|xlsx/save`, `DELETE/POST/PATCH /api/file`, `POST/PATCH/DELETE
|
||||
/api/directory`, `POST /api/move`, `POST .../batch-upload` sont servis par
|
||||
le nouveau `backend/routers/files_write.py` (effets de bord inchangés :
|
||||
audit, index, SSE, webhooks, plugins, historique) ; 15 modèles dans
|
||||
`schemas.py`.
|
||||
- **#85 (T6a) — extraction lecture fichiers hors du monolithe `backend/main.py`.**
|
||||
`/api/browse/{vault}`, `/api/file/{vault}/raw|download|backlinks` et
|
||||
`GET /api/file/{vault}` (vue rendue tous formats) sont servis par le
|
||||
nouveau `backend/routers/files_read.py` ; modèles dans `schemas.py`,
|
||||
`_content_disposition`/`_media_max_inline_bytes` dans
|
||||
`backend/routers/helpers.py` (partagés avec les tranches suivantes).
|
||||
Correctif au passage : décorateur orphelin `/s/{token}` resté en T3 et
|
||||
double-enregistrement de `/api/conflicts` supprimés.
|
||||
- **#85 (T5) — extraction du domaine `search` hors du monolithe `backend/main.py`.**
|
||||
Les 11 routes (`/api/search`, `/advanced`, `/replace`, `/tags`,
|
||||
`/tree-search`, `/vault/{vault}/paths`, `/suggest`, `/tags/suggest`,
|
||||
`/graph/{vault}`, `/index/reload`, `/index/reload/{vault}`) sont servies
|
||||
par le nouveau `backend/routers/search.py` ; les modèles search dans
|
||||
`schemas.py` et le pool de threads dans `backend/search_executor.py`
|
||||
(même dimensionnement, même cycle de vie) — comportement inchangé.
|
||||
- **#85 (T4) — extraction du domaine `backups` hors du monolithe `backend/main.py`.**
|
||||
Les 9 routes (`/api/file/{vault}/backups|diff|restore`, `/api/backups`,
|
||||
`/delete`, `/purge`, `/content`, `/compress`, `/auto`) sont servies par le
|
||||
nouveau `backend/routers/backups.py` ; `Diff/Restore*` déménagent dans
|
||||
`schemas.py` et le singleton SSE dans `backend/sse.py` (partagé avec
|
||||
`main`) — comportement inchangé, aucun impact utilisateur.
|
||||
- **#85 (T3) — extraction du domaine `sharing` hors du monolithe `backend/main.py`.**
|
||||
`POST /api/share/{vault}`, `GET /api/shares`, `DELETE /api/share/{share_id}`
|
||||
et les pages publiques `/s/{token}`, `/s/{token}/raw`, `/s/{token}/pdf`
|
||||
sont servis par le nouveau `backend/routers/sharing.py` — chemins,
|
||||
réponses, tags OpenAPI et authentification inchangés (aucun impact
|
||||
utilisateur).
|
||||
- **#85 (T2) — extraction du domaine `webhooks` hors du monolithe `backend/main.py`.**
|
||||
Le CRUD `GET/POST/PATCH/DELETE /api/webhooks` (admin) est servi par le
|
||||
nouveau `backend/routers/webhooks.py` — chemins, réponses, tags OpenAPI et
|
||||
authentification inchangés (aucun impact utilisateur).
|
||||
- **#85 (T1) — extraction du domaine `health` hors du monolithe `backend/main.py`.**
|
||||
`GET /api/health` et `GET /api/health/detailed` (admin) sont servis par le
|
||||
nouveau `backend/routers/health.py` (monté dans `main.py`) et le modèle
|
||||
`HealthResponse` déménage dans `backend/schemas.py` — chemins, réponses,
|
||||
tags OpenAPI et authentification inchangés (aucun impact utilisateur).
|
||||
|
||||
---
|
||||
|
||||
## [2.27.1] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **Roadmap — priorisation dette & sécurité (décisions 2026-09-26).**
|
||||
Items #85 (refonte architecturale) et #87 (CI/CD) détaillés et marqués
|
||||
prioritaires : `backend/main.py` mesuré à ~4 827 lignes, `tools/registry.py`
|
||||
à créer, persistance SQLite/Redis, verrous asyncio, audit des `except`
|
||||
larges, CI sécurité bloquante (bandit/semgrep/trivy, audits pip/npm),
|
||||
finition CSP nonce (BUG-034), cookies `Secure` par défaut, rotation clé
|
||||
DeepSeek à confirmer (BUG-006). #73 Sync reporté (P4, hors chemin
|
||||
critique) ; desktop #77 confirmé non signé + doc SmartScreen, reste les
|
||||
6 tests E2E manuels. Corrections : sections livrées #83/#84 retirées du
|
||||
backlog (détail dans l'archive, index inchangé), total restant recalculé
|
||||
(~12-18 jours chemin critique : #77 fin + #85 + #87).
|
||||
|
||||
---
|
||||
|
||||
## [2.27.0] — 2026-09-25
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#152 — Viewer XLSX** : affichage des fichiers `.xlsx` en tableaux multi-feuilles (onglets,
|
||||
en-têtes A1), édition inline des cellules avec `PUT /api/file/{vault}/xlsx/save` (backup avant
|
||||
écriture, coercion numérique) et téléchargement du fichier d'origine.
|
||||
|
||||
---
|
||||
|
||||
## [2.25.1] — 2026-09-24
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **`/api/diagnostics` — erreur 500 « dictionary changed size during iteration ».**
|
||||
Le calcul des statistiques d'index itérait `inv.word_index` et `index` en
|
||||
direct, pendant que l'indexeur les modifiait depuis un autre thread (build au
|
||||
démarrage, hooks incrémentaux) : l'itérateur de dict levait `RuntimeError` et
|
||||
l'endpoint renvoyait 500. Les deux dicts sont désormais **copiés avant
|
||||
itération** (copie atomique sous le GIL), ce qui supprime la course.
|
||||
|
||||
---
|
||||
|
||||
## [2.25.0] — 2026-09-24
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#115 — barre d'outils de lecture toujours visible.** La barre d'actions d'un
|
||||
document (pop-out, bookmark, Editer, Source, Copier, Partager…) est désormais
|
||||
**épinglée en haut** de la zone de lecture : elle reste accessible en permanence,
|
||||
même au bas d'un document long, au lieu de disparaître au défilement. Elle est
|
||||
rendue comme un enfant direct du conteneur de défilement (`.file-toolbar`), masquée
|
||||
en mode lecture, et alignée dans la fenêtre pop-out.
|
||||
- **#117 — avatars prédéfinis dans le profil.** La section **Profil** propose une
|
||||
galerie de **12 avatars** fournis avec l'application (`frontend/icons/avatar/`) :
|
||||
un clic charge l'image, la recadre au format carré 256 px (même pipeline que
|
||||
l'import) et l'enregistre sur le compte. L'avatar actif est surligné ; l'import
|
||||
d'une photo personnalisée et la suppression restent disponibles.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-078 — coloration syntaxique des fichiers de code perdue.** Les feuilles de
|
||||
style highlight.js étaient basculées à partir de la **clé de thème**
|
||||
(`defaut-obsigate`, …) au lieu du **mode** (`dark`/`light`) : les deux feuilles se
|
||||
retrouvaient désactivées et les blocs de code (`.py`, `.sh`, `.ps1`, `.yml`, JSON,
|
||||
blocs Markdown…) s'affichaient en texte brut. Le basculement est désormais piloté
|
||||
par le mode, de façon déterministe, dans le moteur de thème (`themes.js`) comme au
|
||||
premier rendu (`ui.js`) ; sépia et contraste élevé réutilisent la palette claire.
|
||||
|
||||
---
|
||||
|
||||
## [2.24.0] — 2026-09-24
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **BUG-077 — assistant IA : bouton « Stop ».** Pendant qu'une réponse se diffuse, le
|
||||
bouton d'envoi du composeur devient un bouton **Stop** (icône carrée, couleur
|
||||
d'alerte) : un clic interrompt immédiatement l'exécution de l'agent (abort de la
|
||||
requête SSE, annulation de la tâche côté serveur à la déconnexion) et la réponse
|
||||
partielle est conservée avec un marqueur « ⏹ Exécution arrêtée. ». Le bouton reste
|
||||
actif (il n'est plus désactivé) tant que l'agent travaille, y compris pendant la
|
||||
reprise d'une confirmation.
|
||||
|
||||
### Modifié
|
||||
|
||||
- **BUG-075 — assistant IA : approbation globale des actions.** Une même réponse du
|
||||
modèle peut demander **plusieurs** mutations (créer un dossier et les fichiers
|
||||
qu'il contient…). Elles sont désormais **regroupées en une seule confirmation** (au
|
||||
lieu d'une action après l'autre) : la carte liste chaque action avec son libellé et
|
||||
son aperçu de diff, et un unique bouton « **Tout approuver (N)** » envoie
|
||||
`confirm_all` — les actions en attente sont appliquées d'un bloc, puis la suite de
|
||||
l'exécution est autorisée sans nouvelle carte. Les appels en lecture du même lot
|
||||
s'exécutent immédiatement (conversation valide). Côté backend, `pending.actions`
|
||||
remplace le report `deferred` des mutations d'un lot (`backend/agent/loop.py`) et
|
||||
`confirm_all` arme `ToolContext.confirmed` pour le reste du run
|
||||
(`backend/bookslm_routes.py`).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-074 — assistant IA : bloc d'étapes sans titre et compteur figé à 1.** Le
|
||||
résumé replié affiche désormais un **titre** (le libellé de la première action,
|
||||
« N étapes — Fichier créé : notes/a.md ▶ »), le compteur ne compte plus les
|
||||
**réflexions** (seules les actions) et la reprise d'une confirmation continue de
|
||||
diffuser dans **le même message** au lieu de créer un nouveau message « 1 étape »
|
||||
par approbation : le bloc d'étapes s'accumule sur tout l'échange.
|
||||
- **BUG-076 — assistant IA : arborescence et document ouvert non rafraîchis.** Après
|
||||
une action mutatrice de l'agent (création/suppression/renommage de fichier ou
|
||||
dossier, écriture), l'arborescence est rafraîchie immédiatement (rafraîchissement
|
||||
débouncé sur les événements `tool` mutateurs, sans attendre le watcher) et le
|
||||
document affiché est rechargé depuis le disque ; les outils de création de documents
|
||||
(xlsx/docx/csv/pdf) notifient désormais aussi la visionneuse.
|
||||
|
||||
---
|
||||
|
||||
## [2.23.0] — 2026-09-24
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#114 — Configuration, refonte mobile-responsive.** La page « Configurations »
|
||||
est désormais pensée pour le tactile (≤ 768 px) : modale **plein écran**
|
||||
(`100dvh`, safe-area), sommaire en **drawer coulissant** gauche
|
||||
(`position: fixed`, largeur `min(320px, 88vw)`) avec fond assombri
|
||||
(`#config-modal.config-toc-open::before`) et bouton de fermeture
|
||||
`#config-toc-close` (tap sur le backdrop ou Échap ferme le drawer d'abord,
|
||||
puis la modale) ; formulaires sur **une colonne** ; tous les boutons
|
||||
(save/secondary/danger/add/sm/hamburger/fermer) et liens du sommaire en cibles
|
||||
**≥ 44 px** ; champs et selects en **16 px + min-height 44 px** (anti-zoom
|
||||
iOS) ; rangée « Sauvegarder / Réindexer / Réinitialiser » **sticky** en bas de
|
||||
sa section avec safe-area ; MFA (codes de secours en 1 colonne, champ de code
|
||||
full-width et wrap des rangées d'action) ; débordements webhook/token/share
|
||||
corrigés. Dettes HTML/i18n de l'audit incluses : `.config-actions-row` replacée
|
||||
dans `#cfg-backend-settings` (+ `</section>` orphelin supprimé), id dupliqué
|
||||
`cfg-partages-publics` retiré du `<h2>`, conteneur mort
|
||||
`#plugins-settings-container` supprimé, libellé `#mt-explorer` i18n
|
||||
(`settings.explorer`), doublons `.config-btn-add` et règle morte
|
||||
`.mfa-recovery-input` purgés ; i18n : clés mortes `settings.backend`,
|
||||
`settings.backend_hint`, `settings.restart_badge`, `settings.save`,
|
||||
`settings.plugins` supprimées, `config.toc_close` ajoutée, `settings.tabs` FR
|
||||
corrigé (« Onglets »). Tests : `config-mobile.test.mjs` étendu (27, au CI) +
|
||||
E2E `config-mobile.spec.js` (5, projet `chromium-mobile`). Fiche :
|
||||
[docs/features/settings-mobile-114.md](./docs/features/settings-mobile-114.md).
|
||||
|
||||
---
|
||||
|
||||
## [2.22.1] — 2026-09-23
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-073 — mobile, fin de contenu masquée.** La barre de navigation fixe du bas
|
||||
(`#mobile-toolbar`, 64 px + safe-area) recouvrait le bas de tous les documents et
|
||||
pages en vue mobile (≤ 768 px) : les dernières lignes restaient définitivement sous
|
||||
la barre. Cause : la règle de clearance du bloc mobile ciblait `.main-layout`,
|
||||
classe absente de `index.html` (le vrai conteneur est `.main-body`) — sélecteur
|
||||
mort, aucun dégagement réservé. Correctif : cible `.main-body`
|
||||
(`padding-bottom: calc(64px + env(safe-area-inset-bottom, 0))`), suppression de la
|
||||
règle sœur morte `.editor-modal.active ~ .main-layout`, reset du dégagement en mode
|
||||
lecture (barre masquée) et `body.np-active .content-area` ramené à `76 px` (le
|
||||
dégagement dock, sans double comptage avec `.main-body`). Tests :
|
||||
`tests/frontend/mobile-toolbar.test.mjs` (7, au CI) et E2E
|
||||
`tests/e2e/mobile-toolbar.spec.js` (2, projet `chromium-mobile`).
|
||||
|
||||
---
|
||||
|
||||
## [2.22.0] — 2026-09-23
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#113 — avatar utilisateur.** La section « Profil » des Configurations permet de
|
||||
**choisir / importer une image** (PNG, JPG, WEBP, 8 Mo max) : elle est recadrée en
|
||||
carré 256 px côté client, validée côté serveur (data-URL + octets magiques, sans SVG)
|
||||
et persistée sur le compte (`avatar` dans `data/users.json`, exposé par
|
||||
`GET/PATCH /api/auth/me` et la réponse de login). L'image s'affiche dans le **cercle
|
||||
du profil en bas de la sidebar** (initiales en repli) et peut être supprimée. UI :
|
||||
aperçu circulaire avec overlay caméra au survol, boutons Choisir / Supprimer, erreurs
|
||||
en ligne. Clés i18n FR/EN.
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#113 — ordre des sections Configurations.** La TOC et la page sont réorganisées
|
||||
dans un ordre naturel et **parfaitement synchronisées** : **Profil en premier**,
|
||||
puis Sécurité, Thèmes, Recherche, Historique récent, Tags, Fichiers cachés,
|
||||
Synchronisation, Backend, Diagnostics, IA, Sources connectées, Clés API & MCP,
|
||||
Push, Webhooks, Partages publics, Plugins et **À propos en dernier**. Les ancres
|
||||
`cfg-tags` et `cfg-partages-publics` sont désormais portées par leur `<section>`
|
||||
(cible de défilement = haut de section). Garde-fous : test statique
|
||||
`tests/frontend/settings-order-avatar.test.mjs` (ordre TOC = page, pas d'ancre morte).
|
||||
|
||||
---
|
||||
|
||||
## [2.21.0] — 2026-09-23
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#112 — section compte en bas de la sidebar.** L'identité (avatar avec
|
||||
initiales, nom, rôle) et la **déconnexion** sont regroupées dans un bloc
|
||||
épinglé au bas de la barre latérale, comme sur les sites professionnels ; un
|
||||
clic sur l'identité ouvre le Profil. Nouvelle clef i18n FR/EN pour le rôle.
|
||||
- **#112 — pellicule d'images défilable.** La molette de la souris fait défiler
|
||||
horizontalement la bande de miniatures et deux **flèches translucides** sur les
|
||||
côtés (révélées au survol) remplissent la même fonction.
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#112 — en-tête allégé.** La **version** quitte le header pour le menu
|
||||
**Options** (ligne « Version ») ; le **bouton de déconnexion** et le **nom de
|
||||
l'utilisateur** sont retirés du header au profit de la section compte de la
|
||||
sidebar. Le header droit ne conserve que l'indicateur hors-ligne, le contexte
|
||||
vault et le bouton Options.
|
||||
|
||||
---
|
||||
|
||||
## [2.20.0] — 2026-09-23
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#111 — visionneuse d'images : flèches latérales translucides.** Deux boutons
|
||||
superposés le long des bords du cadre passent à l'image précédente/suivante ;
|
||||
quasi invisibles au repos, ils se révèlent au survol (et restent visibles sur
|
||||
les appareils tactiles). Un compteur `n / total` complète la barre d'outils.
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#111 — navigation d'images fluide.** Changer d'image se fait désormais **en
|
||||
place** (`showSibling` remplace le `src` du `<img>`) : plus de rechargement de
|
||||
la vue ni de nouvel appel `/api/browse` à chaque flèche, **même dans un dossier
|
||||
contenant beaucoup d'images**. La liste du dossier est mise en cache (TTL court)
|
||||
et les images voisines sont préchargées ; la **pellicule de miniatures reste
|
||||
visible** et la vignette active est simplement re-marquée.
|
||||
- **#111 — image toujours ajustée au cadre.** La visionneuse occupe tout l'espace
|
||||
disponible (zone de contenu en flex, sans défilement de page) et l'image est
|
||||
systématiquement redimensionnée dans son cadre, y compris panneau
|
||||
« Métadonnées » ouvert.
|
||||
|
||||
---
|
||||
|
||||
## [2.19.2] — 2026-09-23
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Lanceur E2E Windows/PowerShell** : `scripts/run-e2e-local.ps1` (+ script npm
|
||||
`test:e2e:ps`) reproduit localement le job CI `e2e` (uvicorn natif, auth
|
||||
désactivée, fixtures TestVault/TestDir, port 2029, projet `chromium-desktop`)
|
||||
sans dépendre de `bash` — indispensable sur les postes Windows où WSL ne
|
||||
démarre pas et où git-bash est bloqué par une politique de contrôle
|
||||
d'application. Le script `bash` reste la référence pour la CI/Linux.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-072 — visionneuse d'images** : le plein écran (lightbox) et le panneau
|
||||
« Métadonnées » sont désormais **conservés lors de la navigation** entre images
|
||||
(flèches ←/→ et clic sur la pellicule) ; auparavant chaque changement d'image
|
||||
recréait la visionneuse et perdait ces états. Le panneau de métadonnées s'affiche
|
||||
maintenant en **barre latérale à droite de l'image** (au lieu d'une bande sous la
|
||||
pellicule) et reste visible en plein écran.
|
||||
|
||||
---
|
||||
|
||||
## [2.19.1] — 2026-09-23
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **#110 — lisibilité et positionnement du lecteur média** : en mode mobile, la
|
||||
barre audio passe en grille (barre de progression pleine largeur sur sa propre
|
||||
ligne) et les actions secondaires (précédent/suivant/agrandir/volume) sont
|
||||
masquées pour éviter tout chevauchement ; le volume est aussi masqué sur les
|
||||
écrans intermédiaires en desktop, et `overflow: hidden` empêche le débordement
|
||||
hors de la pilule. La mini-fenêtre vidéo n'est plus ré-aimantée sur les bords :
|
||||
elle se déplace et se redimensionne **librement** (position absolue mémorisée,
|
||||
centre autorisé), le glisser fonctionnant désormais depuis n'importe quel point
|
||||
de la fenêtre (boutons/curseurs/poignée exclus, seuil de 4 px pour préserver
|
||||
les contrôles natifs de la vidéo).
|
||||
|
||||
---
|
||||
|
||||
## [2.19.0] — 2026-09-23
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#110 — Lecteur média persistant « Now Playing »** : les fichiers audio et
|
||||
vidéo continuent de jouer pendant la navigation grâce à un contrôleur global
|
||||
`frontend/js/now-playing.js` qui possède **un seul** élément `<audio>`/`<video>`
|
||||
téléporté entre la vue inline (onglet/panneau) et un dock flottant
|
||||
(`<body>`), sans interruption de lecture. Dock desktop : pilule verre dépoli
|
||||
(artwork, titre, voûte, play/pause, précédent/suivant, barre de progression,
|
||||
volume, retour au média, agrandir, fermer). Mini-fenêtre vidéo flottante
|
||||
déplaçable/redimensionnable avec aimantation aux coins et bouton
|
||||
Picture-in-Picture natif. Mode mobile : mini-player fixé au-dessus de la barre
|
||||
d'outils (safe-area `viewport-fit=cover`). Intégration **Media Session** (écran
|
||||
verrouillé, touches matérielles, Windows SMTC), panneau étendu façon « Now
|
||||
Playing », lecture auto du média suivant/précédent du dossier, reprise après
|
||||
rechargement, notification quand l'onglet est fermé pendant la lecture. Fiche :
|
||||
[docs/features/media-viewers-109.md](docs/features/media-viewers-109.md) (§ Now
|
||||
Playing, #110).
|
||||
|
||||
---
|
||||
|
||||
## [2.18.0] — 2026-09-23
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#109 — Support audio & vidéo (lecteurs HTML5 intégrés)** : les fichiers audio
|
||||
(`.mp3 .m4a .aac .wav .ogg .oga .opus .flac`) et vidéo (`.mp4 .webm .mov .m4v`)
|
||||
apparaissent désormais dans l'arborescence et l'index (nom/taille/date
|
||||
uniquement, contenu jamais lu, intégrés à `SUPPORTED_EXTENSIONS` via
|
||||
`media_types.py`). Nouvel endpoint `GET /api/media/{vault}?path=…` avec support
|
||||
HTTP `Range` / `206 Partial Content` (`Content-Range`, `Accept-Ranges`, `416`
|
||||
sur plage invalide, `413` au-delà de `OBSIGATE_MEDIA_MAX_INLINE_MB`, défaut
|
||||
500 Mo) — le helper Range de `pdf/stream` a été extrait en
|
||||
`_stream_file_with_range()` et est partagé. `api_file_view()` expose
|
||||
`is_audio`/`is_video`/`stream_url`/`media_mime` avant toute lecture texte.
|
||||
Frontend : lecteur audio dédié (artwork, durée via `loadedmetadata`) et lecteur
|
||||
vidéo (`playsinline`, scène noire letterboxée), icônes Lucide `audio-lines` /
|
||||
`video`, pause à la réinitialisation de la vue, repli téléchargement si le codec
|
||||
n'est pas lisible ou le fichier trop volumineux. Les médias sont exclus du
|
||||
contexte textuel BooksLM et du cache hors-ligne du service worker. Fiche :
|
||||
[docs/features/media-viewers-109.md](docs/features/media-viewers-109.md).
|
||||
|
||||
---
|
||||
|
||||
## [2.17.0] — 2026-09-23
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#108 — Support complet des images (arborescence, visionneuse, indexation)** :
|
||||
les images (`.png .jpg .jpeg .gif .svg .webp .bmp .ico`) apparaissent désormais
|
||||
dans l'arborescence et l'index comme les autres fichiers — nom/taille/date
|
||||
uniquement, jamais les octets (contenu indexé vide, TF-IDF préservé). Nouveau
|
||||
module partagé `backend/media_types.py` (extensions + MIME, socle réutilisé par
|
||||
#109). Visionneuse dédiée : zoom molette 0,1×–8×, pan au glisser, double-clic
|
||||
pour réinitialiser, boutons +/−/reset et badge de zoom, navigation ←/→ entre
|
||||
les images du dossier avec pellicule de miniatures, panneau métadonnées
|
||||
(dimensions, taille, type, chemin, date), lightbox plein écran, « Ouvrir
|
||||
l'original » et téléchargement. Endpoint `GET /api/media/{vault}/thumb`
|
||||
(miniature WebP 256 px, cache disque invalidé par mtime, repli sur l'original
|
||||
pour le SVG, `pillow>=10.0`). Filtre `ext:png`/`ext:jpg` opérationnel ;
|
||||
compteurs d'images séparés dans `/api/dashboard` (`image_count`/`total_images`).
|
||||
Fiche : [docs/features/image-support.md](docs/features/image-support.md).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **#108-B1 — Affichage isolé d'une image** : le `<img>` généré par
|
||||
`api_file_view()` pointait vers `/api/file/{vault}/raw` (qui renvoie du JSON)
|
||||
au lieu de `/api/image/{vault}` (octets + MIME correct). Corrigé côté backend
|
||||
et dans `viewer.js` (bouton Plein écran), avec encodage d'URL des chemins.
|
||||
- **#108-B3 — XSS via SVG** : `/api/image` (et le repli miniatures) pose
|
||||
`Content-Security-Policy: sandbox` sur les SVG ouverts directement, pour
|
||||
empêcher l'exécution du JavaScript embarqué ; le middleware n'écrase plus une
|
||||
politique stricte posée par une route.
|
||||
|
||||
---
|
||||
|
||||
## [2.16.6] — 2026-09-22
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Guides d'utilisation `docs/GUIDES/`** : nouvel index + 10 guides FR
|
||||
(prise en main, recherche/PDF/Excalidraw, assistant IA & Forge,
|
||||
collaboration temps réel, PWA & hors-ligne, API REST, serveur MCP,
|
||||
authentification & sécurité, déploiement Docker, desktop Tauri). Le guide MCP
|
||||
est déplacé dans `docs/GUIDES/MCP.md` ; `docs/MCP_GUIDE.md` devient une page
|
||||
de redirection.
|
||||
|
||||
### Modifié
|
||||
|
||||
- **README.md / README.fr.md** : capture d'écran réelle de l'application en tête
|
||||
(remplace l'illustration ASCII) ; un emoji sur chaque entrée de la table des
|
||||
matières ; nouvelle section « Guides » avec liens vers `docs/GUIDES/` ;
|
||||
renvois vers les guides depuis les sections API, Recherche, Sécurité, Desktop
|
||||
et Collaboration.
|
||||
|
||||
---
|
||||
|
||||
## [2.16.5] — 2026-09-22
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-071 (complément) - spec E2E mobile de la page Configurations** :
|
||||
`tests/e2e/config-mobile.spec.js` (nouveau, projet `chromium-mobile`,
|
||||
ignoré en `chromium-desktop` comme `mobile-editor.spec.js`) : le hamburger
|
||||
révèle le sommaire, le choix d'une section y défile + lien actif + repli
|
||||
auto, aucun débordement horizontal à 393px. Vérifié en local contre
|
||||
l'instance de test (port 2029, auth désactivée) : 3/3.
|
||||
|
||||
---
|
||||
|
||||
## [2.16.4] — 2026-09-22
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-071 - Page « Configurations » inutilisable en mode mobile** : trois
|
||||
causes. (1) Le sommaire (`#config-nav`) partageait la règle `.help-nav`
|
||||
qui le masque sous 768px, mais — contrairement au Guide — la modale
|
||||
n'avait aucun bouton pour l'afficher : aucun moyen d'atteindre une section.
|
||||
Nouvel hamburger `#config-hamburger` dans l'en-tête (même traitement
|
||||
`.help-hamburger` que le Guide, libellé traduit `config.toc_toggle`
|
||||
FR/EN). (2) Les liens du sommaire étaient des ancres brutes sans JS :
|
||||
`config.js` les intercepte désormais (défilement doux vers la section dans
|
||||
la modale, lien actif, repli automatique du sommaire sur mobile, réinit à
|
||||
l'ouverture). (3) Les grilles 2 colonnes (fournisseur/modèle IA, clé/modèle
|
||||
par fournisseur), les rangées d'ajout à largeurs fixes (jetons, webhooks)
|
||||
et les lignes webhook/jeton/partage en flex une ligne débordaient en
|
||||
360px : bloc CSS mobile scopé `#config-modal` (1 colonne, wrap, largeurs
|
||||
inline neutralisées, cibles tactiles 44px, sommaire plafonné à 46vh).
|
||||
`data-i18n-attr` accepte désormais plusieurs paires `attr:clé` séparées
|
||||
par `;` (titre + aria-label traduits). Tests :
|
||||
`tests/frontend/config-mobile.test.mjs` (nouveau, 11 — hamburger, i18n,
|
||||
câblage JS, CSS mobile, garde-fou ancres mortes façon BUG-067),
|
||||
enregistré dans le CI.
|
||||
|
||||
---
|
||||
|
||||
## [2.16.3] — 2026-09-22
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-070 - Activation clé physique WebAuthn impossible (« Validation du
|
||||
credential WebAuthn échouée »)** : deux causes. (1) Les valeurs par défaut
|
||||
(`rp_id localhost`, origines `http://localhost` sans port) rejetaient toute
|
||||
URL réelle — logs : `Unexpected client data origin "http://localhost:2020",
|
||||
expected one of ['http://localhost']`. `rp_id`/origines sont désormais
|
||||
dérivés de la requête (hôte exact, port inclus ; `X-Forwarded-Host/Proto`
|
||||
si `OBSIGATE_TRUST_PROXY=true`), la config explicite restant prioritaire
|
||||
(`backend/auth/webauthn_mfa.py::resolve_relying_party`, appliqué aux 4
|
||||
endpoints d'enregistrement et de login). (2) Challenge à usage unique
|
||||
fragile au double-clic/retry (`challenge was not expected`) : les 5
|
||||
derniers challenges sont conservés et la vérification accepte le challenge
|
||||
correspondant à la cérémonie en cours. `.env.example` documente le nouveau
|
||||
comportement. Vérifié au navigateur avec authentificateur virtuel
|
||||
(Playwright CDP, instance Docker) : enregistrement 200 + clé listée, puis
|
||||
clé de test retirée. Tests : `tests/test_webauthn.py` (+8 : résolution RP,
|
||||
forwarded, retry, roundtrip sans config).
|
||||
|
||||
---
|
||||
|
||||
## [2.16.2] — 2026-09-22
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-069 - Login 2FA bloqué sans erreur** : après user+mot de passe corrects
|
||||
sur un compte avec 2FA, la page de login restait affichée sans erreur et le
|
||||
challenge MFA n'apparaissait jamais. Cause : `showMfaChallenge`
|
||||
(`frontend/js/auth.js`) montait le challenge dans `.login-box`, inexistant
|
||||
dans `index.html` (marquage réel : `#login-screen > .login-card`) →
|
||||
`return` silencieux. Correctif : montage dans `.login-card` (repli
|
||||
`#login-screen`) + erreur visible (`mfa.challenge_unavailable`, FR/EN) au
|
||||
lieu d'un retour silencieux si le point de montage manque. Vérifié de bout
|
||||
en bout au navigateur (Playwright, instance Docker) : challenge affiché,
|
||||
code erroné → erreur, code valide → connecté. Tests :
|
||||
`tests/frontend/mfa-settings.test.mjs` (+2 contrôles d'ancrage DOM).
|
||||
|
||||
---
|
||||
|
||||
## [2.16.1] — 2026-09-22
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-068 - Configuration : section « 🔒 Sécurité du compte » inachevée** :
|
||||
boutons `config-btn-primary` / `config-btn-danger` définis depuis les
|
||||
variables du thème (`frontend/style.css`) ; QR code TOTP généré en local par
|
||||
le backend (`POST /api/auth/mfa/totp/setup` → `qr_data_url`, SVG `data:`
|
||||
via `segno`, `backend/requirements.txt`) au lieu de l'image tierce bloquée
|
||||
par la CSP (`img-src 'self' data: blob:`, secret TOTP exposé) ; codes de
|
||||
récupération affichés aussi à la première activation WebAuthn ; carte
|
||||
« Mot de passe » (changement via `POST /api/auth/change-password`) et
|
||||
échappement des libellés de clés WebAuthn. Tests :
|
||||
`tests/test_mfa.py::test_mfa_setup_returns_local_qr_data_url`,
|
||||
`tests/frontend/mfa-settings.test.mjs` (nouveau, 9 contrôles).
|
||||
|
||||
---
|
||||
|
||||
## [2.16.0] — 2026-09-22
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#86 - Optimisation globale des performances (phase 3)** : ferme les deux derniers
|
||||
points de la phase 3 (recherche via inverted index, PDF lazy et caps regex déjà livrés
|
||||
via BUG-033/BUG-040/BUG-025). Scan **différentiel** : `_scan_vault` réutilise les
|
||||
entrées inchangées (`size` + `modified`) d'un snapshot précédent — seuls `os.walk` +
|
||||
`stat` tournent à chaque rebuild (`build_index`, `reload_single_vault`). Extraction
|
||||
**excalidraw différée** : le scan ne lit plus les `.excalidraw` / `.excalidraw.md`
|
||||
(flag `excalidraw_text_pending`), `enrich_pdf_texts()` extrait leur texte après index
|
||||
comme pour les PDF. Garde-fou `MAX_REPLACE_FILE_BYTES` (5 Mio) sur `replace_in_files`.
|
||||
Tests : `tests/test_perf_phase3.py` (9). Voir
|
||||
[docs/features/perf-phase3-86.md](./docs/features/perf-phase3-86.md).
|
||||
|
||||
---
|
||||
|
||||
## [2.15.0] — 2026-09-22
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#107 - Configuration : gestion des clés API & MCP** — nouvelle section
|
||||
« 🔑 Clés API & MCP » dans le panneau de configuration : création, liste
|
||||
(créée / expire / dernière utilisation) et révocation de jetons longue
|
||||
durée utilisables aussi bien sur l'API REST que sur le serveur MCP
|
||||
(`/mcp`) — un seul et même jeton Bearer pour les deux. Choix
|
||||
d'expiration à la création : 1 jour, 1 mois, 6 mois, 1 an, sans fin.
|
||||
Le secret n'est affiché qu'une fois (jamais persisté en clair,
|
||||
`data/api_tokens.json` ne contient que les métadonnées) ; révocation
|
||||
immédiate des deux côtés, plafond 50 clés par utilisateur, isolation
|
||||
par utilisateur, audit `config_change`. Corrigé au passage : le store
|
||||
de révocation (`revoked_tokens.json`) bornait toute entrée à 7 jours —
|
||||
un jeton longue durée révoqué « reprenait vie » après purge ; il est
|
||||
désormais calé sur l'expiration réelle du jeton. Voir
|
||||
[docs/features/api-mcp-tokens-107.md](./docs/features/api-mcp-tokens-107.md).
|
||||
|
||||
---
|
||||
|
||||
## [2.14.1] — 2026-09-22
|
||||
|
||||
---
|
||||
|
||||
## [2.14.0] — 2026-09-19
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#106 - Assistant IA : actions instantanées contextuelles + catalogue de prompts** :
|
||||
la zone d'accueil remplace les 3 suggestions statiques par des **actions suggérées
|
||||
selon le contexte détecté** (sélection dans l'éditeur → concis/corriger/expliquer ;
|
||||
fichier de code → expliquer/bugs/tests ; 2+ docs → fusionner/comparer/frictions ;
|
||||
1 doc → résumé 3 points/checklist/frontmatter ; répertoire ou général sinon), avec
|
||||
badge de contexte dans l'en-tête. Un bouton **« Toutes les actions »** ouvre un
|
||||
tiroir catalogue (recherche instantanée, 6 catégories, 25 actions i18n FR/EN).
|
||||
Nouveau prompt **« Générer le frontmatter YAML »** au format complet du vault
|
||||
(titre, auteur, dates ISO-8601, tags, aliases, booléens, NomDeVoute, Description)
|
||||
et **« Mettre à jour le frontmatter »** (conserve les champs existants, actualise
|
||||
`modification_date`, recalcule tags/aliases/Description, complète les manquants) —
|
||||
tous deux basculent en mode agent et appliquent le résultat via la carte de
|
||||
confirmation. Détail : [features/ai-quick-actions.md](./features/ai-quick-actions.md).
|
||||
|
||||
---
|
||||
|
||||
## [2.13.1] — 2026-09-18
|
||||
|
||||
---
|
||||
|
||||
## [2.13.0] — 2026-09-18
|
||||
|
||||
### Ajouté
|
||||
@@ -41,6 +718,18 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
Source de vérité du nouveau contenu : `scripts/guide_content.py` (locales générées,
|
||||
jamais éditées à la main). Tests : `tests/test_guide.py` (11).
|
||||
|
||||
### Modifié (ajustements retour utilisateur sur #105)
|
||||
|
||||
- Boutons de téléchargement du guide : **icônes seules** (plus de libellé « Markdown »/
|
||||
« PDF »), tooltip i18n explicite sur chaque bouton.
|
||||
- PDF du guide : le bloc Mermaid de la section Architecture est converti en **diagramme
|
||||
rendu** (PNG pré-rendu commité via `scripts/build_guide_diagrams.py` +
|
||||
`scripts/render_guide_diagram.mjs`, inséré par `diagram_png_for()`), au lieu du code
|
||||
brut ; le Markdown garde le fenced ` ```mermaid ` (copiable).
|
||||
- PDF du guide : emoji rendus **en couleur** au lieu de rectangles — `fonts-noto-color-emoji`
|
||||
ajouté à l'image Docker et `"Noto Color Emoji"` en fin de pile de polices du PDF
|
||||
(la TOC était correcte car elle utilise DejaVu, qui n'a pas les emoji pleine chasse).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-067 - Guide : entrée « 📱 Mobile » morte** : le sommaire pointait vers
|
||||
|
||||
@@ -24,7 +24,7 @@ COPY --from=builder /install /usr/local
|
||||
|
||||
# WeasyPrint runtime dependencies
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libpango-1.0-0 libpangocairo-1.0-0 shared-mime-info \
|
||||
&& apt-get install -y --no-install-recommends libpango-1.0-0 libpangocairo-1.0-0 shared-mime-info fonts-noto-color-emoji \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -1,61 +1,75 @@
|
||||
# ObsiGate
|
||||
|
||||
> **Version française** — ce document est le miroir synchronisé de [README.md](README.md) (référence complète). Dernière synchronisation : juin 2026.
|
||||
> **Version française** — ce document est le miroir synchronisé de [README.md](README.md) (référence complète). Dernière synchronisation : septembre 2026.
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
[](https://git.dracodev.net/Projets/ObsiGate/actions)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ [🔍 Recherche...] [☀/🌙 Thème] ObsiGate │
|
||||
├──────────────┬──────────────────────────────────────────┤
|
||||
│ SIDEBAR │ CONTENT AREA │
|
||||
│ ▼ Recettes │ 📄 Titre du fichier │
|
||||
│ 📁 Soupes │ Tags: #recette #rapide │
|
||||
│ 📄 Pizza │ [Contenu Markdown rendu] │
|
||||
│ ▼ IT │ │
|
||||
│ 📁 Docker │ │
|
||||
│ Tags Cloud │ │
|
||||
└──────────────┴──────────────────────────────────────────┘
|
||||
```
|
||||

|
||||
|
||||
> Interface web d'ObsiGate : sidebar multi-vault, recherche globale, statistiques et raccourcis.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Guides
|
||||
|
||||
Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/GUIDES/) :
|
||||
|
||||
| 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 |
|
||||
| 🤖 [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 |
|
||||
| 🔌 [API REST](docs/GUIDES/API_REST.md) | Authentification, clés API, endpoints, exemples `curl`, SSE |
|
||||
| 🧩 [Serveur MCP](docs/GUIDES/MCP.md) | Brancher Claude Desktop, Cursor, Cline… sur vos vaults |
|
||||
| 🔒 [Authentification & sécurité](docs/GUIDES/AUTHENTIFICATION_SECURITE.md) | Utilisateurs, MFA, permissions par vault, durcissement |
|
||||
| 🐳 [Déploiement Docker](docs/GUIDES/DEPLOIEMENT_DOCKER.md) | `docker-compose`, volumes, reverse proxy, mises à jour |
|
||||
| 🖥️ [Desktop (Tauri)](docs/GUIDES/DESKTOP.md) | Installation, premier lancement, build depuis les sources, dépannage |
|
||||
|
||||
> Index complet : [`docs/GUIDES/README.md`](docs/GUIDES/README.md).
|
||||
|
||||
---
|
||||
|
||||
## 📋 Table des matières
|
||||
|
||||
- [Fonctionnalités](#fonctionnalites)
|
||||
- [Prérequis](#prerequis)
|
||||
- [Installation rapide](#installation-rapide)
|
||||
- [Configuration détaillée](#configuration-detaillee)
|
||||
- [Variables d'environnement](#variables-denvironnement)
|
||||
- [🔒 Authentification](#authentification)
|
||||
- [Ajouter une nouvelle vault](#ajouter-une-nouvelle-vault)
|
||||
- [Build & déploiement avec build.sh](#build-deploiement-avec-buildsh)
|
||||
- [Rendu d'images Obsidian](#rendu-dimages-obsidian)
|
||||
- [Desktop (Tauri) — Application native](#desktop-tauri-application-native)
|
||||
- [Utilisation](#utilisation)
|
||||
- [API](#api)
|
||||
- [Recherche avancée](#recherche-avancee)
|
||||
- [Dépannage](#depannage)
|
||||
- [Performance](#performance)
|
||||
- [Sécurité](#securite)
|
||||
- [Stack technique](#stack-technique)
|
||||
- [Architecture](#architecture)
|
||||
- [Développement](#developpement)
|
||||
- [Licence](#licence)
|
||||
- [Changelog](#changelog)
|
||||
- ✨ [Fonctionnalités](#fonctionnalites)
|
||||
- 📚 [Guides](#guides)
|
||||
- 🚀 [Prérequis](#prerequis)
|
||||
- ⚡ [Installation rapide](#installation-rapide)
|
||||
- ⚙️ [Configuration détaillée](#configuration-detaillee)
|
||||
- 🌍 [Variables d'environnement](#variables-denvironnement)
|
||||
- 🔒 [Authentification](#authentification)
|
||||
- ➕ [Ajouter une nouvelle vault](#ajouter-une-nouvelle-vault)
|
||||
- 🔨 [Build & déploiement avec build.sh](#build-deploiement-avec-buildsh)
|
||||
- 🖼️ [Rendu d'images Obsidian](#rendu-dimages-obsidian)
|
||||
- 🖥️ [Desktop (Tauri) — Application native](#desktop-tauri-application-native)
|
||||
- 📖 [Utilisation](#utilisation)
|
||||
- 👥 [Collaboration temps réel](#collaboration-temps-reel)
|
||||
- 🔌 [API](#api)
|
||||
- 🔍 [Recherche avancée](#recherche-avancee)
|
||||
- 🔧 [Dépannage](#depannage)
|
||||
- ⚡ [Performance](#performance)
|
||||
- 🛡️ [Sécurité](#securite)
|
||||
- 🏗️ [Stack technique](#stack-technique)
|
||||
- 🏠 [Architecture](#architecture)
|
||||
- 📝 [Développement](#developpement)
|
||||
- 📄 [Licence](#licence)
|
||||
- 🤝 [Support](#support)
|
||||
- 📝 [Changelog](#changelog)
|
||||
|
||||
---
|
||||
|
||||
## ✨ Fonctionnalités
|
||||
|
||||
- **🤖 AI Editor intégré** — Éditeur CodeMirror 6 avec toolbar IA : amélioration, correction, traduction, génération, réécriture personnalisée, toolbox (liste, tableau, frontmatter, canvas) — multi-provider DeepSeek/OpenRouter/Gemini
|
||||
- **🧩 Serveur MCP & agent IA** — Serveur Model Context Protocol intégré (`/mcp`) et assistant avec function calling : lisez, cherchez et modifiez vos vaults depuis Claude Desktop, Cursor… avec confirmations two-step, permissions par vault, rate limiting et redaction des secrets ([guide](docs/MCP_GUIDE.md))
|
||||
- **🧩 Serveur MCP & agent IA** — Serveur Model Context Protocol intégré (`/mcp`) et assistant avec function calling : lisez, cherchez et modifiez vos vaults depuis Claude Desktop, Cursor… avec confirmations two-step, permissions par vault, rate limiting et redaction des secrets ([guide](docs/GUIDES/MCP.md))
|
||||
- **👥 Collaboration temps réel** — Édition simultanée d'un même document (Yjs/CRDT) : curseurs distants colorés, indicateur de présence, fusion sans conflit, reconnexion automatique et persistance serveur ([détail](docs/features/collaboration.md))
|
||||
- **📖 Guide d'utilisation intégré** — Aide complète en FR/EN accessible depuis le menu Options : interface, navigation, recherche, fichiers, IA, sécurité, API & intégrations (OpenAPI, MCP), hors-ligne, collaboration, desktop, plus une section **Architecture** avec diagramme Mermaid ; téléchargeable en **Markdown** et **PDF** dans la langue courante ([détail](docs/features/guide-coverage-105.md))
|
||||
- **📱 É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))
|
||||
@@ -69,7 +83,9 @@
|
||||
- **🏷️ Tag cloud** : Filtrage par tags extraits des frontmatters YAML
|
||||
- **🔗 Wikilinks** : Les `[[liens internes]]` Obsidian sont cliquables
|
||||
- **🖼️ 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
|
||||
- **🎨 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
|
||||
@@ -282,6 +298,7 @@ Un compte **admin** connecté voit une icône 🛡️ dans le header : liste, cr
|
||||
| `OBSIGATE_WEBHOOK_ALLOW_HTTP` | Autoriser les webhooks non HTTPS | `false` |
|
||||
| `OBSIGATE_WEBHOOK_ALLOW_PRIVATE` | Autoriser les webhooks vers des adresses privées/boucle | `false` |
|
||||
| `OBSIGATE_PDF_MAX_SIZE_MB` | Taille max des PDF extraits (text indexation) | `50` |
|
||||
| `OBSIGATE_MEDIA_MAX_INLINE_MB` | Taille max pour la lecture audio/vidéo intégrée (au-delà : téléchargement) | `500` |
|
||||
| `OBSIGATE_PDF_EXTRACT_TIMEOUT` | Timeout extraction PDF (secondes) | `30` |
|
||||
| `OBSIGATE_TAVILY_API_KEY` / `OBSIGATE_BRAVE_API_KEY` / `OBSIGATE_SERPAPI_API_KEY` / `OBSIGATE_EXA_API_KEY` | Fournisseurs de recherche web à clé (essayés avant SearXNG) | — |
|
||||
| `OBSIGATE_WEB_PROVIDERS` | Ordre des fournisseurs de recherche (ex. `brave,searxng`) | — |
|
||||
@@ -392,6 +409,18 @@ ObsiGate supporte **toutes les syntaxes d'images Obsidian** avec résolution int
|
||||
6. Index de démarrage (match le plus proche)
|
||||
7. Fallback : placeholder stylisé `[image not found: filename.ext]`
|
||||
|
||||
### Visionneuse & arborescence
|
||||
|
||||
Les images sont de plein droit des fichiers du vault : elles apparaissent dans
|
||||
l'arborescence, sont indexées (nom + métadonnées, **jamais les octets**) et
|
||||
s'ouvrent dans une **visionneuse dédiée** — zoom molette 0,1×–8×, pan au
|
||||
glisser, double-clic pour réinitialiser, navigation ←/→ entre les images du
|
||||
dossier (avec pellicule de miniatures WebP), panneau de métadonnées, lightbox
|
||||
plein écran, ouverture de l'original et téléchargement. Le filtre de recherche
|
||||
`ext:png`/`ext:jpg` est disponible. Formats décodables : PNG, JPEG, GIF, WebP,
|
||||
BMP, ICO, SVG (SVG servi avec une politique CSP `sandbox`). **HEIC/HEIF**
|
||||
(iPhone) n'est pas décodable par les navigateurs et n'est pas pris en charge.
|
||||
|
||||
### Configuration
|
||||
|
||||
```yaml
|
||||
@@ -412,6 +441,8 @@ curl -X POST http://localhost:2020/api/attachments/rescan/MonVault
|
||||
|
||||
## 🖥️ Desktop (Tauri) — Application native
|
||||
|
||||
> 📖 Guide complet : [Desktop (Tauri)](docs/GUIDES/DESKTOP.md)
|
||||
|
||||
ObsiGate Desktop est une application native construite avec [Tauri](https://tauri.app/) (Rust + webview système). Elle embarque le backend Python et le frontend dans un exécutable standalone — zéro Docker, zéro ligne de commande.
|
||||
|
||||
> 🚧 **Version 2.0.0 — binaires en cours de stabilisation.** Pour l'instant, le build depuis les sources est recommandé.
|
||||
@@ -571,6 +602,8 @@ Cycle de vie : Tauri spawn le backend Python → health check → splash de dém
|
||||
|
||||
## 👥 Collaboration temps réel
|
||||
|
||||
> 📖 Guide complet : [Édition & collaboration](docs/GUIDES/COLLABORATION.md)
|
||||
|
||||
Plusieurs utilisateurs peuvent éditer le même document markdown simultanément (façon Google Docs) :
|
||||
|
||||
- **Fusion sans conflit** grâce à Yjs (CRDT) : deux personnes peuvent taper au même endroit, aucune
|
||||
@@ -590,6 +623,8 @@ fenêtres) pour voir la collaboration en action.
|
||||
|
||||
## 🔌 API
|
||||
|
||||
> 📖 Guide complet : [API REST](docs/GUIDES/API_REST.md) · [Serveur MCP](docs/GUIDES/MCP.md)
|
||||
|
||||
ObsiGate expose une API REST complète :
|
||||
|
||||
| Endpoint | Description | Méthode | Auth |
|
||||
@@ -617,6 +652,7 @@ ObsiGate expose une API REST complète :
|
||||
| `/api/events` | Flux SSE temps réel | GET | Oui |
|
||||
| `/api/vaults/add` / `/api/vaults/{name}` | Gestion dynamique des vaults | POST/DELETE | Admin |
|
||||
| `/api/image/{vault}?path=` | Servir une image | GET | Oui |
|
||||
| `/api/media/{vault}/thumb?path=&size=` | Miniature WebP (cache disque) | GET | Oui |
|
||||
| `/api/config` | Lire / écrire la configuration | GET/POST | Oui/Admin |
|
||||
| `/api/diagnostics` | Statistiques index et mémoire | GET | Admin |
|
||||
|
||||
@@ -637,6 +673,8 @@ curl "http://localhost:2020/api/file/Recettes?path=pizza.md"
|
||||
|
||||
## 🔍 Recherche avancée
|
||||
|
||||
> 📖 Guide complet : [Recherche, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
|
||||
|
||||
### Syntaxe de requête
|
||||
|
||||
| Opérateur | Description | Exemple |
|
||||
@@ -758,6 +796,8 @@ Configurables via l'interface (Settings) ou l'API `/api/config`.
|
||||
|
||||
## 🛡️ Sécurité
|
||||
|
||||
> 📖 Guide complet : [Authentification & sécurité](docs/GUIDES/AUTHENTIFICATION_SECURITE.md)
|
||||
|
||||
- **Path traversal** : tous les endpoints fichier valident que le chemin résolu reste dans la vault
|
||||
- **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)
|
||||
@@ -833,7 +873,7 @@ Configurables via l'interface (Settings) ou l'API `/api/config`.
|
||||
| Validation des imports frontend | `node tests/frontend/validate-imports.mjs` | `lint` |
|
||||
| Tests unitaires frontend | `node tests/frontend/unit.test.mjs` | `lint` |
|
||||
| Tests backend | `pytest tests/ -q` | `test` |
|
||||
| **E2E Playwright** | `npm run test:e2e` (~5 min) | `e2e` |
|
||||
| **E2E Playwright** | `npm run test:e2e` (~10 min) | `e2e` |
|
||||
|
||||
#### Tests E2E locaux (`npm run test:e2e`)
|
||||
|
||||
@@ -856,6 +896,15 @@ bash scripts/run-e2e-local.sh --headed # navigateur visible
|
||||
bash scripts/run-e2e-local.sh -g "reset panes" # filtre sur un test
|
||||
```
|
||||
|
||||
Sous Windows, si `bash` n'est pas exploitable (WSL indisponible, git-bash
|
||||
bloqué par une politique de contrôle d'application), utiliser le lanceur
|
||||
PowerShell équivalent :
|
||||
|
||||
```powershell
|
||||
npm run test:e2e:ps
|
||||
pwsh -File scripts/run-e2e-local.ps1 -PlaywrightArgs @('-g','reset panes')
|
||||
```
|
||||
|
||||
La suite doit se terminer sur **tous les tests passant** (60 actuellement),
|
||||
sans échec ni dépendance aux retries. En cas d'échec : corriger et relancer
|
||||
localement jusqu'à 100 %, puis seulement commiter.
|
||||
@@ -927,8 +976,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.13.0).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.28.1).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.13.0 | Dernière mise à jour : Juin 2026*
|
||||
*Projet : ObsiGate | Version : 2.28.1 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,53 +2,73 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
[](https://git.dracodev.net/Projets/ObsiGate/actions)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ [🔍 Search...] [☀/🌙 Theme] ObsiGate │
|
||||
├──────────────┬──────────────────────────────────────────┤
|
||||
│ SIDEBAR │ CONTENT AREA │
|
||||
│ ▼ Recipes │ 📄 File Title │
|
||||
│ 📁 Soups │ Tags: #recipe #quick │
|
||||
│ 📄 Pizza │ [Rendered Markdown Content] │
|
||||
│ ▼ IT │ │
|
||||
│ 📁 Docker │ │
|
||||
│ Tags Cloud │ │
|
||||
└──────────────┴──────────────────────────────────────────┘
|
||||
```
|
||||

|
||||
|
||||
> ObsiGate web interface: multi-vault sidebar, global search, dashboard stats and shortcuts.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Guides
|
||||
|
||||
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 |
|
||||
| 🤖 [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 |
|
||||
| 🔌 [REST API](docs/GUIDES/API_REST.md) | Authentication, API keys, endpoints, `curl` examples, SSE |
|
||||
| 🧩 [MCP Server](docs/GUIDES/MCP.md) | Connect Claude Desktop, Cursor, Cline… to your vaults |
|
||||
| 🔒 [Auth & Security](docs/GUIDES/AUTHENTIFICATION_SECURITE.md) | Users, MFA, per-vault permissions, hardening |
|
||||
| 🐳 [Docker Deployment](docs/GUIDES/DEPLOIEMENT_DOCKER.md) | `docker-compose`, volumes, reverse proxy, updates |
|
||||
| 🖥️ [Desktop (Tauri)](docs/GUIDES/DESKTOP.md) | Install, first run, build from source, troubleshooting |
|
||||
|
||||
> All guides are currently written in **French**. See the full index:
|
||||
> [`docs/GUIDES/README.md`](docs/GUIDES/README.md).
|
||||
|
||||
---
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Architecture](#architecture)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Quick Installation](#quick-installation)
|
||||
- [Detailed Configuration](#detailed-configuration)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [🔒 Authentication](#authentication)
|
||||
- [Adding a New Vault](#adding-a-new-vault)
|
||||
- [Build & Deployment with build.sh](#build--deployment-with-buildsh)
|
||||
- [Desktop (Tauri) — Native Application](#desktop-tauri--native-application)
|
||||
- [Usage](#usage)
|
||||
- [API](#api)
|
||||
- [Performance](#performance)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Changelog](#changelog)
|
||||
- ✨ [Features](#features)
|
||||
- 📚 [Guides](#guides)
|
||||
- 🚀 [Prerequisites](#prerequisites)
|
||||
- ⚡ [Quick Installation](#quick-installation)
|
||||
- ⚙️ [Detailed Configuration](#detailed-configuration)
|
||||
- 🌍 [Environment Variables](#environment-variables)
|
||||
- 🔒 [Authentication](#authentication)
|
||||
- ➕ [Adding a New Vault](#adding-a-new-vault)
|
||||
- 🔨 [Build & Deployment with build.sh](#build--deployment-with-buildsh)
|
||||
- 🖼️ [Obsidian Image Rendering](#obsidian-image-rendering)
|
||||
- 🖥️ [Desktop (Tauri) — Native Application](#desktop-tauri--native-application)
|
||||
- 📖 [Usage](#usage)
|
||||
- 👥 [Real-time Collaboration](#real-time-collaboration)
|
||||
- 🔌 [API](#api)
|
||||
- 🔍 [Advanced Search](#advanced-search)
|
||||
- 🛡️ [Security](#security)
|
||||
- ⚡ [Performance](#performance)
|
||||
- 🔧 [Troubleshooting](#troubleshooting)
|
||||
- 🏗️ [Tech Stack](#tech-stack)
|
||||
- 🏠 [Architecture](#architecture)
|
||||
- 📝 [Development](#development)
|
||||
- 📄 [License](#license)
|
||||
- 🤝 [Support](#support)
|
||||
- 📝 [Changelog](#changelog)
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🤖 Integrated AI Editor** — CodeMirror 6 editor with AI toolbar: improve, correct, translate, generate, custom rewrite, toolbox (list, table, frontmatter, canvas) — multi-provider DeepSeek/OpenRouter/Gemini
|
||||
- **🧩 MCP Server & AI Agent** — Built-in Model Context Protocol server (`/mcp`) and tool-calling assistant: read, search and edit your vaults from Claude Desktop, Cursor… with two-step confirmations, per-vault permissions, rate limiting and secret redaction ([guide](docs/MCP_GUIDE.md))
|
||||
- **🧩 MCP Server & AI Agent** — Built-in Model Context Protocol server (`/mcp`) and tool-calling assistant: read, search and edit your vaults from Claude Desktop, Cursor… with two-step confirmations, per-vault permissions, rate limiting and secret redaction ([guide](docs/GUIDES/MCP.md))
|
||||
- **👥 Real-time Collaboration** — Simultaneous editing of the same document (Yjs/CRDT): colored remote cursors, presence indicator, conflict-free merge, automatic reconnection and server-side persistence ([details](docs/features/collaboration.md))
|
||||
- **📖 Built-in User Guide** — Complete FR/EN help from the Options menu: interface, navigation, search, files, AI, security, API & integrations (OpenAPI, MCP), offline, collaboration, desktop, plus an **Architecture** section with a Mermaid diagram; downloadable as **Markdown** and **PDF** in the current language ([details](docs/features/guide-coverage-105.md))
|
||||
- **📱 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))
|
||||
@@ -62,7 +82,9 @@
|
||||
- **🏷️ Tag Cloud** : Filtering by tags extracted from YAML frontmatters
|
||||
- **🔗 Wikilinks** : `[[internal links]]` from Obsidian are clickable
|
||||
- **🖼️ 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
|
||||
- **🎨 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
|
||||
@@ -320,6 +342,7 @@ When an **admin** account is logged in, a 🛡️ icon appears in the header. Cl
|
||||
| `OBSIGATE_WEBHOOK_ALLOW_HTTP` | Allow non-HTTPS webhook targets | `false` |
|
||||
| `OBSIGATE_WEBHOOK_ALLOW_PRIVATE` | Allow webhooks to private/loopback addresses | `false` |
|
||||
| `OBSIGATE_PDF_MAX_SIZE_MB` | Max PDF size for text extraction | `50` |
|
||||
| `OBSIGATE_MEDIA_MAX_INLINE_MB` | Max size for inline audio/video playback (above: download) | `500` |
|
||||
| `OBSIGATE_PDF_EXTRACT_TIMEOUT` | PDF extraction timeout (seconds) | `30` |
|
||||
| `OBSIGATE_TAVILY_API_KEY` / `OBSIGATE_BRAVE_API_KEY` / `OBSIGATE_SERPAPI_API_KEY` / `OBSIGATE_EXA_API_KEY` | Keyed web-search providers (tried before SearXNG) | — |
|
||||
| `OBSIGATE_WEB_PROVIDERS` | Search provider order (e.g. `brave,searxng`) | — |
|
||||
@@ -496,6 +519,17 @@ ObsiGate uses 7 resolution strategies in order of priority:
|
||||
6. **Startup index (closest match)** : If multiple files have the same name
|
||||
7. **Fallback** : Display a styled placeholder `[image not found: filename.ext]`
|
||||
|
||||
### Viewer & file tree
|
||||
|
||||
Images are first-class vault files: they appear in the tree, are indexed (name +
|
||||
metadata, **never the bytes**) and open in a **dedicated viewer** — wheel zoom
|
||||
0.1×–8×, drag pan, double-click to reset, ←/→ navigation between images in the
|
||||
same folder (WebP thumbnail filmstrip), metadata panel, full-screen lightbox,
|
||||
open original and download. The `ext:png`/`ext:jpg` search filter is available.
|
||||
Decodable formats: PNG, JPEG, GIF, WebP, BMP, ICO, SVG (SVG served with a
|
||||
`sandbox` CSP). **HEIC/HEIF** (iPhone) is not decodable by browsers and is not
|
||||
supported.
|
||||
|
||||
### Configuration
|
||||
|
||||
To optimize resolution, configure the attachments folder for each vault:
|
||||
@@ -520,6 +554,8 @@ curl -X POST http://localhost:2020/api/attachments/rescan/MyVault
|
||||
|
||||
## 🖥️ Desktop (Tauri) — Native Application
|
||||
|
||||
> 📖 Full guide: [Desktop (Tauri)](docs/GUIDES/DESKTOP.md)
|
||||
|
||||
ObsiGate Desktop is a native application built with [Tauri](https://tauri.app/) (Rust + system webview). It embeds the Python backend and frontend in a standalone executable — zero Docker, zero command line.
|
||||
|
||||
> 🚧 **Version 2.0.0 — binaries are being stabilized.** For now, building from source is recommended.
|
||||
@@ -687,6 +723,8 @@ Lifecycle: Tauri spawns the Python backend → health check → opens the webvie
|
||||
|
||||
## 👥 Real-time Collaboration
|
||||
|
||||
> 📖 Full guide: [Editing & Collaboration](docs/GUIDES/COLLABORATION.md)
|
||||
|
||||
Multiple users can edit the same markdown document simultaneously (Google Docs style):
|
||||
|
||||
- **Conflict-free merge** via Yjs (CRDT): two people can type in the same place, no change is lost.
|
||||
@@ -703,6 +741,8 @@ No configuration is required: open the same file in two browsers (or two windows
|
||||
|
||||
## 🔌 API
|
||||
|
||||
> 📖 Full guide: [REST API](docs/GUIDES/API_REST.md) · [MCP Server](docs/GUIDES/MCP.md)
|
||||
|
||||
ObsiGate exposes a complete REST API :
|
||||
|
||||
| Endpoint | Description | Method | Auth |
|
||||
@@ -730,6 +770,7 @@ ObsiGate exposes a complete REST API :
|
||||
| `/api/events` | Real-time SSE stream | GET | Yes |
|
||||
| `/api/vaults/add` / `/api/vaults/{name}` | Dynamic vault management | POST/DELETE | Admin |
|
||||
| `/api/image/{vault}?path=` | Serve an image | GET | Yes |
|
||||
| `/api/media/{vault}/thumb?path=&size=` | WebP thumbnail (disk cache) | GET | Yes |
|
||||
| `/api/config` | Read / write configuration | GET/POST | Yes/Admin |
|
||||
| `/api/diagnostics` | Index and memory statistics | GET | Admin |
|
||||
|
||||
@@ -763,6 +804,8 @@ curl "http://localhost:2020/api/file/Recipes?path=pizza.md"
|
||||
|
||||
## 🔍 Advanced Search
|
||||
|
||||
> 📖 Full guide: [Search, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
|
||||
|
||||
### Query Syntax
|
||||
|
||||
| Operator | Description | Example |
|
||||
@@ -915,6 +958,8 @@ These parameters are configurable via the interface (Settings) or the `/api/conf
|
||||
|
||||
## 🛡️ Security
|
||||
|
||||
> 📖 Full guide: [Auth & Security](docs/GUIDES/AUTHENTIFICATION_SECURITE.md)
|
||||
|
||||
- **Path traversal** : All file endpoints validate that the resolved path stays within the vault
|
||||
- **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)
|
||||
@@ -998,7 +1043,7 @@ These parameters are configurable via the interface (Settings) or the `/api/conf
|
||||
| Frontend import validation | `node tests/frontend/validate-imports.mjs` | `lint` |
|
||||
| Frontend unit tests | `node tests/frontend/unit.test.mjs` | `lint` |
|
||||
| Backend tests | `pytest tests/ -q` | `test` |
|
||||
| **E2E Playwright** | `npm run test:e2e` (~5 min) | `e2e` |
|
||||
| **E2E Playwright** | `npm run test:e2e` (~10 min) | `e2e` |
|
||||
|
||||
#### Local E2E Tests (`npm run test:e2e`)
|
||||
|
||||
@@ -1021,6 +1066,14 @@ bash scripts/run-e2e-local.sh --headed # visible browser
|
||||
bash scripts/run-e2e-local.sh -g "reset panes" # filter on a test
|
||||
```
|
||||
|
||||
On Windows, when `bash` is unusable (WSL unavailable, git-bash blocked by an
|
||||
Application Control policy), use the equivalent PowerShell launcher:
|
||||
|
||||
```powershell
|
||||
npm run test:e2e:ps
|
||||
pwsh -File scripts/run-e2e-local.ps1 -PlaywrightArgs @('-g','reset panes')
|
||||
```
|
||||
|
||||
The suite must end with **all tests passing** (60 currently), with no failure
|
||||
or reliance on retries. In case of failure: fix and re-run locally until 100 %,
|
||||
then only commit.
|
||||
@@ -1070,7 +1123,9 @@ ObsiGate/
|
||||
├── Dockerfile # Multi-stage, healthcheck, non-root
|
||||
├── docker-compose.yml # Deployment with healthcheck and auth env vars
|
||||
├── build.sh # Automated build & deployment (docker compose build + up)
|
||||
└── docs/CONTRIBUTING.md # Contribution guide
|
||||
└── docs/
|
||||
├── GUIDES/ # User guides (getting started, API, MCP, desktop…)
|
||||
└── CONTRIBUTING.md # Contribution guide
|
||||
```
|
||||
|
||||
### Contributing
|
||||
@@ -1096,8 +1151,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.13.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.28.1).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.13.0 | Last updated: May 2026*
|
||||
*Project: ObsiGate | Version: 2.28.1 | Last updated: September 2026*
|
||||
|
||||
@@ -28,6 +28,7 @@ from backend.tools.api import (
|
||||
ToolError,
|
||||
ToolScope,
|
||||
call_tool,
|
||||
get_tool,
|
||||
get_tool_schemas,
|
||||
)
|
||||
from backend.tools.labels import thought_step_label, tool_step_label
|
||||
@@ -121,12 +122,15 @@ def _assistant_tool_message(content: str | None, tool_calls: list[Any]) -> dict[
|
||||
def _deferred_tool_message(call: Any, reason: str | None = None) -> dict[str, Any]:
|
||||
"""Answer a tool call that was not reached because the run stopped early.
|
||||
|
||||
A single LLM response may carry several tool calls. When one of them is
|
||||
mutating and pauses the run for confirmation, the assistant message already
|
||||
lists *all* of them, so every ``tool_call_id`` must get a tool result before
|
||||
the next LLM call (the OpenAI tool protocol rejects dangling ids). The calls
|
||||
that were not reached get a synthetic ``deferred`` result; the model
|
||||
re-issues them once the confirmed call has been applied (BUG-050).
|
||||
A single LLM response may carry several tool calls; when the run stops
|
||||
before reaching some of them (tool-call quota), the assistant message still
|
||||
lists *all* of them, so every ``tool_call_id`` must get a tool result
|
||||
before the next LLM call (the OpenAI tool protocol rejects dangling ids).
|
||||
The calls that were not reached get a synthetic ``deferred`` result.
|
||||
|
||||
Note: mutating calls that pause the run for confirmation are no longer
|
||||
deferred — they are batched and applied together on resume (BUG-075); this
|
||||
helper remains for budget stops (BUG-050/BUG-052).
|
||||
"""
|
||||
return {
|
||||
"role": "tool",
|
||||
@@ -135,13 +139,29 @@ def _deferred_tool_message(call: Any, reason: str | None = None) -> dict[str, An
|
||||
"content": json.dumps({
|
||||
"status": "deferred",
|
||||
"reason": reason or (
|
||||
"Not executed: the run paused to confirm an earlier tool call. "
|
||||
"Not executed: the run stopped before reaching this tool call. "
|
||||
"Re-issue this call if it is still needed."
|
||||
),
|
||||
}, ensure_ascii=False),
|
||||
}
|
||||
|
||||
|
||||
def _action_descriptor(call: Any) -> dict[str, Any]:
|
||||
"""Describe one paused mutating tool call for the confirmation payload.
|
||||
|
||||
A single LLM response may request several mutations (create a folder and
|
||||
the files inside it…). They are batched into one confirmation so the user
|
||||
approves the whole plan in one click (BUG-075). ``step`` reuses the
|
||||
Notion-style label, so the confirmation card reads like the steps block.
|
||||
"""
|
||||
return {
|
||||
"id": call.id,
|
||||
"tool": call.name,
|
||||
"arguments": call.arguments,
|
||||
"step": tool_step_label(call.name, call.arguments),
|
||||
}
|
||||
|
||||
|
||||
def _fallback_summary(executed: list[ToolCallRecord]) -> str:
|
||||
"""Deterministic non-empty answer built from the gathered tool results.
|
||||
|
||||
@@ -212,53 +232,66 @@ def _execute_confirmed(
|
||||
executed: list[ToolCallRecord],
|
||||
on_tool_call: Callable[[ToolCallRecord], None] | None,
|
||||
) -> None:
|
||||
"""Apply a previously-paused mutating tool call and feed its result back.
|
||||
"""Apply previously-paused mutating tool calls and feed their results back.
|
||||
|
||||
The pending payload is the ``error`` object emitted by a ``confirmation``
|
||||
event. The assistant tool-call message is expected to already be in
|
||||
event, optionally carrying an ``actions`` list with every mutating call of
|
||||
the LLM turn (BUG-075). Each action is applied with a one-shot confirmation
|
||||
and its ``tool_call_id`` answered, keeping the conversation valid for the
|
||||
resumed turn. The assistant tool-call message is expected to already be in
|
||||
``convo`` (it is part of the snapshot returned with the confirmation).
|
||||
"""
|
||||
from backend.ai_chat import ToolCall
|
||||
|
||||
error = confirm_pending.get("error", confirm_pending)
|
||||
name = error.get("tool")
|
||||
arguments = error.get("arguments") or {}
|
||||
call_id = error.get("id") or "call_pending"
|
||||
error = confirm_pending.get("error", confirm_pending) or {}
|
||||
actions = confirm_pending.get("actions")
|
||||
if not isinstance(actions, list) or not actions:
|
||||
# Legacy single-action payload (no ``actions`` list).
|
||||
actions = [{
|
||||
"id": error.get("id") or "call_pending",
|
||||
"tool": error.get("tool"),
|
||||
"arguments": error.get("arguments") or {},
|
||||
}]
|
||||
|
||||
if not name:
|
||||
raise ToolError("Malformed confirmation payload", code="invalid_confirmation")
|
||||
for action in actions:
|
||||
name = action.get("tool")
|
||||
arguments = action.get("arguments") or {}
|
||||
call_id = action.get("id") or "call_pending"
|
||||
|
||||
# Make sure the assistant tool-call message is present in the snapshot.
|
||||
if not any(
|
||||
m.get("role") == "assistant" and any(
|
||||
tc.get("id") == call_id for tc in (m.get("tool_calls") or [])
|
||||
if not name:
|
||||
raise ToolError("Malformed confirmation payload", code="invalid_confirmation")
|
||||
|
||||
# Make sure the assistant tool-call message is present in the snapshot.
|
||||
if not any(
|
||||
m.get("role") == "assistant" and any(
|
||||
tc.get("id") == call_id for tc in (m.get("tool_calls") or [])
|
||||
)
|
||||
for m in convo
|
||||
):
|
||||
convo.append(_assistant_tool_message(None, [ToolCall(id=call_id, name=name, arguments=arguments)]))
|
||||
|
||||
try:
|
||||
result = call_tool(name, ctx, arguments, confirm=True)
|
||||
payload = result.data
|
||||
ok = True
|
||||
except ToolError as e:
|
||||
payload = e.to_dict()
|
||||
ok = False
|
||||
|
||||
record = ToolCallRecord(
|
||||
name=name, arguments=arguments, ok=ok, result=payload,
|
||||
step=tool_step_label(name, arguments),
|
||||
)
|
||||
for m in convo
|
||||
):
|
||||
convo.append(_assistant_tool_message(None, [ToolCall(id=call_id, name=name, arguments=arguments)]))
|
||||
executed.append(record)
|
||||
if on_tool_call is not None:
|
||||
on_tool_call(record)
|
||||
|
||||
try:
|
||||
result = call_tool(name, ctx, arguments, confirm=True)
|
||||
payload = result.data
|
||||
ok = True
|
||||
except ToolError as e:
|
||||
payload = e.to_dict()
|
||||
ok = False
|
||||
|
||||
record = ToolCallRecord(
|
||||
name=name, arguments=arguments, ok=ok, result=payload,
|
||||
step=tool_step_label(name, arguments),
|
||||
)
|
||||
executed.append(record)
|
||||
if on_tool_call is not None:
|
||||
on_tool_call(record)
|
||||
|
||||
convo.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"name": name,
|
||||
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
|
||||
})
|
||||
convo.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"name": name,
|
||||
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
|
||||
})
|
||||
|
||||
|
||||
async def run_agent(
|
||||
@@ -315,6 +348,36 @@ async def run_agent(
|
||||
convo = [dict(m) for m in (resume_messages if resume_messages is not None else messages)]
|
||||
executed: list[ToolCallRecord] = []
|
||||
|
||||
def _run_call(call: Any) -> None:
|
||||
"""Execute one tool call, record it and answer its ``tool_call_id``.
|
||||
|
||||
``ToolConfirmationRequired`` propagates to the caller so the loop can
|
||||
pause and batch the mutating calls of the turn (BUG-075).
|
||||
"""
|
||||
try:
|
||||
result = call_tool(call.name, ctx, call.arguments)
|
||||
payload: Any = result.data
|
||||
ok = True
|
||||
except ToolConfirmationRequired:
|
||||
raise
|
||||
except ToolError as e:
|
||||
payload = e.to_dict()
|
||||
ok = False
|
||||
record = ToolCallRecord(
|
||||
name=call.name, arguments=call.arguments, ok=ok, result=payload,
|
||||
step=tool_step_label(call.name, call.arguments),
|
||||
)
|
||||
executed.append(record)
|
||||
steps.append(record.step)
|
||||
if on_tool_call is not None:
|
||||
on_tool_call(record)
|
||||
convo.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": call.id,
|
||||
"name": call.name,
|
||||
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
|
||||
})
|
||||
|
||||
if confirm_pending:
|
||||
if quota is not None and len(executed) >= quota:
|
||||
return AgentResult(
|
||||
@@ -357,19 +420,25 @@ async def run_agent(
|
||||
llm, convo, executed, steps, iteration, STOP_QUOTA_EXCEEDED
|
||||
)
|
||||
try:
|
||||
result = call_tool(call.name, ctx, call.arguments)
|
||||
payload = result.data
|
||||
ok = True
|
||||
_run_call(call)
|
||||
except ToolConfirmationRequired as e:
|
||||
logger.info(f"Agent paused: confirmation required for '{call.name}'")
|
||||
pending = e.to_dict()
|
||||
# Include the tool-call id so the client can echo it back.
|
||||
pending["error"]["id"] = call.id
|
||||
# BUG-050: the assistant message lists every tool call of this
|
||||
# batch, so answer the ones we did not reach to keep the
|
||||
# conversation valid for the resumed turn.
|
||||
for skipped in response.tool_calls[index + 1:]:
|
||||
convo.append(_deferred_tool_message(skipped))
|
||||
# BUG-075: batch every mutating call of this LLM turn so the
|
||||
# user approves the whole plan at once (one resume applies them
|
||||
# all) instead of approving one action after another. Read-only
|
||||
# calls of the batch run immediately and answer their
|
||||
# ``tool_call_id`` so the resumed turn stays valid.
|
||||
actions = [_action_descriptor(call)]
|
||||
for after in response.tool_calls[index + 1:]:
|
||||
spec = get_tool(after.name)
|
||||
if spec is not None and spec.requires_confirmation:
|
||||
actions.append(_action_descriptor(after))
|
||||
else:
|
||||
_run_call(after)
|
||||
pending["actions"] = actions
|
||||
return AgentResult(
|
||||
content=response.content or "",
|
||||
messages=convo,
|
||||
@@ -379,25 +448,6 @@ async def run_agent(
|
||||
stopped=STOP_CONFIRMATION_REQUIRED,
|
||||
pending=pending,
|
||||
)
|
||||
except ToolError as e:
|
||||
payload = e.to_dict()
|
||||
ok = False
|
||||
|
||||
record = ToolCallRecord(
|
||||
name=call.name, arguments=call.arguments, ok=ok, result=payload,
|
||||
step=tool_step_label(call.name, call.arguments),
|
||||
)
|
||||
executed.append(record)
|
||||
steps.append(record.step)
|
||||
if on_tool_call is not None:
|
||||
on_tool_call(record)
|
||||
|
||||
convo.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": call.id,
|
||||
"name": call.name,
|
||||
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
|
||||
})
|
||||
|
||||
logger.warning(f"Agent reached max iterations ({max_iterations})")
|
||||
return await _finalize_answer(
|
||||
|
||||
|
After Width: | Height: | Size: 186 KiB |
@@ -4,10 +4,9 @@ import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("obsigate.attachment_indexer")
|
||||
from backend.media_types import IMAGE_EXTENSIONS
|
||||
|
||||
# Image file extensions to index
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"}
|
||||
logger = logging.getLogger("obsigate.attachment_indexer")
|
||||
|
||||
# Global attachment index: {vault_name: {filename_lower: [absolute_path, ...]}}
|
||||
attachment_index: dict[str, dict[str, list[Path]]] = {}
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
@@ -23,6 +24,22 @@ ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_SECONDS = int(os.environ.get("OBSIGATE_ACCESS_TOKEN_TTL", "3600")) # default 1 hour
|
||||
REFRESH_TOKEN_EXPIRE_SECONDS = int(os.environ.get("OBSIGATE_REFRESH_TOKEN_TTL", "604800")) # default 7 days
|
||||
|
||||
#: Persistent API/MCP access tokens (user-managed, shown in the config panel).
|
||||
API_TOKENS_FILE = Path("data/api_tokens.json")
|
||||
#: Accepted values for the expiry selector in the UI (1 day, 1 month, 6 months,
|
||||
#: 1 year, never). "never" → no ``exp`` claim → token valid until revoked.
|
||||
API_TOKEN_EXPIRY_CHOICES = {
|
||||
"1d": 24 * 3600,
|
||||
"30d": 30 * 24 * 3600,
|
||||
"180d": 180 * 24 * 3600,
|
||||
"365d": 365 * 24 * 3600,
|
||||
"never": None,
|
||||
}
|
||||
#: Max active tokens per user (anti hoarding; revoking frees a slot).
|
||||
API_TOKEN_MAX_PER_USER = 50
|
||||
#: AES-GCM key derived once from the JWT secret to encrypt stored tokens.
|
||||
_API_TOKEN_KEY: bytes | None = None
|
||||
|
||||
# In-memory revoked token set (loaded from disk on startup)
|
||||
_revoked_jtis: set = set()
|
||||
_revoked_loaded = False
|
||||
@@ -92,48 +109,197 @@ def decode_token(token: str) -> dict | None:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token revocation
|
||||
# ---------------------------------------------------------------------------
|
||||
# The store is a dict {jti: valid_until}: the revocation record may be dropped
|
||||
# once the underlying token's own expiry has passed (by then the JWT is dead
|
||||
# anyway). Long-lived API/MCP tokens (see create_api_token) must therefore be
|
||||
# revoked with their real expiry — a 1-year token revoked last week must not
|
||||
# silently come back to life when a 7-day cleanup purges the record (BUG in
|
||||
# the previous set-based store, fixed with feature #107).
|
||||
|
||||
_revoked_map: dict[str, int] = {}
|
||||
_revoked_loaded = False
|
||||
|
||||
# ROADMAP #85 T10a — verrou autour du read-modify-write du store de
|
||||
# révocation (perte de révocations en cas de logouts concurrents).
|
||||
_revoked_lock = threading.RLock()
|
||||
|
||||
|
||||
def _load_revoked():
|
||||
"""Load revoked token JTIs from disk into memory (once)."""
|
||||
global _revoked_loaded, _revoked_jtis
|
||||
if _revoked_loaded:
|
||||
return
|
||||
if REVOKED_TOKENS_FILE.exists():
|
||||
try:
|
||||
data = json.loads(REVOKED_TOKENS_FILE.read_text())
|
||||
# Clean expired entries (older than 7 days)
|
||||
now = int(time.time())
|
||||
_revoked_jtis = {
|
||||
jti for jti, exp in data.items()
|
||||
if exp > now
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load revoked tokens: {e}")
|
||||
_revoked_jtis = set()
|
||||
_revoked_loaded = True
|
||||
global _revoked_loaded, _revoked_map
|
||||
with _revoked_lock:
|
||||
if _revoked_loaded:
|
||||
return
|
||||
if REVOKED_TOKENS_FILE.exists():
|
||||
try:
|
||||
data = json.loads(REVOKED_TOKENS_FILE.read_text())
|
||||
# Drop entries whose underlying token has itself expired.
|
||||
now = int(time.time())
|
||||
_revoked_map = {
|
||||
jti: int(exp) for jti, exp in data.items()
|
||||
if int(exp) > now
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load revoked tokens: {e}")
|
||||
_revoked_map = {}
|
||||
_revoked_loaded = True
|
||||
|
||||
|
||||
def _save_revoked():
|
||||
"""Persist revoked JTIs to disk."""
|
||||
"""Persist revoked JTIs to disk with their per-token expiry."""
|
||||
REVOKED_TOKENS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Store with expiry timestamp for cleanup
|
||||
now = int(time.time())
|
||||
# Keep entries for 7 days max
|
||||
data = {jti: now + REFRESH_TOKEN_EXPIRE_SECONDS for jti in _revoked_jtis}
|
||||
tmp = REVOKED_TOKENS_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data))
|
||||
tmp.write_text(json.dumps(_revoked_map))
|
||||
tmp.replace(REVOKED_TOKENS_FILE)
|
||||
|
||||
|
||||
def revoke_token(jti: str):
|
||||
"""Add a token JTI to the revocation list."""
|
||||
_load_revoked()
|
||||
_revoked_jtis.add(jti)
|
||||
_save_revoked()
|
||||
def revoke_token(jti: str, expires_at: int | None = None):
|
||||
"""Add a token JTI to the revocation list.
|
||||
|
||||
``expires_at`` is the revoked token's own ``exp`` (unix seconds) — the
|
||||
record is kept at least that long so a long-lived API token cannot
|
||||
outlive its revocation. ``None`` means the token never expires (API/MCP
|
||||
"sans fin") → the record is kept forever (capped at ~100 years, the JWT
|
||||
store's practical infinity). Default keeps 7 days (session tokens).
|
||||
"""
|
||||
with _revoked_lock:
|
||||
_load_revoked()
|
||||
now = int(time.time())
|
||||
if expires_at is None:
|
||||
until = now + 100 * 365 * 24 * 3600
|
||||
else:
|
||||
until = max(int(expires_at), now + REFRESH_TOKEN_EXPIRE_SECONDS)
|
||||
_revoked_map[jti] = until
|
||||
_save_revoked()
|
||||
logger.debug(f"Revoked token JTI: {jti[:8]}...")
|
||||
|
||||
|
||||
def is_token_revoked(jti: str) -> bool:
|
||||
"""Check if a token JTI has been revoked."""
|
||||
_load_revoked()
|
||||
return jti in _revoked_jtis
|
||||
with _revoked_lock:
|
||||
_load_revoked()
|
||||
return jti in _revoked_map
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API / MCP tokens (feature #107)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Long-lived access tokens the user creates from the config panel. They are
|
||||
# plain HS256 access-type JWTs (``api: true`` claim), so they authenticate
|
||||
# against BOTH the REST API and the MCP endpoint (/mcp) — which share
|
||||
# ``get_current_user``. The raw token is shown exactly once at creation; the
|
||||
# store keeps metadata only (name, owner, expiry, last use) — no secret
|
||||
# material is written to disk.
|
||||
#
|
||||
# File: data/api_tokens.json
|
||||
# {"version": 1, "tokens": {jti: {name, username, created_at, expires_at, last_used_at}}}
|
||||
|
||||
_api_tokens_lock = threading.RLock()
|
||||
_touch_last_write: dict[str, float] = {}
|
||||
|
||||
|
||||
def _load_api_tokens() -> dict:
|
||||
if not API_TOKENS_FILE.exists():
|
||||
return {"version": 1, "tokens": {}}
|
||||
try:
|
||||
return json.loads(API_TOKENS_FILE.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.error(f"Failed to read api_tokens.json: {e}")
|
||||
return {"version": 1, "tokens": {}}
|
||||
|
||||
|
||||
def _save_api_tokens(data: dict):
|
||||
API_TOKENS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = API_TOKENS_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")
|
||||
tmp.replace(API_TOKENS_FILE)
|
||||
|
||||
|
||||
def create_api_token(user: dict, name: str, expiry_key: str) -> 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).
|
||||
"""
|
||||
if expiry_key not in API_TOKEN_EXPIRY_CHOICES:
|
||||
raise ValueError("Expiration invalide")
|
||||
seconds = API_TOKEN_EXPIRY_CHOICES[expiry_key]
|
||||
with _api_tokens_lock:
|
||||
data = _load_api_tokens()
|
||||
tokens = data["tokens"]
|
||||
mine = sum(1 for t in tokens.values() if t["username"] == user["username"])
|
||||
if mine >= API_TOKEN_MAX_PER_USER:
|
||||
raise ValueError(f"Maximum {API_TOKEN_MAX_PER_USER} tokens par utilisateur")
|
||||
now = int(time.time())
|
||||
jti = str(uuid.uuid4())
|
||||
payload = {
|
||||
"sub": user["username"],
|
||||
"role": user.get("role", "user"),
|
||||
"vaults": user.get("vaults", []),
|
||||
"jti": jti,
|
||||
"iat": now,
|
||||
"type": "access",
|
||||
"api": True,
|
||||
}
|
||||
if seconds is not None:
|
||||
payload["exp"] = now + seconds
|
||||
token = jwt.encode(payload, get_secret_key(), algorithm=ALGORITHM)
|
||||
record = {
|
||||
"jti": jti,
|
||||
"name": name[:64] or "API token",
|
||||
"username": user["username"],
|
||||
"created_at": now,
|
||||
"expires_at": payload.get("exp"),
|
||||
"expiry_key": expiry_key,
|
||||
"last_used_at": None,
|
||||
}
|
||||
tokens[jti] = record
|
||||
_save_api_tokens(data)
|
||||
return record, token
|
||||
|
||||
|
||||
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
|
||||
]
|
||||
return sorted(items, key=lambda t: t["created_at"], reverse=True)
|
||||
|
||||
|
||||
def delete_api_token(jti: str, username: str) -> dict:
|
||||
"""Revoke and remove an API token. Raises KeyError when unknown/not owned."""
|
||||
with _api_tokens_lock:
|
||||
data = _load_api_tokens()
|
||||
record = data["tokens"].get(jti)
|
||||
if not record or record["username"] != username:
|
||||
raise KeyError(jti)
|
||||
# Revoke by jti so the presented JWT stops working even though it is
|
||||
# stateless — kept until its natural expiry (no-expiry → forever).
|
||||
revoke_token(jti, record.get("expires_at"))
|
||||
del data["tokens"][jti]
|
||||
_save_api_tokens(data)
|
||||
return record
|
||||
|
||||
|
||||
def maybe_touch_api_token(jti: str | None, created_or_expires: bool = False):
|
||||
"""Record last usage of an API token, throttled to one disk write/hour."""
|
||||
if not jti:
|
||||
return
|
||||
now = time.time()
|
||||
if now - _touch_last_write.get(jti, 0) < 3600:
|
||||
return
|
||||
_touch_last_write[jti] = now
|
||||
try:
|
||||
with _api_tokens_lock:
|
||||
data = _load_api_tokens()
|
||||
record = data["tokens"].get(jti)
|
||||
if record is None:
|
||||
return
|
||||
record["last_used_at"] = int(now)
|
||||
_save_api_tokens(data)
|
||||
except Exception as e: # never fail an authenticated request over stats
|
||||
logger.debug(f"api_token touch failed: {e}")
|
||||
|
||||
@@ -11,7 +11,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from backend.services.net import get_client_ip
|
||||
|
||||
from .jwt_handler import decode_token, is_token_revoked
|
||||
from .jwt_handler import decode_token, is_token_revoked, maybe_touch_api_token
|
||||
from .user_store import get_user
|
||||
|
||||
logger = logging.getLogger("obsigate.auth.middleware")
|
||||
@@ -115,6 +115,10 @@ def get_current_user(
|
||||
user["_token_vaults"] = payload.get("vaults", [])
|
||||
# Attach the token id for per-token rate limiting (AI tool layer).
|
||||
user["_token_jti"] = payload.get("jti")
|
||||
# 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"):
|
||||
maybe_touch_api_token(payload.get("jti"))
|
||||
# BUG-030: expose the real client IP to the audit log.
|
||||
user["_request_ip"] = get_client_ip(request)
|
||||
return user
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
# All /api/auth/* endpoints: login, logout, refresh, me, change-password,
|
||||
# and admin user CRUD.
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import re
|
||||
|
||||
@@ -17,10 +19,14 @@ from backend.services.net import get_client_ip
|
||||
|
||||
from .jwt_handler import (
|
||||
ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||
API_TOKEN_EXPIRY_CHOICES,
|
||||
create_access_token,
|
||||
create_api_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
delete_api_token,
|
||||
is_token_revoked,
|
||||
list_api_tokens,
|
||||
revoke_token,
|
||||
)
|
||||
from .mfa import (
|
||||
@@ -105,6 +111,43 @@ class UpdateUserRequest(BaseModel):
|
||||
return validate_password_strength(v)
|
||||
|
||||
|
||||
# ── Profile avatar (#113) ───────────────────────────────────────────
|
||||
|
||||
#: Avatar data-URL pattern — PNG/JPEG/WebP only (no SVG: XSS surface).
|
||||
_AVATAR_DATA_URL_RE = re.compile(
|
||||
r"^data:image/(?:png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$"
|
||||
)
|
||||
#: ~300 KB of base64 payload (a 256px JPEG is ~15 KB; generous headroom).
|
||||
_AVATAR_MAX_CHARS = 400_000
|
||||
|
||||
|
||||
def _validate_avatar(data_url: str) -> str | None:
|
||||
"""Validate an avatar data-URL for storage on the user profile.
|
||||
|
||||
Returns the normalized data-URL, or ``None`` when clearing the avatar
|
||||
(empty string). Raises ``HTTPException(400)`` on anything else.
|
||||
"""
|
||||
if data_url == "":
|
||||
return None
|
||||
if len(data_url) > _AVATAR_MAX_CHARS:
|
||||
raise HTTPException(400, "Avatar image too large")
|
||||
if not _AVATAR_DATA_URL_RE.match(data_url):
|
||||
raise HTTPException(400, "Avatar must be a PNG, JPEG or WebP data URL")
|
||||
try:
|
||||
raw = base64.b64decode(data_url.split(",", 1)[1], validate=True)
|
||||
except (ValueError, binascii.Error) as exc: # pragma: no cover — regex guards
|
||||
raise HTTPException(400, "Avatar payload is not valid base64") from exc
|
||||
# Confirm the decoded bytes really are a supported image (magic numbers).
|
||||
is_png = raw.startswith(b"\x89PNG\r\n\x1a\n")
|
||||
is_jpeg = raw.startswith(b"\xff\xd8\xff")
|
||||
is_webp = (
|
||||
len(raw) >= 12 and raw[:4] == b"RIFF" and raw[8:12] == b"WEBP"
|
||||
)
|
||||
if not (is_png or is_jpeg or is_webp):
|
||||
raise HTTPException(400, "Avatar payload is not a PNG, JPEG or WebP image")
|
||||
return data_url
|
||||
|
||||
|
||||
# ── Public endpoints ──────────────────────────────────────────────────
|
||||
|
||||
@router.get("/status")
|
||||
@@ -210,13 +253,15 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
|
||||
)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer", # nosec B105 — OAuth2 token_type, pas un mot de passe
|
||||
# OAuth2 token_type, pas un mot de passe (B105) :
|
||||
"token_type": "bearer", # nosec B105
|
||||
"expires_in": ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||
"user": {
|
||||
"username": user["username"],
|
||||
"display_name": user["display_name"],
|
||||
"role": user["role"],
|
||||
"vaults": user["vaults"],
|
||||
"avatar": user.get("avatar"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -288,7 +333,8 @@ async def refresh_token_endpoint(request: Request, response: Response):
|
||||
|
||||
return {
|
||||
"access_token": new_access_token,
|
||||
"token_type": "bearer", # nosec B105 — OAuth2 token_type, pas un mot de passe
|
||||
# OAuth2 token_type, pas un mot de passe (B105) :
|
||||
"token_type": "bearer", # nosec B105
|
||||
"expires_in": ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||
}
|
||||
|
||||
@@ -339,6 +385,7 @@ async def get_me(current_user=Depends(require_auth)):
|
||||
"vaults": current_user["vaults"],
|
||||
"language": current_user.get("language", "fr"),
|
||||
"last_login": current_user.get("last_login"),
|
||||
"avatar": current_user.get("avatar"),
|
||||
}
|
||||
|
||||
|
||||
@@ -346,19 +393,23 @@ class UpdateMeRequest(BaseModel):
|
||||
"""Fields the user can update on their own profile."""
|
||||
display_name: str | None = None
|
||||
language: str | None = None
|
||||
#: Image data-URL (PNG/JPEG/WebP), or ``""`` to remove the avatar (#113).
|
||||
avatar: str | None = None
|
||||
|
||||
|
||||
@router.patch("/me")
|
||||
async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)):
|
||||
"""Update current user's profile fields (display_name, language)."""
|
||||
"""Update current user's profile fields (display_name, language, avatar)."""
|
||||
from .user_store import update_user
|
||||
updates = {}
|
||||
updates: dict[str, object] = {}
|
||||
if req.display_name is not None:
|
||||
updates["display_name"] = req.display_name
|
||||
if req.language is not None:
|
||||
if req.language not in ("fr", "en"):
|
||||
raise HTTPException(400, "language must be 'fr' or 'en'")
|
||||
updates["language"] = req.language
|
||||
if req.avatar is not None:
|
||||
updates["avatar"] = _validate_avatar(req.avatar)
|
||||
if not updates:
|
||||
raise HTTPException(400, "No fields to update")
|
||||
updated = update_user(current_user["username"], updates)
|
||||
@@ -369,6 +420,7 @@ async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)):
|
||||
"vaults": updated["vaults"],
|
||||
"language": updated.get("language", "fr"),
|
||||
"last_login": updated.get("last_login"),
|
||||
"avatar": updated.get("avatar"),
|
||||
}
|
||||
|
||||
|
||||
@@ -444,7 +496,9 @@ class MfaEnableRequest(BaseModel):
|
||||
async def mfa_totp_setup(current_user=Depends(require_auth)):
|
||||
"""Generate a TOTP secret and QR URI for MFA setup.
|
||||
|
||||
Returns the secret and otpauth URI — client displays QR code.
|
||||
Returns the secret, the otpauth URI and a ready-to-display QR code
|
||||
(`qr_data_url`, SVG `data:` URI — no third-party service, CSP-safe).
|
||||
|
||||
Does NOT enable MFA yet; call /mfa/totp/enable after first successful verify.
|
||||
"""
|
||||
from .user_store import update_user
|
||||
@@ -454,10 +508,21 @@ async def mfa_totp_setup(current_user=Depends(require_auth)):
|
||||
update_user(current_user["username"], {
|
||||
"mfa_secret_pending": secret,
|
||||
})
|
||||
# BUG-068: the QR code is generated locally (segno, stdlib-free SVG data
|
||||
# URI). The previous client-side https://api.qrserver.com image was blocked
|
||||
# by the CSP (img-src 'self' data: blob:) and leaked the otpauth URI —
|
||||
# including the TOTP secret — to a third party.
|
||||
qr_data_url: str | None = None
|
||||
try:
|
||||
import segno
|
||||
qr_data_url = segno.make(qr_uri).svg_data_uri(scale=5)
|
||||
except Exception:
|
||||
qr_data_url = None
|
||||
return {
|
||||
"secret": secret,
|
||||
"qr_uri": qr_uri,
|
||||
"otpauth_uri": qr_uri,
|
||||
"qr_data_url": qr_data_url,
|
||||
}
|
||||
|
||||
|
||||
@@ -559,18 +624,25 @@ class WebauthnRemoveRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post("/mfa/webauthn/register/options")
|
||||
async def mfa_webauthn_register_options(current_user=Depends(require_auth)):
|
||||
async def mfa_webauthn_register_options(request: Request,
|
||||
current_user=Depends(require_auth)):
|
||||
"""Start WebAuthn key enrolment — returns publicKey creation options for the browser."""
|
||||
from .webauthn_mfa import begin_registration
|
||||
from .webauthn_mfa import begin_registration, resolve_relying_party
|
||||
|
||||
# BUG-070: rp_id/origins derive from the request (exact host incl. port)
|
||||
# unless explicitly configured — the old localhost defaults rejected
|
||||
# every real access URL ("Unexpected client data origin").
|
||||
rp, _ = resolve_relying_party(request)
|
||||
options = begin_registration(current_user["username"],
|
||||
current_user.get("display_name", ""))
|
||||
current_user.get("display_name", ""),
|
||||
rp_id_override=rp)
|
||||
return {"options": options}
|
||||
|
||||
|
||||
@router.post("/mfa/webauthn/register")
|
||||
async def mfa_webauthn_register(
|
||||
req: WebauthnRegisterRequest,
|
||||
request: Request,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Verify the created credential, store it, and enable MFA if not already on.
|
||||
@@ -580,14 +652,17 @@ async def mfa_webauthn_register(
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .user_store import get_user, update_user
|
||||
from .webauthn_mfa import complete_registration
|
||||
from .webauthn_mfa import complete_registration, resolve_relying_party
|
||||
|
||||
user = get_user(current_user["username"])
|
||||
if user is None:
|
||||
raise HTTPException(404, "Utilisateur introuvable")
|
||||
rp, origins = resolve_relying_party(request)
|
||||
try:
|
||||
record = complete_registration(current_user["username"], req.credential,
|
||||
label=req.label)
|
||||
label=req.label,
|
||||
rp_id_override=rp,
|
||||
origins_override=origins)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except Exception as e:
|
||||
@@ -666,7 +741,7 @@ async def mfa_webauthn_remove(
|
||||
|
||||
|
||||
@router.post("/mfa/webauthn/options")
|
||||
async def mfa_webauthn_login_options(body: dict = Body(...)):
|
||||
async def mfa_webauthn_login_options(request: Request, body: dict = Body(...)):
|
||||
"""Unauthenticated: begin the login assertion for a user with registered keys.
|
||||
|
||||
Enumeration-safe: always 200 — returns null options (caller falls back to
|
||||
@@ -678,8 +753,9 @@ async def mfa_webauthn_login_options(body: dict = Body(...)):
|
||||
if not user or not user.get("mfa_enabled") or not creds:
|
||||
return {"mfa_method": "totp", "options": None}
|
||||
|
||||
from .webauthn_mfa import begin_authentication
|
||||
options = begin_authentication(username, creds)
|
||||
from .webauthn_mfa import begin_authentication, resolve_relying_party
|
||||
rp, _ = resolve_relying_party(request)
|
||||
options = begin_authentication(username, creds, rp_id_override=rp)
|
||||
if options is None:
|
||||
return {"mfa_method": "totp", "options": None}
|
||||
return {"mfa_method": "webauthn", "options": options}
|
||||
@@ -693,7 +769,7 @@ async def mfa_webauthn_verify(
|
||||
):
|
||||
"""Unauthenticated: verify the WebAuthn assertion and issue JWT tokens."""
|
||||
from .user_store import get_user, update_user
|
||||
from .webauthn_mfa import complete_authentication
|
||||
from .webauthn_mfa import complete_authentication, resolve_relying_party
|
||||
|
||||
client_ip = _enforce_mfa_rate_limit(request, body.username)
|
||||
|
||||
@@ -704,13 +780,16 @@ async def mfa_webauthn_verify(
|
||||
if not user.get("mfa_enabled"):
|
||||
raise HTTPException(400, "MFA non activé pour cet utilisateur")
|
||||
|
||||
rp, origins = resolve_relying_party(request)
|
||||
creds = user.get("webauthn_credentials", [])
|
||||
try:
|
||||
credential_id = body.credential.get("id", "")
|
||||
stored = next((c for c in creds if c.get("credential_id") == credential_id), None)
|
||||
if stored is None:
|
||||
raise ValueError("Credential non enregistré")
|
||||
new_count = complete_authentication(body.username, body.credential, stored)
|
||||
new_count = complete_authentication(body.username, body.credential, stored,
|
||||
rp_id_override=rp,
|
||||
origins_override=origins)
|
||||
except ValueError as e:
|
||||
_record_mfa_failure(client_ip, body.username)
|
||||
raise HTTPException(401, str(e))
|
||||
@@ -861,3 +940,58 @@ async def delete_user_endpoint(
|
||||
return {"message": f"Utilisateur '{username}' supprimé"}
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
|
||||
|
||||
# ── API / MCP tokens (feature #107) ──────────────────────────────────
|
||||
# One long-lived token authenticates BOTH the REST API and the MCP
|
||||
# endpoint (/mcp): the MCP server resolves the caller through the same
|
||||
# get_current_user() dependency, so the same Bearer JWT works everywhere.
|
||||
|
||||
class CreateApiTokenRequest(BaseModel):
|
||||
name: str
|
||||
expiry: str # 1d | 30d | 180d | 365d | never
|
||||
|
||||
|
||||
@router.get("/tokens")
|
||||
async def list_user_tokens(current_user=Depends(require_auth)):
|
||||
"""List the caller's API/MCP tokens (metadata only — the secret is never stored)."""
|
||||
return {
|
||||
"tokens": list_api_tokens(current_user["username"]),
|
||||
"expiry_choices": list(API_TOKEN_EXPIRY_CHOICES.keys()),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tokens")
|
||||
async def create_user_token(
|
||||
req: CreateApiTokenRequest,
|
||||
request: Request,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Create a long-lived API/MCP token. The raw JWT is returned ONCE."""
|
||||
try:
|
||||
record, token = create_api_token(current_user, req.name.strip(), req.expiry)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
from backend.audit import log_config_change
|
||||
log_config_change(current_user["username"],
|
||||
{"action": "api_token_create", "name": record["name"],
|
||||
"expiry": record["expiry_key"]}, ip=get_client_ip(request))
|
||||
return {"token": token, **record}
|
||||
|
||||
|
||||
@router.delete("/tokens/{jti}")
|
||||
async def delete_user_token(
|
||||
jti: str,
|
||||
request: Request,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Revoke + delete an API/MCP token (immediate effect on API and MCP)."""
|
||||
try:
|
||||
record = delete_api_token(jti, current_user["username"])
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Token introuvable")
|
||||
from backend.audit import log_config_change
|
||||
log_config_change(current_user["username"],
|
||||
{"action": "api_token_revoke", "name": record["name"]},
|
||||
ip=get_client_ip(request))
|
||||
return {"message": f"Token '{record['name']}' révoqué"}
|
||||
|
||||
@@ -96,6 +96,7 @@ def create_user(
|
||||
"vaults": vaults or [],
|
||||
"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(),
|
||||
"last_login": None,
|
||||
|
||||
@@ -38,8 +38,16 @@ logger = logging.getLogger("obsigate.auth.webauthn")
|
||||
# Challenge lifetime: clients have 3 minutes to complete the ceremony.
|
||||
CHALLENGE_TTL_SECONDS = 180
|
||||
|
||||
# In-memory pending challenges: key -> (challenge_bytes, expires_at)
|
||||
_pending: dict[str, tuple[bytes, float]] = {}
|
||||
# How many outstanding challenges to keep per key. BUG-070: a single slot made
|
||||
# the flow fragile — a double-click on "add key" (or any retry) overwrote the
|
||||
# pending challenge and the in-flight ceremony failed with
|
||||
# "Client data challenge was not expected challenge". The verifier now accepts
|
||||
# any recent challenge for the key.
|
||||
MAX_PENDING_PER_KEY = 5
|
||||
|
||||
# In-memory pending challenges: key -> [(challenge_bytes, expires_at), ...]
|
||||
# (newest last)
|
||||
_pending: dict[str, list[tuple[bytes, float]]] = {}
|
||||
|
||||
|
||||
def rp_id() -> str:
|
||||
@@ -55,24 +63,100 @@ def expected_origins() -> list[str]:
|
||||
return [o.strip() for o in raw.split(",") if o.strip()]
|
||||
|
||||
|
||||
def resolve_relying_party(request: Any = None) -> tuple[str, list[str]]:
|
||||
"""Resolve the WebAuthn (rp_id, expected_origins) for a ceremony.
|
||||
|
||||
BUG-070: the previous defaults (rp_id ``localhost``, origins
|
||||
``http://localhost``) rejected every real-world access URL — any port
|
||||
(``http://localhost:2020``), ``127.0.0.1``, a LAN host or a public domain
|
||||
failed verification with "Unexpected client data origin".
|
||||
|
||||
Explicit configuration still wins: when ``OBSIGATE_WEBAUTHN_RP_ID`` /
|
||||
``OBSIGATE_WEBAUTHN_ORIGINS`` are set they are used unchanged. Otherwise
|
||||
the values are derived from the incoming request (exact ``Host``, port
|
||||
included, since the browser origin carries non-default ports).
|
||||
|
||||
Behind a reverse proxy the external host/proto come from
|
||||
``X-Forwarded-Host`` / ``X-Forwarded-Proto``, honored only when
|
||||
``OBSIGATE_TRUST_PROXY=true`` (same rule as ``get_client_ip``).
|
||||
"""
|
||||
env_rp = os.environ.get("OBSIGATE_WEBAUTHN_RP_ID")
|
||||
env_raw = os.environ.get("OBSIGATE_WEBAUTHN_ORIGINS")
|
||||
if request is None:
|
||||
return (env_rp or "localhost",
|
||||
[o.strip() for o in env_raw.split(",") if o.strip()]
|
||||
if env_raw else ["http://localhost"])
|
||||
|
||||
from backend.services.net import is_trusted_proxy
|
||||
|
||||
if is_trusted_proxy():
|
||||
fwd_host = request.headers.get("x-forwarded-host", "")
|
||||
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "")
|
||||
fwd_proto = request.headers.get("x-forwarded-proto", "")
|
||||
scheme = fwd_proto.split(",")[0].strip() or request.url.scheme
|
||||
else:
|
||||
host = request.headers.get("host", "")
|
||||
scheme = request.url.scheme
|
||||
if not host:
|
||||
url = request.url
|
||||
host = url.netloc or url.hostname or ""
|
||||
scheme = scheme or url.scheme or "http"
|
||||
rp = env_rp or _hostname_only(host) or "localhost"
|
||||
if env_raw:
|
||||
origins = [o.strip() for o in env_raw.split(",") if o.strip()]
|
||||
else:
|
||||
origins = [f"{scheme or 'http'}://{host}"] if host else ["http://localhost"]
|
||||
return rp, origins
|
||||
|
||||
|
||||
def _hostname_only(host: str) -> str:
|
||||
"""Strip the port (and IPv6 brackets) from a Host header value."""
|
||||
host = host.strip()
|
||||
if host.startswith("["): # [::1]:8080 or [::1]
|
||||
end = host.find("]")
|
||||
return host[1:end] if end > 0 else host
|
||||
if host.count(":") == 1:
|
||||
name, _, port = host.partition(":")
|
||||
return name if port.isdigit() else host
|
||||
return host
|
||||
|
||||
|
||||
def _prune_expired() -> None:
|
||||
now = time.time()
|
||||
for key in [k for k, (_, exp) in _pending.items() if exp < now]:
|
||||
_pending.pop(key, None)
|
||||
for key in list(_pending):
|
||||
remaining = [(c, exp) for c, exp in _pending[key] if exp >= now]
|
||||
if remaining:
|
||||
_pending[key] = remaining
|
||||
else:
|
||||
_pending.pop(key, None)
|
||||
|
||||
|
||||
def _store_challenge(key: str) -> bytes:
|
||||
_prune_expired()
|
||||
challenge = secrets.token_bytes(32)
|
||||
_pending[key] = (challenge, time.time() + CHALLENGE_TTL_SECONDS)
|
||||
slot = _pending.setdefault(key, [])
|
||||
slot.append((challenge, time.time() + CHALLENGE_TTL_SECONDS))
|
||||
del slot[:-MAX_PENDING_PER_KEY] # keep only the most recent ones
|
||||
return challenge
|
||||
|
||||
|
||||
def _take_challenge(key: str) -> bytes | None:
|
||||
"""Pop a challenge (single-use). Returns None if missing/expired."""
|
||||
"""Pop the newest challenge (single-use). Returns None if missing/expired."""
|
||||
_prune_expired()
|
||||
entry = _pending.pop(key, None)
|
||||
return entry[0] if entry else None
|
||||
slot = _pending.get(key)
|
||||
if not slot:
|
||||
return None
|
||||
challenge, _ = slot.pop()
|
||||
if not slot:
|
||||
_pending.pop(key, None)
|
||||
return challenge
|
||||
|
||||
|
||||
def _take_all_challenges(key: str) -> list[bytes]:
|
||||
"""Pop every outstanding challenge for *key* (newest last)."""
|
||||
_prune_expired()
|
||||
slot = _pending.pop(key, None)
|
||||
return [c for c, _ in slot] if slot else []
|
||||
|
||||
|
||||
def clear_pending(username: str) -> None:
|
||||
@@ -83,9 +167,12 @@ def clear_pending(username: str) -> None:
|
||||
|
||||
# ── Registration (enrol a key in settings) ─────────────────────────────
|
||||
|
||||
def begin_registration(username: str, display_name: str) -> dict:
|
||||
def begin_registration(username: str, display_name: str,
|
||||
rp_id_override: str | None = None,
|
||||
origins_override: list[str] | None = None) -> dict:
|
||||
_ = origins_override # origins only matter at verification time
|
||||
options = generate_registration_options(
|
||||
rp_id=rp_id(),
|
||||
rp_id=rp_id_override or rp_id(),
|
||||
rp_name=rp_name(),
|
||||
user_name=username,
|
||||
user_display_name=display_name or username,
|
||||
@@ -98,19 +185,44 @@ def begin_registration(username: str, display_name: str) -> dict:
|
||||
return _finalize_options(options)
|
||||
|
||||
|
||||
def complete_registration(username: str, credential_json: dict[str, Any],
|
||||
label: str = "") -> dict:
|
||||
challenge = _take_challenge(f"{username}:register")
|
||||
if challenge is None:
|
||||
raise ValueError("Session d'enregistrement expirée — recommencez")
|
||||
def _verify_with_any_challenge(key: str, verify_one: Any, empty_message: str) -> Any:
|
||||
"""Run *verify_one(challenge)* against every outstanding challenge.
|
||||
|
||||
Returns the first success; re-raises the last error when all fail.
|
||||
BUG-070: lets an in-flight ceremony survive a re-requested options call
|
||||
(double-click / retry) that stored a newer challenge afterwards.
|
||||
"""
|
||||
challenges = _take_all_challenges(key)
|
||||
if not challenges:
|
||||
raise ValueError(empty_message)
|
||||
last_error: Exception | None = None
|
||||
for challenge in challenges:
|
||||
try:
|
||||
return verify_one(challenge)
|
||||
except Exception as e: # try the next candidate challenge
|
||||
last_error = e
|
||||
assert last_error is not None
|
||||
raise last_error
|
||||
|
||||
|
||||
def complete_registration(username: str, credential_json: dict[str, Any],
|
||||
label: str = "", rp_id_override: str | None = None,
|
||||
origins_override: list[str] | None = None) -> dict:
|
||||
credential = parse_registration_credential_json(credential_json)
|
||||
verification = verify_registration_response(
|
||||
credential=credential,
|
||||
expected_challenge=challenge,
|
||||
expected_rp_id=rp_id(),
|
||||
expected_origin=expected_origins(),
|
||||
)
|
||||
effective_rp = rp_id_override or rp_id()
|
||||
effective_origins = origins_override or expected_origins()
|
||||
|
||||
def _verify(challenge: bytes) -> Any:
|
||||
return verify_registration_response(
|
||||
credential=credential,
|
||||
expected_challenge=challenge,
|
||||
expected_rp_id=effective_rp,
|
||||
expected_origin=effective_origins,
|
||||
)
|
||||
|
||||
verification = _verify_with_any_challenge(
|
||||
f"{username}:register", _verify,
|
||||
"Session d'enregistrement expirée — recommencez")
|
||||
|
||||
transports = credential.response.transports or []
|
||||
label = (label or str(credential_json.get("label") or "")).strip() or "Security key"
|
||||
@@ -126,9 +238,12 @@ def complete_registration(username: str, credential_json: dict[str, Any],
|
||||
|
||||
# ── Authentication (assertion at login) ────────────────────────────────
|
||||
|
||||
def begin_authentication(username: str, credentials: list[dict]) -> dict | None:
|
||||
def begin_authentication(username: str, credentials: list[dict],
|
||||
rp_id_override: str | None = None,
|
||||
origins_override: list[str] | None = None) -> dict | None:
|
||||
if not credentials:
|
||||
return None
|
||||
_ = origins_override # origins only matter at verification time
|
||||
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
||||
|
||||
allow = [
|
||||
@@ -136,7 +251,7 @@ def begin_authentication(username: str, credentials: list[dict]) -> dict | None:
|
||||
for c in credentials
|
||||
]
|
||||
options = generate_authentication_options(
|
||||
rp_id=rp_id(),
|
||||
rp_id=rp_id_override or rp_id(),
|
||||
challenge=_store_challenge(f"{username}:login"),
|
||||
allow_credentials=allow,
|
||||
)
|
||||
@@ -147,21 +262,27 @@ def complete_authentication(
|
||||
username: str,
|
||||
credential_json: dict[str, Any],
|
||||
stored: dict,
|
||||
rp_id_override: str | None = None,
|
||||
origins_override: list[str] | None = None,
|
||||
) -> int:
|
||||
"""Verify an assertion. Returns the new sign_count. Raises ValueError on failure."""
|
||||
challenge = _take_challenge(f"{username}:login")
|
||||
if challenge is None:
|
||||
raise ValueError("Session expirée — rechargez la page")
|
||||
|
||||
"""Verify an assertion. Returns the new sign_count. Raises on failure."""
|
||||
credential = parse_authentication_credential_json(credential_json)
|
||||
verification = verify_authentication_response(
|
||||
credential=credential,
|
||||
expected_challenge=challenge,
|
||||
expected_rp_id=rp_id(),
|
||||
expected_origin=expected_origins(),
|
||||
credential_public_key=base64url_to_bytes(stored["public_key"]),
|
||||
credential_current_sign_count=int(stored.get("sign_count", 0)),
|
||||
)
|
||||
effective_rp = rp_id_override or rp_id()
|
||||
effective_origins = origins_override or expected_origins()
|
||||
|
||||
def _verify(challenge: bytes) -> Any:
|
||||
return verify_authentication_response(
|
||||
credential=credential,
|
||||
expected_challenge=challenge,
|
||||
expected_rp_id=effective_rp,
|
||||
expected_origin=effective_origins,
|
||||
credential_public_key=base64url_to_bytes(stored["public_key"]),
|
||||
credential_current_sign_count=int(stored.get("sign_count", 0)),
|
||||
)
|
||||
|
||||
verification = _verify_with_any_challenge(
|
||||
f"{username}:login", _verify,
|
||||
"Session expirée — rechargez la page")
|
||||
return int(verification.new_sign_count)
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.media_types import is_media
|
||||
from backend.secret_redactor import redact_file_content
|
||||
|
||||
logger = logging.getLogger("obsigate.bookslm")
|
||||
@@ -185,6 +186,10 @@ def collect_directory_context(vault_path: Path, directory: str) -> dict[str, Any
|
||||
def _file_entry(target: Path, rel_path: str, remaining: int) -> dict[str, Any] | None:
|
||||
"""Read, redact and truncate a single file into a context entry."""
|
||||
suffix = target.suffix.lower()
|
||||
# #109-D3 — audio/video (and images) carry no extractable text; never feed
|
||||
# raw bytes to the model. Images are handled separately via vision data URLs.
|
||||
if is_media(suffix):
|
||||
return None
|
||||
try:
|
||||
if suffix == ".pdf":
|
||||
from backend.pdf_reader import extract_pdf_text
|
||||
|
||||
@@ -110,6 +110,12 @@ class BooksLMChatRequest(BaseModel):
|
||||
description="Conversation snapshot returned alongside a ``confirmation`` event, "
|
||||
"echoed back to resume the agent run.",
|
||||
)
|
||||
confirm_all: bool = Field(
|
||||
default=False,
|
||||
description="Global approval (BUG-075): apply every pending action of the batch "
|
||||
"and auto-approve the remaining mutating calls of the same run, "
|
||||
"so the run does not pause on each action.",
|
||||
)
|
||||
app_context: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Live client UI state for the General assistant: open_documents, "
|
||||
@@ -506,9 +512,11 @@ async def api_bookslm_agent(
|
||||
Same context as ``/chat`` but the model may call tools (read/search the
|
||||
vault) through the shared tool layer. Emits one ``tool`` event per executed
|
||||
tool call, then a final ``message`` event. Mutating tools pause the run with
|
||||
a ``confirmation`` event (two-step propose/apply) carrying the pending call
|
||||
and the conversation snapshot; the client resumes by echoing them back in
|
||||
``confirm`` / ``confirm_messages``.
|
||||
a ``confirmation`` event (two-step propose/apply) carrying the pending
|
||||
``actions`` (every mutating call of the turn) and the conversation snapshot;
|
||||
the client resumes by echoing them back in ``confirm`` / ``confirm_messages``,
|
||||
optionally with ``confirm_all`` to apply the whole batch and auto-approve the
|
||||
rest of the run (BUG-075).
|
||||
"""
|
||||
_validate_vision_support(req)
|
||||
system_prompt = _resolve_system_prompt(req, current_user, agent=True)
|
||||
@@ -523,6 +531,10 @@ async def api_bookslm_agent(
|
||||
messages.append({"role": "user", "content": _build_user_content(req, vault_path)})
|
||||
|
||||
ctx = ToolContext(user=current_user, mode=ToolMode.IN_APP)
|
||||
if req.confirm_all:
|
||||
# BUG-075: a single global approval authorizes the whole plan, so the
|
||||
# run no longer pauses on every subsequent mutating call.
|
||||
ctx.confirmed = True
|
||||
|
||||
async def _llm(msgs, tool_schemas):
|
||||
return await chat_completion(
|
||||
|
||||
@@ -13,6 +13,7 @@ quand les bibliothèques natives GTK manquent (Windows).
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
@@ -26,6 +27,18 @@ ROOT = Path(__file__).resolve().parent.parent
|
||||
INDEX_HTML = ROOT / "frontend" / "index.html"
|
||||
LOCALES_DIR = ROOT / "frontend" / "locales"
|
||||
VERSION_FILE = ROOT / "VERSION"
|
||||
DIAGRAMS_DIR = ROOT / "backend" / "assets" / "guide_diagrams"
|
||||
|
||||
|
||||
def diagram_png_for(code: str) -> Path | None:
|
||||
"""Chemin du PNG pré-rendu (scripts/build_guide_diagrams.py) pour un code
|
||||
Mermaid, ou None. Le hash doit rester synchrone avec le script de build :
|
||||
sha1(unescape(code).strip())[:16]."""
|
||||
normalized = html.unescape(code).strip()
|
||||
# Identifiant de cache déterministe (pas un usage sécurité).
|
||||
sha = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:16] # nosec B324
|
||||
png = DIAGRAMS_DIR / (sha + ".png")
|
||||
return png if png.exists() else None
|
||||
|
||||
# Éléments décoratifs exclus des exports
|
||||
_SKIP_CLASSES = {"help-hero-visual", "editor-modal", "help-nav"}
|
||||
@@ -388,8 +401,15 @@ def _html_block(node: Node | str, out: list[str], loc: dict[str, str]) -> None:
|
||||
tag = node.tag
|
||||
|
||||
if tag == "pre":
|
||||
raw = html.escape(_pre_text(node), quote=False)
|
||||
out.append(f"<pre><code>{raw}</code></pre>")
|
||||
raw = _pre_text(node)
|
||||
classes = _pre_classes(node)
|
||||
if "language-mermaid" in classes:
|
||||
png = diagram_png_for(raw)
|
||||
if png is not None:
|
||||
url = "file:///" + str(png).replace("\\", "/").lstrip("/")
|
||||
out.append(f'<img src="{url}" style="max-width: 100%" />')
|
||||
return
|
||||
out.append(f"<pre><code>{html.escape(raw, quote=False)}</code></pre>")
|
||||
return
|
||||
|
||||
kids = _resolve_i18n(node, loc)
|
||||
|
||||
@@ -11,6 +11,8 @@ from typing import Any
|
||||
|
||||
import frontmatter
|
||||
|
||||
from backend.media_types import AUDIO_EXTENSIONS, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS, is_media
|
||||
|
||||
logger = logging.getLogger("obsigate.indexer")
|
||||
|
||||
# Global in-memory index
|
||||
@@ -63,13 +65,14 @@ SUPPORTED_EXTENSIONS = {
|
||||
".sh", ".bash", ".zsh", ".fish", ".bat", ".cmd", ".ps1",
|
||||
".json", ".yaml", ".yml", ".toml", ".xml", ".csv",
|
||||
".cfg", ".ini", ".conf", ".env", ".pdf",
|
||||
".xlsx",
|
||||
".html", ".css", ".scss", ".less",
|
||||
".java", ".c", ".cpp", ".h", ".hpp", ".cs", ".go", ".rs", ".rb",
|
||||
".php", ".sql", ".r", ".m", ".swift", ".kt",
|
||||
".dockerfile", ".makefile", ".cmake",
|
||||
".excalidraw",
|
||||
".excalidraw.md",
|
||||
}
|
||||
} | set(IMAGE_EXTENSIONS) | set(AUDIO_EXTENSIONS) | set(VIDEO_EXTENSIONS)
|
||||
|
||||
|
||||
# Ignored directories (configurable via OBSIGATE_IGNORED_DIRS env var)
|
||||
@@ -397,31 +400,52 @@ def parse_markdown_file(raw: str) -> frontmatter.Post:
|
||||
return frontmatter.Post(content)
|
||||
|
||||
|
||||
def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
def _scan_vault(
|
||||
vault_name: str,
|
||||
vault_path: str,
|
||||
vault_cfg: dict[str, Any] | None = None,
|
||||
previous_files: dict[str, dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Synchronously scan a single vault directory and build file index.
|
||||
|
||||
Walks the vault tree, reads supported files, extracts metadata
|
||||
(tags, title, content preview) and stores a capped content snapshot
|
||||
for in-memory full-text search.
|
||||
|
||||
|
||||
All files and directories are indexed, including hidden files (starting with '.').
|
||||
|
||||
Differential scan (#86): when ``previous_files`` maps a relative path to
|
||||
its previous ``file_info`` dict, entries whose ``size`` and ``modified``
|
||||
timestamp are unchanged are reused verbatim (no disk read, no re-parse).
|
||||
Only the cheap ``os.walk`` + ``stat`` runs on every pass; heavy content
|
||||
extraction (PDF metadata excepted — always cheap) is skipped for
|
||||
unchanged files. This replaces the full ``rglob`` re-read on rebuilds.
|
||||
|
||||
Excalidraw diagrams (#86, like PDFs since BUG-040) are deferred: the scan
|
||||
only records the title and sets ``excalidraw_text_pending``; the expensive
|
||||
JSON/lz-string text extraction runs in ``enrich_pdf_texts()`` after the
|
||||
index is queryable.
|
||||
|
||||
Args:
|
||||
vault_name: Display name of the vault.
|
||||
vault_path: Absolute filesystem path to the vault root.
|
||||
vault_cfg: Optional vault configuration dict (unused for indexing, kept for compatibility).
|
||||
previous_files: Optional ``{relative_path: file_info}`` snapshot from a
|
||||
previous scan used for differential reuse.
|
||||
|
||||
Returns:
|
||||
Dict with keys ``files`` (list), ``tags`` (counter dict), ``path`` (str), ``paths`` (list).
|
||||
Dict with keys ``files`` (list), ``tags`` (counter dict), ``path`` (str),
|
||||
``paths`` (list) and ``reused`` (int, differential hits).
|
||||
"""
|
||||
vault_root = Path(vault_path)
|
||||
files: list[dict[str, Any]] = []
|
||||
tag_counts: dict[str, int] = {}
|
||||
paths: list[dict[str, str]] = []
|
||||
reused = 0
|
||||
|
||||
if not vault_root.exists():
|
||||
logger.warning(f"Vault path does not exist: {vault_path}")
|
||||
return {"files": [], "tags": {}, "path": vault_path, "paths": []}
|
||||
return {"files": [], "tags": {}, "path": vault_path, "paths": [], "reused": 0}
|
||||
|
||||
root_resolved = vault_root.resolve(strict=False)
|
||||
|
||||
@@ -479,9 +503,37 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
|
||||
stat = fpath.stat()
|
||||
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
|
||||
|
||||
# #86 differential scan: reuse the previous entry when neither
|
||||
# size nor mtime changed — skips the disk read + parse below.
|
||||
if previous_files:
|
||||
prev = previous_files.get(rel_path_str)
|
||||
if (
|
||||
prev is not None
|
||||
and prev.get("size") == stat.st_size
|
||||
and prev.get("modified") == modified
|
||||
):
|
||||
file_info = {**prev, "tags": list(prev.get("tags", []))}
|
||||
files.append(file_info)
|
||||
for tag in file_info.get("tags", []):
|
||||
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
||||
reused += 1
|
||||
# The global backlink index is rebuilt on every scan,
|
||||
# so re-register this file's wikilinks from its
|
||||
# (cached) content instead of re-reading the disk.
|
||||
if file_info.get("extension") == ".md" and file_info.get("content"):
|
||||
try:
|
||||
_extract_wikilinks_for_backlinks(
|
||||
vault_name, file_info["path"],
|
||||
file_info.get("title", ""), file_info["content"],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
# PDF handling — special path (binary, uses pdf_reader)
|
||||
tags: list[str] = []
|
||||
pdf_text_pending = False
|
||||
excalidraw_text_pending = False
|
||||
if ext == ".pdf":
|
||||
from backend.pdf_reader import extract_pdf_metadata
|
||||
# BUG-040: only the (cheap) metadata is read during the
|
||||
@@ -494,10 +546,26 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
|
||||
content_preview = ""
|
||||
pdf_text_pending = True
|
||||
elif ext == ".excalidraw" or fpath.name.lower().endswith(".excalidraw.md"):
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
raw = extract_excalidraw_indexable(raw)
|
||||
# #86: defer the expensive JSON/lz-string text extraction
|
||||
# (read + decompress + element walk) to ``enrich_pdf_texts``
|
||||
# so the scan stays cheap; title comes from the filename.
|
||||
raw = ""
|
||||
title = fpath.stem.replace(".excalidraw", "").replace("-", " ").replace("_", " ")
|
||||
content_preview = raw[:200].strip()
|
||||
content_preview = ""
|
||||
excalidraw_text_pending = True
|
||||
elif is_media(ext):
|
||||
# #108 — images (and future media, #109) are binary: index
|
||||
# name/size/mtime only and never read the bytes. ``content``
|
||||
# stays empty so the TF-IDF index remains clean.
|
||||
raw = ""
|
||||
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 = ""
|
||||
title = fpath.stem.replace("-", " ").replace("_", " ")
|
||||
content_preview = ""
|
||||
else:
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
title = fpath.stem.replace("-", " ").replace("_", " ")
|
||||
@@ -528,6 +596,8 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
|
||||
}
|
||||
if pdf_text_pending:
|
||||
file_info["pdf_text_pending"] = True
|
||||
if excalidraw_text_pending:
|
||||
file_info["excalidraw_text_pending"] = True
|
||||
files.append(file_info)
|
||||
|
||||
for tag in tags:
|
||||
@@ -540,28 +610,49 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
|
||||
logger.error(f"Error indexing {fpath}: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"Vault '{vault_name}': indexed {len(files)} files, {len(paths)} paths, {len(tag_counts)} unique tags")
|
||||
return {"files": files, "tags": tag_counts, "path": vault_path, "paths": paths, "config": {}}
|
||||
logger.info(
|
||||
f"Vault '{vault_name}': indexed {len(files)} files "
|
||||
f"({reused} reused), {len(paths)} paths, {len(tag_counts)} unique tags"
|
||||
)
|
||||
return {"files": files, "tags": tag_counts, "path": vault_path, "paths": paths, "config": {}, "reused": reused}
|
||||
|
||||
|
||||
def _read_excalidraw_indexable_text(file_path: Path) -> str:
|
||||
"""Read an excalidraw file and return its indexable text (blocking helper).
|
||||
|
||||
Runs inside an executor via ``enrich_pdf_texts`` so the lz-string
|
||||
decompression of large diagrams never blocks the event loop.
|
||||
"""
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return ""
|
||||
try:
|
||||
return extract_excalidraw_indexable(raw)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return ""
|
||||
|
||||
|
||||
async def enrich_pdf_texts(vault_name: str | None = None) -> int:
|
||||
"""Extract text from PDFs whose extraction was deferred during the scan (BUG-040).
|
||||
"""Extract text deferred during the scan: PDFs (BUG-040) + excalidraw (#86).
|
||||
|
||||
``_scan_vault`` only reads PDF metadata so a vault with many or large PDFs
|
||||
starts serving immediately. This coroutine runs *after* the index (and the
|
||||
inverted index) is ready, extracts the missing text off the event loop and
|
||||
updates the in-memory entry plus the incremental index hooks.
|
||||
``_scan_vault`` only reads PDF metadata and excalidraw filenames so a vault
|
||||
with many or large heavy files starts serving immediately. This coroutine
|
||||
runs *after* the index (and the inverted index) is ready, extracts the
|
||||
missing text off the event loop and updates the in-memory entry plus the
|
||||
incremental index hooks.
|
||||
|
||||
Args:
|
||||
vault_name: Restrict the pass to a single vault; ``None`` covers every
|
||||
indexed vault.
|
||||
|
||||
Returns:
|
||||
Number of deferred PDFs whose text extraction was attempted.
|
||||
Number of deferred files (PDF + excalidraw) whose text extraction was
|
||||
attempted.
|
||||
"""
|
||||
from backend.pdf_reader import extract_pdf_text
|
||||
|
||||
pending: list[tuple[str, dict[str, Any], Path]] = []
|
||||
pending: list[tuple[str, dict[str, Any], Path, str]] = []
|
||||
with _index_lock:
|
||||
for name, vault_data in index.items():
|
||||
if vault_name is not None and name != vault_name:
|
||||
@@ -569,32 +660,38 @@ async def enrich_pdf_texts(vault_name: str | None = None) -> int:
|
||||
vault_root = Path(vault_data.get("path", ""))
|
||||
for file_info in vault_data.get("files", []):
|
||||
if file_info.get("pdf_text_pending"):
|
||||
pending.append((name, file_info, vault_root / file_info["path"]))
|
||||
pending.append((name, file_info, vault_root / file_info["path"], "pdf"))
|
||||
elif file_info.get("excalidraw_text_pending"):
|
||||
pending.append((name, file_info, vault_root / file_info["path"], "excalidraw"))
|
||||
|
||||
if not pending:
|
||||
return 0
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
enriched = 0
|
||||
for name, file_info, file_path in pending:
|
||||
for name, file_info, file_path, kind in pending:
|
||||
try:
|
||||
raw = await loop.run_in_executor(None, extract_pdf_text, file_path, 100000)
|
||||
if kind == "pdf":
|
||||
raw = await loop.run_in_executor(None, extract_pdf_text, file_path, 100000)
|
||||
else:
|
||||
raw = await loop.run_in_executor(None, _read_excalidraw_indexable_text, file_path)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning("PDF enrichment failed for %s: %s", file_path, exc)
|
||||
logger.warning("Deferred text enrichment failed for %s: %s", file_path, exc)
|
||||
raw = ""
|
||||
file_info["content"] = raw[:SEARCH_CONTENT_LIMIT]
|
||||
file_info["content_preview"] = raw[:200].strip()
|
||||
file_info.pop("pdf_text_pending", None)
|
||||
file_info.pop("excalidraw_text_pending", None)
|
||||
enriched += 1
|
||||
if _on_index_change:
|
||||
try:
|
||||
_on_index_change("add", name, file_info["path"], file_info)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning(
|
||||
"Index hook failed after PDF enrichment for %s: %s", file_path, exc
|
||||
"Index hook failed after deferred enrichment for %s: %s", file_path, exc
|
||||
)
|
||||
|
||||
logger.info("PDF enrichment: extracted text for %d deferred PDF(s)", enriched)
|
||||
logger.info("Deferred text enrichment: extracted text for %d file(s)", enriched)
|
||||
return enriched
|
||||
|
||||
|
||||
@@ -603,16 +700,24 @@ async def build_index(progress_callback=None) -> None:
|
||||
|
||||
Runs vault scans concurrently, inserting them incrementally into the global index.
|
||||
Notifies progress via the provided callback.
|
||||
|
||||
#86 differential rebuild: the previous per-vault ``{path: file_info}``
|
||||
snapshots are captured before the clear and handed to ``_scan_vault`` so
|
||||
unchanged files (same size + mtime) are reused without disk re-reads.
|
||||
"""
|
||||
global index, vault_config
|
||||
vault_config.clear()
|
||||
vault_config.update(load_vault_config())
|
||||
|
||||
|
||||
# Note: vault_settings are now only used for UI display preferences (hideHiddenFiles)
|
||||
# Indexing always includes all files regardless of settings
|
||||
|
||||
|
||||
global _index_generation
|
||||
with _index_lock:
|
||||
previous_snapshot: dict[str, dict[str, dict[str, Any]]] = {
|
||||
name: {f["path"]: f for f in vdata.get("files", [])}
|
||||
for name, vdata in index.items()
|
||||
}
|
||||
index.clear()
|
||||
_file_lookup.clear()
|
||||
path_index.clear()
|
||||
@@ -631,8 +736,13 @@ async def build_index(progress_callback=None) -> None:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
async def _process_vault(name: str, config: dict[str, Any]):
|
||||
import functools
|
||||
|
||||
vault_path = config["path"]
|
||||
vault_data = await loop.run_in_executor(None, _scan_vault, name, vault_path, config)
|
||||
scan = functools.partial(
|
||||
_scan_vault, name, vault_path, config, previous_snapshot.get(name)
|
||||
)
|
||||
vault_data = await loop.run_in_executor(None, scan)
|
||||
vault_data["config"] = config
|
||||
|
||||
# Build lookup entries for the new vault
|
||||
@@ -695,7 +805,7 @@ async def reload_index() -> dict[str, Any]:
|
||||
Dict mapping vault names to their file/tag counts.
|
||||
"""
|
||||
await build_index()
|
||||
# BUG-040: complete the deferred PDF extraction for the rebuilt index.
|
||||
# BUG-040/#86: complete the deferred PDF + excalidraw extraction.
|
||||
await enrich_pdf_texts()
|
||||
stats = {}
|
||||
for name, data in index.items():
|
||||
@@ -724,14 +834,22 @@ async def reload_single_vault(vault_name: str) -> dict[str, Any]:
|
||||
raise ValueError(f"Vault '{vault_name}' not found in configuration")
|
||||
|
||||
config = vault_config[vault_name]
|
||||
|
||||
|
||||
# #86 differential rescan: snapshot this vault's entries before removal so
|
||||
# unchanged files are reused without disk re-reads.
|
||||
with _index_lock:
|
||||
_previous = {f["path"]: f for f in index.get(vault_name, {}).get("files", [])}
|
||||
|
||||
# Remove old vault data from index structures
|
||||
await remove_vault_from_index(vault_name)
|
||||
|
||||
|
||||
# Re-add the vault with updated configuration
|
||||
import functools
|
||||
|
||||
vault_path = config["path"]
|
||||
loop = asyncio.get_event_loop()
|
||||
vault_data = await loop.run_in_executor(None, _scan_vault, vault_name, vault_path, config)
|
||||
scan = functools.partial(_scan_vault, vault_name, vault_path, config, _previous)
|
||||
vault_data = await loop.run_in_executor(None, scan)
|
||||
vault_data["config"] = config
|
||||
|
||||
# Build lookup entries for the vault
|
||||
@@ -761,7 +879,7 @@ async def reload_single_vault(vault_name: str) -> dict[str, Any]:
|
||||
from backend.attachment_indexer import build_attachment_index
|
||||
await build_attachment_index({vault_name: config})
|
||||
|
||||
# BUG-040: complete the deferred PDF extraction for this vault.
|
||||
# BUG-040/#86: complete the deferred PDF + excalidraw extraction.
|
||||
await enrich_pdf_texts(vault_name)
|
||||
|
||||
stats = {"file_count": len(vault_data["files"]), "tag_count": len(vault_data["tags"])}
|
||||
@@ -832,6 +950,14 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
|
||||
raw = extract_excalidraw_indexable(raw)
|
||||
title = fpath.stem.replace(".excalidraw", "").replace("-", " ").replace("_", " ")
|
||||
content_preview = raw[:200].strip()
|
||||
elif is_media(ext):
|
||||
# #108 — binary media: metadata only, never read the bytes.
|
||||
raw = ""
|
||||
content_preview = ""
|
||||
elif ext == ".xlsx":
|
||||
# #152 — binary workbook: metadata only (parity with _scan_vault).
|
||||
raw = ""
|
||||
content_preview = ""
|
||||
else:
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
content_preview = raw[:200].strip()
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Image thumbnail generation and disk cache (roadmap #108-C).
|
||||
|
||||
Thumbnails are generated on demand with Pillow and cached under
|
||||
``<OBSIGATE_DATA_DIR>/.obsigate-cache/thumbs/<sha1>.webp``. The cache key
|
||||
embeds the source path, mtime (ns) and size, so an edited image naturally
|
||||
invalidates its stale thumbnail without any explicit cleanup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_THUMB_SIZE = 256
|
||||
|
||||
# Extensions Pillow cannot decode without extra native libraries: served as-is.
|
||||
_UNDECODABLE = {".svg"}
|
||||
|
||||
|
||||
def thumbs_cache_dir() -> Path:
|
||||
"""Return (and create) the thumbnail cache directory."""
|
||||
base = Path(os.environ.get("OBSIGATE_DATA_DIR", "data")) / ".obsigate-cache" / "thumbs"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return base
|
||||
|
||||
|
||||
def thumb_cache_path(file_path: Path, size: int) -> Path:
|
||||
"""Compute the deterministic cache path for *file_path* at *size*."""
|
||||
try:
|
||||
st = file_path.stat()
|
||||
stamp = f"{st.st_mtime_ns}:{st.st_size}"
|
||||
except OSError:
|
||||
stamp = "0:0"
|
||||
# Clé de cache miniature (pas un usage sécurité).
|
||||
key = hashlib.sha1(f"{file_path}:{stamp}:{size}".encode()).hexdigest() # nosec B324
|
||||
return thumbs_cache_dir() / f"{key}.webp"
|
||||
|
||||
|
||||
def is_decodable(file_path: Path) -> bool:
|
||||
"""True when Pillow can be expected to decode *file_path*."""
|
||||
return file_path.suffix.lower() not in _UNDECODABLE
|
||||
|
||||
|
||||
def generate_thumbnail(file_path: Path, size: int = DEFAULT_THUMB_SIZE) -> Path | None:
|
||||
"""Generate (or reuse) a WebP thumbnail and return its path.
|
||||
|
||||
Returns ``None`` when the file cannot be decoded (e.g. SVG) or Pillow is
|
||||
unavailable, so the caller can fall back to serving the original.
|
||||
"""
|
||||
cache_path = thumb_cache_path(file_path, size)
|
||||
if cache_path.exists():
|
||||
return cache_path
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageOps
|
||||
except Exception: # pragma: no cover - Pillow is an optional runtime dep
|
||||
return None
|
||||
|
||||
try:
|
||||
with Image.open(file_path) as opened:
|
||||
# Animated formats: keep only the first frame.
|
||||
if getattr(opened, "is_animated", False):
|
||||
opened.seek(0)
|
||||
img = ImageOps.exif_transpose(opened) or opened
|
||||
if img.mode not in ("RGB", "RGBA"):
|
||||
img = img.convert("RGBA")
|
||||
img.thumbnail((size, size))
|
||||
|
||||
tmp = cache_path.with_suffix(".tmp")
|
||||
img.save(tmp, "WEBP", quality=80)
|
||||
os.replace(tmp, cache_path)
|
||||
return cache_path
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Shared media type constants and helpers.
|
||||
|
||||
Single source of truth for the file extensions and MIME types handled by the
|
||||
image support (roadmap #108) and reused by the audio/video players (#109).
|
||||
Keeping these sets here avoids the previous duplication (``indexer.py``,
|
||||
``attachment_indexer.py`` and ``main.py`` each carried their own copy).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
|
||||
# Image extensions viewable in the browser (HEIC/HEIF deliberately excluded —
|
||||
# no browser decodes them natively; see roadmap #108).
|
||||
IMAGE_EXTENSIONS: frozenset[str] = frozenset({
|
||||
".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico",
|
||||
})
|
||||
|
||||
# Audio extensions (socle for #109, not wired into the index yet).
|
||||
AUDIO_EXTENSIONS: frozenset[str] = frozenset({
|
||||
".mp3", ".m4a", ".aac", ".wav", ".ogg", ".oga", ".opus", ".flac",
|
||||
})
|
||||
|
||||
# Video extensions (socle for #109, not wired into the index yet).
|
||||
VIDEO_EXTENSIONS: frozenset[str] = frozenset({
|
||||
".mp4", ".webm", ".mov", ".m4v",
|
||||
})
|
||||
|
||||
MEDIA_EXTENSIONS: frozenset[str] = IMAGE_EXTENSIONS | AUDIO_EXTENSIONS | VIDEO_EXTENSIONS
|
||||
|
||||
# Explicit MIME types for extensions ``mimetypes`` gets wrong or does not know.
|
||||
_MIME_OVERRIDES: dict[str, str] = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".webp": "image/webp",
|
||||
".m4a": "audio/mp4",
|
||||
".oga": "audio/ogg",
|
||||
".opus": "audio/ogg",
|
||||
".mov": "video/quicktime",
|
||||
".m4v": "video/mp4",
|
||||
}
|
||||
|
||||
|
||||
def is_image(ext: str) -> bool:
|
||||
"""Return True when *ext* (with leading dot, any case) is an image."""
|
||||
return ext.lower() in IMAGE_EXTENSIONS
|
||||
|
||||
|
||||
def is_audio(ext: str) -> bool:
|
||||
"""Return True when *ext* is an audio extension."""
|
||||
return ext.lower() in AUDIO_EXTENSIONS
|
||||
|
||||
|
||||
def is_video(ext: str) -> bool:
|
||||
"""Return True when *ext* is a video extension."""
|
||||
return ext.lower() in VIDEO_EXTENSIONS
|
||||
|
||||
|
||||
def is_media(ext: str) -> bool:
|
||||
"""Return True when *ext* is any supported image/audio/video extension."""
|
||||
return ext.lower() in MEDIA_EXTENSIONS
|
||||
|
||||
|
||||
def media_mime_type(path: str) -> str:
|
||||
"""Return the best MIME type for *path* (extension based).
|
||||
|
||||
Falls back to ``application/octet-stream`` when the type is unknown.
|
||||
"""
|
||||
lower = path.lower()
|
||||
for ext, mime in _MIME_OVERRIDES.items():
|
||||
if lower.endswith(ext):
|
||||
return mime
|
||||
guessed, _ = mimetypes.guess_type(path)
|
||||
return guessed or "application/octet-stream"
|
||||
@@ -181,6 +181,10 @@ _ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = {
|
||||
"request": {"path": "notes/Accueil.md", "content": "# Accueil\n\nMis à jour."},
|
||||
"response": {"status": "ok", "vault": "TestVault", "path": "notes/Accueil.md", "size": 26},
|
||||
},
|
||||
("put", "/api/file/{vault_name}/xlsx/save"): {
|
||||
"request": {"sheet": "Budget", "cells": {"B1": "250"}},
|
||||
"response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 1},
|
||||
},
|
||||
("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},
|
||||
|
||||
@@ -43,7 +43,7 @@ def build_pdf_html(body_html: str, title: str, theme: str = "light") -> str:
|
||||
<head><meta charset="utf-8"><title>{title}</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-family: Georgia, "Times New Roman", serif, "Noto Color Emoji";
|
||||
max-width: 720px;
|
||||
margin: 40px auto;
|
||||
padding: 0 20px;
|
||||
|
||||
@@ -12,14 +12,24 @@ the per-account lockout in ``user_store.py``.
|
||||
deployment, front this service with a shared store (Redis) or a single
|
||||
worker. This limitation is intentional and documented (BUG-031).
|
||||
|
||||
Opt-in persistence (ROADMAP #85 T10b) : if ``OBSIGATE_RATELIMIT_DB`` points
|
||||
to a SQLite file, counters are stored there instead (WAL mode, one short
|
||||
connection per call — safe across threads, processes and restarts sharing
|
||||
the same file). Semantics (windows, budgets, success reset) are identical
|
||||
to the in-memory store, which remains the default when the variable is
|
||||
unset.
|
||||
|
||||
Configuration via environment variables:
|
||||
OBSIGATE_LOGIN_MAX_ATTEMPTS Max failures per IP (default: 10)
|
||||
OBSIGATE_ACCOUNT_MAX_ATTEMPTS Max failures per account (default: 10)
|
||||
OBSIGATE_LOGIN_WINDOW_SECONDS Lockout window in seconds (default: 900)
|
||||
OBSIGATE_RATELIMIT_DB SQLite file for shared/persistent counters (default: unset = memory)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
@@ -37,6 +47,127 @@ _last_cleanup = time.time()
|
||||
CLEANUP_INTERVAL = 60 # seconds
|
||||
|
||||
|
||||
def _db_path() -> str | None:
|
||||
"""SQLite file for shared counters, or ``None`` for the in-memory store."""
|
||||
path = os.environ.get("OBSIGATE_RATELIMIT_DB", "").strip()
|
||||
return path or None
|
||||
|
||||
|
||||
def _db_connect(path: str) -> sqlite3.Connection:
|
||||
"""Open a short-lived connection (WAL + busy timeout for concurrent workers)."""
|
||||
_db_ensure_schema(path)
|
||||
conn = sqlite3.connect(path, timeout=10.0)
|
||||
conn.execute("PRAGMA busy_timeout=10000")
|
||||
return conn
|
||||
|
||||
|
||||
_schema_ready: set[str] = set()
|
||||
_schema_lock = threading.Lock()
|
||||
|
||||
|
||||
def _db_ensure_schema(path: str) -> None:
|
||||
"""Create the store schema once per file (DDL under a process-wide lock)."""
|
||||
with _schema_lock:
|
||||
if path in _schema_ready:
|
||||
return
|
||||
conn = sqlite3.connect(path, timeout=10.0)
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS attempts"
|
||||
" (kind TEXT NOT NULL, key TEXT NOT NULL, ts REAL NOT NULL, success INTEGER NOT NULL)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_attempts_kind_key_ts"
|
||||
" ON attempts (kind, key, ts)"
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
_schema_ready.add(path)
|
||||
|
||||
|
||||
def _db_write(fn, *args):
|
||||
"""Run a write op, retrying once on lock contention (concurrent workers)."""
|
||||
try:
|
||||
return fn(*args)
|
||||
except sqlite3.OperationalError as e:
|
||||
if "locked" not in str(e).lower():
|
||||
raise
|
||||
time.sleep(0.05)
|
||||
return fn(*args)
|
||||
|
||||
|
||||
def _db_prune(conn: sqlite3.Connection, cutoff: float) -> None:
|
||||
"""Drop expired entries (best-effort cap on disk growth)."""
|
||||
conn.execute("DELETE FROM attempts WHERE ts <= ?", (cutoff,))
|
||||
|
||||
|
||||
def _db_record(kind: str, key: str, success: bool) -> int:
|
||||
"""Record one attempt in SQLite; return the live failure count."""
|
||||
path = _db_path()
|
||||
assert path is not None
|
||||
now = time.time()
|
||||
cutoff = now - WINDOW_SECONDS
|
||||
|
||||
def _write() -> int:
|
||||
with _db_connect(path) as conn:
|
||||
_db_prune(conn, cutoff)
|
||||
if success:
|
||||
# Mirror the in-memory reset: replace history with one success.
|
||||
conn.execute("DELETE FROM attempts WHERE kind = ? AND key = ?", (kind, key))
|
||||
conn.execute(
|
||||
"INSERT INTO attempts (kind, key, ts, success) VALUES (?, ?, ?, ?)",
|
||||
(kind, key, now, int(success)),
|
||||
)
|
||||
conn.commit()
|
||||
(failures,) = conn.execute(
|
||||
"SELECT COUNT(*) FROM attempts WHERE kind = ? AND key = ? AND ts > ? AND success = 0",
|
||||
(kind, key, cutoff),
|
||||
).fetchone()
|
||||
return failures
|
||||
|
||||
return _db_write(_write)
|
||||
|
||||
|
||||
def _db_failures(kind: str, key: str) -> int:
|
||||
"""Live failure count in SQLite (expired entries never count)."""
|
||||
path = _db_path()
|
||||
assert path is not None
|
||||
cutoff = time.time() - WINDOW_SECONDS
|
||||
with _db_connect(path) as conn:
|
||||
(failures,) = conn.execute(
|
||||
"SELECT COUNT(*) FROM attempts WHERE kind = ? AND key = ? AND ts > ? AND success = 0",
|
||||
(kind, key, cutoff),
|
||||
).fetchone()
|
||||
return failures
|
||||
|
||||
|
||||
def _db_tracked(kind: str) -> int:
|
||||
"""Number of distinct keys ever seen for one budget (SQLite)."""
|
||||
path = _db_path()
|
||||
assert path is not None
|
||||
with _db_connect(path) as conn:
|
||||
(n,) = conn.execute(
|
||||
"SELECT COUNT(DISTINCT key) FROM attempts WHERE kind = ?", (kind,)
|
||||
).fetchone()
|
||||
return n
|
||||
|
||||
|
||||
def _db_limited_count(kind: str, max_attempts: int) -> int:
|
||||
"""Number of keys currently over budget (SQLite)."""
|
||||
path = _db_path()
|
||||
assert path is not None
|
||||
cutoff = time.time() - WINDOW_SECONDS
|
||||
with _db_connect(path) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT key, COUNT(*) FROM attempts"
|
||||
" WHERE kind = ? AND ts > ? AND success = 0 GROUP BY key",
|
||||
(kind, cutoff),
|
||||
).fetchall()
|
||||
return sum(1 for _, n in rows if n >= max_attempts)
|
||||
|
||||
|
||||
def _prune(store: dict[str, list], cutoff: float) -> None:
|
||||
"""Drop expired entries from one store in place."""
|
||||
expired = []
|
||||
@@ -66,6 +197,12 @@ def record_failure(ip: str) -> tuple[int, int]:
|
||||
Returns:
|
||||
(current_failure_count, remaining_attempts)
|
||||
"""
|
||||
if _db_path() is not None:
|
||||
failures = _db_record("ip", ip, False)
|
||||
remaining = max(0, MAX_ATTEMPTS - failures)
|
||||
if failures >= MAX_ATTEMPTS:
|
||||
logger.warning(f"IP {ip} rate-limited after {failures} failed logins")
|
||||
return failures, remaining
|
||||
_cleanup_expired()
|
||||
_ip_attempts[ip].append((time.time(), False))
|
||||
failures = sum(1 for _, success in _ip_attempts[ip] if not success)
|
||||
@@ -77,12 +214,17 @@ def record_failure(ip: str) -> tuple[int, int]:
|
||||
|
||||
def record_success(ip: str):
|
||||
"""Clear rate limit state for an IP after successful login."""
|
||||
if _db_path() is not None:
|
||||
_db_record("ip", ip, True)
|
||||
return
|
||||
_cleanup_expired()
|
||||
_ip_attempts[ip] = [(time.time(), True)]
|
||||
|
||||
|
||||
def is_rate_limited(ip: str) -> bool:
|
||||
"""Check if an IP has exceeded the rate limit."""
|
||||
if _db_path() is not None:
|
||||
return _db_failures("ip", ip) >= MAX_ATTEMPTS
|
||||
_cleanup_expired()
|
||||
failures = sum(1 for _, success in _ip_attempts.get(ip, []) if not success)
|
||||
return failures >= MAX_ATTEMPTS
|
||||
@@ -94,8 +236,14 @@ def record_account_failure(account: str) -> tuple[int, int]:
|
||||
Returns:
|
||||
(current_failure_count, remaining_attempts)
|
||||
"""
|
||||
_cleanup_expired()
|
||||
key = account.lower()
|
||||
if _db_path() is not None:
|
||||
failures = _db_record("account", key, False)
|
||||
remaining = max(0, ACCOUNT_MAX_ATTEMPTS - failures)
|
||||
if failures >= ACCOUNT_MAX_ATTEMPTS:
|
||||
logger.warning(f"Account {account} rate-limited after {failures} failed attempts")
|
||||
return failures, remaining
|
||||
_cleanup_expired()
|
||||
_account_attempts[key].append((time.time(), False))
|
||||
failures = sum(1 for _, success in _account_attempts[key] if not success)
|
||||
remaining = max(0, ACCOUNT_MAX_ATTEMPTS - failures)
|
||||
@@ -106,12 +254,17 @@ def record_account_failure(account: str) -> tuple[int, int]:
|
||||
|
||||
def record_account_success(account: str):
|
||||
"""Clear the per-account rate limit state after a successful login."""
|
||||
if _db_path() is not None:
|
||||
_db_record("account", account.lower(), True)
|
||||
return
|
||||
_cleanup_expired()
|
||||
_account_attempts[account.lower()] = [(time.time(), True)]
|
||||
|
||||
|
||||
def is_account_rate_limited(account: str) -> bool:
|
||||
"""Check if an account has exceeded the per-account rate limit."""
|
||||
if _db_path() is not None:
|
||||
return _db_failures("account", account.lower()) >= ACCOUNT_MAX_ATTEMPTS
|
||||
_cleanup_expired()
|
||||
failures = sum(
|
||||
1 for _, success in _account_attempts.get(account.lower(), []) if not success
|
||||
@@ -121,6 +274,24 @@ def is_account_rate_limited(account: str) -> bool:
|
||||
|
||||
def get_status(ip: str | None = None) -> dict:
|
||||
"""Get rate limit status for an IP (for diagnostics)."""
|
||||
if _db_path() is not None:
|
||||
if ip:
|
||||
failures = _db_failures("ip", ip)
|
||||
return {
|
||||
"ip": ip,
|
||||
"failures": failures,
|
||||
"max": MAX_ATTEMPTS,
|
||||
"limited": failures >= MAX_ATTEMPTS,
|
||||
"window_seconds": WINDOW_SECONDS,
|
||||
}
|
||||
return {
|
||||
"tracked_ips": _db_tracked("ip"),
|
||||
"tracked_accounts": _db_tracked("account"),
|
||||
"max_attempts": MAX_ATTEMPTS,
|
||||
"account_max_attempts": ACCOUNT_MAX_ATTEMPTS,
|
||||
"window_seconds": WINDOW_SECONDS,
|
||||
"limited_ips": _db_limited_count("ip", MAX_ATTEMPTS),
|
||||
}
|
||||
_cleanup_expired()
|
||||
if ip:
|
||||
attempts = _ip_attempts.get(ip, [])
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Markdown rendering pipeline (ROADMAP #85, tranche 9).
|
||||
|
||||
Helpers extraits de :mod:`backend.main` sans changement de comportement :
|
||||
slugification des headings, IDs d'ancrage, rendu mistune singleton,
|
||||
wikilinks, normalisation des sauts de ligne et pipeline complet
|
||||
:func:`_render_markdown` (rendu + sanitizer XSS BUG-021).
|
||||
|
||||
Les noms gardent leur préfixe ``_`` d'origine pour un déplacement
|
||||
strictement verbatim (tests et routers pointent ici désormais).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html as html_mod
|
||||
import re
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
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.services.sanitizer import sanitize_html
|
||||
|
||||
|
||||
def _heading_slugify(text: str) -> str:
|
||||
"""Generate a URL-safe slug from heading text.
|
||||
|
||||
Matches the JavaScript slugify algorithm exactly using
|
||||
Unicode-aware character classification:
|
||||
1. Strip HTML tags (e.g. wikilink spans rendered inside headings)
|
||||
2. Decode HTML entities (e.g. ``&`` → ``&``)
|
||||
3. Lowercase
|
||||
4. NFD normalize + strip combining marks
|
||||
5. Keep only Unicode letters, numbers, spaces, hyphens
|
||||
6. Replace spaces with hyphens, collapse multiple hyphens
|
||||
|
||||
Args:
|
||||
text: The heading text content (may contain inline HTML).
|
||||
|
||||
Returns:
|
||||
A URL-safe slug string.
|
||||
"""
|
||||
# Strip any inline HTML so it does not pollute the slug
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
# Decode HTML entities so & becomes & before slugification
|
||||
text = html_mod.unescape(text)
|
||||
text = text.lower()
|
||||
text = unicodedata.normalize("NFD", text)
|
||||
text = "".join(ch for ch in text if not unicodedata.combining(ch))
|
||||
# Unicode-aware: keep letters (L*), numbers (N*), spaces, and hyphens
|
||||
cleaned = []
|
||||
for ch in text:
|
||||
cat = unicodedata.category(ch)
|
||||
if cat.startswith('L') or cat.startswith('N') or ch in (' ', '-'):
|
||||
cleaned.append(ch)
|
||||
text = "".join(cleaned)
|
||||
text = re.sub(r"\s+", "-", text)
|
||||
text = re.sub(r"-+", "-", text)
|
||||
result = text.strip("-")
|
||||
return result if result else "heading"
|
||||
|
||||
|
||||
def _add_heading_ids(html: str) -> str:
|
||||
"""Post-process rendered HTML to add IDs to heading tags.
|
||||
|
||||
Adds an ``id`` attribute to every ``<h1>`` through ``<h6>`` tag
|
||||
using a slug generated from the heading's text content.
|
||||
Duplicate slugs get a ``-2``, ``-3``, etc. suffix.
|
||||
|
||||
Args:
|
||||
html: Rendered HTML string.
|
||||
|
||||
Returns:
|
||||
HTML with heading IDs injected.
|
||||
"""
|
||||
used_ids: dict[str, int] = {}
|
||||
|
||||
def _replace_heading(match):
|
||||
tag = match.group(1)
|
||||
content = match.group(2)
|
||||
slug = _heading_slugify(content)
|
||||
count = used_ids.get(slug, 0)
|
||||
used_ids[slug] = count + 1
|
||||
if count > 0:
|
||||
slug = f"{slug}-{count + 1}"
|
||||
return f'<{tag} id="{slug}">{content}</{tag}>'
|
||||
|
||||
# Match h1-h6 tags with text content (no existing id attribute)
|
||||
return re.sub(
|
||||
r'<(h[1-6])>([^<]*(?:<(?!/?h[1-6])[^<]*)*)</h[1-6]>',
|
||||
_replace_heading,
|
||||
html,
|
||||
)
|
||||
|
||||
|
||||
# Cached mistune renderer — avoids re-creating on every request
|
||||
_markdown_renderer = mistune.create_markdown(
|
||||
escape=False,
|
||||
plugins=["table", "strikethrough", "footnotes", "task_lists"],
|
||||
)
|
||||
|
||||
|
||||
def _convert_wikilinks(content: str, current_vault: str) -> str:
|
||||
"""Convert ``[[wikilinks]]`` and ``[[target|display]]`` to clickable HTML.
|
||||
|
||||
Supports:
|
||||
- Internal file links: ``[[My Note]]`` / ``[[My Note|display]]``
|
||||
- Same-document anchors: ``[[#Heading]]`` / ``[[#Heading|display]]``
|
||||
|
||||
Resolved file links get a ``data-vault`` / ``data-path`` attribute pair.
|
||||
Anchor links target the slugified heading ID in the current document.
|
||||
Unresolved links are rendered as ``<span class="wikilink-missing">``.
|
||||
|
||||
Args:
|
||||
content: Markdown string potentially containing wikilinks.
|
||||
current_vault: Active vault name for resolution priority.
|
||||
|
||||
Returns:
|
||||
Markdown string with wikilinks replaced by HTML anchors.
|
||||
"""
|
||||
def _replace(match):
|
||||
target = match.group(1).strip()
|
||||
display = match.group(2).strip() if match.group(2) else target
|
||||
|
||||
# Same-document anchor link: [[#Heading|display]]
|
||||
if target.startswith("#"):
|
||||
anchor_text = target[1:].strip()
|
||||
anchor_slug = _heading_slugify(anchor_text)
|
||||
link_display = display if display != target else anchor_text
|
||||
return f'<a class="wikilink-anchor" href="#{anchor_slug}">{link_display}</a>'
|
||||
|
||||
found = find_file_in_index(target, current_vault)
|
||||
if found:
|
||||
return (
|
||||
f'<a class="wikilink" href="#" '
|
||||
f'data-vault="{found["vault"]}" '
|
||||
f'data-path="{found["path"]}">{display}</a>'
|
||||
)
|
||||
return f'<span class="wikilink-missing">{display}</span>'
|
||||
|
||||
pattern = r'\[\[([^\]|]+)(?:\|([^\]]+))?\]\]'
|
||||
return re.sub(pattern, _replace, content)
|
||||
|
||||
|
||||
def _normalize_line_breaks(text: str) -> str:
|
||||
"""Convert single newlines to hard breaks (matching Obsidian default behavior).
|
||||
|
||||
In standard Markdown, a single ``\\n`` is a "soft break" — it renders as a space,
|
||||
not a visible line break. Obsidian defaults to treating single newlines as hard
|
||||
breaks (equivalent to ``<br>``). This function pre-processes the Markdown source
|
||||
so that mistune renders standalone lines on separate rows, while still honouring
|
||||
blank lines as paragraph separators.
|
||||
|
||||
Fenced code blocks (`` ``` ``) are left untouched so their internal newlines are
|
||||
preserved verbatim.
|
||||
"""
|
||||
parts = re.split(r"(```[\s\S]*?```)", text)
|
||||
for i, part in enumerate(parts):
|
||||
if part.startswith("```"):
|
||||
continue # Protect fenced code blocks
|
||||
# Single \n (not preceded or followed by another \n) → two spaces + \n
|
||||
parts[i] = re.sub(r"(?<!\n)\n(?!\n)", " \n", part)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | None = None) -> str:
|
||||
"""Render a markdown string to HTML with wikilink and image support.
|
||||
|
||||
Uses the cached singleton mistune renderer for performance.
|
||||
|
||||
Args:
|
||||
raw_md: Raw markdown text (frontmatter already stripped).
|
||||
vault_name: Current vault for wikilink resolution context.
|
||||
current_file_path: Absolute path to the current markdown file.
|
||||
|
||||
Returns:
|
||||
HTML string.
|
||||
"""
|
||||
# Get vault data for image resolution
|
||||
vault_data = get_vault_data(vault_name)
|
||||
vault_root = Path(vault_data["path"]) if vault_data else None
|
||||
attachments_path = vault_data.get("config", {}).get("attachmentsPath") if vault_data else None
|
||||
|
||||
# Redact secrets before rendering (P0 security)
|
||||
raw_md = redact_file_content(raw_md, str(current_file_path) if current_file_path else "")
|
||||
|
||||
# Preprocess images first
|
||||
if vault_root:
|
||||
raw_md = preprocess_images(raw_md, vault_name, vault_root, current_file_path, attachments_path)
|
||||
|
||||
# Convert wikilinks
|
||||
converted = _convert_wikilinks(raw_md, vault_name)
|
||||
|
||||
# Normalize line breaks to match Obsidian behavior (single \n → hard break)
|
||||
converted = _normalize_line_breaks(converted)
|
||||
|
||||
rendered = _markdown_renderer(converted)
|
||||
|
||||
# Add heading IDs for TOC navigation
|
||||
rendered = _add_heading_ids(rendered)
|
||||
|
||||
# Sanitize: raw HTML in vault content must never reach the DOM (BUG-021).
|
||||
rendered = sanitize_html(rendered)
|
||||
|
||||
return rendered
|
||||
@@ -15,6 +15,7 @@ weasyprint>=60.0
|
||||
httpx>=0.27.0
|
||||
pypdf>=4.0
|
||||
pyotp>=2.10.0
|
||||
segno>=1.5.0
|
||||
webauthn==2.6.0
|
||||
psutil>=5.9
|
||||
pywebpush>=2.3.0
|
||||
@@ -23,3 +24,4 @@ sse-starlette==2.1.3
|
||||
openpyxl>=3.1
|
||||
python-docx>=1.1
|
||||
reportlab>=4.0
|
||||
pillow>=10.0
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""ObsiGate — routers FastAPI par domaine (ROADMAP #85).
|
||||
|
||||
Découpage progressif du monolithe ``backend/main.py`` : chaque module de ce
|
||||
paquet expose un ``APIRouter`` monté par ``main.py``. Les handlers sont
|
||||
déplacés sans changement de comportement (mêmes chemins, mêmes modèles de
|
||||
réponse, mêmes dépendances d'authentification).
|
||||
"""
|
||||
@@ -0,0 +1,412 @@
|
||||
"""Backup endpoints (ROADMAP #85, tranche 4).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/file/{vault}/backups|diff|restore``,
|
||||
``/api/backups*``), mêmes modèles de réponse, mêmes dépendances
|
||||
d'authentification. La logique métier vit déjà dans
|
||||
:mod:`backend.services.backups`.
|
||||
|
||||
Adaptations strictement équivalentes :
|
||||
- ``_resolve_safe_path`` / ``_backup_file`` / ``_list_backup_files`` de
|
||||
``main`` n'étaient que des wrappers directs : appelés ici via
|
||||
:mod:`backend.services.paths` et :mod:`backend.services.backups`.
|
||||
- ``RestoreRequest`` / ``RestoreResponse`` / ``DiffResponse`` ont déménagé
|
||||
dans :mod:`backend.schemas`.
|
||||
- Le singleton SSE vit désormais dans :mod:`backend.sse` (partagé avec
|
||||
``main`` : les clients ``/api/events`` reçoivent les mêmes broadcasts).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.indexer import get_vault_data, index, update_single_file
|
||||
from backend.schemas import (
|
||||
BackupContentResponse,
|
||||
BackupsAutoResponse,
|
||||
BackupsCompressResponse,
|
||||
BackupsDeletedResponse,
|
||||
BackupsListResponse,
|
||||
BackupsResponse,
|
||||
DiffResponse,
|
||||
RestoreRequest,
|
||||
RestoreResponse,
|
||||
)
|
||||
from backend.services.backups import (
|
||||
create_backup,
|
||||
)
|
||||
from backend.services.backups import (
|
||||
diff_backup as service_diff_backup,
|
||||
)
|
||||
from backend.services.backups import (
|
||||
list_backup_files as service_list_backup_files,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
restore_backup as service_restore_backup,
|
||||
)
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.sse import sse_manager
|
||||
from backend.webhooks import dispatch_webhooks
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
router = APIRouter(tags=["backups"])
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/backups", response_model=BackupsResponse)
|
||||
async def api_file_backups(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""List all available backups for a file.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative path of the file within the vault.
|
||||
|
||||
Returns:
|
||||
BackupListResponse with backups sorted newest first.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
|
||||
try:
|
||||
backups = service_list_backup_files(vault_name, path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing backups for {vault_name}/{path}: {type(e).__name__}: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Erreur lors de la lecture des backups: {e!s}")
|
||||
|
||||
return {"vault": vault_name, "path": path, "backups": backups}
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/diff", response_model=DiffResponse)
|
||||
async def api_file_diff(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
version: int = Query(..., description="Timestamp of the backup version (left/old side)"),
|
||||
compare_with: int | None = Query(default=None, description="Timestamp of another backup (right/new side). If omitted, compares with the current file."),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Generate a unified diff between a backup version and another version or the current file.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative path of the file within the vault.
|
||||
version: Timestamp of the backup to use as the old/left side.
|
||||
compare_with: Optional timestamp of another backup as the new/right side.
|
||||
If omitted, the current file on disk is used.
|
||||
|
||||
Returns:
|
||||
DiffResponse containing the unified diff string.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
return service_diff_backup(vault_name, path, version, compare_with)
|
||||
|
||||
|
||||
@router.post("/api/file/{vault_name}/restore", response_model=RestoreResponse)
|
||||
async def api_file_restore(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
body: RestoreRequest = ..., # type: ignore
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Restore a file from a backup version.
|
||||
|
||||
The current file is backed up before being overwritten (so the operation is reversible).
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative path of the file within the vault.
|
||||
body: RestoreRequest with the backup version timestamp.
|
||||
|
||||
Returns:
|
||||
RestoreResponse confirming the restore.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_restore_backup(vault_name, path, body.version)
|
||||
current_backed_up = result["current_backed_up"]
|
||||
|
||||
# Update index
|
||||
await update_single_file(vault_name, path)
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("file_restored", {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"restored_from": body.version,
|
||||
"current_backed_up": current_backed_up,
|
||||
})
|
||||
await dispatch_webhooks("file_restored", {"vault": vault_name, "path": path, "restored_from": body.version})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"restored_from": body.version,
|
||||
"current_backed_up": current_backed_up,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/backups", response_model=BackupsListResponse)
|
||||
async def api_backups_list(
|
||||
vault: str | None = Query(None, description="Filter by vault name"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""List all backups across vaults, grouped by file."""
|
||||
result: list[dict[str, Any]] = []
|
||||
try:
|
||||
for vault_name in index:
|
||||
if vault and vault_name != vault:
|
||||
continue
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
continue
|
||||
vd = get_vault_data(vault_name)
|
||||
if not vd:
|
||||
continue
|
||||
vault_root = Path(vd["path"])
|
||||
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
|
||||
if not backup_root.is_absolute():
|
||||
backup_root = vault_root / backup_root
|
||||
vault_backup_dir = backup_root / vault_name
|
||||
if not vault_backup_dir.exists():
|
||||
continue
|
||||
for fpath in vault_backup_dir.rglob("*.bak"):
|
||||
if not fpath.is_file():
|
||||
continue
|
||||
st = fpath.stat()
|
||||
fsize = st.st_size
|
||||
ts_part = fpath.name.rsplit(".", 2)
|
||||
if len(ts_part) < 3 or not ts_part[-2].isdigit():
|
||||
continue
|
||||
ts = int(ts_part[-2])
|
||||
rel_dir = str(fpath.parent.relative_to(vault_backup_dir)).replace("\\", "/")
|
||||
rel_file = rel_dir + "/" + ts_part[0] if rel_dir != "." else ts_part[0]
|
||||
result.append({
|
||||
"vault": vault_name,
|
||||
"file": rel_file,
|
||||
"backup_file": fpath.name,
|
||||
"timestamp": ts,
|
||||
"datetime": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(),
|
||||
"size": fsize,
|
||||
"full_path": str(fpath),
|
||||
})
|
||||
|
||||
result.sort(key=lambda x: x["timestamp"], reverse=True)
|
||||
total_size = sum(r["size"] for r in result)
|
||||
return {"backups": result, "total": len(result), "total_size_bytes": total_size}
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing backups: {type(e).__name__}: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Erreur listing backups: {e!s}")
|
||||
|
||||
|
||||
@router.post("/api/backups/delete", response_model=BackupsDeletedResponse)
|
||||
async def api_backups_delete(
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Delete one or more backup files."""
|
||||
paths = body.get("paths", [])
|
||||
if not paths:
|
||||
raise HTTPException(status_code=400, detail="No backup paths provided")
|
||||
|
||||
deleted = 0
|
||||
for p in paths:
|
||||
try:
|
||||
fpath = Path(p)
|
||||
# Security: ensure path is within a backup directory
|
||||
if ".obsigate-backup" not in str(fpath):
|
||||
continue
|
||||
if fpath.exists() and fpath.is_file():
|
||||
fpath.unlink()
|
||||
deleted += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete backup {p}: {e}")
|
||||
|
||||
return {"deleted": deleted}
|
||||
|
||||
|
||||
@router.post("/api/backups/purge", response_model=BackupsDeletedResponse)
|
||||
async def api_backups_purge(
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Purge all backups for a specific file or entire vault."""
|
||||
vault_name = body.get("vault")
|
||||
file_path = body.get("file") # optional
|
||||
|
||||
if not vault_name:
|
||||
raise HTTPException(status_code=400, detail="Vault name required")
|
||||
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
vd = get_vault_data(vault_name)
|
||||
if not vd:
|
||||
raise HTTPException(status_code=404, detail="Vault not found")
|
||||
|
||||
vault_root = Path(vd["path"])
|
||||
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
|
||||
if not backup_root.is_absolute():
|
||||
backup_root = vault_root / backup_root
|
||||
|
||||
if file_path:
|
||||
# Delete backups for specific file
|
||||
backup_dir = backup_root / vault_name / Path(file_path).parent
|
||||
if backup_dir.exists():
|
||||
fname = Path(file_path).name
|
||||
deleted = 0
|
||||
for f in backup_dir.iterdir():
|
||||
if f.is_file() and f.name.startswith(fname + ".") and f.name.endswith(".bak"):
|
||||
f.unlink()
|
||||
deleted += 1
|
||||
return {"deleted": deleted}
|
||||
return {"deleted": 0}
|
||||
else:
|
||||
# Delete all backups for vault
|
||||
vault_backup_dir = backup_root / vault_name
|
||||
if vault_backup_dir.exists():
|
||||
deleted = 0
|
||||
for f in vault_backup_dir.rglob("*.bak"):
|
||||
if f.is_file():
|
||||
f.unlink()
|
||||
deleted += 1
|
||||
return {"deleted": deleted}
|
||||
return {"deleted": 0}
|
||||
|
||||
|
||||
|
||||
@router.get("/api/backups/content", response_model=BackupContentResponse)
|
||||
async def api_backups_content(
|
||||
path: str = Query(..., description="Full path to backup file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return the content of a specific backup file."""
|
||||
try:
|
||||
fpath = Path(path)
|
||||
if ".obsigate-backup" not in str(fpath):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
if not fpath.exists() or not fpath.is_file():
|
||||
raise HTTPException(status_code=404, detail="Backup not found")
|
||||
content = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
# Truncate large files to 100KB
|
||||
if len(content) > 102400:
|
||||
content = content[:102400] + "\n\n... (tronque a 100 Ko)"
|
||||
return {"content": content, "name": fpath.name, "size": len(content)}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/backups/compress", response_model=BackupsCompressResponse)
|
||||
async def api_backups_compress(
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Compress backups older than N days. Body: {older_than_days: 30, dry_run: false}"""
|
||||
import gzip as gz_mod
|
||||
older_than = body.get("older_than_days", 30)
|
||||
dry_run = body.get("dry_run", False)
|
||||
cutoff = time.time() - (older_than * 86400)
|
||||
compressed = 0
|
||||
saved_bytes = 0
|
||||
|
||||
for vault_name in index:
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
continue
|
||||
vd = get_vault_data(vault_name)
|
||||
if not vd:
|
||||
continue
|
||||
vault_root = Path(vd["path"])
|
||||
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
|
||||
if not backup_root.is_absolute():
|
||||
backup_root = vault_root / backup_root
|
||||
vault_dir = backup_root / vault_name
|
||||
if not vault_dir.exists():
|
||||
continue
|
||||
for fpath in vault_dir.rglob("*.bak"):
|
||||
if not fpath.is_file():
|
||||
continue
|
||||
if fpath.name.endswith(".bak.gz"):
|
||||
continue
|
||||
mtime = fpath.stat().st_mtime
|
||||
if mtime > cutoff:
|
||||
continue
|
||||
if not dry_run:
|
||||
try:
|
||||
gz_path = fpath.with_suffix(fpath.suffix + ".gz")
|
||||
data = fpath.read_bytes()
|
||||
with gz_mod.open(str(gz_path), "wb", compresslevel=6) as gzf:
|
||||
gzf.write(data)
|
||||
orig_size = len(data)
|
||||
gz_size = gz_path.stat().st_size
|
||||
if gz_size < orig_size:
|
||||
fpath.unlink()
|
||||
saved_bytes += (orig_size - gz_size)
|
||||
else:
|
||||
gz_path.unlink() # compression didn't help
|
||||
compressed += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to compress {fpath}: {e}")
|
||||
else:
|
||||
compressed += 1
|
||||
|
||||
return {"compressed": compressed, "saved_bytes": saved_bytes, "dry_run": dry_run}
|
||||
|
||||
|
||||
@router.post("/api/backups/auto", response_model=BackupsAutoResponse)
|
||||
async def api_backups_auto(
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Create backups for files modified since a given time. Body: {since_hours: 24}"""
|
||||
since_hours = body.get("since_hours", 24)
|
||||
cutoff = time.time() - (since_hours * 3600)
|
||||
backed_up = 0
|
||||
|
||||
for vault_name in index:
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
continue
|
||||
vd = get_vault_data(vault_name)
|
||||
if not vd:
|
||||
continue
|
||||
vault_root = Path(vd["path"])
|
||||
for fpath in vault_root.rglob("*"):
|
||||
if not fpath.is_file():
|
||||
continue
|
||||
if fpath.name.startswith('.'):
|
||||
continue
|
||||
if any(p.startswith('.') or p in {'.obsidian', '.trash', '.git', '.obsigate-backup', '__pycache__', 'node_modules'} for p in fpath.relative_to(vault_root).parts):
|
||||
continue
|
||||
mtime = fpath.stat().st_mtime
|
||||
if mtime < cutoff:
|
||||
continue
|
||||
try:
|
||||
rel = str(fpath.relative_to(vault_root)).replace("\\", "/")
|
||||
create_backup(fpath, vault_name, rel)
|
||||
backed_up += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Auto-backup failed for {rel}: {e}")
|
||||
|
||||
return {"backed_up": backed_up, "since_hours": since_hours}
|
||||
@@ -0,0 +1,531 @@
|
||||
"""Configuration, AI keys, diagnostics & dashboard endpoints (ROADMAP #85, tranche 7).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/config*``, ``/api/diagnostics``,
|
||||
``/api/dashboard``), mêmes modèles de réponse, mêmes dépendances
|
||||
d'authentification.
|
||||
|
||||
Adaptations strictement équivalentes :
|
||||
- ``_load_config`` / ``_save_config`` / ``_DEFAULT_CONFIG`` /
|
||||
``_CONFIG_PATH`` / ``_BASE_DIR`` ont déménagé ici : ``main`` les
|
||||
réimporte pour son lifespan (pas de cycle : ce module ne dépend pas de
|
||||
``main``).
|
||||
- ``AI_KEYS_FILE`` / ``_write_ai_keys`` / ``_FALLBACK_MODELS`` ont déménagé
|
||||
ici (``AI_KEYS_FILE`` garde son chemin relatif ``data/api_keys.json``,
|
||||
résolu depuis le même CWD au runtime).
|
||||
"""
|
||||
|
||||
import json as _json
|
||||
import logging
|
||||
import os
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
|
||||
from backend.ai import PROVIDERS, _read_ai_keys, get_ai_key
|
||||
from backend.auth.middleware import require_admin, require_auth
|
||||
from backend.indexer import index
|
||||
from backend.media_types import IMAGE_EXTENSIONS
|
||||
from backend.schemas import (
|
||||
AIKeyDeleteResponse,
|
||||
AIKeysResponse,
|
||||
AIModelsResponse,
|
||||
AITestResponse,
|
||||
AppConfigResponse,
|
||||
DashboardResponse,
|
||||
DiagnosticsResponse,
|
||||
StatusResponse,
|
||||
)
|
||||
from backend.search_executor import get_search_executor
|
||||
from backend.tools.secrets import (
|
||||
TOOL_KEY_NAMES as _TOOL_KEY_NAMES,
|
||||
)
|
||||
from backend.tools.secrets import (
|
||||
delete_tool_key as _delete_tool_key,
|
||||
)
|
||||
from backend.tools.secrets import (
|
||||
get_tool_key as _get_tool_key,
|
||||
)
|
||||
from backend.tools.secrets import (
|
||||
mask_value as _mask_tool_value,
|
||||
)
|
||||
from backend.tools.secrets import (
|
||||
set_tool_key as _set_tool_key,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
router = APIRouter(tags=["System"])
|
||||
|
||||
_BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
_CONFIG_PATH = _BASE_DIR / "data" / "config.json"
|
||||
|
||||
_DEFAULT_CONFIG = {
|
||||
"search_workers": 2,
|
||||
"debounce_ms": 300,
|
||||
"results_per_page": 50,
|
||||
"min_query_length": 2,
|
||||
"search_timeout_ms": 30000,
|
||||
"max_content_size": 100000,
|
||||
"snippet_context_chars": 120,
|
||||
"max_snippet_highlights": 5,
|
||||
"title_boost": 3.0,
|
||||
"path_boost": 1.5,
|
||||
"watcher_enabled": True,
|
||||
"watcher_use_polling": False,
|
||||
"watcher_polling_interval": 5.0,
|
||||
"watcher_debounce": 2.0,
|
||||
"tag_boost": 2.0,
|
||||
"prefix_max_expansions": 50,
|
||||
"recent_files_limit": 20,
|
||||
"max_backups_per_file": 10,
|
||||
"ai_default_provider": "deepseek",
|
||||
"ai_default_models": {},
|
||||
}
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
"""Load config from disk, merging with defaults."""
|
||||
config = dict(_DEFAULT_CONFIG)
|
||||
if _CONFIG_PATH.exists():
|
||||
try:
|
||||
stored = _json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
config.update(stored)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read config.json: {e}")
|
||||
return config
|
||||
|
||||
|
||||
def _save_config(config: dict) -> None:
|
||||
"""Persist config to disk."""
|
||||
try:
|
||||
_CONFIG_PATH.write_text(
|
||||
_json.dumps(config, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to write config.json: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to save config: {e}")
|
||||
|
||||
|
||||
AI_KEYS_FILE = Path("data/api_keys.json")
|
||||
|
||||
def _write_ai_keys(data: dict):
|
||||
AI_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = AI_KEYS_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(_json.dumps(data, indent=2), encoding="utf-8")
|
||||
tmp.replace(AI_KEYS_FILE)
|
||||
|
||||
@router.get("/api/config", response_model=AppConfigResponse)
|
||||
async def api_get_config(current_user=Depends(require_auth)):
|
||||
"""Return current configuration with defaults for missing keys."""
|
||||
return _load_config()
|
||||
|
||||
|
||||
@router.post("/api/config", response_model=AppConfigResponse)
|
||||
async def api_set_config(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Update configuration. Only known keys are accepted.
|
||||
|
||||
Keys matching ``_DEFAULT_CONFIG`` are validated and persisted.
|
||||
Unknown keys are silently ignored.
|
||||
Returns the full merged config after update.
|
||||
"""
|
||||
current = _load_config()
|
||||
updated_keys = []
|
||||
for key, value in body.items():
|
||||
if key in _DEFAULT_CONFIG:
|
||||
expected_type = type(_DEFAULT_CONFIG[key])
|
||||
if isinstance(value, expected_type) or (expected_type is float and isinstance(value, (int, float))):
|
||||
current[key] = value
|
||||
updated_keys.append(key)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid type for '{key}': expected {expected_type.__name__}, got {type(value).__name__}",
|
||||
)
|
||||
_save_config(current)
|
||||
if any(k.startswith("ai_") for k in updated_keys):
|
||||
try:
|
||||
from backend.ai import reload_ai_config
|
||||
reload_ai_config()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to reload AI config: {e}")
|
||||
logger.info(f"Config updated: {updated_keys}")
|
||||
return current
|
||||
|
||||
|
||||
@router.get("/api/config/ai-keys", response_model=AIKeysResponse)
|
||||
async def api_get_ai_keys(current_user=Depends(require_admin)):
|
||||
"""Return stored AI keys (values masked)."""
|
||||
keys = _read_ai_keys()
|
||||
masked = {}
|
||||
for k in ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "NVIDIA_API_KEY", "QWENCLOUD_API_KEY", "XIAOMI_API_KEY", "MISTRAL_API_KEY"]:
|
||||
val = keys.get(k, "") or os.environ.get(k, "")
|
||||
if val:
|
||||
masked[k] = val[:4] + "..." + val[-4:] if len(val) > 8 else "***"
|
||||
else:
|
||||
masked[k] = ""
|
||||
return masked
|
||||
|
||||
@router.post("/api/config/ai-keys", response_model=StatusResponse)
|
||||
async def api_set_ai_keys(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Save AI keys. Pass {"DEEPSEEK_API_KEY":"sk-...","OPENROUTER_API_KEY":"...","GEMINI_API_KEY":"..."}"""
|
||||
keys = _read_ai_keys()
|
||||
for k in ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "NVIDIA_API_KEY", "QWENCLOUD_API_KEY", "XIAOMI_API_KEY", "MISTRAL_API_KEY"]:
|
||||
if body.get(k):
|
||||
keys[k] = body[k]
|
||||
_write_ai_keys(keys)
|
||||
logger.info("AI keys updated")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/api/config/ai-keys/{provider_env}", response_model=AIKeyDeleteResponse)
|
||||
async def api_delete_ai_key(provider_env: str, current_user=Depends(require_admin)):
|
||||
"""Delete a specific AI provider key from storage."""
|
||||
allowed = {"DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY",
|
||||
"NVIDIA_API_KEY", "QWENCLOUD_API_KEY", "XIAOMI_API_KEY", "MISTRAL_API_KEY"}
|
||||
key_name = provider_env.upper()
|
||||
if key_name not in allowed:
|
||||
raise HTTPException(status_code=400, detail=f"Clé inconnue: {provider_env}")
|
||||
keys = _read_ai_keys()
|
||||
if key_name in keys:
|
||||
del keys[key_name]
|
||||
_write_ai_keys(keys)
|
||||
# Also clear from env at runtime so get_ai_key() no longer finds it
|
||||
os.environ.pop(key_name, None)
|
||||
logger.info(f"AI key deleted: {key_name}")
|
||||
return {"status": "deleted", "key": key_name}
|
||||
|
||||
|
||||
@router.get("/api/config/tool-keys", response_model=AIKeysResponse)
|
||||
async def api_get_tool_keys(current_user=Depends(require_admin)):
|
||||
"""Return tool/connected-source configuration (tokens masked, URLs clear)."""
|
||||
masked = {}
|
||||
for name in _TOOL_KEY_NAMES:
|
||||
masked[name] = _mask_tool_value(name, _get_tool_key(name))
|
||||
return masked
|
||||
|
||||
|
||||
@router.post("/api/config/tool-keys", response_model=StatusResponse)
|
||||
async def api_set_tool_keys(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Save tool/connected-source keys.
|
||||
|
||||
Only whitelisted names (``backend.tools.secrets.TOOL_KEY_NAMES``) are
|
||||
accepted: Tavily/Brave/SerpAPI/Exa API keys, Gitea URL + token, GitHub
|
||||
token. Empty values delete the stored entry.
|
||||
"""
|
||||
updated = []
|
||||
for name, value in body.items():
|
||||
if name not in _TOOL_KEY_NAMES:
|
||||
raise HTTPException(status_code=400, detail=f"Clé inconnue: {name}")
|
||||
if value is not None and not isinstance(value, str):
|
||||
raise HTTPException(status_code=400, detail=f"Type invalide pour {name}")
|
||||
_set_tool_key(name, value or "")
|
||||
updated.append(name)
|
||||
logger.info(f"Tool keys updated: {updated}")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/api/config/tool-keys/{name}", response_model=AIKeyDeleteResponse)
|
||||
async def api_delete_tool_key(name: str, current_user=Depends(require_admin)):
|
||||
"""Delete a stored tool key (the environment fallback still applies)."""
|
||||
key_name = name.upper()
|
||||
try:
|
||||
existed = _delete_tool_key(key_name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
logger.info(f"Tool key deleted: {key_name} (existed={existed})")
|
||||
return {"status": "deleted", "key": key_name}
|
||||
|
||||
|
||||
@router.post("/api/config/ai-keys/test", response_model=AITestResponse)
|
||||
async def api_test_ai_keys(current_user=Depends(require_admin)):
|
||||
"""Test which AI providers are configured.
|
||||
|
||||
Each provider has a dedicated (URL, header-name) test pair.
|
||||
- Most OpenAI-compatible APIs use `Authorization: Bearer KEY`
|
||||
- Xiaomi MiMo uses `api-key: KEY`
|
||||
- Gemini uses a query-string key
|
||||
"""
|
||||
results = {}
|
||||
for key_name, label, test_url_tmpl, header_name in [
|
||||
# OpenAI-compatible — Authorization: Bearer
|
||||
("DEEPSEEK_API_KEY", "deepseek", "https://api.deepseek.com/v1/models", "Authorization"),
|
||||
("OPENROUTER_API_KEY","openrouter", "https://openrouter.ai/api/v1/models", "Authorization"),
|
||||
("NVIDIA_API_KEY", "nvidia", "https://integrate.api.nvidia.com/v1/models", "Authorization"),
|
||||
("QWENCLOUD_API_KEY", "qwencloud", "https://dashscope.aliyuncs.com/compatible-mode/v1/models", "Authorization"),
|
||||
("MISTRAL_API_KEY", "mistral", "https://api.mistral.ai/v1/models", "Authorization"),
|
||||
# Xiaomi MiMo — dedicated api-key header (NOT Authorization: Bearer)
|
||||
("XIAOMI_API_KEY", "xiaomi", "https://api.xiaomimimo.com/v1/models", "api-key"),
|
||||
# Gemini — key in query string
|
||||
("GEMINI_API_KEY", "gemini", "https://generativelanguage.googleapis.com/v1beta/models?key={key}", None),
|
||||
]:
|
||||
key = get_ai_key(key_name)
|
||||
if not key:
|
||||
results[label] = "non configuré"
|
||||
continue
|
||||
try:
|
||||
url = test_url_tmpl.replace("{key}", key) if "{key}" in test_url_tmpl else test_url_tmpl
|
||||
if header_name:
|
||||
req = urllib.request.Request(url, headers={header_name: key})
|
||||
else:
|
||||
req = urllib.request.Request(url)
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
results[label] = "ok"
|
||||
except Exception as e:
|
||||
# Truncate the error to keep the response small.
|
||||
results[label] = "erreur: " + str(e)[:80]
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/api/config/ai-models", response_model=AIModelsResponse)
|
||||
async def api_list_ai_models(provider: str = Query(...), current_user=Depends(require_admin)):
|
||||
"""List available models for a given AI provider.
|
||||
|
||||
Strategy:
|
||||
1. Try the provider's public models endpoint (OpenAI-compatible /v1/models or Gemini).
|
||||
2. If the network call fails (timeout, 4xx, 5xx, DNS, etc.), fall back to a
|
||||
curated static list of known-good models for that provider.
|
||||
3. Always return a non-empty list when the provider is known, so the UI
|
||||
dropdown is never empty.
|
||||
"""
|
||||
provider = provider.lower()
|
||||
|
||||
from backend.model_capabilities import get_capabilities_for_models
|
||||
from backend.provider_capabilities import remember_declared_capabilities
|
||||
|
||||
all_providers = ("deepseek", "openrouter", "gemini", "nvidia", "qwencloud", "xiaomi", "mistral")
|
||||
if provider not in all_providers:
|
||||
return {"models": [], "error": f"Unknown provider: {provider}", "source": "validation"}
|
||||
|
||||
key_name = f"{provider.upper()}_API_KEY"
|
||||
key = get_ai_key(key_name)
|
||||
if not key:
|
||||
# No key configured — return curated fallback list so the UI can
|
||||
# still show what WOULD be available once a key is set.
|
||||
fallback = _FALLBACK_MODELS.get(provider, [])
|
||||
return {"models": fallback, "source": "fallback",
|
||||
"capabilities": get_capabilities_for_models(provider, fallback),
|
||||
"note": "API key not configured — showing default model list"}
|
||||
|
||||
# Build URL
|
||||
if provider == "gemini":
|
||||
url = f"https://generativelanguage.googleapis.com/v1beta/models?key={key}"
|
||||
elif provider == "deepseek":
|
||||
url = "https://api.deepseek.com/v1/models"
|
||||
elif provider == "openrouter":
|
||||
url = "https://openrouter.ai/api/v1/models"
|
||||
elif provider == "nvidia":
|
||||
url = "https://integrate.api.nvidia.com/v1/models"
|
||||
elif provider == "qwencloud":
|
||||
url = "https://dashscope.aliyuncs.com/compatible-mode/v1/models"
|
||||
elif provider == "xiaomi":
|
||||
# Xiaomi MiMo — dedicated api-key header (NOT Authorization: Bearer).
|
||||
# Endpoint: https://api.xiaomimimo.com/v1/models
|
||||
url = "https://api.xiaomimimo.com/v1/models"
|
||||
models = [] # parsed below with the custom header
|
||||
elif provider == "mistral":
|
||||
url = "https://api.mistral.ai/v1/models"
|
||||
|
||||
try:
|
||||
if provider == "gemini":
|
||||
req = urllib.request.Request(url)
|
||||
elif provider == "xiaomi":
|
||||
# Xiaomi MiMo uses a dedicated api-key header.
|
||||
req = urllib.request.Request(url, headers={"api-key": key})
|
||||
else:
|
||||
req = urllib.request.Request(url, headers={"Authorization": "Bearer " + key})
|
||||
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = _json.loads(resp.read().decode())
|
||||
|
||||
if provider == "gemini":
|
||||
models = [m.get("name", "") for m in data.get("models", []) if m.get("name")]
|
||||
# Gemini returns names like "models/gemini-1.5-flash" — strip prefix
|
||||
models = [m.replace("models/", "") for m in models]
|
||||
else:
|
||||
models = [m.get("id", "") for m in data.get("data", []) if m.get("id")]
|
||||
|
||||
# Cache the capabilities the provider declares for these models
|
||||
# (BUG-044) — get_capabilities_for_models() below then returns the
|
||||
# provider's own truth for the flags it declares, the curated table
|
||||
# for the rest. Providers that declare nothing are left untouched.
|
||||
remember_declared_capabilities(provider, data)
|
||||
|
||||
if models:
|
||||
# Prepend the configured default if not already present
|
||||
default = PROVIDERS.get(provider, {}).get("model")
|
||||
if default and default not in models:
|
||||
models = [default] + models
|
||||
return {"models": models, "source": "live", "count": len(models),
|
||||
"capabilities": get_capabilities_for_models(provider, models)}
|
||||
# Empty list from API — fall through to fallback
|
||||
raise ValueError("empty model list from provider API")
|
||||
except Exception as e:
|
||||
# Network error, auth error, parsing error — use curated fallback
|
||||
fallback = _FALLBACK_MODELS.get(provider, [])
|
||||
return {"models": fallback, "source": "fallback", "error": str(e)[:200],
|
||||
"capabilities": get_capabilities_for_models(provider, fallback),
|
||||
"note": "Could not reach provider API — showing default model list"}
|
||||
|
||||
|
||||
# ── Curated fallback model lists ──────────────────────────────────────────
|
||||
# Used when the provider API is unreachable or returns empty.
|
||||
# Keep these short and focused on models known to work with the
|
||||
# OpenAI-compatible chat completions interface (or Gemini's generateContent).
|
||||
_FALLBACK_MODELS: dict[str, list[str]] = {
|
||||
"deepseek": [
|
||||
"deepseek-chat",
|
||||
"deepseek-reasoner",
|
||||
],
|
||||
"openrouter": [
|
||||
"openai/gpt-4o-mini",
|
||||
"openai/gpt-4o",
|
||||
"anthropic/claude-3.5-sonnet",
|
||||
"anthropic/claude-3-haiku",
|
||||
"google/gemini-2.0-flash-exp:free",
|
||||
"meta-llama/llama-3.1-70b-instruct",
|
||||
"meta-llama/llama-3.1-8b-instruct:free",
|
||||
"mistralai/mistral-large-latest",
|
||||
],
|
||||
"gemini": [
|
||||
"gemini-2.0-flash",
|
||||
"gemini-2.0-flash-exp",
|
||||
"gemini-1.5-pro",
|
||||
"gemini-1.5-flash",
|
||||
"gemini-1.5-flash-8b",
|
||||
],
|
||||
"nvidia": [
|
||||
"meta/llama-3.1-405b-instruct",
|
||||
"meta/llama-3.1-70b-instruct",
|
||||
"meta/llama-3.1-8b-instruct",
|
||||
"mistralai/mistral-large",
|
||||
"google/gemma-2-27b-it",
|
||||
"nvidia/llama-3.1-nemotron-70b-instruct",
|
||||
],
|
||||
"qwencloud": [
|
||||
"qwen-max",
|
||||
"qwen-plus",
|
||||
"qwen-turbo",
|
||||
"qwen-long",
|
||||
"qwen-vl-max",
|
||||
"qwen-vl-plus",
|
||||
],
|
||||
"xiaomi": [
|
||||
# Xiaomi MiMo models — the public /v1/models endpoint requires the
|
||||
# `api-key` custom header (NOT Authorization: Bearer), so the live
|
||||
# call often fails with 401 even with the right key. We ship a
|
||||
# known-good list as fallback. See https://mimo.mi.com/docs/
|
||||
"mimo-v2.5-pro",
|
||||
"mimo-v2.5",
|
||||
"mimo-v2.5-asr",
|
||||
"mimo-v2.5-tts",
|
||||
"mimo-v2.5-tts-voiceclone",
|
||||
"mimo-v2.5-tts-voicedesign",
|
||||
],
|
||||
"mistral": [
|
||||
"mistral-large-latest",
|
||||
"mistral-medium-latest",
|
||||
"mistral-small-latest",
|
||||
"open-mistral-7b",
|
||||
"open-mixtral-8x7b",
|
||||
"codestral-latest",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/diagnostics", response_model=DiagnosticsResponse)
|
||||
async def api_diagnostics(current_user=Depends(require_admin)):
|
||||
"""Return index statistics and system diagnostics.
|
||||
|
||||
Includes document counts, token counts, memory estimates,
|
||||
and inverted index status.
|
||||
"""
|
||||
import sys
|
||||
|
||||
from backend.search import get_inverted_index
|
||||
|
||||
inv = get_inverted_index()
|
||||
|
||||
# Per-vault stats
|
||||
vault_stats = {}
|
||||
total_files = 0
|
||||
total_tags = 0
|
||||
# Snapshot both dicts first: the indexer mutates them from background
|
||||
# threads, and iterating a live dict raises "dictionary changed size".
|
||||
for vname, vdata in list(index.items()):
|
||||
file_count = len(vdata.get("files", []))
|
||||
tag_count = len(vdata.get("tags", {}))
|
||||
vault_stats[vname] = {"file_count": file_count, "tag_count": tag_count}
|
||||
total_files += file_count
|
||||
total_tags += tag_count
|
||||
|
||||
# Memory estimate for inverted index
|
||||
word_index = inv.word_index.copy()
|
||||
word_index_entries = sum(len(docs) for docs in word_index.values())
|
||||
mem_estimate_mb = round(
|
||||
(sys.getsizeof(inv.word_index) + word_index_entries * 80
|
||||
+ len(inv.doc_info) * 200
|
||||
+ len(inv._sorted_tokens) * 60) / (1024 * 1024), 2
|
||||
)
|
||||
|
||||
return {
|
||||
"index": {
|
||||
"total_files": total_files,
|
||||
"total_tags": total_tags,
|
||||
"vaults": vault_stats,
|
||||
},
|
||||
"inverted_index": {
|
||||
"unique_tokens": len(word_index),
|
||||
"total_postings": word_index_entries,
|
||||
"documents": inv.doc_count,
|
||||
"sorted_tokens": len(inv._sorted_tokens),
|
||||
"is_stale": inv.is_stale(),
|
||||
"memory_estimate_mb": mem_estimate_mb,
|
||||
},
|
||||
"config": _load_config(),
|
||||
"search_executor": {
|
||||
"active": get_search_executor() is not None,
|
||||
"max_workers": get_search_executor()._max_workers if get_search_executor() else 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/dashboard", response_model=DashboardResponse)
|
||||
async def api_dashboard(current_user=Depends(require_auth)):
|
||||
"""Aggregated dashboard statistics across all accessible vaults."""
|
||||
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:
|
||||
continue
|
||||
files = vdata.get("files", [])
|
||||
fc = len(files)
|
||||
total_files += fc
|
||||
vtags = set()
|
||||
vsize = 0
|
||||
vimages = 0
|
||||
for f in files:
|
||||
vtags.update(f.get("tags", []))
|
||||
vsize += f.get("size", 0)
|
||||
if (f.get("extension") or "").lower() in IMAGE_EXTENSIONS:
|
||||
vimages += 1
|
||||
total_tags.update(vtags)
|
||||
total_size += vsize
|
||||
total_images += vimages
|
||||
vault_stats.append({
|
||||
"name": vname, "file_count": fc, "tag_count": len(vtags),
|
||||
"total_size_bytes": vsize, "image_count": vimages,
|
||||
})
|
||||
return {
|
||||
"vaults": vault_stats,
|
||||
"total_files": total_files,
|
||||
"total_tags": len(total_tags),
|
||||
"total_size_bytes": total_size,
|
||||
"total_images": total_images,
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Syncthing conflict endpoints (ROADMAP #85, tranche 8).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/conflicts*``), mêmes modèles de
|
||||
réponse, mêmes dépendances d'authentification.
|
||||
|
||||
Adaptations strictement équivalentes :
|
||||
- ``_resolve_safe_path`` / ``_backup_file`` → :mod:`backend.services.paths`
|
||||
et :mod:`backend.services.backups` (pass-through).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
|
||||
from backend.audit import log_file_delete
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.indexer import get_conflicts, get_vault_data, remove_single_file
|
||||
from backend.schemas import ConflictResolveResponse, ConflictsResponse
|
||||
from backend.services.backups import create_backup
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.sse import sse_manager
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
router = APIRouter(tags=["conflicts"])
|
||||
|
||||
|
||||
@router.get("/api/conflicts", response_model=ConflictsResponse)
|
||||
async def api_conflicts(current_user=Depends(require_auth)):
|
||||
"""List sync-conflict files across accessible vaults."""
|
||||
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]
|
||||
return {"conflicts": all_conflicts, "total": len(all_conflicts)}
|
||||
|
||||
|
||||
@router.post("/api/conflicts/resolve", response_model=ConflictResolveResponse)
|
||||
async def api_conflict_resolve(body: dict = Body(...), current_user=Depends(require_auth)):
|
||||
"""Resolve a conflict: keep_local (delete conflict file) or keep_conflict (replace original)."""
|
||||
vault_name = body.get("vault")
|
||||
conflict_path = body.get("conflict_path")
|
||||
original_path = body.get("original_path")
|
||||
action = body.get("action") # "keep_local" or "keep_conflict"
|
||||
# mypy: narrow down from dict values
|
||||
assert isinstance(vault_name, str), "'vault' is required and must be a string"
|
||||
assert isinstance(conflict_path, str), "'conflict_path' is required and must be a string"
|
||||
assert isinstance(original_path, str), "'original_path' is required and must be a string"
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(404, "Vault not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
conf_file = resolve_safe_path(vault_root, conflict_path)
|
||||
orig_file = resolve_safe_path(vault_root, original_path)
|
||||
if not conf_file.exists():
|
||||
raise HTTPException(404, "Conflict file not found")
|
||||
try:
|
||||
if action == "keep_conflict":
|
||||
create_backup(orig_file, vault_name, original_path)
|
||||
shutil.copy2(conf_file, orig_file)
|
||||
logger.info(f"Conflict resolved (keep_conflict): {conflict_path} → {original_path}")
|
||||
conf_file.unlink()
|
||||
await remove_single_file(vault_name, conflict_path)
|
||||
log_file_delete(current_user["username"], vault_name, conflict_path)
|
||||
await sse_manager.broadcast("file_deleted", {"vault": vault_name, "path": conflict_path})
|
||||
return {"status": "resolved", "action": action}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Error resolving conflict: {e!s}")
|
||||
@@ -0,0 +1,569 @@
|
||||
"""Media, PDF, export & vault-settings endpoints (ROADMAP #85, tranche 6c).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/file/*/pdf*``, ``/api/export/*``,
|
||||
``/api/guide/download``, ``/api/image/*``, ``/api/media*``,
|
||||
``/api/attachments/*``, ``/api/vaults/*/settings``, ``/api/vault/*/files``,
|
||||
``/api/vaults/settings/all``), mêmes modèles de réponse, mêmes dépendances
|
||||
d'authentification.
|
||||
|
||||
Adaptations strictement équivalentes :
|
||||
- ``_resolve_safe_path`` → :mod:`backend.services.paths` (pass-through).
|
||||
- ``_render_markdown`` vient de :mod:`backend.render` (#85 T9, sans cycle
|
||||
d'import).
|
||||
- ``_resolve_export_target`` / ``_safe_export_name`` (export uniquement)
|
||||
sont définis ici ; ``stream_file_with_range`` vit dans
|
||||
:mod:`backend.routers.helpers` (partagé).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import FileResponse, Response
|
||||
|
||||
from backend.attachment_indexer import get_attachment_stats, rescan_vault_attachments
|
||||
from backend.auth.middleware import check_vault_access, require_admin, require_auth
|
||||
from backend.export import ExportError, export_epub, export_html, export_md_bundle
|
||||
from backend.history import record_open
|
||||
from backend.indexer import get_vault_data, index, parse_markdown_file
|
||||
from backend.media_thumbs import generate_thumbnail, is_decodable
|
||||
from backend.media_types import is_audio, is_image, is_video, media_mime_type
|
||||
from backend.render import _render_markdown
|
||||
from backend.routers.helpers import media_max_inline_bytes, stream_file_with_range
|
||||
from backend.schemas import (
|
||||
AllVaultSettingsResponse,
|
||||
AttachmentRescanResponse,
|
||||
AttachmentStatsResponse,
|
||||
PdfInfoResponse,
|
||||
VaultFilesResponse,
|
||||
VaultSettingsResponse,
|
||||
)
|
||||
from backend.secret_redactor import redact_file_content
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import list_all_files
|
||||
from backend.vault_settings import get_vault_setting, update_vault_setting
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
# Lazy import: WeasyPrint PDF export (requires GTK, may not be available everywhere)
|
||||
try:
|
||||
from backend.pdf_export import build_pdf_html, generate_pdf
|
||||
except Exception: # pragma: no cover - WeasyPrint/GTK missing
|
||||
generate_pdf = None # type: ignore[assignment]
|
||||
build_pdf_html = None # type: ignore[assignment]
|
||||
|
||||
logging.getLogger("obsigate").warning("PDF export unavailable (WeasyPrint/GTK not found)")
|
||||
|
||||
router = APIRouter() # pas de tags : assignation par chemin via openapi_docs.tag_for_path (comme avant)
|
||||
|
||||
|
||||
def _resolve_export_target(vault_name: str, path: str, current_user: dict) -> tuple[Path, Path]:
|
||||
"""Resolve a vault + relative path into (vault_root, absolute file path).
|
||||
|
||||
Enforces auth (vault access) and path traversal protection.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
target = resolve_safe_path(vault_root, path)
|
||||
return vault_root, target
|
||||
|
||||
|
||||
def _safe_export_name(name: str) -> str:
|
||||
"""ASCII-safe, filename-safe download name (falls back to 'document')."""
|
||||
cleaned = "".join(c for c in name if c.isascii() and (c.isalnum() or c in " _-.")).strip()
|
||||
return cleaned or "document"
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/file/{vault_name}/pdf",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/pdf": {}}, "description": "PDF document"}},
|
||||
)
|
||||
async def api_file_pdf(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||
"""Download a markdown file as PDF."""
|
||||
if generate_pdf is None:
|
||||
raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)")
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(404, f"Vault '{vault_name}' not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, f"File not found: {path}")
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
raise HTTPException(500, "Cannot read file")
|
||||
record_open(current_user.get("username"), vault_name, path)
|
||||
raw = redact_file_content(raw, str(file_path))
|
||||
post = parse_markdown_file(raw)
|
||||
html = _render_markdown(post.content, vault_name, file_path)
|
||||
title = post.metadata.get("title", file_path.stem)
|
||||
pdf_html = build_pdf_html(html, str(title))
|
||||
pdf_bytes = generate_pdf(pdf_html, str(title))
|
||||
safe_name = "".join(c for c in str(title) if c.isascii() and (c.isalnum() or c in " _-.")).strip() or "document"
|
||||
return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{safe_name}.pdf"'})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/export/html",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"text/html": {}}, "description": "Standalone HTML file"}},
|
||||
)
|
||||
async def api_export_html(
|
||||
vault: str = Query(..., description="Vault name"),
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Export a markdown note as a standalone HTML file."""
|
||||
try:
|
||||
vault_root, target = _resolve_export_target(vault, path, current_user)
|
||||
html_bytes = export_html(vault_root, target)
|
||||
except ExportError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
record_open(current_user.get("username"), vault, path)
|
||||
safe_name = _safe_export_name(target.stem)
|
||||
return Response(
|
||||
content=html_bytes,
|
||||
media_type="text/html; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}.html"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/export/md-bundle",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/zip": {}}, "description": "Markdown ZIP bundle"}},
|
||||
)
|
||||
async def api_export_md_bundle(
|
||||
vault: str = Query(..., description="Vault name"),
|
||||
path: str = Query(..., description="Relative path to directory or file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Export a directory (or single file) of markdown as a ZIP bundle."""
|
||||
try:
|
||||
vault_root, target = _resolve_export_target(vault, path, current_user)
|
||||
zip_bytes = export_md_bundle(vault_root, target)
|
||||
except ExportError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
safe_name = _safe_export_name(target.name)
|
||||
return Response(
|
||||
content=zip_bytes,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}.zip"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/export/epub",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/epub+zip": {}}, "description": "ePub document"}},
|
||||
)
|
||||
async def api_export_epub(
|
||||
vault: str = Query(..., description="Vault name"),
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Export a markdown note as an ePub document."""
|
||||
try:
|
||||
vault_root, target = _resolve_export_target(vault, path, current_user)
|
||||
epub_bytes = export_epub(vault_root, target)
|
||||
except ExportError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
record_open(current_user.get("username"), vault, path)
|
||||
safe_name = _safe_export_name(target.stem)
|
||||
return Response(
|
||||
content=epub_bytes,
|
||||
media_type="application/epub+zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}.epub"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/guide/download",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/pdf": {}, "text/markdown": {}}}},
|
||||
)
|
||||
async def api_guide_download(
|
||||
format: str = Query("md", description="Download format: 'md' or 'pdf'"),
|
||||
lang: str = Query("fr", description="Guide language: 'fr' or 'en'"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Download the in-app user guide as Markdown or PDF (#105).
|
||||
|
||||
The document is generated from the live help modal in index.html resolved
|
||||
through the locale files, so it always mirrors exactly what the user sees.
|
||||
"""
|
||||
from backend.guide_export import get_guide_document
|
||||
|
||||
if format not in ("md", "pdf"):
|
||||
raise HTTPException(status_code=400, detail="format doit être 'md' ou 'pdf'")
|
||||
try:
|
||||
payload, media, fname = get_guide_document(format, lang)
|
||||
except Exception as e: # weasyprint/reportlab unavailable
|
||||
logger.exception("guide export failed")
|
||||
raise HTTPException(status_code=500, detail=f"Export impossible: {e}") from e
|
||||
return Response(
|
||||
content=payload,
|
||||
media_type=media,
|
||||
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/pdf/stream", response_class=FileResponse)
|
||||
async def api_pdf_stream(
|
||||
request: Request,
|
||||
vault_name: str,
|
||||
path: str = Query(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Stream a PDF file with Content-Type: application/pdf for inline browser viewing.
|
||||
|
||||
Supports HTTP Range requests (206 Partial Content) so browsers can
|
||||
progressively render large PDFs in the native viewer.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
if file_path.suffix.lower() != ".pdf":
|
||||
raise HTTPException(status_code=400, detail="Not a PDF file")
|
||||
|
||||
return stream_file_with_range(file_path, request, "application/pdf")
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/pdf/info", response_model=PdfInfoResponse)
|
||||
async def api_pdf_info(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to PDF file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return PDF metadata (pages, title, author, size) without the document content.
|
||||
|
||||
Lets the UI display file info before loading a heavy PDF into the viewer.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
if file_path.suffix.lower() != ".pdf":
|
||||
raise HTTPException(status_code=400, detail="Not a PDF file")
|
||||
|
||||
from backend.pdf_reader import extract_pdf_metadata
|
||||
meta = extract_pdf_metadata(file_path)
|
||||
stat = file_path.stat()
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"pages": meta.get("pages", 0),
|
||||
"title": meta.get("title") or file_path.name,
|
||||
"author": meta.get("author", ""),
|
||||
"size_bytes": stat.st_size,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/image/{vault_name}",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/octet-stream": {}}, "description": "Image bytes"}},
|
||||
)
|
||||
async def api_image(vault_name: str, path: str = Query(..., description="Relative path to image"), current_user=Depends(require_auth)):
|
||||
"""Serve an image file with proper MIME type.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
|
||||
Returns:
|
||||
Image file with appropriate content-type header.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"Image not found: {path}")
|
||||
|
||||
mime_type = media_mime_type(str(file_path))
|
||||
|
||||
# #108-B3 — a standalone SVG opened in a tab executes its embedded JS
|
||||
# (same-origin XSS). ``sandbox`` forces a unique opaque origin with no
|
||||
# script execution; inside an <img> tag the header is irrelevant.
|
||||
headers = {"X-Content-Type-Options": "nosniff"}
|
||||
if file_path.suffix.lower() == ".svg":
|
||||
headers["Content-Security-Policy"] = "sandbox"
|
||||
|
||||
try:
|
||||
# Read and return the image file
|
||||
content = file_path.read_bytes()
|
||||
return Response(content=content, media_type=mime_type, headers=headers)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="Permission denied")
|
||||
except Exception as e:
|
||||
logger.error(f"Error serving image {vault_name}/{path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error serving image: {e!s}")
|
||||
|
||||
|
||||
@router.get("/api/media/{vault_name}", response_class=FileResponse)
|
||||
async def api_media_stream(
|
||||
request: Request,
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to audio/video file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Stream an audio/video file with HTTP Range support (roadmap #109-A2).
|
||||
|
||||
Serves the bytes with the correct MIME type and honours ``Range`` requests
|
||||
(``206 Partial Content`` + ``Content-Range``/``Accept-Ranges``), which is
|
||||
what enables scrubbing in ``<audio>``/``<video>`` and is required by Safari
|
||||
for MP4. Files above ``OBSIGATE_MEDIA_MAX_INLINE_MB`` (default 500 MB) are
|
||||
refused with ``413`` — the viewer falls back to the download button.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"Media not found: {path}")
|
||||
|
||||
ext = file_path.suffix.lower()
|
||||
if not (is_audio(ext) or is_video(ext)):
|
||||
raise HTTPException(status_code=400, detail="Not an audio/video file")
|
||||
|
||||
if file_path.stat().st_size > media_max_inline_bytes():
|
||||
raise HTTPException(status_code=413, detail="Media too large for inline streaming")
|
||||
|
||||
return stream_file_with_range(file_path, request, media_mime_type(str(file_path)))
|
||||
|
||||
|
||||
@router.get("/api/media/{vault_name}/thumb", response_class=FileResponse)
|
||||
async def api_media_thumb(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to image"),
|
||||
size: int = Query(256, ge=32, le=1024, description="Max thumbnail edge in pixels"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Serve a cached WebP thumbnail of an image (roadmap #108-C).
|
||||
|
||||
SVG (and any format Pillow cannot decode) falls back to the original
|
||||
bytes. Generation runs in a thread and is capped at 2 s; on timeout or
|
||||
failure the original is served so the UI never breaks.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"Image not found: {path}")
|
||||
if not is_image(file_path.suffix.lower()):
|
||||
raise HTTPException(status_code=400, detail="Not an image file")
|
||||
|
||||
mime_type = media_mime_type(str(file_path))
|
||||
if not is_decodable(file_path):
|
||||
# SVG: never let a standalone navigation execute embedded JS (#108-B3).
|
||||
svg_headers = {"X-Content-Type-Options": "nosniff"}
|
||||
if file_path.suffix.lower() == ".svg":
|
||||
svg_headers["Content-Security-Policy"] = "sandbox"
|
||||
return FileResponse(str(file_path), media_type=mime_type, headers=svg_headers)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
thumb: Path | None = None
|
||||
try:
|
||||
thumb = await asyncio.wait_for(
|
||||
loop.run_in_executor(None, generate_thumbnail, file_path, size),
|
||||
timeout=2.0,
|
||||
)
|
||||
except Exception:
|
||||
thumb = None
|
||||
|
||||
if thumb is not None and thumb.exists():
|
||||
return FileResponse(str(thumb), media_type="image/webp")
|
||||
return FileResponse(str(file_path), media_type=mime_type)
|
||||
|
||||
|
||||
@router.post("/api/attachments/rescan/{vault_name}", response_model=AttachmentRescanResponse)
|
||||
async def api_rescan_attachments(vault_name: str, current_user=Depends(require_admin)):
|
||||
"""Rescan attachments for a specific vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault to rescan.
|
||||
|
||||
Returns:
|
||||
Dict with status and attachment count.
|
||||
"""
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_path = vault_data["path"]
|
||||
count = await rescan_vault_attachments(vault_name, vault_path)
|
||||
|
||||
logger.info(f"Rescanned attachments for vault '{vault_name}': {count} attachments")
|
||||
return {"status": "ok", "vault": vault_name, "attachment_count": count}
|
||||
|
||||
|
||||
@router.get("/api/attachments/stats", response_model=AttachmentStatsResponse)
|
||||
async def api_attachment_stats(vault: str | None = Query(None, description="Vault filter"), current_user=Depends(require_auth)):
|
||||
"""Get attachment statistics for vaults.
|
||||
|
||||
Args:
|
||||
vault: Optional vault name to filter stats.
|
||||
|
||||
Returns:
|
||||
Dict with vault names as keys and attachment counts as values.
|
||||
"""
|
||||
stats = get_attachment_stats(vault)
|
||||
return {"vaults": stats}
|
||||
|
||||
|
||||
@router.get("/api/vaults/{vault_name}/settings", response_model=VaultSettingsResponse)
|
||||
async def api_get_vault_settings(vault_name: str, current_user=Depends(require_auth)):
|
||||
"""Get UI display settings for a specific vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
|
||||
Returns:
|
||||
Dict with vault settings including hideHiddenFiles.
|
||||
"""
|
||||
if vault_name not in index:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
# Get persisted settings
|
||||
persisted = get_vault_setting(vault_name) or {}
|
||||
|
||||
# Default settings
|
||||
settings = {
|
||||
"hideHiddenFiles": False,
|
||||
}
|
||||
settings.update(persisted)
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
@router.post("/api/vaults/{vault_name}/settings", response_model=VaultSettingsResponse)
|
||||
async def api_update_vault_settings(vault_name: str, body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Update UI display settings for a specific vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
body: Dict with settings to update (hideHiddenFiles).
|
||||
|
||||
Returns:
|
||||
Updated settings dict.
|
||||
"""
|
||||
if vault_name not in index:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
# Validate settings
|
||||
settings_to_update = {}
|
||||
|
||||
if "hideHiddenFiles" in body:
|
||||
if not isinstance(body["hideHiddenFiles"], bool):
|
||||
raise HTTPException(status_code=400, detail="hideHiddenFiles must be a boolean")
|
||||
settings_to_update["hideHiddenFiles"] = body["hideHiddenFiles"]
|
||||
|
||||
# Update persisted settings
|
||||
try:
|
||||
updated = update_vault_setting(vault_name, settings_to_update)
|
||||
except PermissionError as e:
|
||||
logger.error(f"Permission error saving settings for vault '{vault_name}': {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Permission denied: Cannot write to settings file. Check /app/data permissions."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving settings for vault '{vault_name}': {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to save settings: {e!s}"
|
||||
)
|
||||
|
||||
logger.info(f"Updated settings for vault '{vault_name}': {settings_to_update}")
|
||||
|
||||
return updated
|
||||
|
||||
|
||||
@router.get("/api/vault/{vault_name}/files", response_model=VaultFilesResponse)
|
||||
async def api_vault_recent_files(
|
||||
vault_name: str,
|
||||
dir: str = Query("", description="Directory path within the vault (empty = root)"),
|
||||
limit: int = Query(200, description="Maximum number of files to return"),
|
||||
recursive: bool = Query(True, description="If true, list files recursively from directory and all subdirectories"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""List files in a vault directory sorted by modification time (newest first).
|
||||
|
||||
Returns file metadata suitable for a vault home page display.
|
||||
Unlike /api/browse, this endpoint sorts by mtime and returns
|
||||
additional metadata (size, modified time, extension).
|
||||
|
||||
When recursive=True (default), lists files from the directory
|
||||
AND all its subdirectories, with a ``rel_dir`` field indicating
|
||||
the subdirectory path relative to the requested directory.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
dir: Relative directory path within the vault (empty for root).
|
||||
limit: Maximum files to return (default 200).
|
||||
recursive: If true, recursively list files in subdirectories (default true).
|
||||
|
||||
Returns:
|
||||
JSON with vault, directory, count, recursive flag, and list of file entries.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
return list_all_files(vault_name, dir=dir, limit=limit, recursive=recursive)
|
||||
|
||||
|
||||
@router.get("/api/vaults/settings/all", response_model=AllVaultSettingsResponse)
|
||||
async def api_get_all_vault_settings(current_user=Depends(require_auth)):
|
||||
"""Get UI display settings for all vaults.
|
||||
|
||||
Returns:
|
||||
Dict mapping vault names to their settings.
|
||||
"""
|
||||
all_settings = {}
|
||||
|
||||
for vault_name in index:
|
||||
persisted = get_vault_setting(vault_name) or {}
|
||||
|
||||
settings = {
|
||||
"hideHiddenFiles": False,
|
||||
}
|
||||
settings.update(persisted)
|
||||
all_settings[vault_name] = settings
|
||||
|
||||
return all_settings
|
||||
@@ -0,0 +1,524 @@
|
||||
"""File browsing & reading endpoints (ROADMAP #85, tranche 6a).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/browse/*``, ``/api/file/*`` en
|
||||
lecture), mêmes modèles de réponse (déménagés dans
|
||||
:mod:`backend.schemas`), mêmes dépendances d'authentification.
|
||||
|
||||
Adaptations strictement équivalentes :
|
||||
- ``_resolve_safe_path`` → :mod:`backend.services.paths` (pass-through).
|
||||
- ``_render_markdown`` vient de :mod:`backend.render` (#85 T9, sans cycle
|
||||
d'import).
|
||||
- ``_content_disposition`` / ``_media_max_inline_bytes`` / ``EXT_TO_LANG``
|
||||
ont déménagé : helpers partagés dans :mod:`backend.routers.helpers`
|
||||
(``EXT_TO_LANG`` n'était utilisé que par la vue fichier).
|
||||
"""
|
||||
|
||||
import html as html_mod
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.history import record_open
|
||||
from backend.indexer import (
|
||||
_extract_tags,
|
||||
get_backlinks,
|
||||
get_vault_data,
|
||||
parse_markdown_file,
|
||||
)
|
||||
from backend.media_types import is_audio, is_image, is_video, media_mime_type
|
||||
from backend.render import _render_markdown
|
||||
from backend.routers.helpers import media_max_inline_bytes
|
||||
from backend.schemas import (
|
||||
BacklinksResponse,
|
||||
BrowseResponse,
|
||||
FileContentResponse,
|
||||
FileRawResponse,
|
||||
)
|
||||
from backend.services.files import read_raw_file
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import browse_directory
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
# Map file extensions to highlight.js language hints
|
||||
EXT_TO_LANG = {
|
||||
".py": "python", ".js": "javascript", ".ts": "typescript",
|
||||
".jsx": "jsx", ".tsx": "tsx", ".sh": "bash", ".bash": "bash",
|
||||
".zsh": "bash", ".fish": "fish", ".bat": "batch", ".cmd": "batch",
|
||||
".ps1": "powershell", ".json": "json", ".yaml": "yaml", ".yml": "yaml",
|
||||
".toml": "toml", ".xml": "xml", ".csv": "plaintext",
|
||||
".cfg": "ini", ".ini": "ini", ".conf": "ini", ".env": "bash",
|
||||
".html": "html", ".css": "css", ".scss": "scss", ".less": "less",
|
||||
".java": "java", ".c": "c", ".cpp": "cpp", ".h": "c", ".hpp": "cpp",
|
||||
".cs": "csharp", ".go": "go", ".rs": "rust", ".rb": "ruby",
|
||||
".php": "php", ".sql": "sql", ".r": "r", ".swift": "swift",
|
||||
".kt": "kotlin", ".txt": "plaintext", ".log": "plaintext",
|
||||
".lua": "lua", ".pl": "perl", ".pm": "perl", ".ex": "elixir", ".exs": "elixir",
|
||||
".dart": "dart", ".tf": "haskell", ".gradle": "groovy", ".groovy": "groovy",
|
||||
".graphql": "graphql", ".gql": "graphql", ".prisma": "sql", ".proto": "c",
|
||||
".vb": "basic", ".asm": "x86asm", ".s": "armasm",
|
||||
".vue": "xml", ".svelte": "xml", ".astro": "xml",
|
||||
".properties": "ini", ".service": "ini", ".hosts": "ini",
|
||||
".ksh": "bash", ".dockerfile": "dockerfile",
|
||||
".makefile": "makefile", ".cmake": "cmake",
|
||||
}
|
||||
|
||||
router = APIRouter(tags=["files"])
|
||||
|
||||
|
||||
@router.get("/api/browse/{vault_name}", response_model=BrowseResponse)
|
||||
async def api_browse(vault_name: str, path: str = "", current_user=Depends(require_auth)):
|
||||
"""Browse directories and files in a vault at a given path level.
|
||||
|
||||
Returns sorted entries (directories first, then files) with metadata.
|
||||
Hidden files/directories (starting with ``"."`` ) are excluded.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault to browse.
|
||||
path: Relative directory path within the vault (empty = root).
|
||||
|
||||
Returns:
|
||||
``BrowseResponse`` with vault name, path, and item list.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
return browse_directory(vault_name, path)
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/raw", response_model=FileRawResponse)
|
||||
async def api_file_raw(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||
"""Return raw file content as plain text.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
|
||||
Returns:
|
||||
``FileRawResponse`` with vault, path, and raw text content.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
return read_raw_file(vault_name, path)
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/download", response_class=FileResponse)
|
||||
async def api_file_download(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||
"""Download a file as an attachment.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
|
||||
Returns:
|
||||
``FileResponse`` with ``application/octet-stream`` content-type.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
|
||||
# Record history
|
||||
record_open(current_user.get("username"), vault_name, path)
|
||||
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
filename=file_path.name,
|
||||
media_type="application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/backlinks", response_model=BacklinksResponse)
|
||||
async def api_file_backlinks(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Get backlinks (files linking to this file via wikilinks).
|
||||
|
||||
Returns a list of files that contain `[[wikilinks]]` pointing
|
||||
to the requested file, across all accessible vaults.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault containing the target file.
|
||||
path: Relative path of the target file within the vault.
|
||||
|
||||
Returns:
|
||||
``{"vault": str, "path": str, "backlinks": [...]}``
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
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]
|
||||
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"backlinks": backlinks,
|
||||
"total": len(backlinks),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}", response_model=FileContentResponse)
|
||||
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||
"""Return rendered HTML and metadata for a file.
|
||||
|
||||
Markdown files are parsed for frontmatter, rendered with wikilink
|
||||
support, and returned with extracted tags. Other supported file
|
||||
types are syntax-highlighted as code blocks.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
|
||||
Returns:
|
||||
``FileContentResponse`` with HTML, metadata, and tags.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
|
||||
# Record history
|
||||
record_open(current_user.get("username"), vault_name, path, title=file_path.name)
|
||||
|
||||
ext = file_path.suffix.lower()
|
||||
|
||||
# === PDF: special handling before read_text (binary file) ===
|
||||
if ext == ".pdf":
|
||||
try:
|
||||
from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text, extract_pdf_toc
|
||||
pdf_text = extract_pdf_text(file_path, max_chars=100000)
|
||||
pdf_meta = extract_pdf_metadata(file_path)
|
||||
pdf_toc = extract_pdf_toc(file_path)
|
||||
size = file_path.stat().st_size
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": pdf_meta.get("title") or file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": f"<div class='pdf-viewer'><p>PDF — {pdf_meta.get('pages', '?')} pages</p><pre>{pdf_text[:5000]}</pre></div>",
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"is_pdf": True,
|
||||
"unsupported": False,
|
||||
"pdf_metadata": pdf_meta,
|
||||
"pdf_toc": pdf_toc,
|
||||
"size_bytes": size,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"PDF read error for {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading PDF: {e!s}")
|
||||
|
||||
# === Excel .xlsx: render sheets as HTML tables (binary, before read_text) ===
|
||||
if ext == ".xlsx":
|
||||
try:
|
||||
from backend.xlsx_reader import 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,
|
||||
"unsupported": False,
|
||||
"size_bytes": size,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"XLSX read error for {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
|
||||
|
||||
# === Images: return as viewable image ===
|
||||
if is_image(ext):
|
||||
size = file_path.stat().st_size
|
||||
mime = media_mime_type(str(file_path))
|
||||
# #108-B1 — the raw endpoint returns JSON (FileRawResponse), so the
|
||||
# standalone <img> must point to /api/image, which serves the bytes
|
||||
# with the right MIME type. Paths are URL-encoded (accents, spaces).
|
||||
img_url = f"/api/image/{quote(vault_name, safe='')}?path={quote(path, safe='')}"
|
||||
html = (
|
||||
f'<div class="image-viewer">'
|
||||
f'<img src="{img_url}" '
|
||||
f'alt="{html_mod.escape(file_path.name, quote=True)}" '
|
||||
f'style="max-width:100%;max-height:80vh;object-fit:contain" />'
|
||||
f'</div>'
|
||||
)
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": html,
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"is_image": True,
|
||||
"image_mime": mime,
|
||||
"size_bytes": size,
|
||||
}
|
||||
|
||||
# === Audio / Video: HTML5 players streamed from /api/media (roadmap #109) ===
|
||||
if is_audio(ext) or is_video(ext):
|
||||
size = file_path.stat().st_size
|
||||
mime = media_mime_type(str(file_path))
|
||||
media_kind = "audio" if is_audio(ext) else "video"
|
||||
|
||||
# #109-A3 — beyond the inline limit the viewer falls back to download
|
||||
# (a single uvicorn worker must not be pinned by multi-GB media).
|
||||
if size > media_max_inline_bytes():
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": "",
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"unsupported": True,
|
||||
"media_too_large": True,
|
||||
"size_bytes": size,
|
||||
}
|
||||
|
||||
# #109-A2 — byte-range endpoint: enables scrub and is required by Safari.
|
||||
stream_url = f"/api/media/{quote(vault_name, safe='')}?path={quote(path, safe='')}"
|
||||
if media_kind == "audio":
|
||||
html = (
|
||||
f'<div class="audio-viewer">'
|
||||
f'<audio controls preload="metadata" src="{stream_url}"></audio>'
|
||||
f'</div>'
|
||||
)
|
||||
else:
|
||||
html = (
|
||||
f'<div class="video-viewer">'
|
||||
f'<video controls playsinline preload="metadata" src="{stream_url}"></video>'
|
||||
f'</div>'
|
||||
)
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": html,
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"is_audio": media_kind == "audio",
|
||||
"is_video": media_kind == "video",
|
||||
"media_mime": mime,
|
||||
"stream_url": stream_url,
|
||||
"size_bytes": size,
|
||||
}
|
||||
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except PermissionError as e:
|
||||
logger.error(f"Permission denied reading file {path}: {e}")
|
||||
raise HTTPException(status_code=403, detail=f"Permission denied: cannot read file {path}")
|
||||
except UnicodeDecodeError:
|
||||
# Binary / unsupported file — return structured info with download option
|
||||
size = file_path.stat().st_size
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": "",
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"unsupported": True,
|
||||
"size_bytes": size,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error reading file {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}")
|
||||
|
||||
# === CSV: render as HTML table ===
|
||||
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>"
|
||||
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,
|
||||
}
|
||||
|
||||
# === JSON: syntax-highlighted display ===
|
||||
if ext == ".json":
|
||||
import json as json_mod
|
||||
try:
|
||||
parsed = json_mod.loads(raw)
|
||||
formatted = json_mod.dumps(parsed, indent=2, ensure_ascii=False)
|
||||
except json_mod.JSONDecodeError:
|
||||
formatted = raw
|
||||
html = f"<pre class='json-viewer'><code>{html_mod.escape(formatted)}</code></pre>"
|
||||
return {
|
||||
"vault": vault_name, "path": path,
|
||||
"title": file_path.name, "tags": [], "frontmatter": {},
|
||||
"html": html, "raw_length": len(raw), "extension": ext,
|
||||
"is_markdown": False, "is_json": True,
|
||||
}
|
||||
|
||||
# === Excalidraw .excalidraw.md (Obsidian plugin format) ===
|
||||
if path.lower().endswith(".excalidraw.md"):
|
||||
import re as re_mod
|
||||
raw_lower = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
# Check for excalidraw-plugin in frontmatter or body
|
||||
if "excalidraw-plugin:" in raw_lower:
|
||||
# Extract compressed JSON block
|
||||
match = re_mod.search(r'```compressed-json\n(.*?)\n```', raw_lower, re_mod.DOTALL)
|
||||
if match:
|
||||
compressed = match.group(1).strip()
|
||||
return {
|
||||
"vault": vault_name, "path": path,
|
||||
"title": file_path.name.replace(".excalidraw.md", ""),
|
||||
"tags": [], "frontmatter": {},
|
||||
"html": "", "raw_length": len(raw_lower),
|
||||
"extension": ".excalidraw.md",
|
||||
"is_markdown": False,
|
||||
"is_excalidraw": True,
|
||||
"excalidraw_data_compressed": compressed,
|
||||
}
|
||||
# Fallback: treat as regular markdown
|
||||
raw = raw_lower
|
||||
if ext == ".excalidraw":
|
||||
import json as json_mod
|
||||
try:
|
||||
parsed = json_mod.loads(raw)
|
||||
except json_mod.JSONDecodeError:
|
||||
parsed = None
|
||||
if parsed and parsed.get("type") == "excalidraw":
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": parsed.get("appState", {}).get("name") or file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": "",
|
||||
"raw_length": len(raw),
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"is_excalidraw": True,
|
||||
"excalidraw_data": {
|
||||
"elements": parsed.get("elements", []),
|
||||
"appState": parsed.get("appState", {}),
|
||||
"files": parsed.get("files", {}),
|
||||
},
|
||||
}
|
||||
else:
|
||||
# Not a valid Excalidraw file — fall through to text viewer
|
||||
pass
|
||||
|
||||
# === Plain text / other readable files ===
|
||||
TEXT_EXTENSIONS = {".txt", ".log", ".yml", ".yaml", ".toml", ".ini", ".cfg",
|
||||
".sh", ".bash", ".py", ".js", ".ts", ".html", ".css",
|
||||
".xml", ".rst", ".tex", ".sql", ".conf", ".env"}
|
||||
if ext in TEXT_EXTENSIONS or ext == ".md":
|
||||
pass # handled below or by markdown section
|
||||
|
||||
if ext == ".md":
|
||||
post = parse_markdown_file(raw)
|
||||
|
||||
# Extract metadata using shared indexer logic
|
||||
tags = _extract_tags(post)
|
||||
|
||||
title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " "))
|
||||
html_content = _render_markdown(post.content, vault_name, file_path)
|
||||
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": str(title),
|
||||
"tags": tags,
|
||||
"frontmatter": dict(post.metadata) if post.metadata else {},
|
||||
"html": html_content,
|
||||
"raw_length": len(raw),
|
||||
"extension": ext,
|
||||
"is_markdown": True,
|
||||
}
|
||||
else:
|
||||
# Non-markdown: wrap in syntax-highlighted code block
|
||||
lang = EXT_TO_LANG.get(ext, "")
|
||||
if not lang:
|
||||
# Fichiers sans extension usuels (Dockerfile, Makefile, etc.)
|
||||
NAME_TO_LANG = {
|
||||
"dockerfile": "dockerfile", "makefile": "makefile",
|
||||
"cmakelists.txt": "cmake", "jenkinsfile": "groovy",
|
||||
"vagrantfile": "ruby", "rakefile": "ruby", "gemfile": "ruby",
|
||||
"procfile": "plaintext", "bashrc": "bash", "bash_profile": "bash",
|
||||
"zshrc": "bash", "profile": "bash", "gitignore": "plaintext",
|
||||
}
|
||||
lang = NAME_TO_LANG.get(file_path.name.lower(), "plaintext")
|
||||
escaped = html_mod.escape(raw)
|
||||
html_content = f'<pre><code class="language-{lang}">{escaped}</code></pre>'
|
||||
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": html_content,
|
||||
"raw_length": len(raw),
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
"""File & directory mutation endpoints (ROADMAP #85, tranche 6b).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``PUT/DELETE/PATCH/POST /api/file/*``,
|
||||
``/api/directory/*``, ``/api/move/*``, ``/api/vault/*/batch-upload``),
|
||||
mêmes modèles de requête/réponse (déménagés dans :mod:`backend.schemas`),
|
||||
mêmes dépendances d'authentification et mêmes effets de bord (audit, index
|
||||
incrémental, SSE, webhooks, plugins, historique).
|
||||
|
||||
La logique métier vit déjà dans :mod:`backend.services.mutations`.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
|
||||
from backend.audit import log_file_delete, log_file_save
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.history import (
|
||||
remove_recent,
|
||||
update_bookmarks_after_rename,
|
||||
update_history_after_rename,
|
||||
)
|
||||
from backend.indexer import handle_file_move, remove_single_file, update_single_file
|
||||
from backend.schemas import (
|
||||
BatchUploadRequest,
|
||||
BatchUploadResponse,
|
||||
DirectoryCreateRequest,
|
||||
DirectoryCreateResponse,
|
||||
DirectoryDeleteResponse,
|
||||
DirectoryRenameRequest,
|
||||
DirectoryRenameResponse,
|
||||
FileCreateRequest,
|
||||
FileCreateResponse,
|
||||
FileDeleteResponse,
|
||||
FileMoveRequest,
|
||||
FileMoveResponse,
|
||||
FileRenameRequest,
|
||||
FileRenameResponse,
|
||||
FileSaveResponse,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
batch_upload_files as service_batch_upload_files,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
create_directory as service_create_directory,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
create_file as service_create_file,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
delete_directory as service_delete_directory,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
delete_file as service_delete_file,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
edit_file as service_edit_file,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
edit_xlsx_cells as service_edit_xlsx_cells,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
move_path as service_move_path,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
rename_directory as service_rename_directory,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
rename_file as service_rename_file,
|
||||
)
|
||||
from backend.share import update_shares_after_rename
|
||||
from backend.sse import sse_manager
|
||||
from backend.webhooks import dispatch_webhooks
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
router = APIRouter(tags=["files"])
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/save", response_model=FileSaveResponse)
|
||||
async def api_file_save(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
body: dict = Body(...),
|
||||
backup: bool = Query(True, description="Create a backup before saving (default true, set false for auto-save)"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Save (overwrite) a file's content.
|
||||
|
||||
Expects a JSON body with a ``content`` key containing the new text.
|
||||
The path is validated against traversal attacks before writing.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
body: JSON body with ``content`` string.
|
||||
|
||||
Returns:
|
||||
``FileSaveResponse`` confirming the write.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
content = body.get("content", "")
|
||||
result = service_edit_file(vault_name, path, content, backup=backup)
|
||||
|
||||
# Audit log
|
||||
client_ip = current_user.get("_request_ip", "unknown")
|
||||
log_file_save(current_user["username"], vault_name, path, len(content), client_ip)
|
||||
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/xlsx/save", response_model=FileSaveResponse)
|
||||
async 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}}'),
|
||||
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.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
sheet = body.get("sheet")
|
||||
cells = body.get("cells")
|
||||
if not isinstance(sheet, str) or not sheet:
|
||||
raise HTTPException(status_code=400, detail="Feuille manquante")
|
||||
if not isinstance(cells, dict) or not cells or len(cells) > 500:
|
||||
raise HTTPException(status_code=400, detail="Cellules invalides (1 à 500 par requête)")
|
||||
for ref, value in cells.items():
|
||||
if not isinstance(ref, str) or not isinstance(value, (str, int, float, bool, type(None))):
|
||||
raise HTTPException(status_code=400, detail=f"Cellule invalide: {ref!r}")
|
||||
|
||||
result = service_edit_xlsx_cells(vault_name, path, sheet, cells)
|
||||
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"]}
|
||||
|
||||
|
||||
@router.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
|
||||
async def api_file_delete(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||
"""Delete a file from the vault.
|
||||
|
||||
The path is validated against traversal attacks before deletion.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative file path within the vault.
|
||||
|
||||
Returns:
|
||||
``FileDeleteResponse`` confirming the deletion.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_delete_file(vault_name, path)
|
||||
|
||||
# Audit log
|
||||
client_ip = current_user.get("_request_ip", "unknown")
|
||||
log_file_delete(current_user["username"], vault_name, path, client_ip)
|
||||
|
||||
# Update index
|
||||
await remove_single_file(vault_name, path)
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("file_deleted", {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
})
|
||||
|
||||
from backend.plugins import emit_file_deleted
|
||||
emit_file_deleted(vault_name, path)
|
||||
|
||||
# Remove from recent files
|
||||
remove_recent(current_user["username"], vault_name, path)
|
||||
|
||||
# Dispatch webhooks
|
||||
await dispatch_webhooks("file_deleted", {"vault": vault_name, "path": path})
|
||||
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"]}
|
||||
|
||||
|
||||
@router.post("/api/directory/{vault_name}", response_model=DirectoryCreateResponse)
|
||||
async def api_directory_create(
|
||||
vault_name: str,
|
||||
body: DirectoryCreateRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Create a new directory in a vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
body: Request body with directory path.
|
||||
|
||||
Returns:
|
||||
DirectoryCreateResponse confirming creation.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_create_directory(vault_name, body.path)
|
||||
|
||||
# Update path_index with the new directory
|
||||
from backend.indexer import _index_lock
|
||||
from backend.indexer import path_index as _path_idx
|
||||
with _index_lock:
|
||||
if vault_name not in _path_idx:
|
||||
_path_idx[vault_name] = []
|
||||
existing = {p["path"] for p in _path_idx[vault_name]}
|
||||
# Build all parent segments
|
||||
parts = body.path.split("/")
|
||||
for i in range(1, len(parts) + 1):
|
||||
seg_path = "/".join(parts[:i])
|
||||
if seg_path and seg_path not in existing:
|
||||
existing.add(seg_path)
|
||||
_path_idx[vault_name].append({
|
||||
"path": seg_path,
|
||||
"name": parts[i - 1],
|
||||
"type": "directory",
|
||||
})
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("directory_created", {
|
||||
"vault": vault_name,
|
||||
"path": result["path"],
|
||||
})
|
||||
await dispatch_webhooks("directory_created", {"vault": vault_name, "path": result["path"]})
|
||||
|
||||
return {"success": True, "path": result["path"]}
|
||||
|
||||
|
||||
@router.patch("/api/directory/{vault_name}", response_model=DirectoryRenameResponse)
|
||||
async def api_directory_rename(
|
||||
vault_name: str,
|
||||
body: DirectoryRenameRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Rename a directory in a vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
body: Request body with current path and new name.
|
||||
|
||||
Returns:
|
||||
DirectoryRenameResponse with old and new paths.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_rename_directory(vault_name, body.path, body.new_name)
|
||||
old_path_str = result["old_path"]
|
||||
new_path_str = result["new_path"]
|
||||
|
||||
# Update index for all files in the directory
|
||||
from backend.indexer import reload_single_vault
|
||||
await reload_single_vault(vault_name)
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("directory_renamed", {
|
||||
"vault": vault_name,
|
||||
"old_path": old_path_str,
|
||||
"new_path": new_path_str,
|
||||
})
|
||||
await dispatch_webhooks("directory_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
|
||||
|
||||
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
|
||||
|
||||
|
||||
@router.delete("/api/directory/{vault_name}", response_model=DirectoryDeleteResponse)
|
||||
async def api_directory_delete(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to directory"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Delete a directory and all its contents from a vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative directory path within the vault.
|
||||
|
||||
Returns:
|
||||
DirectoryDeleteResponse with count of deleted files.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_delete_directory(vault_name, path, recursive=True)
|
||||
file_count = result["deleted_count"]
|
||||
|
||||
# Update index
|
||||
from backend.indexer import reload_single_vault
|
||||
await reload_single_vault(vault_name)
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("directory_deleted", {
|
||||
"vault": vault_name,
|
||||
"path": result["path"],
|
||||
"deleted_count": file_count,
|
||||
})
|
||||
await dispatch_webhooks("directory_deleted", {"vault": vault_name, "path": result["path"]})
|
||||
|
||||
return {"success": True, "deleted_count": file_count}
|
||||
|
||||
|
||||
@router.post("/api/file/{vault_name}", response_model=FileCreateResponse)
|
||||
async def api_file_create(
|
||||
vault_name: str,
|
||||
body: FileCreateRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Create a new file in a vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
body: Request body with file path and initial content.
|
||||
|
||||
Returns:
|
||||
FileCreateResponse confirming creation.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_create_file(vault_name, body.path, body.content)
|
||||
|
||||
# Update index
|
||||
await update_single_file(vault_name, result["path"])
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("file_created", {
|
||||
"vault": vault_name,
|
||||
"path": result["path"],
|
||||
})
|
||||
await dispatch_webhooks("file_created", {"vault": vault_name, "path": result["path"]})
|
||||
from backend.plugins import emit_file_created
|
||||
emit_file_created(vault_name, result["path"])
|
||||
|
||||
return {"success": True, "path": result["path"]}
|
||||
|
||||
|
||||
@router.post("/api/vault/{vault_name}/batch-upload", response_model=BatchUploadResponse)
|
||||
async def api_batch_upload(
|
||||
vault_name: str,
|
||||
body: BatchUploadRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Upload multiple files and directories (recursively) into a vault.
|
||||
|
||||
Accepts base64 encoded or plain text files with relative directory paths.
|
||||
Creates missing parent folders safely.
|
||||
|
||||
Args:
|
||||
vault_name: Target vault name.
|
||||
body: BatchUploadRequest with target_dir and files list.
|
||||
|
||||
Returns:
|
||||
BatchUploadResponse with summary of uploaded files and errors.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
import base64
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for f in body.files:
|
||||
if f.is_dir:
|
||||
items.append({"path": f.path, "is_dir": True})
|
||||
continue
|
||||
|
||||
raw_bytes = b""
|
||||
if f.content is not None:
|
||||
# Check if content is base64 encoded data URI or raw base64
|
||||
content_str = f.content
|
||||
if content_str.startswith("data:") and ";base64," in content_str:
|
||||
content_str = content_str.split(";base64,", 1)[1]
|
||||
try:
|
||||
raw_bytes = base64.b64decode(content_str)
|
||||
except Exception:
|
||||
# Fallback to utf-8 text encoding
|
||||
raw_bytes = f.content.encode("utf-8")
|
||||
|
||||
items.append({"path": f.path, "content": raw_bytes, "is_dir": False})
|
||||
|
||||
result = service_batch_upload_files(
|
||||
vault_name,
|
||||
body.target_dir,
|
||||
items,
|
||||
overwrite=body.overwrite,
|
||||
)
|
||||
|
||||
# Update index and SSE notifications for uploaded files
|
||||
for path in result["uploaded"]:
|
||||
try:
|
||||
await update_single_file(vault_name, path)
|
||||
await sse_manager.broadcast("file_created", {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
})
|
||||
await dispatch_webhooks("file_created", {"vault": vault_name, "path": path})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to post-process upload of {path}: {e}")
|
||||
|
||||
# SSE notification for tree refresh
|
||||
if result["uploaded"] or result["created_dirs"]:
|
||||
await sse_manager.broadcast("tree_updated", {
|
||||
"vault": vault_name,
|
||||
"target_dir": result["target_dir"],
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.patch("/api/file/{vault_name}", response_model=FileRenameResponse)
|
||||
async def api_file_rename(
|
||||
vault_name: str,
|
||||
body: FileRenameRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Rename a file in a vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
body: Request body with current path and new name.
|
||||
|
||||
Returns:
|
||||
FileRenameResponse with old and new paths.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_rename_file(vault_name, body.path, body.new_name)
|
||||
old_path_str = result["old_path"]
|
||||
new_path_str = result["new_path"]
|
||||
|
||||
# Update index
|
||||
await handle_file_move(vault_name, old_path_str, new_path_str)
|
||||
|
||||
# Update bookmarks, history, and shares
|
||||
update_bookmarks_after_rename(vault_name, old_path_str, new_path_str)
|
||||
update_history_after_rename(vault_name, old_path_str, new_path_str)
|
||||
update_shares_after_rename(vault_name, old_path_str, new_path_str)
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("file_renamed", {
|
||||
"vault": vault_name,
|
||||
"old_path": old_path_str,
|
||||
"new_path": new_path_str,
|
||||
})
|
||||
await dispatch_webhooks("file_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
|
||||
|
||||
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
|
||||
|
||||
|
||||
@router.post("/api/move/{vault_name}", response_model=FileMoveResponse)
|
||||
async def api_file_move(
|
||||
vault_name: str,
|
||||
body: FileMoveRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Move a file or directory to a different parent directory within the same vault.
|
||||
|
||||
Supports both files and directories. The item keeps its original name;
|
||||
only the parent directory changes.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
body: Request body with source_path and destination_dir.
|
||||
|
||||
Returns:
|
||||
FileMoveResponse with old and new paths.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_move_path(vault_name, body.source_path, body.destination_dir)
|
||||
old_path_str = result["old_path"]
|
||||
new_path_str = result["new_path"]
|
||||
item_type = result["item_type"]
|
||||
|
||||
# Update index
|
||||
if item_type == "directory":
|
||||
from backend.indexer import reload_single_vault
|
||||
await reload_single_vault(vault_name)
|
||||
else:
|
||||
await handle_file_move(vault_name, old_path_str, new_path_str)
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("item_moved", {
|
||||
"vault": vault_name,
|
||||
"old_path": old_path_str,
|
||||
"new_path": new_path_str,
|
||||
"item_type": item_type,
|
||||
})
|
||||
await dispatch_webhooks("item_moved", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type})
|
||||
|
||||
return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}
|
||||
@@ -0,0 +1,143 @@
|
||||
"""System health endpoints (ROADMAP #85, tranche 1).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/health``, ``/api/health/detailed``),
|
||||
même ``response_model`` (:class:`backend.schemas.HealthResponse`), même
|
||||
dépendance admin. Seule différence : la version est lue via
|
||||
:func:`backend.version.get_version` au lieu de ``app.version`` (valeur
|
||||
identique, figée au démarrage depuis le fichier ``VERSION``).
|
||||
|
||||
Note : ``uptime_seconds`` reprend l'expression d'origine
|
||||
(``'_SERVER_START_TIME' in globals()``), qui vaut toujours 0 — le global
|
||||
n'est défini nulle part dans ``backend.main`` (voir ``backend.admin`` qui
|
||||
possède son propre compteur). Ce comportement est préservé tel quel ; le
|
||||
corriger fera l'objet d'une tranche ultérieure avec test dédié.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from backend.auth.middleware import require_admin
|
||||
from backend.indexer import index
|
||||
from backend.schemas import HealthResponse
|
||||
from backend.version import get_git_commit, get_git_describe, get_version
|
||||
|
||||
router = APIRouter(tags=["System"])
|
||||
|
||||
|
||||
@router.get("/api/health", response_model=HealthResponse)
|
||||
async def api_health():
|
||||
"""Health check endpoint for Docker and monitoring.
|
||||
|
||||
Returns:
|
||||
Application status, version, vault count and total file count.
|
||||
"""
|
||||
total_files = sum(len(v["files"]) for v in index.values())
|
||||
total_tokens = sum(len(v.get("files", [])) * 1000 for v in index.values()) # rough approx
|
||||
import time
|
||||
|
||||
from backend.indexer import _last_full_index_ts
|
||||
# `_SERVER_START_TIME` n'existe dans aucun module (comportement d'origine
|
||||
# préservé : uptime toujours 0 — voir docstring du module).
|
||||
uptime = int(time.time() - _SERVER_START_TIME) if '_SERVER_START_TIME' in globals() else 0 # noqa: F821
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": get_version(),
|
||||
"vaults": len(index),
|
||||
"total_files": total_files,
|
||||
"total_tokens": total_tokens,
|
||||
"last_full_index_ts": _last_full_index_ts,
|
||||
"uptime_seconds": uptime,
|
||||
"git_describe": get_git_describe(),
|
||||
"git_commit": get_git_commit(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/health/detailed", response_model=HealthResponse)
|
||||
async def api_health_detailed(current_user=Depends(require_admin)):
|
||||
"""Detailed health check — admin only.
|
||||
|
||||
Returns enriched metrics including memory, disk, SSE connections, and backup stats.
|
||||
"""
|
||||
|
||||
import psutil
|
||||
|
||||
from backend.admin import _count_active_sessions, _get_disk_stats
|
||||
from backend.indexer import _last_full_index_ts, index
|
||||
|
||||
total_files = sum(len(v["files"]) for v in index.values())
|
||||
total_tokens = sum(len(v.get("files", [])) * 1000 for v in index.values())
|
||||
import time
|
||||
uptime = int(time.time() - _SERVER_START_TIME) if '_SERVER_START_TIME' in globals() else 0 # noqa: F821 — voir ci-dessus
|
||||
|
||||
# Memory
|
||||
vm = psutil.virtual_memory()
|
||||
mem_used_mb = round(vm.used / (1024 ** 2), 1)
|
||||
mem_total_mb = round(vm.total / (1024 ** 2), 1)
|
||||
mem_pct = round(vm.percent, 1)
|
||||
|
||||
# CPU
|
||||
cpu_pct = psutil.cpu_percent(interval=None)
|
||||
|
||||
# Disk
|
||||
disk_used_gb, disk_total_gb = _get_disk_stats()
|
||||
disk_free_gb = round(disk_total_gb - disk_used_gb, 2)
|
||||
disk_pct = round((disk_used_gb / disk_total_gb * 100) if disk_total_gb > 0 else 0, 1)
|
||||
|
||||
# SSE connections (approximation)
|
||||
active_sessions = _count_active_sessions()
|
||||
|
||||
# Backups
|
||||
from backend.admin import _scan_backups
|
||||
backup_rows = _scan_backups()
|
||||
total_backups = len(backup_rows)
|
||||
total_backup_size_mb = round(sum(r["size"] for r in backup_rows) / (1024 ** 2), 2)
|
||||
oldest_backup_age_days = 0.0
|
||||
if backup_rows:
|
||||
now_ts = int(time.time())
|
||||
oldest_ts = min(r["timestamp"] for r in backup_rows)
|
||||
oldest_backup_age_days = round((now_ts - oldest_ts) / 86400, 2)
|
||||
|
||||
# Index details
|
||||
index_detail = {}
|
||||
for name, data in index.items():
|
||||
index_detail[name] = {
|
||||
"file_count": len(data["files"]),
|
||||
"tag_count": len(data.get("tags", [])),
|
||||
"token_count_approx": len(data.get("files", [])) * 1000,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": get_version(),
|
||||
"vaults": len(index),
|
||||
"total_files": total_files,
|
||||
"total_tokens": total_tokens,
|
||||
"last_full_index_ts": _last_full_index_ts,
|
||||
"uptime_seconds": uptime,
|
||||
"git_describe": get_git_describe(),
|
||||
"git_commit": get_git_commit(),
|
||||
# Enriched fields
|
||||
"memory": {
|
||||
"used_mb": mem_used_mb,
|
||||
"total_mb": mem_total_mb,
|
||||
"percent": mem_pct,
|
||||
},
|
||||
"cpu": {
|
||||
"percent": cpu_pct,
|
||||
},
|
||||
"disk": {
|
||||
"used_gb": disk_used_gb,
|
||||
"total_gb": disk_total_gb,
|
||||
"free_gb": disk_free_gb,
|
||||
"percent": disk_pct,
|
||||
},
|
||||
"connections": {
|
||||
"active_sse": active_sessions,
|
||||
},
|
||||
"backups": {
|
||||
"total_count": total_backups,
|
||||
"total_size_mb": total_backup_size_mb,
|
||||
"oldest_age_days": oldest_backup_age_days,
|
||||
},
|
||||
"index": index_detail,
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Shared helpers for the file routers (ROADMAP #85, tranche 6a).
|
||||
|
||||
Petites fonctions pures extraites de :mod:`backend.main` sans changement
|
||||
de comportement. Regroupées ici car utilisées par plusieurs routers
|
||||
(``files_read`` aujourd'hui, ``files_media`` / mutations ensuite) :
|
||||
- :func:`content_disposition` — aussi utilisée par ``_stream_file_with_range``
|
||||
(resté dans ``main`` jusqu'à la tranche media).
|
||||
- :func:`media_max_inline_bytes` — aussi utilisée par ``/api/media``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
|
||||
def content_disposition(disposition: str, filename: str) -> str:
|
||||
"""Build a header-safe Content-Disposition value.
|
||||
|
||||
HTTP header values must be ASCII. Unicode filenames are sent per
|
||||
RFC 5987 via ``filename*`` (percent-encoded UTF-8) with a pure-ASCII
|
||||
``filename`` fallback. This avoids a UnicodeDecodeError / HTTP 500 when
|
||||
the filename contains accented characters (e.g. 'Bière blonde…pdf').
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
ascii_name = "".join(c for c in filename if c.isascii() and (c.isalnum() or c in " _-.")).strip() or "file"
|
||||
ext = Path(filename).suffix
|
||||
if ext and not Path(ascii_name).suffix:
|
||||
ascii_name = ascii_name + ext
|
||||
return f"{disposition}; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(filename)}"
|
||||
|
||||
|
||||
def media_max_inline_bytes() -> int:
|
||||
"""Maximum size (bytes) for inline audio/video playback (roadmap #109-A3).
|
||||
|
||||
Configurable via ``OBSIGATE_MEDIA_MAX_INLINE_MB`` (default 500 MB). Files
|
||||
above the limit are not streamed in the viewer (the UI falls back to the
|
||||
download button), which keeps a single uvicorn worker from being pinned by
|
||||
multi-gigabyte media. Invalid or non-positive values fall back to default.
|
||||
"""
|
||||
default_mb = 500
|
||||
raw = os.environ.get("OBSIGATE_MEDIA_MAX_INLINE_MB", "").strip()
|
||||
if not raw:
|
||||
return default_mb * 1024 * 1024
|
||||
try:
|
||||
mb = float(raw)
|
||||
except ValueError:
|
||||
return default_mb * 1024 * 1024
|
||||
if mb <= 0:
|
||||
return default_mb * 1024 * 1024
|
||||
return int(mb * 1024 * 1024)
|
||||
|
||||
|
||||
def stream_file_with_range(file_path: Path, request: Request, media_type: str):
|
||||
"""Return a file response honouring the HTTP ``Range`` header (roadmap #109).
|
||||
|
||||
Extrait de :mod:`backend.main` (``_stream_file_with_range``) sans
|
||||
changement de comportement. Shared by ``pdf/stream`` and ``/api/media``:
|
||||
a plain :class:`FileResponse` with ``Accept-Ranges: bytes`` when no range
|
||||
is requested, or a :class:`StreamingResponse` (206 Partial Content,
|
||||
64 KiB chunks) for a valid single range. An unsatisfiable range yields
|
||||
``416`` with a ``Content-Range: bytes */size`` header.
|
||||
|
||||
Reads are offloaded to threads so the event loop is never blocked
|
||||
(ASYNC230), matching the previous inline implementation.
|
||||
"""
|
||||
file_size = file_path.stat().st_size
|
||||
range_header = request.headers.get("range")
|
||||
disposition = content_disposition("inline", file_path.name)
|
||||
|
||||
if range_header:
|
||||
# Parse "bytes=start-end" (single range only; multi-range is not used by viewers)
|
||||
m = re.match(r"bytes=(\d*)-(\d*)", range_header)
|
||||
if not m:
|
||||
raise HTTPException(status_code=416,
|
||||
headers={"Content-Range": f"bytes */{file_size}"})
|
||||
start_s, end_s = m.group(1), m.group(2)
|
||||
if start_s == "" and end_s == "":
|
||||
raise HTTPException(status_code=416,
|
||||
headers={"Content-Range": f"bytes */{file_size}"})
|
||||
if start_s == "":
|
||||
# suffix range: last N bytes
|
||||
length = min(int(end_s), file_size)
|
||||
start = file_size - length
|
||||
end = file_size - 1
|
||||
else:
|
||||
start = int(start_s)
|
||||
end = int(end_s) if end_s else file_size - 1
|
||||
end = min(end, file_size - 1)
|
||||
if start > end or start >= file_size:
|
||||
raise HTTPException(status_code=416,
|
||||
headers={"Content-Range": f"bytes */{file_size}"})
|
||||
|
||||
chunk_size = end - start + 1
|
||||
|
||||
async def _partial():
|
||||
f = await asyncio.to_thread(open, str(file_path), "rb")
|
||||
try:
|
||||
await asyncio.to_thread(f.seek, start)
|
||||
remaining = chunk_size
|
||||
while remaining > 0:
|
||||
data = await asyncio.to_thread(f.read, min(64 * 1024, remaining))
|
||||
if not data:
|
||||
break
|
||||
remaining -= len(data)
|
||||
yield data
|
||||
finally:
|
||||
await asyncio.to_thread(f.close)
|
||||
|
||||
return StreamingResponse(
|
||||
_partial(),
|
||||
status_code=206,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(chunk_size),
|
||||
"Content-Disposition": disposition,
|
||||
},
|
||||
)
|
||||
|
||||
return FileResponse(str(file_path), media_type=media_type, headers={
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Disposition": disposition})
|
||||
@@ -0,0 +1,160 @@
|
||||
"""History endpoints — recent, bookmarks, saved searches (ROADMAP #85, tranche 8).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins, mêmes modèles (``BookmarkToggleRequest``
|
||||
déménagé dans :mod:`backend.schemas`), mêmes dépendances
|
||||
d'authentification.
|
||||
|
||||
Adaptations strictement équivalentes :
|
||||
- ``_resolve_safe_path`` / ``_backup_file`` → :mod:`backend.services.paths`
|
||||
et :mod:`backend.services.backups` (pass-through).
|
||||
- ``_load_config`` vient de :mod:`backend.routers.config`.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import frontmatter
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.history import get_bookmarks, toggle_bookmark
|
||||
from backend.indexer import find_file_in_index, get_vault_data, update_single_file
|
||||
from backend.routers.config import _load_config
|
||||
from backend.saved_searches import delete_saved, get_saved, save_search
|
||||
from backend.schemas import (
|
||||
BookmarksResponse,
|
||||
BookmarkToggleRequest,
|
||||
BookmarkToggleResponse,
|
||||
RecentResponse,
|
||||
SavedSearch,
|
||||
StatusResponse,
|
||||
)
|
||||
from backend.services.backups import create_backup
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.recent import humanize_mtime, list_recent
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
router = APIRouter(tags=["Bookmarks"])
|
||||
|
||||
|
||||
@router.get("/api/recent", response_model=RecentResponse)
|
||||
async def api_recent(limit: int | None = Query(None), vault: str | None = Query(None), mode: str | None = Query("opened"), current_user=Depends(require_auth)):
|
||||
config = _load_config()
|
||||
actual_limit = limit if limit is not None else config.get("recent_files_limit", 20)
|
||||
|
||||
username = current_user.get("username")
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
|
||||
return list_recent(
|
||||
username,
|
||||
user_vaults,
|
||||
vault=vault,
|
||||
limit=actual_limit,
|
||||
mode=mode or "opened",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/bookmarks", response_model=BookmarksResponse)
|
||||
async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
|
||||
if not username:
|
||||
return {"files": []}
|
||||
|
||||
history = get_bookmarks(username, vault_filter=vault)
|
||||
files_resp = []
|
||||
for item in history:
|
||||
v_name = item["vault"]
|
||||
if "*" not in user_vaults and v_name not in user_vaults:
|
||||
continue
|
||||
|
||||
# Find in index to get metadata
|
||||
f_idx = find_file_in_index(item["path"], v_name)
|
||||
if f_idx:
|
||||
files_resp.append({
|
||||
"path": f_idx["path"],
|
||||
"title": f_idx.get("title") or item["path"].split("/")[-1],
|
||||
"vault": v_name,
|
||||
"mtime": item["bookmarked_at"],
|
||||
"mtime_human": humanize_mtime(item["bookmarked_at"]),
|
||||
"size_bytes": f_idx.get("size", 0),
|
||||
"tags": [f"#{t}" for t in f_idx.get("tags", [])][:5],
|
||||
"bookmarked": True
|
||||
})
|
||||
else:
|
||||
files_resp.append({
|
||||
"path": item["path"],
|
||||
"title": item.get("title") or item["path"].split("/")[-1],
|
||||
"vault": v_name,
|
||||
"mtime": item["bookmarked_at"],
|
||||
"mtime_human": humanize_mtime(item["bookmarked_at"]),
|
||||
"tags": [],
|
||||
"bookmarked": True
|
||||
})
|
||||
return {
|
||||
"files": files_resp,
|
||||
"total": len(files_resp)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/bookmarks/toggle", response_model=BookmarkToggleResponse)
|
||||
async def api_toggle_bookmark(req: BookmarkToggleRequest, current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
if not username:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
# Check vault access
|
||||
if not check_vault_access(req.vault, current_user):
|
||||
raise HTTPException(status_code=403, detail="Access denied to vault")
|
||||
|
||||
is_now_bookmarked = toggle_bookmark(username, req.vault, req.path, req.title or "")
|
||||
|
||||
# Update the file's YAML frontmatter: favoris: true/false
|
||||
vault_data = get_vault_data(req.vault)
|
||||
if vault_data:
|
||||
file_path = resolve_safe_path(Path(vault_data["path"]), req.path)
|
||||
if file_path.exists() and file_path.suffix == ".md":
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
post = frontmatter.loads(raw)
|
||||
if is_now_bookmarked:
|
||||
post.metadata["favoris"] = True
|
||||
elif "favoris" in post.metadata:
|
||||
del post.metadata["favoris"]
|
||||
new_raw = frontmatter.dumps(post)
|
||||
create_backup(file_path, req.vault, req.path)
|
||||
file_path.write_text(new_raw, encoding="utf-8")
|
||||
await update_single_file(req.vault, str(file_path))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update favoris metadata on {req.vault}/{req.path}: {e}")
|
||||
|
||||
return {"bookmarked": is_now_bookmarked}
|
||||
|
||||
|
||||
@router.get("/api/saved-searches", response_model=list[SavedSearch])
|
||||
async def api_saved_searches(current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
if not username:
|
||||
raise HTTPException(401)
|
||||
return get_saved(username)
|
||||
|
||||
|
||||
@router.post("/api/saved-searches", response_model=SavedSearch)
|
||||
async def api_save_search(body: dict = Body(...), current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
if not username:
|
||||
raise HTTPException(401)
|
||||
return save_search(username, body)
|
||||
|
||||
|
||||
@router.delete("/api/saved-searches/{search_id}", response_model=StatusResponse)
|
||||
async def api_delete_saved_search(search_id: str, current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
if not username:
|
||||
raise HTTPException(401)
|
||||
if not delete_saved(username, search_id):
|
||||
raise HTTPException(404, "Not found")
|
||||
return {"status": "deleted"}
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Real-time endpoints — SSE stream & collaboration WebSocket (ROADMAP #85, tranche 9).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/events``,
|
||||
``/ws/collab/{vault}/{path}``), même authentification (Depend pour le SSE,
|
||||
manuelle pour le WebSocket — les ``Depends`` FastAPI ne s'exécutent pas sur
|
||||
les routes WebSocket).
|
||||
|
||||
Pas de tags déclarés : assignation par chemin via
|
||||
``openapi_docs.tag_for_path`` comme avant (``/api/events`` → System).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json as _json
|
||||
|
||||
from fastapi import APIRouter, Depends, WebSocket
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.collab import authenticate_websocket, collab_manager
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import get_vault_root
|
||||
from backend.sse import sse_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/events",
|
||||
response_class=StreamingResponse,
|
||||
responses={200: {"content": {"text/event-stream": {}}, "description": "Server-Sent Events stream"}},
|
||||
)
|
||||
async def api_events(current_user=Depends(require_auth)):
|
||||
"""SSE stream for real-time index update notifications.
|
||||
|
||||
Sends keepalive comments every 30s. Events:
|
||||
- ``index_updated``: partial index change (file create/modify/delete/move)
|
||||
- ``index_reloaded``: full re-index completed
|
||||
- ``vault_added``: new vault added dynamically
|
||||
- ``vault_removed``: vault removed dynamically
|
||||
"""
|
||||
queue = await sse_manager.connect()
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
# Send initial connection event
|
||||
yield f"event: connected\ndata: {_json.dumps({'sse_clients': sse_manager.client_count})}\n\n"
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||
yield f"event: {msg['event']}\ndata: {msg['data']}\n\n"
|
||||
except asyncio.TimeoutError:
|
||||
# Keepalive comment
|
||||
yield ": keepalive\n\n"
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
finally:
|
||||
sse_manager.disconnect(queue)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.websocket("/ws/collab/{vault_name}/{path:path}")
|
||||
async def collab_websocket(websocket: WebSocket, vault_name: str, path: str):
|
||||
"""Real-time collaborative editing over WebSocket (ROADMAP #62).
|
||||
|
||||
One *room* is created per ``vault::path``; all clients editing the same
|
||||
file share Yjs/CRDT updates, awareness (cursors/selection) and a debounced
|
||||
server-side persistence of the markdown content.
|
||||
|
||||
Authentication is performed manually (FastAPI ``Depends`` do not run for
|
||||
WebSocket routes) and vault access is enforced per connection.
|
||||
"""
|
||||
from backend.services.errors import ServiceError
|
||||
|
||||
user = authenticate_websocket(websocket)
|
||||
if user is None:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
|
||||
if not check_vault_access(vault_name, user):
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
try:
|
||||
vault_root = get_vault_root(vault_name)
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
except ServiceError:
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
await collab_manager.connect(websocket, vault_name, path, file_path, user)
|
||||
@@ -0,0 +1,353 @@
|
||||
"""Search, suggest, graph & index-reload endpoints (ROADMAP #85, tranche 5).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins, mêmes modèles de réponse (déménagés dans
|
||||
:mod:`backend.schemas`), mêmes dépendances d'authentification. La logique
|
||||
métier vit déjà dans :mod:`backend.services.search`,
|
||||
:mod:`backend.search`, :mod:`backend.services.graph` et
|
||||
:mod:`backend.services.mutations`.
|
||||
|
||||
Adaptations strictement équivalentes :
|
||||
- Le pool ``_search_executor`` de ``main`` vit désormais dans
|
||||
:mod:`backend.search_executor` (même dimensionnement, même cycle de vie
|
||||
géré par le lifespan de ``main``) : accès via
|
||||
:func:`get_search_executor`.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
|
||||
from backend.audit import log_file_save
|
||||
from backend.auth.middleware import check_vault_access, require_admin, require_auth
|
||||
from backend.indexer import get_vault_data, reload_index, update_single_file
|
||||
from backend.schemas import (
|
||||
AdvancedSearchResponse,
|
||||
GraphResponse,
|
||||
ReloadResponse,
|
||||
ReplaceResponse,
|
||||
SearchResponse,
|
||||
SuggestResponse,
|
||||
TagsResponse,
|
||||
TagSuggestResponse,
|
||||
TreeSearchResponse,
|
||||
VaultPathsResponse,
|
||||
VaultStatsResponse,
|
||||
)
|
||||
from backend.search import suggest_tags, suggest_titles
|
||||
from backend.search_executor import get_search_executor
|
||||
from backend.services.graph import get_graph as service_get_graph
|
||||
from backend.services.mutations import (
|
||||
replace_in_files as service_replace_in_files,
|
||||
)
|
||||
from backend.services.search import advanced_search_vaults, list_paths, search_paths, search_vaults
|
||||
from backend.services.search import list_tags as service_list_tags
|
||||
from backend.sse import sse_manager
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
router = APIRouter(tags=["search"])
|
||||
|
||||
|
||||
@router.get("/api/search", response_model=SearchResponse)
|
||||
async def api_search(
|
||||
q: str = Query("", description="Search query"),
|
||||
vault: str = Query("all", description="Vault filter"),
|
||||
tag: str | None = Query(None, description="Tag filter"),
|
||||
limit: int = Query(50, ge=1, le=200, description="Results per page"),
|
||||
offset: int = Query(0, ge=0, description="Pagination offset"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Full-text search across vaults with relevance scoring.
|
||||
|
||||
Supports combining free-text queries with tag filters.
|
||||
Results are ranked by a multi-factor scoring algorithm.
|
||||
Pagination via ``limit`` and ``offset`` (defaults preserve backward compat).
|
||||
|
||||
Args:
|
||||
q: Free-text search string.
|
||||
vault: Vault name or ``"all"`` to search everywhere.
|
||||
tag: Comma-separated tag names to require.
|
||||
limit: Max results per page (1–200).
|
||||
offset: Pagination offset.
|
||||
|
||||
Returns:
|
||||
``SearchResponse`` with ranked results and snippets.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
# Fetch the full result set (capped at DEFAULT_SEARCH_LIMIT internally) and
|
||||
# paginate in the shared service so routes and tools share the same logic.
|
||||
return await loop.run_in_executor(
|
||||
get_search_executor(),
|
||||
partial(search_vaults, q, vault, tag, limit, offset),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/tags", response_model=TagsResponse)
|
||||
async def api_tags(vault: str | None = Query(None, description="Vault filter"), current_user=Depends(require_auth)):
|
||||
"""Return all unique tags with occurrence counts.
|
||||
|
||||
Args:
|
||||
vault: Optional vault name to restrict tag aggregation.
|
||||
|
||||
Returns:
|
||||
``TagsResponse`` with tags sorted by descending count.
|
||||
"""
|
||||
return {"vault_filter": vault, "tags": service_list_tags(vault)}
|
||||
|
||||
|
||||
@router.get("/api/tree-search", response_model=TreeSearchResponse)
|
||||
async def api_tree_search(
|
||||
q: str = Query("", description="Search query"),
|
||||
vault: str = Query("all", description="Vault filter"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Search for files and directories in the tree structure using pre-built index.
|
||||
|
||||
Uses the in-memory path index for instant filtering without filesystem access.
|
||||
|
||||
Args:
|
||||
q: Search string to match against file/directory paths.
|
||||
vault: Vault name or "all" to search everywhere.
|
||||
|
||||
Returns:
|
||||
``TreeSearchResponse`` with matching paths.
|
||||
"""
|
||||
return search_paths(q, vault)
|
||||
|
||||
|
||||
@router.get("/api/vault/{vault_name}/paths", response_model=VaultPathsResponse)
|
||||
async def api_vault_paths(
|
||||
vault_name: str,
|
||||
limit: int = Query(5000, ge=1, le=20000, description="Maximum number of indexed paths to return"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return a flat list of every indexed file and directory in a vault.
|
||||
|
||||
Used by the AI assistant ``@`` mention menu to filter paths instantly on
|
||||
the client (one request instead of one per keystroke).
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
limit: Maximum number of entries returned.
|
||||
|
||||
Returns:
|
||||
``VaultPathsResponse`` with the vault's indexed paths.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
return list_paths(vault_name, limit=limit)
|
||||
|
||||
|
||||
@router.get("/api/search/advanced", response_model=AdvancedSearchResponse)
|
||||
async def api_advanced_search(
|
||||
q: str = Query("", description="Advanced search query (supports tag:, vault:, title:, path:, ext: operators)"),
|
||||
vault: str = Query("all", description="Vault filter"),
|
||||
tag: str | None = Query(None, description="Comma-separated tag filter"),
|
||||
limit: int = Query(50, ge=1, le=200, description="Results per page"),
|
||||
offset: int = Query(0, ge=0, description="Pagination offset"),
|
||||
sort: str = Query("relevance", description="Sort by 'relevance' or 'modified'"),
|
||||
case_sensitive: bool = Query(False, description="Match case"),
|
||||
whole_word: bool = Query(False, description="Match whole words only"),
|
||||
regex: bool = Query(False, description="Treat query as regex"),
|
||||
include_paths: str | None = Query(None, description="Comma-separated glob patterns to include"),
|
||||
exclude_paths: str | None = Query(None, description="Comma-separated glob patterns to exclude"),
|
||||
created: str | None = Query(None, description="Created date filter (>date, <date, date..date)"),
|
||||
modified: str | None = Query(None, description="Modified date filter (>date, <date, date..date, <Nd)"),
|
||||
size: str | None = Query(None, description="Size filter (>size, <size, size..size, e.g. >1MB, <10KB)"),
|
||||
semantic: bool = Query(False, description="Fuse TF-IDF with semantic embeddings (RRF)"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Advanced full-text search with TF-IDF scoring, facets, and pagination.
|
||||
|
||||
Supports advanced query operators:
|
||||
- ``tag:<name>`` or ``#<name>`` — filter by tag
|
||||
- ``vault:<name>`` — filter by vault
|
||||
- ``title:<text>`` — filter by title substring
|
||||
- ``path:<text>`` — filter by path substring
|
||||
- ``ext:<type>`` — filter by file extension
|
||||
- ``created:>2024-01-01`` — filter by creation date
|
||||
- ``modified:<7d`` or ``modified:2024-01-01..2024-06-01`` — filter by modification date
|
||||
- ``size:>1MB`` or ``size:100KB..1MB`` — filter by file size
|
||||
- Remaining text is scored using TF-IDF with accent normalization.
|
||||
- Toggles: case_sensitive, whole_word, regex
|
||||
- Path filters: include_paths, exclude_paths (glob patterns)
|
||||
- ``semantic=true`` — fuse the TF-IDF ranking with the semantic (embedding)
|
||||
ranking via Reciprocal Rank Fusion and expose ``semantic_score`` per result.
|
||||
|
||||
Results include ``<mark>``-highlighted snippets and faceted tag/vault counts.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
search_fn = partial(advanced_search_vaults, q, vault=vault, tag=tag,
|
||||
limit=limit, offset=offset, sort=sort,
|
||||
case_sensitive=case_sensitive, whole_word=whole_word, regex=regex,
|
||||
include_paths=include_paths, exclude_paths=exclude_paths,
|
||||
created=created, modified=modified, size=size, semantic=semantic)
|
||||
try:
|
||||
return await loop.run_in_executor(get_search_executor(), search_fn)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/api/search/replace", response_model=ReplaceResponse)
|
||||
async def api_search_replace(
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Find and replace across vault files."""
|
||||
query = body.get("query", "")
|
||||
replacement = body.get("replacement", "")
|
||||
vault_filter = body.get("vault", "all")
|
||||
case_sensitive = body.get("case_sensitive", False)
|
||||
whole_word = body.get("whole_word", False)
|
||||
regex_mode = body.get("regex", False)
|
||||
include_paths = body.get("include_paths")
|
||||
exclude_paths = body.get("exclude_paths")
|
||||
replace_all = body.get("replace_all", False)
|
||||
dry_run = body.get("dry_run", not replace_all)
|
||||
|
||||
if not query:
|
||||
raise HTTPException(400, "Query is required")
|
||||
|
||||
result = service_replace_in_files(
|
||||
query,
|
||||
replacement,
|
||||
vault=vault_filter,
|
||||
case_sensitive=case_sensitive,
|
||||
whole_word=whole_word,
|
||||
regex=regex_mode,
|
||||
include_paths=include_paths,
|
||||
exclude_paths=exclude_paths,
|
||||
replace_all=replace_all,
|
||||
dry_run=dry_run,
|
||||
is_vault_allowed=lambda v: check_vault_access(v, current_user),
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
return result
|
||||
|
||||
# Side effects for applied replacements (audit + incremental index).
|
||||
for match in result.get("replaced", []):
|
||||
log_file_save(current_user["username"], match["vault"], match["path"], match.get("size", 0))
|
||||
vault_data = get_vault_data(match["vault"])
|
||||
if vault_data:
|
||||
abs_path = str(Path(vault_data["path"]) / match["path"])
|
||||
await update_single_file(match["vault"], abs_path)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/api/suggest", response_model=SuggestResponse)
|
||||
async def api_suggest(
|
||||
q: str = Query("", description="Prefix to search for in file titles"),
|
||||
vault: str = Query("all", description="Vault filter"),
|
||||
limit: int = Query(10, ge=1, le=50, description="Max suggestions"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Suggest file titles matching a prefix (accent-insensitive).
|
||||
|
||||
Used for autocomplete in the search input.
|
||||
|
||||
Args:
|
||||
q: User-typed prefix (minimum 2 characters).
|
||||
vault: Vault name or ``"all"``.
|
||||
limit: Max number of suggestions.
|
||||
|
||||
Returns:
|
||||
``SuggestResponse`` with matching file title suggestions.
|
||||
"""
|
||||
suggestions = suggest_titles(q, vault_filter=vault, limit=limit)
|
||||
return {"query": q, "suggestions": suggestions}
|
||||
|
||||
|
||||
@router.get("/api/tags/suggest", response_model=TagSuggestResponse)
|
||||
async def api_tags_suggest(
|
||||
q: str = Query("", description="Prefix to search for in tags"),
|
||||
vault: str = Query("all", description="Vault filter"),
|
||||
limit: int = Query(10, ge=1, le=50, description="Max suggestions"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Suggest tags matching a prefix (accent-insensitive).
|
||||
|
||||
Used for autocomplete when typing ``tag:`` or ``#`` in the search input.
|
||||
|
||||
Args:
|
||||
q: User-typed prefix (with or without ``#``, minimum 2 characters).
|
||||
vault: Vault name or ``"all"``.
|
||||
limit: Max number of suggestions.
|
||||
|
||||
Returns:
|
||||
``TagSuggestResponse`` with matching tag suggestions and counts.
|
||||
"""
|
||||
suggestions = suggest_tags(q, vault_filter=vault, limit=limit)
|
||||
return {"query": q, "suggestions": suggestions}
|
||||
|
||||
|
||||
@router.get("/api/index/reload", response_model=ReloadResponse)
|
||||
async def api_reload(current_user=Depends(require_admin)):
|
||||
"""Force a full re-index of all configured vaults.
|
||||
|
||||
Returns:
|
||||
``ReloadResponse`` with per-vault file and tag counts.
|
||||
"""
|
||||
stats = await reload_index()
|
||||
await sse_manager.broadcast("index_reloaded", {
|
||||
"vaults": list(stats.keys()),
|
||||
"stats": stats,
|
||||
})
|
||||
return {"status": "ok", "vaults": stats}
|
||||
|
||||
|
||||
@router.get("/api/graph/{vault_name}", response_model=GraphResponse)
|
||||
async def api_graph(
|
||||
vault_name: str,
|
||||
path: str = Query("", description="Relative path to focus on"),
|
||||
depth: int = Query(1, ge=0, le=3, description="How many levels deep to expand"),
|
||||
scope: str = Query("directory", description="'directory' (default) or 'full' for entire vault"),
|
||||
tag: str = Query("", description="Filter: only show files with this tag"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return graph data (nodes and edges) for a vault or directory.
|
||||
|
||||
Nodes represent files and directories. Edges represent parent-child
|
||||
relationships and wikilinks between markdown files.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative directory path to focus on (empty = root).
|
||||
depth: Expansion depth (0 = only direct children, 1-3 = deeper).
|
||||
scope: 'directory' for subtree, 'full' for entire vault.
|
||||
tag: Optional tag filter (only files with this tag appear).
|
||||
|
||||
Returns:
|
||||
``GraphResponse`` with nodes and edges.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
return service_get_graph(vault_name, path=path, depth=depth, scope=scope, tag=tag)
|
||||
|
||||
|
||||
@router.get("/api/index/reload/{vault_name}", response_model=VaultStatsResponse)
|
||||
async def api_reload_vault(vault_name: str, current_user=Depends(require_admin)):
|
||||
"""Force a re-index of a single vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault to reindex.
|
||||
|
||||
Returns:
|
||||
Dict with vault statistics.
|
||||
"""
|
||||
try:
|
||||
from backend.indexer import reload_single_vault
|
||||
stats = await reload_single_vault(vault_name)
|
||||
await sse_manager.broadcast("vault_reloaded", {
|
||||
"vault": vault_name,
|
||||
"stats": stats,
|
||||
})
|
||||
return {"status": "ok", "vault": vault_name, "stats": stats}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Public share endpoints (ROADMAP #85, tranche 3).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/share/*``, ``/api/shares``,
|
||||
``/s/{token}*``), mêmes modèles de réponse, mêmes dépendances
|
||||
d'authentification (les pages ``/s/*`` restent publiques). La logique
|
||||
métier vit déjà dans :mod:`backend.share`.
|
||||
|
||||
Adaptations strictement équivalentes (pas de changement de comportement) :
|
||||
- ``_resolve_safe_path`` / ``_backup_file`` de ``main`` n'étaient que des
|
||||
wrappers directs : appelés ici via :mod:`backend.services.paths` et
|
||||
:mod:`backend.services.backups` (mêmes signatures, mêmes exceptions
|
||||
``ServiceError`` toujours mappées par le handler global de ``main``).
|
||||
- ``_render_markdown`` vient de :mod:`backend.render` (#85 T9, sans cycle
|
||||
d'import).
|
||||
"""
|
||||
|
||||
import html as html_mod
|
||||
import json as _json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import frontmatter
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.indexer import get_vault_data, parse_markdown_file, update_single_file
|
||||
from backend.render import _render_markdown
|
||||
from backend.schemas import ShareModel, StatusResponse
|
||||
from backend.secret_redactor import redact_file_content
|
||||
from backend.services.backups import create_backup
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.share import (
|
||||
create_share,
|
||||
get_share_by_token,
|
||||
list_shares,
|
||||
record_access,
|
||||
revoke_share,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
# Lazy import: WeasyPrint PDF export (requires GTK, may not be available everywhere)
|
||||
try:
|
||||
from backend.pdf_export import build_pdf_html, generate_pdf
|
||||
except Exception: # pragma: no cover - WeasyPrint/GTK missing
|
||||
generate_pdf = None # type: ignore[assignment]
|
||||
build_pdf_html = None # type: ignore[assignment]
|
||||
|
||||
logging.getLogger("obsigate").warning("PDF export unavailable (WeasyPrint/GTK not found)")
|
||||
|
||||
router = APIRouter(tags=["sharing"])
|
||||
|
||||
|
||||
@router.post("/api/share/{vault_name}", response_model=ShareModel)
|
||||
async def api_share_create(
|
||||
vault_name: str,
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Create a public share link for a document.
|
||||
|
||||
Also sets ``publish: true`` in the file's YAML frontmatter so the
|
||||
frontend can visually indicate the file is publicly shared.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
|
||||
path = body.get("path", "")
|
||||
expires = body.get("expires_in_hours")
|
||||
share = create_share(vault_name, path, current_user["username"], expires)
|
||||
share["url"] = f"/s/{share['token']}"
|
||||
|
||||
# Set publish: true in the file's frontmatter
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if vault_data:
|
||||
file_path = resolve_safe_path(Path(vault_data["path"]), path)
|
||||
if file_path.exists() and file_path.suffix == ".md":
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
post = frontmatter.loads(raw)
|
||||
if not post.metadata.get("publish"):
|
||||
post.metadata["publish"] = True
|
||||
new_raw = frontmatter.dumps(post)
|
||||
create_backup(file_path, vault_name, path)
|
||||
file_path.write_text(new_raw, encoding="utf-8")
|
||||
await update_single_file(vault_name, str(file_path))
|
||||
logger.info(f"Set publish:true on {vault_name}/{path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to set publish metadata on {vault_name}/{path}: {e}")
|
||||
|
||||
return share
|
||||
|
||||
|
||||
@router.get("/api/shares", response_model=list[ShareModel])
|
||||
async def api_shares_list(vault: str | None = Query(None), current_user=Depends(require_auth)):
|
||||
"""List all shares (optionally filtered by vault)."""
|
||||
shares = list_shares(vault)
|
||||
for s in shares:
|
||||
s["url"] = f"/s/{s['token']}"
|
||||
return shares
|
||||
|
||||
|
||||
@router.delete("/api/share/{share_id}", response_model=StatusResponse)
|
||||
async def api_share_revoke(share_id: str, current_user=Depends(require_auth)):
|
||||
if not revoke_share(share_id):
|
||||
raise HTTPException(404, "Share not found")
|
||||
return {"status": "revoked"}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/s/{token}/pdf",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/pdf": {}}, "description": "Shared document as PDF"}},
|
||||
)
|
||||
async def public_share_pdf_download(token: str):
|
||||
"""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")
|
||||
vault_data = get_vault_data(share["vault"])
|
||||
if not vault_data:
|
||||
raise HTTPException(404, "Vault not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, share["path"])
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
raise HTTPException(500, "Cannot read file")
|
||||
record_access(token)
|
||||
raw = redact_file_content(raw, str(file_path))
|
||||
post = parse_markdown_file(raw)
|
||||
ext = file_path.suffix.lower()
|
||||
if ext == ".md":
|
||||
html = _render_markdown(post.content, share["vault"], file_path)
|
||||
else:
|
||||
html = f'<pre style="font-family:monospace;font-size:12px;line-height:1.6;white-space:pre-wrap">{html_mod.escape(raw)}</pre>'
|
||||
title = post.metadata.get("title", file_path.stem)
|
||||
pdf_html = build_pdf_html(html, str(title))
|
||||
pdf_bytes = generate_pdf(pdf_html, str(title))
|
||||
safe_name = "".join(c for c in str(title) if c.isascii() and (c.isalnum() or c in " _-.")).strip() or "document"
|
||||
return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{safe_name}.pdf"'})
|
||||
|
||||
|
||||
@router.get("/s/{token}/raw", response_class=FileResponse)
|
||||
async def public_share_raw(token: str):
|
||||
"""Download the raw (original) shared document."""
|
||||
share = get_share_by_token(token)
|
||||
if not share:
|
||||
raise HTTPException(404, "Share not found or expired")
|
||||
vault_data = get_vault_data(share["vault"])
|
||||
if not vault_data:
|
||||
raise HTTPException(404, "Vault not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, share["path"])
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
record_access(token)
|
||||
return FileResponse(path=str(file_path), filename=file_path.name, media_type="application/octet-stream")
|
||||
|
||||
|
||||
@router.get("/s/{token}", response_class=HTMLResponse)
|
||||
async def public_share_view(token: str):
|
||||
"""Public share view — no authentication required."""
|
||||
share = get_share_by_token(token)
|
||||
if not share:
|
||||
raise HTTPException(404, "Share not found or expired")
|
||||
vault_data = get_vault_data(share["vault"])
|
||||
if not vault_data:
|
||||
raise HTTPException(404, "Vault not found")
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, share["path"])
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
raise HTTPException(500, "Cannot read file")
|
||||
record_access(token)
|
||||
raw = redact_file_content(raw, str(file_path))
|
||||
post = parse_markdown_file(raw)
|
||||
ext = file_path.suffix.lower()
|
||||
|
||||
if ext == ".md":
|
||||
html = _render_markdown(post.content, share["vault"], file_path)
|
||||
else:
|
||||
escaped = html_mod.escape(raw)
|
||||
html = f'<pre style="background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:16px;overflow-x:auto;font-size:0.85rem;line-height:1.6"><code>{escaped}</code></pre>'
|
||||
|
||||
title = post.metadata.get("title", file_path.stem)
|
||||
|
||||
# Escape everything user-controlled before embedding in HTML/JS (BUG-022).
|
||||
title_esc = html_mod.escape(str(title))
|
||||
# Neutralise ``</script>`` in the JS string literal too.
|
||||
title_download_js = (
|
||||
_json.dumps(f"{title}.md")
|
||||
.replace("<", "\\u003c")
|
||||
.replace(">", "\\u003e")
|
||||
.replace("&", "\\u0026")
|
||||
)
|
||||
|
||||
# JSON-escape raw content for embedding in HTML, and neutralise ``</script>``.
|
||||
raw_json = (
|
||||
_json.dumps(raw)
|
||||
.replace("<", "\\u003c")
|
||||
.replace(">", "\\u003e")
|
||||
.replace("&", "\\u0026")
|
||||
)
|
||||
fm_html = ""
|
||||
if post.metadata:
|
||||
fm_items = []
|
||||
skip_keys = {"title", "titre"}
|
||||
for k, v in post.metadata.items():
|
||||
if k in skip_keys:
|
||||
continue
|
||||
if isinstance(v, list):
|
||||
v = ", ".join(str(x) for x in v)
|
||||
elif isinstance(v, bool):
|
||||
v = "✓" if v else "✗"
|
||||
elif v is None:
|
||||
v = "—"
|
||||
fm_items.append(
|
||||
f'<div class="fm-row"><span class="fm-key">{html_mod.escape(str(k))}</span>'
|
||||
f'<span class="fm-val">{html_mod.escape(str(v))}</span></div>'
|
||||
)
|
||||
if fm_items:
|
||||
fm_html = f'<div class="fm-section"><div class="fm-header">Frontmatter</div><div class="fm-body">{"".join(fm_items)}</div></div>'
|
||||
|
||||
return HTMLResponse(f"""<!DOCTYPE html><html lang="fr" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{title_esc} — ObsiGate Share</title>
|
||||
<style>
|
||||
:root {{ --bg:#1a1a2e; --bg-card:#16213e; --text:#e0e0e0; --text-muted:#888; --accent:#6366f1; --border:#2a2a4a; --banner-bg:var(--accent); --banner-text:#fff; }}
|
||||
[data-theme="light"] {{ --bg:#f8f9fa; --bg-card:#fff; --text:#1a1a2e; --text-muted:#666; --accent:#4f46e5; --border:#ddd; --banner-bg:#eef2ff; --banner-text:#4338ca; }}
|
||||
*{{box-sizing:border-box;margin:0;padding:0}}
|
||||
body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);line-height:1.7;min-height:100vh}}
|
||||
.toolbar{{position:sticky;top:0;z-index:10;background:var(--bg-card);border-bottom:1px solid var(--border);padding:8px 16px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}}
|
||||
.toolbar-title{{font-weight:600;font-size:0.9rem;margin-right:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
||||
.toolbar-btn{{padding:6px 12px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);cursor:pointer;font-size:0.8rem;display:flex;align-items:center;gap:5px;transition:all .15s}}
|
||||
.toolbar-btn:hover{{background:var(--accent);color:#fff;border-color:var(--accent)}}
|
||||
.toolbar-btn svg{{width:15px;height:15px;flex-shrink:0}}
|
||||
.toolbar-btn:hover svg{{stroke:#fff}}
|
||||
.share-banner{{background:var(--banner-bg);color:var(--banner-text);padding:6px 16px;font-size:0.8rem;text-align:center;display:flex;align-items:center;justify-content:center;gap:6px}}
|
||||
.share-banner svg{{width:14px;height:14px;flex-shrink:0}}
|
||||
.content{{max-width:820px;margin:0 auto;padding:24px 20px 60px}}
|
||||
.content h1{{font-size:1.8rem;margin-bottom:16px;border-bottom:2px solid var(--border);padding-bottom:8px}}
|
||||
.content h2{{font-size:1.4rem;margin:24px 0 12px}}
|
||||
.content h3{{font-size:1.15rem;margin:20px 0 8px}}
|
||||
.content p{{margin:8px 0}}
|
||||
.content pre{{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:12px 16px;overflow-x:auto;font-size:0.85rem}}
|
||||
.content code{{font-size:0.9em;background:var(--bg-card);padding:1px 4px;border-radius:3px}}
|
||||
.content pre code{{background:none;padding:0}}
|
||||
.content a{{color:var(--accent)}}.content img{{max-width:100%;border-radius:6px}}
|
||||
.fm-section{{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:20px}}
|
||||
.fm-header{{font-weight:600;font-size:0.8rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px}}
|
||||
.fm-body{{display:grid;grid-template-columns:1fr 2fr;gap:4px 12px;font-size:0.85rem}}
|
||||
.fm-row{{display:contents}}
|
||||
.fm-key{{color:var(--accent);font-weight:500}}
|
||||
.fm-val{{color:var(--text);word-break:break-word}}
|
||||
.content blockquote{{border-left:3px solid var(--accent);padding-left:16px;color:var(--text-muted);margin:12px 0}}
|
||||
.content table{{border-collapse:collapse;width:100%;margin:12px 0}}
|
||||
.content th,.content td{{border:1px solid var(--border);padding:8px 12px;text-align:left}}
|
||||
.content th{{background:var(--bg-card)}}
|
||||
@media print{{.toolbar,.share-banner{{display:none}}body{{background:#fff;color:#000}}}}
|
||||
@media(max-width:600px){{.content{{padding:16px 12px 40px}}.toolbar{{gap:4px}}.toolbar-btn{{padding:4px 8px;font-size:0.7rem}}}}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="share-banner">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
Document partagé via ObsiGate
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<span class="toolbar-title">{title_esc}</span>
|
||||
<button class="toolbar-btn" onclick="toggleTheme()" title="Thème clair/sombre">
|
||||
<svg id="theme-icon-dark" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
<svg id="theme-icon-light" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||
</button>
|
||||
<button class="toolbar-btn" onclick="exportMD()" title="Télécharger en Markdown">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
.md
|
||||
</button>
|
||||
<button class="toolbar-btn" onclick="location.href=location.pathname+'/pdf'" title="Télécharger en PDF">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
PDF
|
||||
</button>
|
||||
</div>
|
||||
<div class="content" id="content">{fm_html}{html}</div>
|
||||
<script id="raw-content" type="text/plain" style="display:none">{raw_json}</script>
|
||||
<script>
|
||||
function toggleTheme(){{var t=document.documentElement;var isDark=t.dataset.theme==="dark";t.dataset.theme=isDark?"light":"dark";document.getElementById("theme-icon-dark").style.display=isDark?"none":"";document.getElementById("theme-icon-light").style.display=isDark?"":"none";localStorage.setItem("obsigate-share-theme",t.dataset.theme)}}
|
||||
(function(){{var s=localStorage.getItem("obsigate-share-theme");if(!s)s="dark";document.documentElement.dataset.theme=s;var isDark=s==="dark";document.getElementById("theme-icon-dark").style.display=isDark?"":"none";document.getElementById("theme-icon-light").style.display=isDark?"none":""}})();
|
||||
function exportMD(){{var raw=JSON.parse(document.getElementById("raw-content").textContent);var b=new Blob([raw],{{type:"text/markdown"}});var a=document.createElement("a");a.href=URL.createObjectURL(b);a.download={title_download_js};a.click()}}
|
||||
</script></body></html>""")
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Vault management endpoints (ROADMAP #85, tranche 8).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/vaults*``), mêmes modèles de réponse
|
||||
(``VaultInfo`` déménagé dans :mod:`backend.schemas`), mêmes dépendances
|
||||
d'authentification.
|
||||
|
||||
Le handle du file-watcher vit désormais dans :mod:`backend.watcher_state`
|
||||
(partagé avec le lifespan de ``main``) au lieu du global de ``main``.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
|
||||
from backend.auth.middleware import require_admin, require_auth
|
||||
from backend.indexer import add_vault_to_index, index, remove_vault_from_index
|
||||
from backend.schemas import VaultActionResponse, VaultInfo, VaultsStatusResponse, VaultStatsResponse
|
||||
from backend.services.vaults import list_accessible_vaults
|
||||
from backend.sse import sse_manager
|
||||
from backend.watcher_state import get_watcher
|
||||
|
||||
router = APIRouter(tags=["vaults"])
|
||||
|
||||
|
||||
@router.get("/api/vaults", response_model=list[VaultInfo])
|
||||
async def api_vaults(current_user=Depends(require_auth)):
|
||||
"""List configured vaults the user has access to.
|
||||
|
||||
Returns:
|
||||
List of vault summary objects filtered by user permissions.
|
||||
"""
|
||||
return list_accessible_vaults(current_user)
|
||||
|
||||
|
||||
@router.post("/api/vaults/add", response_model=VaultStatsResponse)
|
||||
async def api_add_vault(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Add a new vault dynamically without restarting.
|
||||
|
||||
Body:
|
||||
name: Display name for the vault.
|
||||
path: Absolute filesystem path to the vault directory.
|
||||
"""
|
||||
name = body.get("name", "").strip()
|
||||
vault_path = body.get("path", "").strip()
|
||||
|
||||
if not name or not vault_path:
|
||||
raise HTTPException(status_code=400, detail="Both 'name' and 'path' are required")
|
||||
|
||||
if name in index:
|
||||
raise HTTPException(status_code=409, detail=f"Vault '{name}' already exists")
|
||||
|
||||
if not Path(vault_path).exists():
|
||||
raise HTTPException(status_code=400, detail=f"Path does not exist: {vault_path}")
|
||||
|
||||
stats = await add_vault_to_index(name, vault_path)
|
||||
|
||||
# Start watching the new vault
|
||||
watcher = get_watcher()
|
||||
if watcher:
|
||||
await watcher.add_vault(name, vault_path)
|
||||
|
||||
await sse_manager.broadcast("vault_added", {"vault": name, "stats": stats})
|
||||
return {"status": "ok", "vault": name, "stats": stats}
|
||||
|
||||
|
||||
@router.delete("/api/vaults/{vault_name}", response_model=VaultActionResponse)
|
||||
async def api_remove_vault(vault_name: str, current_user=Depends(require_admin)):
|
||||
"""Remove a vault from the index and stop watching it.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault to remove.
|
||||
"""
|
||||
if vault_name not in index:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
# Stop watching
|
||||
watcher = get_watcher()
|
||||
if watcher:
|
||||
await watcher.remove_vault(vault_name)
|
||||
|
||||
await remove_vault_from_index(vault_name)
|
||||
await sse_manager.broadcast("vault_removed", {"vault": vault_name})
|
||||
return {"status": "ok", "vault": vault_name}
|
||||
|
||||
|
||||
@router.get("/api/vaults/status", response_model=VaultsStatusResponse)
|
||||
async def api_vaults_status(current_user=Depends(require_auth)):
|
||||
"""Detailed status of all vaults including watcher state.
|
||||
|
||||
Returns per-vault: file count, tag count, watching status, vault path.
|
||||
"""
|
||||
watcher = get_watcher()
|
||||
statuses = {}
|
||||
for vname, vdata in index.items():
|
||||
watching = watcher is not None and vname in watcher.observers
|
||||
statuses[vname] = {
|
||||
"file_count": len(vdata.get("files", [])),
|
||||
"tag_count": len(vdata.get("tags", {})),
|
||||
"path": vdata.get("path", ""),
|
||||
"watching": watching,
|
||||
}
|
||||
return {
|
||||
"vaults": statuses,
|
||||
"watcher_active": watcher is not None,
|
||||
"sse_clients": sse_manager.client_count,
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Webhook CRUD endpoints (ROADMAP #85, tranche 2).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/webhooks``), même modèle de réponse
|
||||
(:class:`backend.schemas.WebhookModel`), même dépendance admin. La logique
|
||||
métier vit déjà dans :mod:`backend.webhooks` (validation d'URL anti-SSRF,
|
||||
store ``webhook_secrets.json`` — BUG-026).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
|
||||
from backend.auth.middleware import require_admin
|
||||
from backend.schemas import StatusResponse, WebhookModel
|
||||
from backend.webhooks import (
|
||||
create_webhook,
|
||||
delete_webhook,
|
||||
get_webhooks,
|
||||
update_webhook,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[WebhookModel])
|
||||
async def api_webhooks_list(current_user=Depends(require_admin)):
|
||||
return get_webhooks()
|
||||
|
||||
|
||||
@router.post("", response_model=WebhookModel)
|
||||
async def api_webhooks_create(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
name = body.get("name", "Unnamed")
|
||||
url = body.get("url", "")
|
||||
events = body.get("events", [])
|
||||
secret = body.get("secret")
|
||||
if not url:
|
||||
raise HTTPException(400, "URL is required")
|
||||
return create_webhook(name, url, events, secret)
|
||||
|
||||
|
||||
@router.patch("/{webhook_id}", response_model=WebhookModel)
|
||||
async def api_webhooks_update(
|
||||
webhook_id: str, body: dict = Body(...), current_user=Depends(require_admin)
|
||||
):
|
||||
result = update_webhook(webhook_id, body)
|
||||
if not result:
|
||||
raise HTTPException(404, "Webhook not found")
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{webhook_id}", response_model=StatusResponse)
|
||||
async def api_webhooks_delete(webhook_id: str, current_user=Depends(require_admin)):
|
||||
if not delete_webhook(webhook_id):
|
||||
raise HTTPException(404, "Webhook not found")
|
||||
return {"status": "deleted"}
|
||||
@@ -188,6 +188,447 @@ class BackupsAutoResponse(BaseModel):
|
||||
since_hours: int | float = Field(description="Look-back window in hours")
|
||||
|
||||
|
||||
class DiffResponse(BaseModel):
|
||||
"""Response containing a unified diff between two file versions (#85 — extrait de backend.main, inchangé)."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
version: int = Field(description="Backup version timestamp (left/old side)")
|
||||
compare_with: int | None = Field(default=None, description="Other backup version or null for current file (right/new side)")
|
||||
diff: str = Field(description="Unified diff (empty if no changes)")
|
||||
|
||||
|
||||
class RestoreRequest(BaseModel):
|
||||
"""Request to restore a file from a backup (#85 — extrait de backend.main, inchangé)."""
|
||||
|
||||
version: int = Field(description="Timestamp of the backup version to restore")
|
||||
|
||||
|
||||
class RestoreResponse(BaseModel):
|
||||
"""Response after restoring a file from backup (#85 — extrait de backend.main, inchangé)."""
|
||||
|
||||
success: bool = Field(description="Whether restore succeeded")
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
restored_from: int = Field(description="Timestamp of the backup used")
|
||||
current_backed_up: int | None = Field(default=None, description="Timestamp of the backup created from the current version before restore, if any")
|
||||
|
||||
|
||||
class BackupEntry(BaseModel):
|
||||
"""A single backup version of a file (#85 — extrait de backend.main, inchangé)."""
|
||||
|
||||
timestamp: int = Field(description="Unix timestamp of when the backup was created")
|
||||
datetime: str = Field(description="ISO 8601 datetime string")
|
||||
size: int = Field(description="File size in bytes")
|
||||
filename: str = Field(description="Backup filename on disk")
|
||||
|
||||
|
||||
class BackupListResponse(BaseModel):
|
||||
"""Response listing all available backups for a file (#85 — extrait de backend.main, inchangé)."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
backups: list[BackupEntry] = Field(description="Available backups, newest first")
|
||||
|
||||
|
||||
class DiffRequest(BaseModel):
|
||||
"""Request parameters for generating a diff (#85 — extrait de backend.main, inchangé)."""
|
||||
|
||||
version: int = Field(description="Timestamp of the backup version to compare")
|
||||
compare_with: int | None = Field(default=None, description="Timestamp of another backup version. If omitted, compares with the current file.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Files — browse / read (#85 — extrait de backend.main, inchangé)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BrowseItem(BaseModel):
|
||||
"""A single entry (file or directory) returned by the browse endpoint."""
|
||||
|
||||
name: str = Field(description="File or directory name")
|
||||
path: str = Field(description="Relative path within vault")
|
||||
type: str = Field(description="'file' or 'directory'")
|
||||
children_count: int | None = Field(default=None, description="Number of children (directories only)")
|
||||
size: int | None = Field(default=None, description="File size in bytes")
|
||||
extension: str | None = Field(default=None, description="File extension")
|
||||
|
||||
|
||||
class BrowseResponse(BaseModel):
|
||||
"""Paginated directory listing for a vault."""
|
||||
|
||||
vault: str
|
||||
path: str
|
||||
items: list[BrowseItem]
|
||||
|
||||
|
||||
class FileContentResponse(BaseModel):
|
||||
"""Rendered file content with metadata."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path within the vault")
|
||||
title: str = Field(description="File title (from frontmatter or filename)")
|
||||
tags: list[str] = Field(description="Extracted tags from frontmatter and inline #tags")
|
||||
frontmatter: dict[str, Any] = Field(description="YAML frontmatter as key-value dict")
|
||||
html: str = Field(description="Rendered HTML content")
|
||||
raw_length: int = Field(description="Length of raw file content in characters")
|
||||
extension: str = Field(description="File extension (e.g. .md, .txt)")
|
||||
is_markdown: bool = Field(description="Whether the file is markdown")
|
||||
unsupported: bool | None = Field(default=False, description="True for binary/unsupported files")
|
||||
size_bytes: int | None = Field(default=None, description="File size in bytes (for unsupported files)")
|
||||
is_pdf: bool | None = Field(default=None, description="True for PDF files")
|
||||
is_image: bool | None = Field(default=None, description="True for image files")
|
||||
is_audio: bool | None = Field(default=None, description="True for audio files (HTML5 <audio>, roadmap #109)")
|
||||
is_video: bool | None = Field(default=None, description="True for video files (HTML5 <video>, roadmap #109)")
|
||||
media_too_large: bool | None = Field(default=None, description="True when audio/video exceeds the inline streaming limit")
|
||||
stream_url: str | None = Field(default=None, description="Byte-range streaming URL under /api/media (audio/video)")
|
||||
media_mime: str | None = Field(default=None, description="MIME type for audio/video files")
|
||||
is_csv: bool | None = Field(default=None, description="True for CSV files")
|
||||
is_xlsx: bool | None = Field(default=None, description="True for Excel .xlsx files")
|
||||
xlsx_sheets: list[dict[str, Any]] | None = Field(
|
||||
default=None, description="Rendered xlsx sheets [{name, html}]"
|
||||
)
|
||||
is_json: bool | None = Field(default=None, description="True for JSON files")
|
||||
is_excalidraw: bool | None = Field(default=None, description="True for Excalidraw diagram files")
|
||||
excalidraw_data: dict[str, Any] | None = Field(default=None, description="Excalidraw diagram data (elements, appState, files)")
|
||||
excalidraw_data_compressed: str | None = Field(default=None, description="Compressed Excalidraw data for .excalidraw.md files")
|
||||
pdf_metadata: dict[str, Any] | None = Field(default=None, description="PDF metadata")
|
||||
pdf_toc: list[dict[str, Any]] | None = Field(default=None, description="PDF table of contents")
|
||||
image_mime: str | None = Field(default=None, description="MIME type for image files")
|
||||
|
||||
|
||||
class FileRawResponse(BaseModel):
|
||||
"""Raw text content of a file."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path within the vault")
|
||||
raw: str = Field(description="Raw file content as text")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Files — mutations (#85 — extrait de backend.main, inchangé)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FileSaveResponse(BaseModel):
|
||||
"""Confirmation after saving a file."""
|
||||
|
||||
status: str = Field(description="Always 'ok'")
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path within the vault")
|
||||
size: int = Field(description="Size of saved content in characters")
|
||||
|
||||
|
||||
class FileDeleteResponse(BaseModel):
|
||||
"""Confirmation after deleting a file."""
|
||||
|
||||
status: str = Field(description="Always 'ok'")
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path within the vault")
|
||||
|
||||
|
||||
class DirectoryCreateRequest(BaseModel):
|
||||
"""Request to create a new directory."""
|
||||
|
||||
path: str = Field(description="Relative path of the new directory")
|
||||
|
||||
|
||||
class DirectoryCreateResponse(BaseModel):
|
||||
"""Response after creating a directory."""
|
||||
|
||||
success: bool = Field(description="Whether creation succeeded")
|
||||
path: str = Field(description="Path of the created directory")
|
||||
|
||||
|
||||
class DirectoryRenameRequest(BaseModel):
|
||||
"""Request to rename a directory."""
|
||||
|
||||
path: str = Field(description="Current path of the directory")
|
||||
new_name: str = Field(description="New name for the directory")
|
||||
|
||||
|
||||
class DirectoryRenameResponse(BaseModel):
|
||||
"""Response after renaming a directory."""
|
||||
|
||||
success: bool = Field(description="Whether rename succeeded")
|
||||
old_path: str = Field(description="Original directory path")
|
||||
new_path: str = Field(description="New directory path")
|
||||
|
||||
|
||||
class DirectoryDeleteResponse(BaseModel):
|
||||
"""Response after deleting a directory."""
|
||||
|
||||
success: bool = Field(description="Whether deletion succeeded")
|
||||
deleted_count: int = Field(description="Number of files recursively deleted")
|
||||
|
||||
|
||||
class FileCreateRequest(BaseModel):
|
||||
"""Request to create a new file."""
|
||||
|
||||
path: str = Field(description="Relative path of the new file")
|
||||
content: str = Field(default="", description="Initial content")
|
||||
|
||||
|
||||
class FileCreateResponse(BaseModel):
|
||||
"""Response after creating a file."""
|
||||
|
||||
success: bool = Field(description="Whether creation succeeded")
|
||||
path: str = Field(description="Path of the created file")
|
||||
|
||||
|
||||
class BatchUploadFileItem(BaseModel):
|
||||
"""A single file/dir entry in a batch upload request."""
|
||||
|
||||
path: str = Field(description="Relative path of the item within the batch")
|
||||
content: str | None = Field(default=None, description="Base64 encoded or text content for files")
|
||||
is_dir: bool = Field(default=False, description="True if entry represents an empty directory")
|
||||
|
||||
|
||||
class BatchUploadRequest(BaseModel):
|
||||
"""Request payload for batch file/directory upload."""
|
||||
|
||||
target_dir: str = Field(default="", description="Base directory in vault to upload into (empty for root)")
|
||||
files: list[BatchUploadFileItem] = Field(description="List of files and directories to upload")
|
||||
overwrite: bool = Field(default=True, description="Whether to overwrite existing files (creates backups)")
|
||||
|
||||
|
||||
class BatchUploadResponse(BaseModel):
|
||||
"""Response from batch file/directory upload."""
|
||||
|
||||
success: bool = Field(description="True if all files uploaded without error")
|
||||
vault: str = Field(description="Vault name")
|
||||
target_dir: str = Field(description="Target directory")
|
||||
uploaded: list[str] = Field(description="List of created/updated file paths")
|
||||
created_dirs: list[str] = Field(description="List of created directory paths")
|
||||
errors: list[dict[str, Any]] = Field(default_factory=list, description="List of items that failed")
|
||||
total_files: int = Field(description="Total uploaded files count")
|
||||
|
||||
|
||||
class FileRenameRequest(BaseModel):
|
||||
"""Request to rename a file."""
|
||||
|
||||
path: str = Field(description="Current path of the file")
|
||||
new_name: str = Field(description="New name for the file")
|
||||
|
||||
|
||||
class FileRenameResponse(BaseModel):
|
||||
"""Response after renaming a file."""
|
||||
|
||||
success: bool = Field(description="Whether rename succeeded")
|
||||
old_path: str
|
||||
new_path: str
|
||||
|
||||
|
||||
class FileMoveRequest(BaseModel):
|
||||
"""Request to move a file or directory to a different parent directory."""
|
||||
|
||||
source_path: str = Field(description="Current relative path of the file/directory")
|
||||
destination_dir: str = Field(description="Target directory relative path (empty string for vault root)")
|
||||
|
||||
|
||||
class FileMoveResponse(BaseModel):
|
||||
"""Response after moving a file or directory."""
|
||||
|
||||
success: bool = Field(description="Whether move succeeded")
|
||||
old_path: str = Field(description="Original path")
|
||||
new_path: str = Field(description="New path after move")
|
||||
item_type: str = Field(description="Type of item moved: 'file' or 'directory'")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vaults & history (#85 — extrait de backend.main, inchangé)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class VaultInfo(BaseModel):
|
||||
"""Summary information about a configured vault."""
|
||||
|
||||
name: str = Field(description="Display name of the vault")
|
||||
file_count: int = Field(description="Number of indexed files")
|
||||
tag_count: int = Field(description="Number of unique tags")
|
||||
type: str = Field(default="VAULT", description="Type of the vault mapping (VAULT or DIR)")
|
||||
|
||||
|
||||
class BookmarkToggleRequest(BaseModel):
|
||||
"""Request to toggle a bookmark on a file."""
|
||||
|
||||
vault: str
|
||||
path: str
|
||||
title: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search / suggest / graph (#85 — extrait de backend.main, inchangé)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SearchResultItem(BaseModel):
|
||||
"""A single search result."""
|
||||
|
||||
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")
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
"""Full-text search response with optional pagination."""
|
||||
|
||||
query: str = Field(description="Original search query")
|
||||
vault_filter: str = Field(description="Vault filter applied ('all' or vault name)")
|
||||
tag_filter: str | None = Field(default=None, description="Tag filter applied")
|
||||
count: int = Field(description="Number of results in this response")
|
||||
total: int = Field(default=0, description="Total results before pagination")
|
||||
offset: int = Field(default=0, description="Current pagination offset")
|
||||
limit: int = Field(default=200, description="Page size")
|
||||
results: list[SearchResultItem] = Field(description="Search result items")
|
||||
|
||||
|
||||
class TagsResponse(BaseModel):
|
||||
"""Tag aggregation response."""
|
||||
|
||||
vault_filter: str | None = Field(default=None, description="Vault filter applied")
|
||||
tags: dict[str, int] = Field(description="Tag name → count mapping")
|
||||
|
||||
|
||||
class TreeSearchResult(BaseModel):
|
||||
"""A single tree search result item."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Full relative path")
|
||||
name: str = Field(description="File or directory name")
|
||||
type: str = Field(description="'file' or 'directory'")
|
||||
matched_path: str = Field(description="Path segment that matched the query")
|
||||
|
||||
|
||||
class TreeSearchResponse(BaseModel):
|
||||
"""Tree search response with matching paths."""
|
||||
|
||||
query: str = Field(description="Search query")
|
||||
vault_filter: str = Field(description="Vault filter applied")
|
||||
results: list[TreeSearchResult] = Field(description="Matching files and directories")
|
||||
|
||||
|
||||
class VaultPathEntry(BaseModel):
|
||||
"""A single indexed path (file or directory) in a vault."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Full relative path")
|
||||
name: str = Field(description="File or directory name")
|
||||
type: str = Field(description="'file' or 'directory'")
|
||||
|
||||
|
||||
class VaultPathsResponse(BaseModel):
|
||||
"""Flat list of every indexed path in a vault (capped)."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
count: int = Field(description="Number of returned entries")
|
||||
results: list[VaultPathEntry] = Field(description="Indexed files and directories")
|
||||
|
||||
|
||||
class AdvancedSearchResultItem(BaseModel):
|
||||
"""A single advanced search result with highlighted snippet."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
title: str = Field(description="File title")
|
||||
tags: list[str] = Field(description="File tags")
|
||||
score: float = Field(description="TF-IDF relevance score (or fused RRF score in semantic mode)")
|
||||
semantic_score: float = Field(default=0.0, description="Cosine similarity from the semantic index (0 when unavailable)")
|
||||
snippet: str = Field(description="Content excerpt with <mark> highlights")
|
||||
modified: str = Field(description="ISO 8601 modification timestamp")
|
||||
extension: str = Field(default="", description="File extension")
|
||||
|
||||
|
||||
class SearchFacets(BaseModel):
|
||||
"""Faceted counts for search results."""
|
||||
|
||||
tags: dict[str, int] = Field(default_factory=dict)
|
||||
vaults: dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AdvancedSearchResponse(BaseModel):
|
||||
"""Advanced search response with TF-IDF scoring, facets, and pagination."""
|
||||
|
||||
results: list[AdvancedSearchResultItem] = Field(description="Search results")
|
||||
total: int = Field(description="Total number of matching results")
|
||||
offset: int = Field(description="Current pagination offset")
|
||||
limit: int = Field(description="Page size")
|
||||
facets: SearchFacets = Field(description="Faceted counts by tag and vault")
|
||||
query_time_ms: float = Field(default=0, description="Server-side query time in milliseconds")
|
||||
semantic_available: bool = Field(default=False, description="True when the semantic (embedding) index is ready")
|
||||
|
||||
|
||||
class TitleSuggestion(BaseModel):
|
||||
"""A file title suggestion for autocomplete."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
title: str = Field(description="File title")
|
||||
|
||||
|
||||
class SuggestResponse(BaseModel):
|
||||
"""Autocomplete suggestions for file titles."""
|
||||
|
||||
query: str = Field(description="Original query string")
|
||||
suggestions: list[TitleSuggestion] = Field(description="Matching file suggestions")
|
||||
|
||||
|
||||
class TagSuggestion(BaseModel):
|
||||
"""A tag suggestion for autocomplete."""
|
||||
|
||||
tag: str = Field(description="Tag name")
|
||||
count: int = Field(description="Number of files with this tag")
|
||||
|
||||
|
||||
class TagSuggestResponse(BaseModel):
|
||||
"""Autocomplete suggestions for tags."""
|
||||
|
||||
query: str = Field(description="Original query string")
|
||||
suggestions: list[TagSuggestion] = Field(description="Matching tag suggestions")
|
||||
|
||||
|
||||
class GraphNode(BaseModel):
|
||||
"""A single node in the graph view."""
|
||||
|
||||
id: str = Field(description="Unique node identifier")
|
||||
name: str = Field(description="Display name")
|
||||
type: str = Field(description="'vault', 'directory', or 'file'")
|
||||
path: str = Field(description="Relative path within vault")
|
||||
size: int = Field(default=0, description="File size in bytes")
|
||||
tags: list[str] = Field(default_factory=list, description="Tags from frontmatter")
|
||||
incoming_count: int = Field(default=0, description="Number of incoming wikilinks")
|
||||
outgoing_count: int = Field(default=0, description="Number of outgoing wikilinks")
|
||||
|
||||
|
||||
class GraphEdge(BaseModel):
|
||||
"""An edge between two nodes in the graph view."""
|
||||
|
||||
source: str = Field(description="Source node ID")
|
||||
target: str = Field(description="Target node ID")
|
||||
relation: str = Field(description="'parent', 'wikilink', or 'backlink'")
|
||||
|
||||
|
||||
class GraphResponse(BaseModel):
|
||||
"""Graph data for a vault or directory."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Root path for the graph")
|
||||
scope: str = Field(default="directory", description="'directory' or 'full'")
|
||||
nodes: list[GraphNode] = Field(description="Graph nodes (files and directories)")
|
||||
edges: list[GraphEdge] = Field(description="Graph edges (parent and wikilink relations)")
|
||||
|
||||
|
||||
class ReloadResponse(BaseModel):
|
||||
"""Index reload confirmation with per-vault stats."""
|
||||
|
||||
status: str = Field(description="Reload status ('ok' or 'error')")
|
||||
vaults: dict[str, Any] = Field(description="Per-vault file counts after reload")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PDF
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -395,6 +836,7 @@ class DashboardVaultStat(BaseModel):
|
||||
file_count: int
|
||||
tag_count: int
|
||||
total_size_bytes: int
|
||||
image_count: int = 0
|
||||
|
||||
|
||||
class DashboardResponse(BaseModel):
|
||||
@@ -404,6 +846,32 @@ class DashboardResponse(BaseModel):
|
||||
total_files: int
|
||||
total_tags: int
|
||||
total_size_bytes: int
|
||||
total_images: int = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System / health (#85 — extrait de backend.main, comportement inchangé)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Application health status.
|
||||
|
||||
Déplacé depuis :mod:`backend.main` sans modification : pas de
|
||||
``extra="allow"`` ici, pour préserver la validation actuelle des
|
||||
réponses (les champs enrichis de ``/api/health/detailed`` restent
|
||||
filtrés comme avant).
|
||||
"""
|
||||
|
||||
status: str = Field(description="Health status ('ok' or 'error')")
|
||||
version: str = Field(description="Application version (x.y.z — latest release tag)")
|
||||
vaults: int = Field(description="Number of configured vaults")
|
||||
total_files: int = Field(description="Total indexed files across all vaults")
|
||||
total_tokens: int = Field(description="Total indexed tokens (approx.) across all vaults", default=0)
|
||||
last_full_index_ts: str = Field(description="ISO timestamp of last full index rebuild", default="")
|
||||
uptime_seconds: int = Field(description="Server uptime in seconds", default=0)
|
||||
git_describe: str = Field(default="", description="Full git describe string (commits beyond tag), empty if no git")
|
||||
git_commit: str = Field(default="", description="Short HEAD commit hash, empty if no git")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Shared thread pool for CPU-bound search (ROADMAP #85, tranche 5).
|
||||
|
||||
Holder extrait de :mod:`backend.main` sans changement de comportement :
|
||||
un seul pool (2 workers, préfixe ``"search"``) créé au démarrage et arrêté
|
||||
à l'extinction par le lifespan de ``main``. Les routers et les endpoints
|
||||
restants y accèdent via :func:`get_search_executor` au lieu du global de
|
||||
``main`` (plus d'import circulaire potentiel).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
_executor: ThreadPoolExecutor | None = None
|
||||
|
||||
|
||||
def init_search_executor(max_workers: int = 2) -> ThreadPoolExecutor:
|
||||
"""Create (or reuse) the shared search thread pool."""
|
||||
global _executor
|
||||
if _executor is None:
|
||||
_executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="search")
|
||||
return _executor
|
||||
|
||||
|
||||
def shutdown_search_executor() -> None:
|
||||
"""Stop the shared search thread pool (best-effort, non-blocking)."""
|
||||
global _executor
|
||||
if _executor is not None:
|
||||
_executor.shutdown(wait=False)
|
||||
_executor = None
|
||||
|
||||
|
||||
def get_search_executor() -> ThreadPoolExecutor | None:
|
||||
"""Return the shared search thread pool (``None`` before startup)."""
|
||||
return _executor
|
||||
@@ -31,7 +31,7 @@ DEFAULT_MAX_BACKUPS = 10
|
||||
def _default_max_backups() -> int:
|
||||
"""Read ``max_backups_per_file`` from app config (lazy, best-effort)."""
|
||||
try:
|
||||
from backend.main import _load_config
|
||||
from backend.routers.config import _load_config # ROADMAP #85 T7 — déménagé depuis backend.main
|
||||
|
||||
return int(_load_config().get("max_backups_per_file", DEFAULT_MAX_BACKUPS))
|
||||
except Exception: # pragma: no cover - config unavailable
|
||||
|
||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
@@ -26,6 +27,11 @@ from backend.services.vaults import get_vault_root
|
||||
|
||||
logger = logging.getLogger("obsigate.services.mutations")
|
||||
|
||||
# #86: per-file size cap for find/replace passes (CPU guard — complements the
|
||||
# BUG-025 regex caps). Files larger than this are skipped instead of being
|
||||
# read fully into memory and scanned with a user-supplied pattern.
|
||||
MAX_REPLACE_FILE_BYTES = 5_000_000
|
||||
|
||||
# Skeleton injected into empty ``.excalidraw`` files (mirrors the route logic).
|
||||
_EXCALIDRAW_SKELETON = (
|
||||
'{"type":"excalidraw","version":2,"elements":[],'
|
||||
@@ -218,6 +224,102 @@ def edit_file(
|
||||
return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)}
|
||||
|
||||
|
||||
# Cell reference like "A1" / "AB42" (Excel A1 notation, up to 3 letters / 8 digits).
|
||||
_XLSX_CELL_RE = re.compile(r"^[A-Z]{1,3}[1-9][0-9]{0,7}$")
|
||||
# ponytail: bare int/float coercion mirrors what Excel does when you type a
|
||||
# number; dates/booleans stay text (upgrade path: parse locale dates too).
|
||||
_XLSX_INT_RE = re.compile(r"^[+-]?\d+$")
|
||||
_XLSX_FLOAT_RE = re.compile(r"^[+-]?(?:\d+\.\d*|\.\d+)$")
|
||||
|
||||
|
||||
def _coerce_xlsx_value(value: Any) -> Any:
|
||||
"""Turn the string sent by the cell editor back into a scalar."""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
text = value.strip()
|
||||
if text == "":
|
||||
return None
|
||||
if _XLSX_INT_RE.match(text):
|
||||
return int(text)
|
||||
if _XLSX_FLOAT_RE.match(text):
|
||||
return float(text)
|
||||
return value
|
||||
|
||||
|
||||
def edit_xlsx_cells(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
sheet: str,
|
||||
cells: dict[str, Any],
|
||||
*,
|
||||
backup: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply a batch of cell edits to an ``.xlsx`` workbook.
|
||||
|
||||
Raises:
|
||||
ServiceError: ``not_found`` (404), ``read_only`` (403) or
|
||||
``invalid`` (400) for a bad sheet, cell reference or value.
|
||||
|
||||
ponytail: openpyxl round-trips values/formulas/styles but drops charts,
|
||||
images and pivot tables; use the SheetJS path if a workbook needs those.
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise ServiceError(
|
||||
f"File not found: {path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": path},
|
||||
)
|
||||
if file_path.suffix.lower() != ".xlsx":
|
||||
raise ServiceError(
|
||||
f"Not an .xlsx file: {path}", code="invalid", status=400
|
||||
)
|
||||
if not cells:
|
||||
raise ServiceError("No cells to update", code="invalid", status=400)
|
||||
for ref in cells:
|
||||
if not isinstance(ref, str) or not _XLSX_CELL_RE.match(ref):
|
||||
raise ServiceError(
|
||||
f"Invalid cell reference: {ref!r}", code="invalid", status=400
|
||||
)
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
try:
|
||||
wb = load_workbook(file_path)
|
||||
except Exception as exc:
|
||||
raise ServiceError(
|
||||
f"Cannot open workbook: {exc}", code="invalid", status=400
|
||||
) from exc
|
||||
if sheet not in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Unknown sheet: {sheet}",
|
||||
code="invalid",
|
||||
status=400,
|
||||
details={"sheets": wb.sheetnames},
|
||||
)
|
||||
|
||||
rel_path = _rel(root, file_path)
|
||||
if backup:
|
||||
create_backup(file_path, vault_name, rel_path)
|
||||
|
||||
ws = wb[sheet]
|
||||
for ref, value in cells.items():
|
||||
ws[ref].value = _coerce_xlsx_value(value)
|
||||
wb.save(file_path)
|
||||
|
||||
logger.info(f"XLSX cells saved: {vault_name}/{rel_path} [{sheet}] +{len(cells)}")
|
||||
return {
|
||||
"success": True,
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"size": len(cells),
|
||||
}
|
||||
|
||||
|
||||
def append_to_file(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
@@ -509,6 +611,16 @@ def replace_in_files(
|
||||
continue
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
continue
|
||||
# #86 CPU guard: skip files too large to scan safely in one pass.
|
||||
try:
|
||||
if file_path.stat().st_size > MAX_REPLACE_FILE_BYTES:
|
||||
logger.warning(
|
||||
"replace_in_files: skipping oversized file %s/%s (%d bytes)",
|
||||
result_vault, result["path"], file_path.stat().st_size,
|
||||
)
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
original = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
|
||||
@@ -10,6 +10,7 @@ No authentication required for public share views.
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -17,6 +18,10 @@ logger = logging.getLogger("obsigate.share")
|
||||
|
||||
SHARES_FILE = Path("data/shares.json")
|
||||
|
||||
# ROADMAP #85 T10a — verrou autour des read-modify-write (perte de mises à
|
||||
# jour en cas de créations/accès/révocations concurrents).
|
||||
_lock = threading.RLock()
|
||||
|
||||
|
||||
def _read() -> dict:
|
||||
if not SHARES_FILE.exists():
|
||||
@@ -41,26 +46,27 @@ def create_share(
|
||||
expires_in_hours: int | None = None,
|
||||
) -> dict:
|
||||
"""Create a new share token for a document."""
|
||||
data = _read()
|
||||
token = secrets.token_hex(32) # 64-char hex token
|
||||
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 = None
|
||||
if expires_in_hours:
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(hours=expires_in_hours)).isoformat()
|
||||
|
||||
share = {
|
||||
"id": token,
|
||||
"token": token,
|
||||
"vault": vault,
|
||||
"path": path,
|
||||
"created_by": created_by,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"expires_at": expires_at,
|
||||
"access_count": 0,
|
||||
"last_accessed": None,
|
||||
}
|
||||
data["shares"][token] = share
|
||||
_write(data)
|
||||
share = {
|
||||
"id": token,
|
||||
"token": token,
|
||||
"vault": vault,
|
||||
"path": path,
|
||||
"created_by": created_by,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"expires_at": expires_at,
|
||||
"access_count": 0,
|
||||
"last_accessed": None,
|
||||
}
|
||||
data["shares"][token] = share
|
||||
_write(data)
|
||||
logger.info(f"Created share for {vault}/{path} by {created_by}")
|
||||
return share
|
||||
|
||||
@@ -80,22 +86,24 @@ def get_share_by_token(token: str) -> dict | None:
|
||||
|
||||
def record_access(token: str):
|
||||
"""Increment access counter for a share."""
|
||||
data = _read()
|
||||
share = data["shares"].get(token)
|
||||
if share:
|
||||
share["access_count"] = share.get("access_count", 0) + 1
|
||||
share["last_accessed"] = datetime.now(timezone.utc).isoformat()
|
||||
_write(data)
|
||||
with _lock:
|
||||
data = _read()
|
||||
share = data["shares"].get(token)
|
||||
if share:
|
||||
share["access_count"] = share.get("access_count", 0) + 1
|
||||
share["last_accessed"] = datetime.now(timezone.utc).isoformat()
|
||||
_write(data)
|
||||
|
||||
|
||||
def revoke_share(share_id: str) -> bool:
|
||||
"""Revoke (delete) a share by its token."""
|
||||
data = _read()
|
||||
if share_id in data["shares"]:
|
||||
del data["shares"][share_id]
|
||||
_write(data)
|
||||
logger.info(f"Revoked share {share_id}")
|
||||
return True
|
||||
with _lock:
|
||||
data = _read()
|
||||
if share_id in data["shares"]:
|
||||
del data["shares"][share_id]
|
||||
_write(data)
|
||||
logger.info(f"Revoked share {share_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -112,12 +120,13 @@ def list_shares(vault_filter: str | None = None) -> list:
|
||||
|
||||
def update_shares_after_rename(vault: str, old_path: str, new_path: str):
|
||||
"""Update all shares when a file is renamed."""
|
||||
data = _read()
|
||||
updated = False
|
||||
for sid, s in data["shares"].items():
|
||||
if s.get("vault") == vault and s.get("path") == old_path:
|
||||
s["path"] = new_path
|
||||
updated = True
|
||||
logger.info(f"Updated share {sid}: {vault}/{old_path} -> {new_path}")
|
||||
if updated:
|
||||
_write(data)
|
||||
with _lock:
|
||||
data = _read()
|
||||
updated = False
|
||||
for sid, s in data["shares"].items():
|
||||
if s.get("vault") == vault and s.get("path") == old_path:
|
||||
s["path"] = new_path
|
||||
updated = True
|
||||
logger.info(f"Updated share {sid}: {vault}/{old_path} -> {new_path}")
|
||||
if updated:
|
||||
_write(data)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Server-Sent Events manager (ROADMAP #85, tranche 4).
|
||||
|
||||
Singleton extrait de :mod:`backend.main` sans changement de comportement :
|
||||
les routers montés par ``main`` partagent la même instance (les clients SSE
|
||||
connectés sur ``/api/events`` reçoivent les broadcasts émis depuis
|
||||
n'importe quel router).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json as _json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
|
||||
class SSEManager:
|
||||
"""Manages SSE client connections and broadcasts events."""
|
||||
|
||||
def __init__(self):
|
||||
self._clients: list[asyncio.Queue] = []
|
||||
|
||||
async def connect(self) -> asyncio.Queue:
|
||||
"""Register a new SSE client and return its message queue."""
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
self._clients.append(queue)
|
||||
logger.debug(f"SSE client connected (total: {len(self._clients)})")
|
||||
return queue
|
||||
|
||||
def disconnect(self, queue: asyncio.Queue):
|
||||
"""Remove a disconnected SSE client."""
|
||||
if queue in self._clients:
|
||||
self._clients.remove(queue)
|
||||
logger.debug(f"SSE client disconnected (total: {len(self._clients)})")
|
||||
|
||||
async def broadcast(self, event_type: str, data: dict):
|
||||
"""Send an event to all connected SSE clients."""
|
||||
message = _json.dumps(data, ensure_ascii=False)
|
||||
dead: list[asyncio.Queue] = []
|
||||
for q in self._clients:
|
||||
try:
|
||||
q.put_nowait({"event": event_type, "data": message})
|
||||
except asyncio.QueueFull:
|
||||
dead.append(q)
|
||||
for q in dead:
|
||||
self.disconnect(q)
|
||||
|
||||
@property
|
||||
def client_count(self) -> int:
|
||||
return len(self._clients)
|
||||
|
||||
|
||||
sse_manager = SSEManager()
|
||||
@@ -19,7 +19,9 @@ import io
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
from xml.sax import saxutils
|
||||
|
||||
# saxutils.escape uniquement (échappement de chaînes, aucun parsing XML).
|
||||
from xml.sax import saxutils # nosec B406
|
||||
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.mutations import save_raw_file
|
||||
|
||||
@@ -17,6 +17,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("obsigate.tools.secrets")
|
||||
@@ -34,6 +35,9 @@ TOOL_KEY_NAMES: tuple[str, ...] = (
|
||||
|
||||
_SECRET_MARKERS = ("API_KEY", "TOKEN")
|
||||
|
||||
# ROADMAP #85 T10a — verrou autour des read-modify-write du store de clés.
|
||||
_lock = threading.RLock()
|
||||
|
||||
|
||||
def _keys_file() -> Path:
|
||||
base = os.environ.get("OBSIGATE_DATA_DIR", "data")
|
||||
@@ -89,21 +93,23 @@ def set_tool_key(name: str, value: str) -> None:
|
||||
if name not in TOOL_KEY_NAMES:
|
||||
raise ValueError(f"Clé non prise en charge: {name}")
|
||||
value = (value or "").strip()
|
||||
keys = _read_keys()
|
||||
if value:
|
||||
keys[name] = value
|
||||
else:
|
||||
keys.pop(name, None)
|
||||
_write_keys(keys)
|
||||
with _lock:
|
||||
keys = _read_keys()
|
||||
if value:
|
||||
keys[name] = value
|
||||
else:
|
||||
keys.pop(name, None)
|
||||
_write_keys(keys)
|
||||
|
||||
|
||||
def delete_tool_key(name: str) -> bool:
|
||||
"""Remove one key from the store; return True when it existed."""
|
||||
if name not in TOOL_KEY_NAMES:
|
||||
raise ValueError(f"Clé non prise en charge: {name}")
|
||||
keys = _read_keys()
|
||||
if name in keys:
|
||||
del keys[name]
|
||||
_write_keys(keys)
|
||||
return True
|
||||
with _lock:
|
||||
keys = _read_keys()
|
||||
if name in keys:
|
||||
del keys[name]
|
||||
_write_keys(keys)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -22,7 +22,7 @@ Exemples :
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent # racine du dépôt ObsiGate
|
||||
@@ -34,7 +34,8 @@ _ENV_VAR = "OBSIGATE_VERSION"
|
||||
def _run_git(args: list[str]) -> str:
|
||||
"""Run a git command in the repo root; return stdout (stripped) or ''."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
# argv fixe (git + args internes), sans shell : pas d'injection.
|
||||
result = subprocess.run( # nosec B404 B603 B607
|
||||
["git", *args],
|
||||
cwd=str(_ROOT),
|
||||
capture_output=True,
|
||||
|
||||
@@ -280,7 +280,8 @@ class VaultWatcher:
|
||||
for observer in self.observers.values():
|
||||
try:
|
||||
observer.join(timeout=5)
|
||||
except Exception: # nosec B110 — best-effort shutdown, ignore failures
|
||||
# best-effort shutdown, ignore failures (B110) :
|
||||
except Exception: # nosec B110
|
||||
pass
|
||||
self.observers.clear()
|
||||
logger.info("VaultWatcher stopped")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Shared VaultWatcher handle (ROADMAP #85, tranche 8).
|
||||
|
||||
Holder extrait de :mod:`backend.main` sans changement de comportement : le
|
||||
lifespan de ``main`` y dépose l'instance (``set_watcher``) et l'y reprend à
|
||||
l'extinction ; le router ``vaults`` la consulte via :func:`get_watcher`
|
||||
(démarrage/arrêt de surveillance à l'ajout/retrait dynamique de vault,
|
||||
état dans ``/api/vaults/status``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.watcher import VaultWatcher
|
||||
|
||||
_watcher: VaultWatcher | None = None
|
||||
|
||||
|
||||
def get_watcher() -> VaultWatcher | None:
|
||||
"""Return the shared VaultWatcher instance (``None`` if disabled)."""
|
||||
return _watcher
|
||||
|
||||
|
||||
def set_watcher(watcher: VaultWatcher | None) -> None:
|
||||
"""Store (or clear) the shared VaultWatcher instance."""
|
||||
global _watcher
|
||||
_watcher = watcher
|
||||
@@ -26,6 +26,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -144,6 +145,12 @@ def _read_secrets() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
# ROADMAP #85 T10a — verrou autour des read-modify-write des deux stores
|
||||
# (webhooks + secrets) : perte de mises à jour en cas de mutations
|
||||
# concurrentes.
|
||||
_lock = threading.RLock()
|
||||
|
||||
|
||||
def _write_secrets(secrets: dict):
|
||||
WEBHOOK_SECRETS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = WEBHOOK_SECRETS_FILE.with_suffix(".tmp")
|
||||
@@ -156,12 +163,13 @@ def _write_secrets(secrets: dict):
|
||||
|
||||
|
||||
def _store_secret(wh_id: str, secret: str | None) -> None:
|
||||
secrets = _read_secrets()
|
||||
if secret:
|
||||
secrets[wh_id] = secret
|
||||
else:
|
||||
secrets.pop(wh_id, None)
|
||||
_write_secrets(secrets)
|
||||
with _lock:
|
||||
secrets = _read_secrets()
|
||||
if secret:
|
||||
secrets[wh_id] = secret
|
||||
else:
|
||||
secrets.pop(wh_id, None)
|
||||
_write_secrets(secrets)
|
||||
|
||||
|
||||
def _get_secret(wh: dict) -> str | None:
|
||||
@@ -189,52 +197,55 @@ def get_webhooks() -> list:
|
||||
|
||||
def create_webhook(name: str, url: str, events: list[str], secret: str | None = None) -> dict:
|
||||
validate_webhook_url(url)
|
||||
webhooks = _read()
|
||||
wh_id = str(uuid.uuid4())
|
||||
wh = {
|
||||
"id": wh_id,
|
||||
"name": name,
|
||||
"url": url,
|
||||
"events": [e for e in events if e in VALID_EVENTS],
|
||||
"enabled": True,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"last_fired_at": None,
|
||||
}
|
||||
webhooks.append(wh)
|
||||
_write(webhooks)
|
||||
if secret:
|
||||
_store_secret(wh_id, secret)
|
||||
with _lock:
|
||||
webhooks = _read()
|
||||
wh_id = str(uuid.uuid4())
|
||||
wh = {
|
||||
"id": wh_id,
|
||||
"name": name,
|
||||
"url": url,
|
||||
"events": [e for e in events if e in VALID_EVENTS],
|
||||
"enabled": True,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"last_fired_at": None,
|
||||
}
|
||||
webhooks.append(wh)
|
||||
_write(webhooks)
|
||||
if secret:
|
||||
_store_secret(wh_id, secret)
|
||||
logger.info(f"Created webhook '{name}' → {url}")
|
||||
return _public_view(wh)
|
||||
|
||||
|
||||
def update_webhook(wh_id: str, updates: dict) -> dict | None:
|
||||
webhooks = _read()
|
||||
for wh in webhooks:
|
||||
if wh["id"] == wh_id:
|
||||
if updates.get("url"):
|
||||
validate_webhook_url(updates["url"])
|
||||
if "secret" in updates:
|
||||
_store_secret(wh_id, updates["secret"])
|
||||
safe_updates = {
|
||||
k: v for k, v in updates.items()
|
||||
if k not in ("id", "secret")
|
||||
}
|
||||
wh.update(safe_updates)
|
||||
_write(webhooks)
|
||||
return _public_view(wh)
|
||||
with _lock:
|
||||
webhooks = _read()
|
||||
for wh in webhooks:
|
||||
if wh["id"] == wh_id:
|
||||
if updates.get("url"):
|
||||
validate_webhook_url(updates["url"])
|
||||
if "secret" in updates:
|
||||
_store_secret(wh_id, updates["secret"])
|
||||
safe_updates = {
|
||||
k: v for k, v in updates.items()
|
||||
if k not in ("id", "secret")
|
||||
}
|
||||
wh.update(safe_updates)
|
||||
_write(webhooks)
|
||||
return _public_view(wh)
|
||||
return None
|
||||
|
||||
|
||||
def delete_webhook(wh_id: str) -> bool:
|
||||
webhooks = _read()
|
||||
new_list = [wh for wh in webhooks if wh["id"] != wh_id]
|
||||
if len(new_list) == len(webhooks):
|
||||
return False
|
||||
_write(new_list)
|
||||
secrets = _read_secrets()
|
||||
if secrets.pop(wh_id, None) is not None:
|
||||
_write_secrets(secrets)
|
||||
with _lock:
|
||||
webhooks = _read()
|
||||
new_list = [wh for wh in webhooks if wh["id"] != wh_id]
|
||||
if len(new_list) == len(webhooks):
|
||||
return False
|
||||
_write(new_list)
|
||||
secrets = _read_secrets()
|
||||
if secrets.pop(wh_id, None) is not None:
|
||||
_write_secrets(secrets)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Render ``.xlsx`` workbooks as HTML tables for the viewer (#xlsx).
|
||||
|
||||
Read-only: formulas are shown as their text (``data_only=False``) so a
|
||||
round-trip through the viewer never depends on Excel's cached values.
|
||||
Write-side lives in ``backend.services.mutations.edit_xlsx_cells``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
# ponytail: hard caps bound the rendered grid (500 rows x 40 cols per sheet).
|
||||
# Raise them, or paginate per sheet, if a real workbook needs more.
|
||||
MAX_ROWS = 500
|
||||
MAX_COLS = 40
|
||||
|
||||
|
||||
def _fmt(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime("%Y-%m-%d %H:%M")
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _trim(grid: list[list[str]]) -> list[list[str]]:
|
||||
"""Drop trailing empty rows and columns (openpyxl pads to max_col)."""
|
||||
while grid and not any(grid[-1]):
|
||||
grid.pop()
|
||||
if not grid:
|
||||
return grid
|
||||
width = 0
|
||||
for row in grid:
|
||||
for i in range(len(row) - 1, -1, -1):
|
||||
if row[i]:
|
||||
width = max(width, i + 1)
|
||||
break
|
||||
return [row[:width] for row in grid]
|
||||
|
||||
|
||||
def _table(grid: list[list[str]]) -> str:
|
||||
if not grid:
|
||||
return "<p><em>Feuille vide</em></p>"
|
||||
n_cols = max(len(row) for row in grid)
|
||||
out = [
|
||||
(
|
||||
'<div class="csv-table-wrapper"><table class="csv-table xlsx-table">'
|
||||
'<thead><tr><th class="xlsx-corner"></th>'
|
||||
)
|
||||
]
|
||||
out += [f"<th>{get_column_letter(c)}</th>" for c in range(1, n_cols + 1)]
|
||||
out.append("</tr></thead><tbody>")
|
||||
for r, row in enumerate(grid, start=1):
|
||||
out.append(f'<tr><th class="xlsx-rownum">{r}</th>')
|
||||
for c, val in enumerate(row, start=1):
|
||||
ref = f"{get_column_letter(c)}{r}"
|
||||
out.append(f'<td data-cell="{ref}">{html.escape(val)}</td>')
|
||||
out.append("</tr>")
|
||||
out.append("</tbody></table></div>")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def render_sheets(file_path: Path) -> list[dict[str, str]]:
|
||||
"""Return ``[{"name": sheet_title, "html": table_html}, ...]``."""
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=False)
|
||||
try:
|
||||
sheets = []
|
||||
for ws in wb.worksheets:
|
||||
grid = [
|
||||
[_fmt(v) for v in row]
|
||||
for row in ws.iter_rows(
|
||||
min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True
|
||||
)
|
||||
]
|
||||
sheets.append({"name": ws.title, "html": _table(_trim(grid))})
|
||||
return sheets
|
||||
finally:
|
||||
wb.close()
|
||||
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.13.0"
|
||||
version = "2.28.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.13.0"
|
||||
version = "2.28.1"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.13.0",
|
||||
"version": "2.28.1",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -347,7 +347,7 @@ Pour répondre au besoin de cibler un fournisseur/modèle sans dépendre uniquem
|
||||
| **0 — Fondations** | `backend/tools/` (registry, context, service, audit) + extraction des services métier + tests unitaires | Couche d'outils testable sans IA |
|
||||
| **1 — Function calling in-app** | Abstraction tool-calling multi-provider, agent loop, confirmations UI, SSE réel, outils de navigation | Assistant qui lit/cherche/lit/ouvre/modifie avec confirmation |
|
||||
| **2 — Serveur MCP** | `backend/mcp/server.py` (tools + resources + prompts), **Streamable HTTP** (`/mcp`, auth JWT), confirmation two-step | ObsiGate accessible comme serveur MCP (local + distant, multi-utilisateur) |
|
||||
| **3 — Durcissement** ✅ | Rate limiting (`backend/tools/ratelimit.py`), quotas `BOOKSLM_MAX_*`, redaction systématique des résultats (`backend/tools/redaction.py`), doc OpenAPI (tag/path MCP) + [guide MCP](./MCP_GUIDE.md), tests E2E | Observabilité et sécurité complètes |
|
||||
| **3 — Durcissement** ✅ | Rate limiting (`backend/tools/ratelimit.py`), quotas `BOOKSLM_MAX_*`, redaction systématique des résultats (`backend/tools/redaction.py`), doc OpenAPI (tag/path MCP) + [guide MCP](./GUIDES/MCP.md), tests E2E | Observabilité et sécurité complètes |
|
||||
|
||||
Voir `docs/ROADMAP.md` (item dédié) pour le détail des activités.
|
||||
|
||||
@@ -380,7 +380,7 @@ Voir `docs/ROADMAP.md` (item dédié) pour le détail des activités.
|
||||
- `backend/mcp/confirmations.py` — jetons de confirmation signés (two-step, anti-rejeu)
|
||||
- `backend/tools/ratelimit.py` — rate limiting par jeton/outil (phase F)
|
||||
- `backend/tools/redaction.py` — redaction récursive des résultats d'outils (phase F)
|
||||
- `docs/MCP_GUIDE.md` — guide d'installation et d'utilisation des clients MCP
|
||||
- `docs/GUIDES/MCP.md` — guide d'installation et d'utilisation des clients MCP
|
||||
- `backend/bookslm.py`, `backend/bookslm_routes.py` — assistant contextuel (+ endpoint `/agent`)
|
||||
- `frontend/js/ai.js`, `frontend/js/bookslm.js` — UI IA
|
||||
- `backend/auth/middleware.py` — permissions
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
# 🔌 Guide de l'API REST
|
||||
|
||||
ObsiGate expose une **API REST complète** couvrant toute l'application :
|
||||
vaults, fichiers, recherche, sauvegardes, exports, IA, partage, webhooks et
|
||||
administration. Ce guide explique l'authentification, la création de clés et
|
||||
donne des exemples prêts à l'emploi.
|
||||
|
||||
> **Public :** développeurs, intégrateurs, scripts d'automatisation
|
||||
> **Doc interactive :** `/docs` (Swagger UI) · `/redoc` (ReDoc) · `/openapi.json`
|
||||
> **Voir aussi :** [Serveur MCP](./MCP.md) · [Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Base et conventions
|
||||
|
||||
| Élément | Valeur |
|
||||
|---|---|
|
||||
| URL de base | `http://<hôte>:2020` (Docker) ou `http://127.0.0.1:17890` (desktop) |
|
||||
| Préfixe API | `/api` |
|
||||
| Format | JSON (`application/json`) |
|
||||
| Version | suit la version d'ObsiGate (header `X-…`, `/api/health`) |
|
||||
| Erreurs | `{"detail": "..."}` + code HTTP (`400`, `401`, `403`, `404`, `409`, `422`, `500`) |
|
||||
|
||||
Quand l'authentification est **désactivée** (`OBSIGATE_AUTH_ENABLED=false`), tous
|
||||
les endpoints sont accessibles sans jeton (utilisateur anonyme avec accès à tous
|
||||
les vaults).
|
||||
|
||||
---
|
||||
|
||||
## 2. Authentification
|
||||
|
||||
### 2.1 Jeton de session (JWT)
|
||||
|
||||
Obtenu via `POST /api/auth/login`. Le jeton d'accès a une durée de vie courte
|
||||
(`OBSIGATE_ACCESS_TOKEN_TTL`, défaut 3600 s) et un refresh token longue durée est
|
||||
posé en cookie HTTP-only.
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:2020/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"votre_mot_de_passe"}'
|
||||
```
|
||||
|
||||
Réponse (extrait) :
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJ...",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 3600,
|
||||
"user": { "username": "admin", "role": "admin", "vaults": ["*"] }
|
||||
}
|
||||
```
|
||||
|
||||
Deux façons de présenter le jeton :
|
||||
|
||||
```http
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
ou, pour un client navigateur, le cookie HTTP-only avec
|
||||
`credentials: "include"` (le login pose aussi un cookie `access_token`).
|
||||
|
||||
### 2.2 Clés API longue durée (recommandé pour scripts & MCP)
|
||||
|
||||
Une **seule clé** authentifie **l'API REST et le serveur MCP**. Créez-la depuis
|
||||
l'interface (Configurations → **🔑 Clés API & MCP**) ou par API :
|
||||
|
||||
```bash
|
||||
# 1. Se connecter, récupérer le token (section 2.1)
|
||||
# 2. Créer une clé valable 30 jours
|
||||
curl -s -X POST http://localhost:2020/api/auth/tokens \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"Script backup","expiry":"30d"}'
|
||||
```
|
||||
|
||||
Réponse (`token` affiché **une seule fois**) :
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "eyJ...",
|
||||
"jti": "…",
|
||||
"name": "Script backup",
|
||||
"created_at": 1790000000,
|
||||
"expires_at": 1792592000,
|
||||
"expiry_key": "30d"
|
||||
}
|
||||
```
|
||||
|
||||
| `expiry` | Durée |
|
||||
|---|---|
|
||||
| `1d` | 1 jour |
|
||||
| `30d` | 1 mois |
|
||||
| `180d` | 6 mois |
|
||||
| `365d` | 1 an |
|
||||
| `never` | sans expiration |
|
||||
|
||||
Gestion :
|
||||
|
||||
| Endpoint | Rôle |
|
||||
|---|---|
|
||||
| `GET /api/auth/tokens` | Lister vos clés (`last_used_at`, statut) |
|
||||
| `POST /api/auth/tokens` | Créer (`{name, expiry}`) |
|
||||
| `DELETE /api/auth/tokens/{jti}` | Révoquer immédiatement (API **et** MCP) |
|
||||
|
||||
> Le JWT brut n'est **jamais persisté** : copiez-le à la création. Plafond :
|
||||
> 50 clés actives par utilisateur.
|
||||
|
||||
---
|
||||
|
||||
## 3. Référence des endpoints
|
||||
|
||||
> Liste non exhaustive — la référence faisant foi est `/openapi.json`. Les
|
||||
> colonnes **Auth** indiquent le niveau requis (`—`, `Oui`, `Admin`).
|
||||
|
||||
### 3.1 Système
|
||||
|
||||
| Endpoint | Description | Méthode | Auth |
|
||||
|---|---|---|---|
|
||||
| `/api/health` | Santé (statut, version, stats) | GET | — |
|
||||
| `/api/health/detailed` | Santé détaillée | GET | — |
|
||||
| `/api/config` | Lire / écrire la configuration | GET/POST | Oui/Admin |
|
||||
| `/api/diagnostics` | Statistiques index & mémoire | GET | Admin |
|
||||
| `/api/dashboard` | Statistiques du tableau de bord | GET | Oui |
|
||||
| `/api/events` | Flux SSE temps réel | GET | Oui |
|
||||
|
||||
### 3.2 Vaults
|
||||
|
||||
| Endpoint | Description | Méthode | Auth |
|
||||
|---|---|---|---|
|
||||
| `/api/vaults` | Liste (filtrée par permissions) | GET | Oui |
|
||||
| `/api/vaults/status` | Statut de toutes les vaults | GET | Oui |
|
||||
| `/api/vaults/add` | Ajouter une vault (volume déjà monté) | POST | Admin |
|
||||
| `/api/vaults/{name}` | Supprimer une vault | DELETE | Admin |
|
||||
| `/api/index/reload` | Réindexation complète | GET | Admin |
|
||||
| `/api/index/reload/{vault}` | Réindexer une vault | GET | Oui |
|
||||
| `/api/vaults/{vault}/settings` | Lire / écrire les réglages | GET/POST | Oui |
|
||||
| `/api/attachments/rescan/{vault}` | Rescanner les attachements | POST | Oui |
|
||||
|
||||
### 3.3 Fichiers
|
||||
|
||||
| Endpoint | Description | Méthode | Auth |
|
||||
|---|---|---|---|
|
||||
| `/api/browse/{vault}?path=` | Naviguer dans les dossiers | GET | Oui |
|
||||
| `/api/file/{vault}?path=` | Contenu rendu (Markdown) | GET | Oui |
|
||||
| `/api/file/{vault}/raw?path=` | Contenu brut | GET | Oui |
|
||||
| `/api/file/{vault}/download?path=` | Télécharger | GET | Oui |
|
||||
| `/api/file/{vault}/save?path=` | Enregistrer | PUT | Oui |
|
||||
| `/api/file/{vault}` | Créer | POST | Oui |
|
||||
| `/api/file/{vault}` | Renommer | PATCH | Oui |
|
||||
| `/api/file/{vault}` | Supprimer | DELETE | Oui |
|
||||
| `/api/directory/{vault}` | Créer / renommer / supprimer un dossier | POST/PATCH/DELETE | Oui |
|
||||
| `/api/move/{vault}` | Déplacer un fichier/dossier | POST | Oui |
|
||||
| `/api/vault/{vault}/batch-upload` | Upload multiple (multipart) | POST | Oui |
|
||||
| `/api/image/{vault}?path=` | Servir une image | GET | Oui |
|
||||
|
||||
### 3.4 Recherche & graphe
|
||||
|
||||
| Endpoint | Description | Méthode | Auth |
|
||||
|---|---|---|---|
|
||||
| `/api/search` | Recherche simple (legacy) | GET | Oui |
|
||||
| `/api/search/advanced` | Recherche TF-IDF avancée (facettes, tri, pagination, `semantic=`) | GET | Oui |
|
||||
| `/api/search/replace` | Recherche/remplacement multi-fichiers | POST | Oui |
|
||||
| `/api/tags?vault=` | Tags uniques avec compteurs | GET | Oui |
|
||||
| `/api/suggest?q=` | Autocomplétion de titres | GET | Oui |
|
||||
| `/api/tags/suggest?q=` | Autocomplétion de tags | GET | Oui |
|
||||
| `/api/tree-search` | Recherche de fichiers/dossiers | GET | Oui |
|
||||
| `/api/vault/{vault}/paths` | Liste de chemins | GET | Oui |
|
||||
| `/api/graph/{vault}` | Graphe de liens | GET | Oui |
|
||||
|
||||
### 3.5 Sauvegardes
|
||||
|
||||
| Endpoint | Description | Méthode | Auth |
|
||||
|---|---|---|---|
|
||||
| `/api/file/{vault}/backups` | Backups d'un fichier | GET | Oui |
|
||||
| `/api/file/{vault}/diff` | Diff avec une version | GET | Oui |
|
||||
| `/api/file/{vault}/restore` | Restaurer une version | POST | Oui |
|
||||
| `/api/backups` | Lister les backups | GET | Oui |
|
||||
| `/api/backups/content` | Contenu d'un backup | GET | Oui |
|
||||
| `/api/backups/delete` / `/purge` / `/compress` / `/auto` | Gestion & purge | POST | Oui |
|
||||
|
||||
### 3.6 Exports
|
||||
|
||||
| Endpoint | Description | Méthode |
|
||||
|---|---|---|
|
||||
| `/api/export/html` | Exporter en HTML | GET |
|
||||
| `/api/export/md-bundle` | Exporter en bundle Markdown (ZIP) | GET |
|
||||
| `/api/export/epub` | Exporter en ePub | GET |
|
||||
| `/api/guide/download?format=md\|pdf&lang=fr\|en` | Télécharger le guide intégré | GET |
|
||||
|
||||
### 3.7 PDF
|
||||
|
||||
| Endpoint | Description | Méthode |
|
||||
|---|---|---|
|
||||
| `/api/file/{vault}/pdf/info` | Métadonnées sans transfert | GET |
|
||||
| `/api/file/{vault}/pdf/stream` | Streaming (HTTP Range, 206) | GET |
|
||||
|
||||
### 3.8 IA
|
||||
|
||||
| Endpoint | Description | Méthode |
|
||||
|---|---|---|
|
||||
| `/api/ai/status` | Statut des fournisseurs | GET |
|
||||
| `/api/ai/improve`, `/fix-spelling`, `/summarize`, `/translate`, `/rewrite`, `/to-list`, `/to-table`, `/frontmatter`, `/inline-complete`, `/to-canvas`… | Actions éditeur IA | POST |
|
||||
| `/api/ai/model-capabilities?provider=&model=` | Capacités d'un modèle | GET |
|
||||
| `/api/ai/bookslm/*` | Console IA par répertoire | POST/GET |
|
||||
| `/api/ai/skills` | Lister / créer / supprimer des skills | GET/POST/DELETE |
|
||||
| `/api/config/ai-keys` · `/api/config/tool-keys` | Clés fournisseurs & sources | GET/POST/DELETE |
|
||||
|
||||
### 3.9 Authentification & administration
|
||||
|
||||
| Endpoint | Description | Méthode | Auth |
|
||||
|---|---|---|---|
|
||||
| `/api/auth/status` | Statut de l'auth | GET | — |
|
||||
| `/api/auth/login` · `/refresh` · `/logout` | Cycle de session | POST | — / Cookie / Oui |
|
||||
| `/api/auth/me` | Profil courant | GET/PATCH | Oui |
|
||||
| `/api/auth/change-password` | Changer le mot de passe | POST | Oui |
|
||||
| `/api/auth/mfa/*` | TOTP, WebAuthn, recovery | POST/GET | Oui |
|
||||
| `/api/auth/tokens` | Clés API (voir §2.2) | GET/POST/DELETE | Oui |
|
||||
| `/api/auth/admin/users` | Lister / créer des utilisateurs | GET/POST | Admin |
|
||||
| `/api/auth/admin/users/{u}` | Modifier / supprimer | PATCH/DELETE | Admin |
|
||||
| `/api/admin/stats` · `/audit` · `/backup-stats` · `/stream` | Monitoring admin | GET | Admin |
|
||||
|
||||
> `PATCH /api/auth/me` accepte `{"avatar": "<data-url>"}` (PNG/JPEG/WebP, 400 000
|
||||
> caractères max, octets magiques contrôlés) ; `{"avatar": ""}` supprime la photo.
|
||||
> La valeur est renvoyée par `GET /api/auth/me` et par le payload `user` du login.
|
||||
|
||||
### 3.10 Partage, webhooks, conflits, plugins, push
|
||||
|
||||
| Endpoint | Description | Méthode |
|
||||
|---|---|---|
|
||||
| `/api/share/{vault}` | Créer un lien de partage public | POST |
|
||||
| `/api/shares` | Lister / supprimer les partages | GET/DELETE |
|
||||
| `/api/webhooks` | CRUD webhooks (HMAC-SHA256) | GET/POST/PATCH/DELETE |
|
||||
| `/api/conflicts` · `/api/conflicts/resolve` | Conflits Syncthing | GET/POST |
|
||||
| `/api/plugins` | Installer / activer / désactiver | GET/POST/DELETE |
|
||||
| `/api/push/*` | Abonnement Web Push (VAPID) | GET/POST/DELETE |
|
||||
|
||||
---
|
||||
|
||||
## 4. Exemples `curl`
|
||||
|
||||
```bash
|
||||
BASE=http://localhost:2020
|
||||
TOKEN=$(curl -s -X POST $BASE/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"secret"}' | jq -r .access_token)
|
||||
|
||||
# Santé
|
||||
curl -s $BASE/api/health
|
||||
|
||||
# Lister les vaults
|
||||
curl -s $BASE/api/vaults -H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Naviguer
|
||||
curl -s "$BASE/api/browse/Recettes?path=" -H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Lire un fichier (rendu Markdown)
|
||||
curl -s "$BASE/api/file/Recettes?path=pizza.md" -H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Lire en brut
|
||||
curl -s "$BASE/api/file/Recettes/raw?path=pizza.md" -H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Sauvegarder
|
||||
curl -s -X PUT "$BASE/api/file/Recettes/save?path=pizza.md" \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"content":"# Pizza\n\nNouvelle recette."}'
|
||||
|
||||
# Recherche avancée
|
||||
curl -s "$BASE/api/search/advanced?q=tag:cuisine%20pizza&vault=all&limit=20&offset=0&sort=relevance" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Autocomplétion
|
||||
curl -s "$BASE/api/suggest?q=piz&vault=all" -H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Forcer une réindexation
|
||||
curl -s $BASE/api/index/reload -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
> Le mot de passe peut aussi être fourni par une clé API dans `Authorization`.
|
||||
> Quand l'auth est désactivée, omettez l'en-tête.
|
||||
|
||||
---
|
||||
|
||||
## 5. Temps réel
|
||||
|
||||
### 5.1 SSE — `/api/events`
|
||||
|
||||
Flux d'événements de changement d'index (fichiers créés/supprimés/modifiés), avec
|
||||
reconnexion automatique côté client.
|
||||
|
||||
```bash
|
||||
curl -N "$BASE/api/events"
|
||||
```
|
||||
|
||||
### 5.2 WebSocket — collaboration
|
||||
|
||||
`ws(s)://<hôte>/ws/collab/{vault}/{path}` transporte les mises à jour
|
||||
Yjs/CRDT et la présence (curseurs distants). Authentification par cookie
|
||||
`access_token` ou paramètre `?token=`, avec contrôle d'accès par vault.
|
||||
Voir [Édition & collaboration](./COLLABORATION.md).
|
||||
|
||||
---
|
||||
|
||||
## 6. Limites et bonnes pratiques
|
||||
|
||||
- **Rate limiting** : les endpoints de login et les outils IA sont limités ;
|
||||
respectez `retry_after` en cas de `429`.
|
||||
- **Permissions** : chaque endpoint fichier vérifie l'accès au vault et rejette
|
||||
les chemins hors vault (path traversal).
|
||||
- **Clés API** : préférez-les aux mots de passe pour les scripts ; révoquez-les
|
||||
dès qu'elles ne servent plus.
|
||||
- **Gros volumes** : utilisez la pagination (`limit`/`offset`) et le streaming
|
||||
HTTP Range pour les PDF.
|
||||
- **Exports** : `md-bundle` et `epub` renvoient un fichier binaire — utilisez
|
||||
`-o` avec `curl`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Dépannage
|
||||
|
||||
| Code | Cause probable |
|
||||
|---|---|
|
||||
| `401` | Jeton absent, expiré ou révoqué |
|
||||
| `403` | Compte sans accès à cette vault / réservé admin |
|
||||
| `404` | Vault, fichier ou chemin inexistant |
|
||||
| `409` | Conflit (fichier déjà existant, etc.) |
|
||||
| `422` | Corps de requête invalide (schéma Pydantic) |
|
||||
| `429` | Rate limit dépassé — voir `retry_after` |
|
||||
| `501` | Export PDF indisponible (WeasyPrint/GTK absent) |
|
||||
@@ -0,0 +1,217 @@
|
||||
# 🤖 Guide Assistant IA & Forge
|
||||
|
||||
ObsiGate intègre un **assistant IA** capable de lire, rechercher et modifier vos
|
||||
notes, ainsi qu'un **éditeur IA** (CodeMirror + toolbar) et une console
|
||||
contextuelle par répertoire (**BooksLM**). Ce guide explique comment les
|
||||
configurer et les utiliser.
|
||||
|
||||
> **Fiches techniques :** [`ai-tools-mcp.md`](../features/ai-tools-mcp.md) ·
|
||||
> [`ai-assistant-commands.md`](../features/ai-assistant-commands.md) ·
|
||||
> [`ai-quick-actions.md`](../features/ai-quick-actions.md) ·
|
||||
> [`forge-assistant.md`](../features/forge-assistant.md) ·
|
||||
> [`bookslm.md`](../features/bookslm.md) ·
|
||||
> [`ai-tools-roadmap.md`](../features/ai-tools-roadmap.md)
|
||||
> **Voir aussi :** [Serveur MCP](./MCP.md) · [API REST](./API_REST.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Vue d'ensemble
|
||||
|
||||
L'IA d'ObsiGate se compose de plusieurs surfaces complémentaires :
|
||||
|
||||
| Surface | Rôle |
|
||||
|---|---|
|
||||
| **Éditeur IA** | Toolbar d'actions sur le document ouvert (CodeMirror) |
|
||||
| **Assistant IA** | Panneau de discussion avec *function calling* sur vos vaults |
|
||||
| **BooksLM** | Console IA contextuelle sur un **répertoire** (style NotebookLM) |
|
||||
| **Forge** | Éditeur avancé avec assistant IA intégré |
|
||||
| **Outils (tools)** | Lecture, recherche, écriture, opérations destructives (two-step) |
|
||||
| **MCP** | Exposition des mêmes outils à Claude Desktop, Cursor, Cline… |
|
||||
|
||||
---
|
||||
|
||||
## 2. Configurer un fournisseur
|
||||
|
||||
### 2.1 Fournisseurs supportés
|
||||
|
||||
ObsiGate est **multi-fournisseur** :
|
||||
|
||||
- **DeepSeek**
|
||||
- **OpenRouter**
|
||||
- **Google Gemini**
|
||||
|
||||
Chaque fournisseur se configure au choix :
|
||||
|
||||
1. **Depuis l'interface** — menu → Configurations → **Clés API IA**. La clé saisie
|
||||
est stockée dans `data/api_keys.json` et **prime** sur la variable
|
||||
d'environnement.
|
||||
2. **Par variable d'environnement** — voir `.env.example`.
|
||||
|
||||
### 2.2 Modèle et capacités
|
||||
|
||||
L'interface affiche les **capacités** de chaque modèle (8 indicateurs : vision,
|
||||
tool calling, contexte long, etc.), via
|
||||
`GET /api/ai/model-capabilities?provider=&model=`. Le picker de l'assistant
|
||||
propose une recherche de modèle et une bulle d'information ⓘ.
|
||||
|
||||
Vous pouvez définir un **modèle par défaut** et un fournisseur par défaut dans la
|
||||
configuration. Le fournisseur/modèle est **partagé** entre l'assistant et Forge.
|
||||
|
||||
### 2.3 Tester la configuration
|
||||
|
||||
`POST /api/config/ai-keys/test` vérifie qu'une clé fonctionne. En cas d'échec,
|
||||
un message explicite s'affiche.
|
||||
|
||||
---
|
||||
|
||||
## 3. Éditeur IA (toolbar)
|
||||
|
||||
Quand un document Markdown est ouvert dans l'éditeur, une **toolbar IA** propose
|
||||
des actions qui remplacent ou insèrent du contenu. Actions principales :
|
||||
|
||||
| Action | Effet |
|
||||
|---|---|
|
||||
| **Améliorer** | Relecture et amélioration générale |
|
||||
| **Corriger** | Correction orthographique et grammaticale |
|
||||
| **Raccourcir / Allonger** | Ajuste la longueur du texte |
|
||||
| **Simplifier** | Vulgarise le contenu |
|
||||
| **Ton** | Adapte le registre (formel, neutre…) |
|
||||
| **Traduire** | Traduit la sélection ou le document |
|
||||
| **Expliquer** | Explique un passage |
|
||||
| **Résumer** | Produit un résumé |
|
||||
| **Continuer** | Prolonge le texte |
|
||||
| **Réécrire** | Réécriture personnalisée libre |
|
||||
| **En liste / En tableau** | Convertit en liste à puces ou tableau Markdown |
|
||||
| **Frontmatter** | Génère ou met à jour le frontmatter YAML |
|
||||
| **Complétion inline** | `Ctrl + J` — complétion directement dans l'éditeur |
|
||||
| **En canvas** | Transforme en diagramme canvas |
|
||||
|
||||
> Les actions sont exposées par `backend/ai_routes.py` (préfixe `/api/ai`). Le
|
||||
> contexte ad-hoc (fichiers ouverts, répertoire, recherche, récents) est injecté
|
||||
> automatiquement.
|
||||
|
||||
---
|
||||
|
||||
## 4. Forge et Editer
|
||||
|
||||
- **Editer** ouvre le document dans l'éditeur CodeMirror classique.
|
||||
- **Forge** ouvre l'**éditeur avancé** : mêmes capacités d'édition, mais avec
|
||||
l'**assistant IA partagé** intégré (bouton AI Panel), insertion rapide
|
||||
(`Alt + I`), aide (`F1`) et mode plein écran.
|
||||
|
||||
Dans les deux cas, `Editer` et `Forge` **remplacent** la vue lecture ; revenez en
|
||||
lecture avec `✓` / `×` ou `Échap`. Le panneau de l'assistant reste accessible à
|
||||
côté.
|
||||
|
||||
---
|
||||
|
||||
## 5. Assistant IA & BooksLM
|
||||
|
||||
### 5.1 Discussion avec outils
|
||||
|
||||
L'assistant (panneau latéral) discute et **appelle des outils** pour agir sur
|
||||
vos vaults : `list_vaults`, `read_file`, `search_fulltext`, `get_backlinks`,
|
||||
`list_tags`, etc. Les opérations d'écriture passent par une **confirmation en
|
||||
deux temps** (aperçu + jeton, puis application).
|
||||
|
||||
### 5.2 Contexte `@`
|
||||
|
||||
Tapez `@` pour attacher :
|
||||
|
||||
- un **fichier** (chip de contexte) ;
|
||||
- un **répertoire** (chip de contexte) ;
|
||||
- une **image** (pièce jointe, si le modèle gère la vision).
|
||||
|
||||
Le menu est alimenté par `/api/tree-search` (repli sur la liste des fichiers du
|
||||
vault). Les chips sont retirables et rechargent le contexte.
|
||||
|
||||
### 5.3 Commandes `/` et skills
|
||||
|
||||
Tapez `/` pour ouvrir le **menu de commandes** (navigation `↑`/`↓`/`Entrée`/`Échap`).
|
||||
|
||||
**30 skills intégrés**, répartis par familles :
|
||||
|
||||
| Famille | Exemples |
|
||||
|---|---|
|
||||
| Base | `/research`, `/resume`, `/reformuler`, `/correction`, `/brainstorm`, `/plan`, `/ask`, `/meeting-note`, `/livrable` |
|
||||
| Extraction & structuration | `/extract`, `/timeline`, `/glossary`, `/tag` |
|
||||
| Transformation & adaptation | `/translate`, `/adapt`, `/clean`, `/summary-progressive` |
|
||||
| Analyse critique & décision | `/critique`, `/compare`, `/prioritize`, `/swot`, `/debate` |
|
||||
| Apprentissage & mémorisation | `/quiz`, `/reading-note`, `/qa-generator` |
|
||||
| Méta-gestion & confidentialité | `/link`, `/anonymize`, `/estimate` |
|
||||
|
||||
Chaque skill applique un bloc de règles commun (français, notes traitées comme
|
||||
données, anti-hallucination, conservation des noms/dates/chiffres).
|
||||
|
||||
**Skills utilisateur** : `/create-new-skill` ouvre une modale et persiste le
|
||||
skill dans `data/skills.json` (par utilisateur). Ils sont listés par
|
||||
`GET /api/ai/skills` et supprimables.
|
||||
|
||||
**Commandes admin** (exécutées localement, sans LLM) : `/help`, `/providers`,
|
||||
`/provider <nom>`, `/model <nom>`, `/keys`.
|
||||
|
||||
### 5.4 Actions rapides
|
||||
|
||||
Un catalogue de **25 actions** en 6 catégories est proposé sous forme de boutons
|
||||
contextuels (« Résumer en 3 points », « Checklist d'actions », « Générer le
|
||||
frontmatter », « Expliquer le code », « Fusionner », « Traduire »…). Un tiroir
|
||||
**« Toutes les actions »** permet de rechercher dans le catalogue.
|
||||
|
||||
### 5.5 Deep Research
|
||||
|
||||
Le mode **Deep Research** enchaîne recherche web et synthèse. Il est activé via
|
||||
le panneau **« + »** de l'assistant (fichiers, contextes, skills, Deep Research).
|
||||
|
||||
### 5.6 Historique
|
||||
|
||||
Les conversations sont **persistées côté backend** et accessibles depuis la
|
||||
sidebar « Historique IA », avec filtre de recherche.
|
||||
|
||||
---
|
||||
|
||||
## 6. Outils (function calling)
|
||||
|
||||
Les outils sont définis dans `backend/tools/` — **source unique de vérité**,
|
||||
partagée par l'assistant in-app et le serveur MCP.
|
||||
|
||||
| Catégorie | Outils |
|
||||
|---|---|
|
||||
| Vaults / navigation | `list_vaults`, `list_directory`, `list_all_files` |
|
||||
| Lecture | `read_file`, `read_file_raw`, `get_backlinks`, `list_backups`, `diff_backup`, `get_graph` |
|
||||
| Recherche | `search_fulltext`, `search_advanced`, `search_paths`, `list_tags`, `suggest_tags`, `list_recent` |
|
||||
| Écriture (propose/apply) | `create_file`, `create_directory`, `edit_file`, `append_to_file`, `restore_backup` |
|
||||
| Destructif (propose/apply) | `rename_file`, `rename_directory`, `move_path`, `replace_in_files`, `delete_file`, `delete_directory` |
|
||||
| Web / sources connectées | `web_search`, `fetch_url`, sources Gitea/GitHub… |
|
||||
|
||||
Les mutations suivent un flux **two-step** : `propose_<tool>` renvoie un aperçu
|
||||
et un **jeton signé à usage unique**, puis `apply_<tool>` exécute.
|
||||
|
||||
---
|
||||
|
||||
## 7. Sécurité
|
||||
|
||||
- **Permissions par vault** appliquées à chaque outil.
|
||||
- **Anti path-traversal** via `resolve_safe_path`.
|
||||
- **Confirmation two-step** pour toute mutation.
|
||||
- **Toggle `aiDestructiveTools`** par vault : le désactiver bloque
|
||||
rename/move/replace/delete, sans bloquer create/edit/append.
|
||||
- **Backup automatique** avant chaque opération destructive.
|
||||
- **Rate limiting** par identité et par outil.
|
||||
- **Redaction des secrets** dans tous les retours d'outils.
|
||||
- **Audit** de chaque appel (`data/audit.log`, action `ai_tool_call`).
|
||||
|
||||
Détails : [Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md) et
|
||||
[`MCP.md`](./MCP.md) §5.
|
||||
|
||||
---
|
||||
|
||||
## 8. Dépannage
|
||||
|
||||
| Symptôme | Piste |
|
||||
|---|---|
|
||||
| « Aucun fournisseur configuré » | Saisir une clé API (Configurations → Clés API IA) et la tester |
|
||||
| L'IA n'a pas accès à un fichier | Vérifier `list_vaults` et les permissions du compte |
|
||||
| L'image est refusée | Le modèle ne supporte pas la vision (400) — choisir un modèle multimodal |
|
||||
| Une mutation reste bloquée | Vérifier `aiDestructiveTools` et le flux `propose_` → `apply_` |
|
||||
| Quota d'outils atteint | Respecter `OBSIGATE_TOOL_RATE_LIMIT` / `retry_after` |
|
||||
| Réponse tronquée | Ajuster `BOOKSLM_MAX_TOOL_READ_BYTES` / le modèle |
|
||||
@@ -0,0 +1,234 @@
|
||||
# 🔒 Guide Authentification & sécurité
|
||||
|
||||
ObsiGate embarque un système d'authentification optionnel **JWT + Argon2id**,
|
||||
un contrôle d'accès **par vault**, du MFA (TOTP, WebAuthn, codes de secours) et
|
||||
des mécanismes de durcissement. Ce guide couvre l'activation, la gestion des
|
||||
comptes et les bonnes pratiques.
|
||||
|
||||
> **Public :** administrateurs · **Voir aussi :**
|
||||
> [`features/api-mcp-tokens-107.md`](../features/api-mcp-tokens-107.md) ·
|
||||
> [API REST](./API_REST.md) · [MCP](./MCP.md) · [Déploiement Docker](./DEPLOIEMENT_DOCKER.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Vue d'ensemble
|
||||
|
||||
- **Désactivée par défaut** (`OBSIGATE_AUTH_ENABLED=false`) — compatible avec
|
||||
toutes les installations existantes.
|
||||
- Quand elle est activée, l'écran de connexion s'affiche et chaque endpoint
|
||||
vérifie l'utilisateur et ses permissions.
|
||||
- Les données d'auth (`users.json`, `secret.key`, `api_tokens.json`) vivent dans
|
||||
`/app/data` — **montez ce dossier en volume** pour les persister.
|
||||
|
||||
---
|
||||
|
||||
## 2. Activer l'authentification
|
||||
|
||||
### 2.1 Fichier `.env`
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
```bash
|
||||
OBSIGATE_AUTH_ENABLED=true
|
||||
OBSIGATE_ADMIN_USER=admin
|
||||
OBSIGATE_ADMIN_PASSWORD=votre_mot_de_passe # vide = auto-généré (voir logs)
|
||||
# OBSIGATE_SECURE_COOKIES=false # true si derrière HTTPS
|
||||
```
|
||||
|
||||
### 2.2 `docker-compose.yml`
|
||||
|
||||
```yaml
|
||||
env_file:
|
||||
- .env
|
||||
```
|
||||
|
||||
> **Ne mettez jamais de mot de passe dans `docker-compose.yml` !** Utilisez
|
||||
> toujours `.env` (non committé).
|
||||
|
||||
### 2.3 Premier démarrage
|
||||
|
||||
Si aucun utilisateur n'existe, ObsiGate crée un compte admin et affiche le mot de
|
||||
passe **une seule fois dans les logs** :
|
||||
|
||||
```bash
|
||||
docker compose logs obsigate | grep -A4 "FIRST"
|
||||
```
|
||||
|
||||
```
|
||||
============================================================
|
||||
FIRST STARTUP — Admin account created automatically
|
||||
Username : admin
|
||||
Password : xK9mQ3pLr7wN2jT5
|
||||
CHANGE THIS PASSWORD on first login!
|
||||
============================================================
|
||||
```
|
||||
|
||||
Changez-le immédiatement (menu profil → *Changer le mot de passe*).
|
||||
|
||||
Vous pouvez aussi ajouter une **photo de profil** : *Configurations → Profil →
|
||||
Choisir une image* (PNG, JPG ou WEBP, 8 Mo maximum — recadrée en carré 256 px).
|
||||
Elle remplace les initiales dans le cercle du compte en bas de la sidebar et peut
|
||||
être supprimée à tout moment depuis la même section.
|
||||
|
||||
---
|
||||
|
||||
## 3. Gestion des utilisateurs
|
||||
|
||||
### 3.1 Interface d'administration
|
||||
|
||||
Un compte **admin** voit une icône 🛡️ dans le header. Le panneau permet de :
|
||||
|
||||
- lister tous les utilisateurs ;
|
||||
- créer / modifier / supprimer des comptes ;
|
||||
- assigner les vaults accessibles par utilisateur ;
|
||||
- activer / désactiver des comptes.
|
||||
|
||||
### 3.2 Ligne de commande
|
||||
|
||||
```bash
|
||||
# Créer un utilisateur
|
||||
docker exec obsigate python backend/create_admin.py create alice MotDePasse --role user --vaults Recettes IT
|
||||
|
||||
# Créer un admin avec accès total
|
||||
docker exec obsigate python backend/create_admin.py create bob SecretPass --role admin --vaults "*"
|
||||
|
||||
# Lister
|
||||
docker exec obsigate python backend/create_admin.py list
|
||||
|
||||
# Supprimer
|
||||
docker exec obsigate python backend/create_admin.py delete alice
|
||||
```
|
||||
|
||||
### 3.3 Contrôle d'accès par vault
|
||||
|
||||
| Valeur `vaults` | Accès |
|
||||
|---|---|
|
||||
| `["*"]` | Toutes les vaults (y compris futures) — défaut admin |
|
||||
| `["Recettes", "IT"]` | Uniquement ces vaults |
|
||||
| `[]` | Aucun accès |
|
||||
|
||||
Les permissions sont revérifiées à chaque requête (et à chaque connexion
|
||||
WebSocket de collaboration).
|
||||
|
||||
---
|
||||
|
||||
## 4. MFA (authentification multifacteur)
|
||||
|
||||
ObsiGate propose trois secondes facteurs, configurables par l'utilisateur.
|
||||
|
||||
### 4.1 TOTP (application d'authentification)
|
||||
|
||||
1. Menu profil → **Sécurité** → *Configurer TOTP* (`POST /api/auth/mfa/totp/setup`).
|
||||
2. Scannez le QR code avec Google Authenticator, Authy, etc.
|
||||
3. Validez le code (`POST /api/auth/mfa/totp/enable`).
|
||||
4. Désactivation : `POST /api/auth/mfa/totp/disable` (mot de passe requis).
|
||||
|
||||
### 4.2 Clés de sécurité & biométrie (WebAuthn)
|
||||
|
||||
- Enregistrement : `POST /api/auth/mfa/webauthn/register/options` puis
|
||||
`POST /api/auth/mfa/webauthn/register`.
|
||||
- Connexion : `POST /api/auth/mfa/webauthn/options` puis `/verify`.
|
||||
- Gestion des clés : `GET /api/auth/mfa/webauthn/credentials`,
|
||||
`POST /api/auth/mfa/webauthn/credentials/remove`.
|
||||
|
||||
> Le *relying party* (domaine) est **dérivé de la requête** (hôte exact, port
|
||||
> inclus) ; derrière un reverse proxy, activez `OBSIGATE_TRUST_PROXY=true` pour
|
||||
> que `X-Forwarded-Host/Proto` soient pris en compte.
|
||||
|
||||
### 4.3 Codes de secours
|
||||
|
||||
À l'activation du MFA, des **codes de récupération** sont générés. Utilisez-en un
|
||||
via `POST /api/auth/mfa/recovery` si vous perdez votre second facteur. Conservez-
|
||||
les hors ligne.
|
||||
|
||||
### 4.4 Statut
|
||||
|
||||
`GET /api/auth/mfa/status` indique les facteurs actifs pour le compte courant.
|
||||
|
||||
---
|
||||
|
||||
## 5. Clés API & MCP
|
||||
|
||||
Pour les scripts et les clients externes, créez une **clé API longue durée**
|
||||
(1 j, 1 mois, 6 mois, 1 an, sans fin) depuis Configurations → 🔑 **Clés API &
|
||||
MCP**. Une seule clé authentifie l'API REST **et** le serveur MCP.
|
||||
|
||||
- Le secret n'est **affiché qu'une fois** (pattern GitHub) et n'est jamais persisté.
|
||||
- La révocation est **immédiate** des deux côtés.
|
||||
- Une colonne « dernière utilisation » (throttlée) aide à repérer les clés
|
||||
dormantes.
|
||||
|
||||
Détails : [API REST §2.2](./API_REST.md#22-clés-api-longue-durée-recommandé-pour-scripts--mcp)
|
||||
et [`features/api-mcp-tokens-107.md`](../features/api-mcp-tokens-107.md).
|
||||
|
||||
---
|
||||
|
||||
## 6. Mécanismes de durcissement
|
||||
|
||||
| Mécanisme | Détail |
|
||||
|---|---|
|
||||
| **Path traversal** | Chaque endpoint fichier valide que le chemin résolu reste dans la vault |
|
||||
| **Rate limiting** | 10 tentatives de login max par IP / 15 min + lockout par compte |
|
||||
| **Rate limiting MFA** | Appliqué aux endpoints TOTP/WebAuthn/recovery |
|
||||
| **Audit log** | Écritures, suppressions, config dans `data/audit.log` (JSON lines, rotation 10 Mo) |
|
||||
| **Backup automatique** | Avant chaque modification/suppression dans `.obsigate-backup/` |
|
||||
| **Redaction** | Masquage des JWT, clés API, tokens dans les aperçus et retours d'outils |
|
||||
| **CSP** | `object-src`, `base-uri`, `form-action`, `frame-ancestors` restreints |
|
||||
| **Cookie HttpOnly** | Jeton retiré de `sessionStorage`, porté par cookie HTTP-only |
|
||||
| **Utilisateur non-root** | Conteneur sous `obsigate` (UID 1000) |
|
||||
| **Volumes read-only** | Vaults montées `:ro` par défaut |
|
||||
| **Atomic writes** | `users.json`, `shares.json`, `webhooks.json` écrits en tmp+replace |
|
||||
| **Symlinks ignorés** | L'index n'indexe pas les liens symboliques |
|
||||
|
||||
### Politique de mot de passe
|
||||
|
||||
Une politique minimale est validée à la création d'un compte. Choisissez des mots
|
||||
de passe longs et uniques ; activez le MFA pour les comptes admin.
|
||||
|
||||
---
|
||||
|
||||
## 7. Variables d'environnement
|
||||
|
||||
| Variable | Description | Défaut |
|
||||
|---|---|---|
|
||||
| `OBSIGATE_AUTH_ENABLED` | Activer l'authentification | `false` |
|
||||
| `OBSIGATE_ADMIN_USER` | Nom de l'admin auto-créé | `admin` |
|
||||
| `OBSIGATE_ADMIN_PASSWORD` | Mot de passe admin (vide = auto-généré) | *(auto)* |
|
||||
| `OBSIGATE_SECURE_COOKIES` | Cookie `Secure` (HTTPS uniquement) | `false` |
|
||||
| `OBSIGATE_ACCESS_TOKEN_TTL` | Durée de vie du token d'accès (s) | `3600` |
|
||||
| `OBSIGATE_REFRESH_TOKEN_TTL` | Durée de vie du refresh token (s) | `2592000` |
|
||||
| `OBSIGATE_LOGIN_MAX_ATTEMPTS` | Tentatives de login max par IP | `10` |
|
||||
| `OBSIGATE_ACCOUNT_MAX_ATTEMPTS` | Tentatives de login max par compte | `10` |
|
||||
| `OBSIGATE_LOGIN_WINDOW_SECONDS` | Fenêtre de rate limiting (s) | `900` |
|
||||
| `OBSIGATE_TRUST_PROXY` | Faire confiance à `X-Forwarded-For` / `Host` | `false` |
|
||||
|
||||
Toutes ces variables sont documentées dans `.env.example`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Déploiement sécurisé (checklist)
|
||||
|
||||
- [ ] `OBSIGATE_AUTH_ENABLED=true` sur toute instance exposée.
|
||||
- [ ] Mot de passe admin fort, changé après le premier démarrage.
|
||||
- [ ] MFA activé pour les comptes admin.
|
||||
- [ ] HTTPS via reverse proxy + `OBSIGATE_SECURE_COOKIES=true`.
|
||||
- [ ] `OBSIGATE_TRUST_PROXY=true` **uniquement** derrière un proxy de confiance.
|
||||
- [ ] Volume `./data:/app/data` monté et **sauvegardé**.
|
||||
- [ ] Vaults montées en `:ro` (lecture seule) sauf besoin d'écriture.
|
||||
- [ ] Clés API révoquées dès qu'elles ne servent plus.
|
||||
- [ ] Accès réseau restreint (VPN / pare-feu) si possible.
|
||||
|
||||
---
|
||||
|
||||
## 9. Dépannage
|
||||
|
||||
| Symptôme | Piste |
|
||||
|---|---|
|
||||
| Login bloqué `429` | Rate limit : attendre la fenêtre (`OBSIGATE_LOGIN_WINDOW_SECONDS`) |
|
||||
| WebAuthn refuse l'enregistrement | Domaine/port non dérivés — activer `OBSIGATE_TRUST_PROXY` derrière un proxy |
|
||||
| TOTP « challenge inattendu » | Relancer la cérémonie ; les 5 derniers challenges sont acceptés |
|
||||
| Perte du second facteur | Utiliser un code de secours (`/api/auth/mfa/recovery`) |
|
||||
| Sessions perdues au redémarrage | Le volume `./data` n'est pas monté |
|
||||
| Clé API `401` | Clé expirée ou révoquée — en créer une nouvelle |
|
||||
@@ -0,0 +1,86 @@
|
||||
# 📝 Guide Édition & collaboration temps réel
|
||||
|
||||
Plusieurs utilisateurs peuvent éditer le **même document Markdown
|
||||
simultanément**, façon Google Docs, grâce à Yjs (CRDT) et à un canal WebSocket.
|
||||
Ce guide explique le fonctionnement et l'utilisation.
|
||||
|
||||
> **Public :** tous les utilisateurs · **Fiche technique :**
|
||||
> [`features/collaboration.md`](../features/collaboration.md)
|
||||
> **Voir aussi :** [Prise en main](./PRISE_EN_MAIN.md) · [API REST](./API_REST.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Ce que fait la collaboration
|
||||
|
||||
- **Fusion sans conflit** via **Yjs (CRDT)** : deux personnes peuvent taper au
|
||||
même endroit, aucune modification n'est perdue.
|
||||
- **Curseurs distants colorés** et sélections visibles dans CodeMirror, étiquetés
|
||||
avec le nom de chaque utilisateur.
|
||||
- **Indicateur de présence** dans l'en-tête de l'éditeur (avatars + statut de
|
||||
connexion).
|
||||
- **Reconnexion automatique** (backoff exponentiel) : l'état est fusionné au retour.
|
||||
- **Persistance serveur** : le document est écrit sur disque **2 s** après la
|
||||
dernière modification.
|
||||
|
||||
---
|
||||
|
||||
## 2. Utilisation
|
||||
|
||||
Aucune configuration n'est nécessaire :
|
||||
|
||||
1. Ouvrez le même fichier dans **deux navigateurs** (ou deux fenêtres).
|
||||
2. Passez en mode **Editer** (ou **Forge**) dans les deux.
|
||||
3. Tapez : les modifications apparaissent en temps réel des deux côtés, avec les
|
||||
curseurs de chacun.
|
||||
|
||||
> L'édition collaborative nécessite que la vault soit **accessible en écriture**
|
||||
> (le volume Docker doit être monté **sans** `:ro` pour les vaults modifiables).
|
||||
|
||||
---
|
||||
|
||||
## 3. Transport & protocole
|
||||
|
||||
| Élément | Valeur |
|
||||
|---|---|
|
||||
| Endpoint | `ws(s)://<hôte>/ws/collab/{vault}/{chemin}` |
|
||||
| Authentification | Cookie `access_token` (ou paramètre `?token=`) |
|
||||
| Autorisation | Contrôle d'accès **par vault** appliqué à chaque connexion |
|
||||
| Protocole | Yjs / CRDT — updates + awareness (curseurs) |
|
||||
| Persistance | Écriture disque débouncée (2 s) côté serveur |
|
||||
|
||||
Le canal est mis à niveau à partir de la même origine que l'application. Derrière
|
||||
un reverse proxy, autorisez les **upgrades WebSocket** et augmentez
|
||||
`proxy_read_timeout` (voir [Déploiement Docker](./DEPLOIEMENT_DOCKER.md)).
|
||||
|
||||
---
|
||||
|
||||
## 4. Sécurité
|
||||
|
||||
- L'accès au document est **revérifié à la connexion** (permissions du compte).
|
||||
- Un utilisateur sans droit sur la vault ne peut pas rejoindre la session.
|
||||
- Les échanges passent par le même domaine que l'application (pas de serveur
|
||||
tiers).
|
||||
|
||||
---
|
||||
|
||||
## 5. Limitations & bonnes pratiques
|
||||
|
||||
- La collaboration vise les fichiers **Markdown**.
|
||||
- Évitez d'éditer le même fichier simultanément depuis ObsiGate **et** une
|
||||
application de synchronisation externe (risque de conflits au niveau fichier).
|
||||
- Le document est écrit après un court délai ; attendez la fin de la sauvegarde
|
||||
avant de fermer brutalement l'onglet.
|
||||
- En cas de conflit de synchronisation externe (Syncthing), l'écran
|
||||
**Conflits** (`/api/conflicts`) aide à résoudre.
|
||||
|
||||
---
|
||||
|
||||
## 6. Dépannage
|
||||
|
||||
| Symptôme | Piste |
|
||||
|---|---|
|
||||
| Les curseurs des autres n'apparaissent pas | Vérifier le WebSocket (proxy sans support `Upgrade`) |
|
||||
| Reconnecté sans cesse | Réseau instable ou timeout proxy trop court |
|
||||
| Modifications non persistées | Vault montée en lecture seule (`:ro`) ? |
|
||||
| `401` à la connexion | Session expirée — se reconnecter |
|
||||
| Accès refusé | Le compte n'a pas la permission sur cette vault |
|
||||
@@ -0,0 +1,221 @@
|
||||
# 🐳 Guide de déploiement Docker
|
||||
|
||||
Ce guide couvre l'installation, la configuration et l'exploitation d'ObsiGate
|
||||
avec Docker / Docker Compose, y compris le reverse proxy HTTPS et les mises à jour.
|
||||
|
||||
> **Public :** administrateurs, ops
|
||||
> **Voir aussi :** [Prise en main](./PRISE_EN_MAIN.md) ·
|
||||
> [Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md) ·
|
||||
> [`DEVELOPMENT_AND_RELEASES.md`](../DEVELOPMENT_AND_RELEASES.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Prérequis
|
||||
|
||||
| Composant | Version minimale |
|
||||
|---|---|
|
||||
| Docker | ≥ 20.10 |
|
||||
| docker-compose | ≥ 2.0 |
|
||||
| Espace disque | ~200 Mo pour l'image |
|
||||
|
||||
Systèmes supportés : Linux (Ubuntu, Debian…), macOS (Intel & Apple Silicon),
|
||||
Windows (Docker Desktop), NAS compatibles Docker (Synology, QNAP…).
|
||||
|
||||
---
|
||||
|
||||
## 2. Configuration de `docker-compose.yml`
|
||||
|
||||
```yaml
|
||||
services:
|
||||
obsigate:
|
||||
build:
|
||||
context: .
|
||||
image: obsigate:latest
|
||||
container_name: obsigate
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "2020:8080" # port local 2020 → conteneur 8080
|
||||
volumes:
|
||||
- /home/user/Documents/Obsidian-Recettes:/vaults/Recettes:ro
|
||||
- /home/user/Documents/Obsidian-IT:/vaults/IT:ro
|
||||
- ./data:/app/data # persistance auth/config/backups
|
||||
environment:
|
||||
- VAULT_1_NAME=Recettes
|
||||
- VAULT_1_PATH=/vaults/Recettes
|
||||
- VAULT_2_NAME=IT
|
||||
- VAULT_2_PATH=/vaults/IT
|
||||
- OBSIGATE_AUTH_ENABLED=true
|
||||
- OBSIGATE_ADMIN_USER=admin
|
||||
env_file:
|
||||
- .env # secrets (mot de passe admin…)
|
||||
```
|
||||
|
||||
> **Important :** les chemins de vaults doivent être **absolus** et montés en
|
||||
> **lecture seule** (`:ro`) sauf si vous voulez autoriser l'édition depuis
|
||||
> ObsiGate. Le dossier `./data` doit être **persistant**.
|
||||
|
||||
### Variables de vault
|
||||
|
||||
| Variable | Description | Exemple |
|
||||
|---|---|---|
|
||||
| `VAULT_N_NAME` | Nom affiché | `Recettes` |
|
||||
| `VAULT_N_PATH` | Chemin dans le conteneur | `/vaults/Recettes` |
|
||||
| `VAULT_N_ATTACHMENTS_PATH` | Dossier d'attachements (optionnel) | `Assets/Images` |
|
||||
| `VAULT_N_SCAN_ATTACHMENTS` | Scanner les images au démarrage | `true` |
|
||||
|
||||
**Nommage :** lettres, chiffres et tirets uniquement ; le nom doit correspondre au
|
||||
chemin interne.
|
||||
|
||||
---
|
||||
|
||||
## 3. Construire et lancer
|
||||
|
||||
### 3.1 Script `build.sh` (recommandé)
|
||||
|
||||
```bash
|
||||
chmod +x build.sh # une seule fois
|
||||
./build.sh
|
||||
```
|
||||
|
||||
Le script :
|
||||
|
||||
1. vérifie Docker et Docker Compose (versions) ;
|
||||
2. valide `docker-compose.yml` (présence + syntaxe) ;
|
||||
3. contrôle chaque volume monté (avertit si la source n'existe pas) ;
|
||||
4. construit l'image (multi-stage, ~180 Mo) ;
|
||||
5. démarre le conteneur ;
|
||||
6. affiche le statut puis les logs en temps réel.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `--help`, `-h` | Aide complète |
|
||||
| `--build-only` | Construire sans démarrer |
|
||||
| `--no-cache` | Rebuild complet sans cache **(défaut)** |
|
||||
| `--cache` | Utiliser le cache Docker (plus rapide) |
|
||||
| `--progress=plain` / `--progress=tty` | Sortie verbeuse / interactive |
|
||||
|
||||
### 3.2 Alternative manuelle
|
||||
|
||||
```bash
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 3.3 Exploitation
|
||||
|
||||
```bash
|
||||
docker compose down # arrêter
|
||||
docker compose up -d # redémarrer sans rebuild
|
||||
docker compose logs -f # logs temps réel
|
||||
docker compose logs --tail=100 obsigate
|
||||
```
|
||||
|
||||
> **Compatibilité Docker :** l'image utilise une variante `uvicorn` minimale et
|
||||
> `fastapi 0.110.3` pour éviter des dépendances natives optionnelles
|
||||
> (`watchfiles`, `uvloop`, `httptools`, `fastapi-cli`…) qui échouent sur Alpine,
|
||||
> ARM ou i386.
|
||||
|
||||
---
|
||||
|
||||
## 4. Reverse proxy & HTTPS
|
||||
|
||||
ObsiGate sert du HTTP en clair ; placez un reverse proxy devant pour TLS.
|
||||
|
||||
### 4.1 Nginx (exemple)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name obsigate.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/obsigate.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/obsigate.example.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:2020;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade; # WebSocket collab
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 3600s; # SSE / WebSocket
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Variables à activer derrière un proxy
|
||||
|
||||
```bash
|
||||
OBSIGATE_SECURE_COOKIES=true # cookie Secure (HTTPS uniquement)
|
||||
OBSIGATE_TRUST_PROXY=true # confiance à X-Forwarded-For / Host
|
||||
```
|
||||
|
||||
> N'activez `OBSIGATE_TRUST_PROXY` **que** derrière un proxy de confiance, sinon
|
||||
> l'adresse IP client peut être usurpée (rate limiting, audit).
|
||||
|
||||
Cloudflare Tunnel, Caddy et Traefik fonctionnent de la même façon (pensez au
|
||||
support WebSocket et aux longs timeouts pour le SSE).
|
||||
|
||||
---
|
||||
|
||||
## 5. Healthcheck & supervision
|
||||
|
||||
L'image intègre un healthcheck sur `/api/health` (statut, version, stats). Vous
|
||||
pouvez aussi l'interroger depuis l'hôte :
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:2020/api/health
|
||||
curl -s http://localhost:2020/api/health/detailed # admin
|
||||
```
|
||||
|
||||
`/api/admin/stream` fournit un flux d'administration (admin uniquement).
|
||||
|
||||
---
|
||||
|
||||
## 6. Mises à jour
|
||||
|
||||
```bash
|
||||
git pull
|
||||
./build.sh # reconstruit et redémarre
|
||||
```
|
||||
|
||||
Vos données (`./data`) et vos vaults (volumes `:ro`) sont conservées. Pour un
|
||||
rebuild propre sans cache : `./build.sh --no-cache`.
|
||||
|
||||
> **Version :** le fichier `VERSION` à la racine est la source unique de vérité ;
|
||||
> l'image et l'UI affichent la même version. Voir
|
||||
> [`DEVELOPMENT_AND_RELEASES.md`](../DEVELOPMENT_AND_RELEASES.md).
|
||||
|
||||
---
|
||||
|
||||
## 7. Sauvegardes
|
||||
|
||||
- **Données applicatives** : sauvegardez `./data` (utilisateurs, clés, partages,
|
||||
webhooks, jetons).
|
||||
- **Vos notes** : ObsiGate n'écrit dans les vaults que si elles sont montées en
|
||||
écriture. Un backup automatique interne est créé dans `.obsigate-backup/` avant
|
||||
chaque modification (rotation 10 Mo d'audit).
|
||||
- **Backups desktop** : voir [Desktop](./DESKTOP.md).
|
||||
|
||||
---
|
||||
|
||||
## 8. Multi-plateforme
|
||||
|
||||
L'image est publiée pour `linux/amd64`, `linux/arm64`, `linux/arm/v7` et
|
||||
`linux/386`. Sur un NAS ou un Raspberry Pi, choisissez la variante correspondante
|
||||
(Buildx / `platform:` dans le compose).
|
||||
|
||||
---
|
||||
|
||||
## 9. Dépannage
|
||||
|
||||
| Symptôme | Piste |
|
||||
|---|---|
|
||||
| Port déjà utilisé | `sudo netstat -tulpn \| grep 2020` puis changer `ports: "2021:8080"` |
|
||||
| Vault introuvable | Chemin absolu, permissions de lecture, redémarrer après modif |
|
||||
| Build qui échoue | `docker system prune -f` puis `./build.sh --progress=plain` |
|
||||
| Logs | `docker compose logs -f obsigate` |
|
||||
| Widgets temps réel inopérants derrière un proxy | Autoriser les upgrades WebSocket et augmenter `proxy_read_timeout` |
|
||||
| Login « insecure cookie » | Passer en HTTPS ou retirer `OBSIGATE_SECURE_COOKIES` |
|
||||
@@ -0,0 +1,201 @@
|
||||
# 🖥️ Guide de l'application desktop (Tauri)
|
||||
|
||||
ObsiGate Desktop est une application native construite avec
|
||||
[Tauri](https://tauri.app/) (Rust + webview système). Elle embarque le backend
|
||||
Python et le frontend dans un exécutable autonome — **zéro Docker, zéro ligne de
|
||||
commande**.
|
||||
|
||||
> **Public :** tous les utilisateurs · **Statut :** version 2.x, binaires en
|
||||
> cours de stabilisation (build depuis les sources recommandé)
|
||||
> **Fiche technique :** [`features/desktop-tauri.md`](../features/desktop-tauri.md) ·
|
||||
> **Checklist E2E :** [`DESKTOP_E2E_CHECKLIST.md`](../DESKTOP_E2E_CHECKLIST.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Fonctionnalités natives
|
||||
|
||||
| Fonctionnalité | Web | Desktop |
|
||||
|---|---|---|
|
||||
| Accès fichiers local | Via upload | Natif (sélecteur de dossier) |
|
||||
| Thème système | Manuel | Auto (suit l'OS clair/sombre) |
|
||||
| Notifications | Service Worker | Natif OS |
|
||||
| Association `.md` | ❌ | ✅ « Ouvrir avec ObsiGate » |
|
||||
| Icône de barre des tâches (tray) | ❌ | ✅ |
|
||||
| Auto-update | ❌ | ✅ (vérifie les releases Gitea) |
|
||||
| Mode hors-ligne | Limité | Complet (backend local) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Téléchargement des binaires
|
||||
|
||||
Les releases sont publiées sur
|
||||
[Gitea](https://git.dracodev.net/Projets/ObsiGate/releases) :
|
||||
|
||||
| Plateforme | Formats |
|
||||
|---|---|
|
||||
| **Linux** | `.deb` + `.AppImage` |
|
||||
| **Windows** | `.msi` + `.exe` (NSIS) |
|
||||
|
||||
### Linux
|
||||
|
||||
```bash
|
||||
# .deb (Debian / Ubuntu / Deepin)
|
||||
sudo dpkg -i obsigate_2.0.0_amd64.deb
|
||||
# Lancer : ObsiGate depuis le menu applications, ou `obsigate-desktop`
|
||||
|
||||
# .AppImage (toute distribution)
|
||||
chmod +x ObsiGate_2.0.0_amd64.AppImage
|
||||
./ObsiGate_2.0.0_amd64.AppImage
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
```cmd
|
||||
:: Double-cliquer sur ObsiGate_2.0.0_x64.msi (ou le setup NSIS)
|
||||
:: Ou lancer ObsiGate depuis le menu Démarrer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Démarrage
|
||||
|
||||
1. **Lancez l'application** depuis le menu ou la ligne de commande.
|
||||
2. Le backend Python démarre automatiquement sur `127.0.0.1:17890`
|
||||
(splash « Démarrage… » pendant le boot).
|
||||
3. La fenêtre s'ouvre et charge l'interface ObsiGate.
|
||||
4. **Premier lancement** : sélectionnez le dossier de vos vaults Obsidian via le
|
||||
sélecteur natif.
|
||||
5. Pour fermer : icône tray → **Quitter** (arrêt propre du backend).
|
||||
|
||||
---
|
||||
|
||||
## 4. Construire depuis les sources
|
||||
|
||||
Guide détaillé : [`desktop/README.md`](../../desktop/README.md).
|
||||
|
||||
### 4.1 Prérequis communs
|
||||
|
||||
| Outil | Version | Installation |
|
||||
|---|---|---|
|
||||
| Rust (cargo) | ≥ 1.75 | `rustup` |
|
||||
| Tauri CLI | ≥ 2.0 | `cargo install tauri-cli` |
|
||||
| Git | — | — |
|
||||
| Dépendances système Linux | — | `sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev` |
|
||||
|
||||
> **Important — staging :** `tauri.conf.json` embarque `backend/**` et
|
||||
> `frontend/**` **depuis le dossier `desktop/`**. Les scripts de build copient
|
||||
> automatiquement `../backend` et `../frontend` dans `desktop/` avant
|
||||
> `cargo tauri build`. Sans ce staging, le build échoue avec
|
||||
> « glob pattern backend/**/* path not found ».
|
||||
|
||||
### 4.2 Windows — `build-windows.bat`
|
||||
|
||||
```cmd
|
||||
REM Prérequis (via Scoop) : rustup, curl, git
|
||||
scoop install rustup curl git
|
||||
rustup default stable
|
||||
cargo install tauri-cli
|
||||
|
||||
cd desktop
|
||||
build-windows.bat
|
||||
```
|
||||
|
||||
Étapes du script :
|
||||
|
||||
1. Tue les processus Python résiduels (`taskkill /F /IM python.exe`).
|
||||
2. Télécharge **Python 3.11 embed** (python.org) → `desktop\python-embed\` +
|
||||
active pip (`python311._pth`).
|
||||
3. `pip install -r ..\backend\requirements.txt` dans l'embed.
|
||||
4. **Staging** : copie `..\backend` et `..\frontend` dans `desktop\`.
|
||||
5. `cargo tauri build --target x86_64-pc-windows-msvc --bundles nsis`.
|
||||
6. Copie `python-embed` à côté de l'exécutable pour le mode dev local.
|
||||
7. Nettoie les dossiers stagés.
|
||||
|
||||
→ **Artefact :** `desktop\target\x86_64-pc-windows-msvc\release\bundle\nsis\ObsiGate_2.0.0_x64-setup.exe`
|
||||
|
||||
### 4.3 Linux — `build-linux.sh`
|
||||
|
||||
```bash
|
||||
cd desktop
|
||||
chmod +x build-linux.sh
|
||||
./build-linux.sh
|
||||
```
|
||||
|
||||
Étapes du script :
|
||||
|
||||
1. Vérifie Rust + Tauri CLI, installe les dépendances système (apt).
|
||||
2. Crée un venv `desktop/python-embed/venv` + `pip install -r ../backend/requirements.txt`.
|
||||
3. **Staging** : copie `../backend` et `../frontend` dans `desktop/`.
|
||||
4. `cargo tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage`.
|
||||
5. Copie le runtime (`python-embed/`, `backend/`, `frontend/`) à côté de l'exécutable.
|
||||
|
||||
→ **Artefacts :**
|
||||
|
||||
- `desktop/target/x86_64-unknown-linux-gnu/release/bundle/deb/obsigate_2.0.0_amd64.deb`
|
||||
- `desktop/target/x86_64-unknown-linux-gnu/release/bundle/appimage/ObsiGate_2.0.0_amd64.AppImage`
|
||||
|
||||
---
|
||||
|
||||
## 5. Builds CI/CD automatiques
|
||||
|
||||
Le workflow [`.gitea/workflows/desktop-build.yml`](../../.gitea/workflows/desktop-build.yml)
|
||||
construit les binaires desktop à chaque push sur `main` touchant `desktop/**`,
|
||||
`frontend/**` ou `backend/**` (et manuellement via `workflow_dispatch`), sur des
|
||||
**runners self-hosted** :
|
||||
|
||||
| Job | Runner | Artefacts (30 jours) |
|
||||
|---|---|---|
|
||||
| `build-windows` | `[self-hosted, windows, desktop]` | `desktop/target/release/bundle/msi/*.msi` |
|
||||
| `build-linux` | `[self-hosted, linux, desktop]` | `*.AppImage` + `*.deb` |
|
||||
|
||||
Les artefacts sont téléchargeables depuis la page **Actions** du run Gitea ; la
|
||||
publication en **Gitea Release** est prévue sur les tags `v*`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Architecture desktop
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ Tauri (Rust) │
|
||||
│ ├─ Webview (webview système) │
|
||||
│ │ └─ Frontend (HTML/JS/CSS) │
|
||||
│ └─ Sidecar Python │
|
||||
│ └─ uvicorn backend.main:app │
|
||||
│ └─ port 127.0.0.1:17890 │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Cycle de vie : Tauri spawn le backend Python → health check → splash → webview.
|
||||
À la fermeture : arrêt propre du backend (SIGTERM / kill).
|
||||
|
||||
---
|
||||
|
||||
## 7. Mises à jour
|
||||
|
||||
L'application vérifie les **releases Gitea** et propose la mise à jour (updater
|
||||
Tauri signé). Le manifeste `latest.json` est généré automatiquement.
|
||||
|
||||
> La **signature de code Windows** n'est pas retenue (pas de certificat) : le
|
||||
> binaire peut déclencher un avertissement SmartScreen. Alternatives possibles :
|
||||
> SignPath.io (OSS gratuit), Certum OSS, Azure Trusted Signing, certificat EV.
|
||||
|
||||
---
|
||||
|
||||
## 8. Logs & dépannage
|
||||
|
||||
Les logs du backend sont écrits dans :
|
||||
|
||||
- **Windows** : `%APPDATA%\ObsiGate\logs\backend.log`
|
||||
- **Linux** : `~/.config/obsigate/logs/backend.log`
|
||||
|
||||
| Symptôme | Piste |
|
||||
|---|---|
|
||||
| « Backend ne répond pas » | Vérifier le port `17890` (conflit) et relancer |
|
||||
| Build « glob pattern backend/**/* not found » | Le staging n'a pas été fait — utiliser les scripts fournis |
|
||||
| Le sélecteur de dossier ne s'ouvre pas | Permissions système / dialogue natif bloqué |
|
||||
| Fenêtre blanche | Consulter `backend.log` ; le backend a peut-être échoué au boot |
|
||||
| Mise à jour non proposée | Vérifier la connectivité aux releases Gitea |
|
||||
|
||||
Voir aussi [Prise en main](./PRISE_EN_MAIN.md) et
|
||||
[Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md).
|
||||
@@ -0,0 +1,191 @@
|
||||
# 🧩 Guide MCP (Model Context Protocol)
|
||||
|
||||
ObsiGate expose ses vaults à des **clients MCP externes** (Claude Desktop, Cursor,
|
||||
Cline, tout client compatible MCP) via un serveur **Streamable HTTP** monté sur
|
||||
`/mcp`. Les outils sont les **mêmes** que ceux de l'assistant in-app : la couche
|
||||
`backend/tools/` est la source unique de vérité.
|
||||
|
||||
> **Statut :** livré (#79 phase E + F) · **Dernière mise à jour :** 2026-09
|
||||
> **Voir aussi :** [`features/ai-tools-mcp.md`](../features/ai-tools-mcp.md) ·
|
||||
> [`AI_ARCHITECTURE_GUIDE.md`](../AI_ARCHITECTURE_GUIDE.md) ·
|
||||
> [API REST](./API_REST.md) · [Assistant IA & Forge](./ASSISTANT_IA_FORGE.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Prérequis
|
||||
|
||||
1. Une instance ObsiGate accessible (locale ou distante).
|
||||
2. Une **clé API** (recommandé) ou un **jeton JWT** valide
|
||||
(`Authorization: Bearer <token>`). Une seule clé fonctionne pour l'API REST
|
||||
**et** le MCP. Créez-la depuis l'interface (Configurations → 🔑 Clés API & MCP)
|
||||
ou via `POST /api/auth/tokens` — voir [API REST §2.2](./API_REST.md#22-clés-api-longue-durée-recommandé-pour-scripts--mcp).
|
||||
3. Si l'authentification est désactivée (`OBSIGATE_AUTH_ENABLED=false`), le
|
||||
serveur MCP accepte un utilisateur anonyme disposant de tous les vaults.
|
||||
|
||||
> Le transport `stdio` n'est pas encore supporté ; utilisez le transport HTTP
|
||||
> (un pont local type `mcp-remote` si votre client ne gère pas nativement le
|
||||
> Streamable HTTP distant).
|
||||
|
||||
---
|
||||
|
||||
## 2. Endpoint & protocole
|
||||
|
||||
| Élément | Valeur |
|
||||
|---|---|
|
||||
| URL | `https://<obsigate>/mcp` |
|
||||
| Transport | Streamable HTTP (`POST` JSON-RPC 2.0, `Accept: application/json, text/event-stream`) |
|
||||
| Auth | `Authorization: Bearer <JWT>` |
|
||||
| Protocole MCP | `2025-03-26` (négocié à l'`initialize`) |
|
||||
| Réponses | JSON (`json_response=True`) |
|
||||
|
||||
Handshake minimal :
|
||||
|
||||
```bash
|
||||
curl -sS https://obsigate.example/mcp \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
|
||||
"protocolVersion":"2025-03-26","capabilities":{},
|
||||
"clientInfo":{"name":"curl","version":"1.0"}}}'
|
||||
```
|
||||
|
||||
La réponse contient l'en-tête `Mcp-Session-Id` à réutiliser pour les appels
|
||||
suivants (`tools/list`, `tools/call`, `resources/read`, …).
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration des clients
|
||||
|
||||
### Claude Desktop (via pont `mcp-remote`)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"obsigate": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y", "mcp-remote",
|
||||
"https://obsigate.example/mcp",
|
||||
"--header", "Authorization: Bearer ${OBSIGATE_TOKEN}"
|
||||
],
|
||||
"env": { "OBSIGATE_TOKEN": "eyJ..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cursor
|
||||
|
||||
`.cursor/mcp.json` :
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"obsigate": {
|
||||
"url": "https://obsigate.example/mcp",
|
||||
"headers": { "Authorization": "Bearer eyJ..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Client générique (config raccourcie)
|
||||
|
||||
```json
|
||||
{"mcpServers": {"obsigate": {
|
||||
"url": "http://localhost:2020/mcp",
|
||||
"headers": {"Authorization": "Bearer <clé API>"}
|
||||
}}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Primitives exposées
|
||||
|
||||
### 4.1 Tools
|
||||
|
||||
Les outils de **lecture/recherche** sont exposés directement. Les outils
|
||||
**d'écriture/destructifs** sont exposés via une paire **two-step** :
|
||||
`propose_<tool>` (aperçu + jeton de confirmation, aucune modification) puis
|
||||
`apply_<tool>` (consomme le jeton et exécute).
|
||||
|
||||
| Catégorie | Outils |
|
||||
|---|---|
|
||||
| Vaults / navigation | `list_vaults`, `list_directory`, `list_all_files` |
|
||||
| Lecture | `read_file`, `read_file_raw`, `get_backlinks`, `list_backups`, `diff_backup`, `get_graph` |
|
||||
| Recherche | `search_fulltext`, `search_advanced`, `search_paths`, `list_tags`, `suggest_tags`, `list_recent` |
|
||||
| Écriture (propose/apply) | `create_file`, `create_directory`, `edit_file`, `append_to_file`, `restore_backup` |
|
||||
| Destructif (propose/apply) | `rename_file`, `rename_directory`, `move_path`, `replace_in_files`, `delete_file`, `delete_directory` |
|
||||
|
||||
Flux d'une mutation :
|
||||
|
||||
```text
|
||||
1. tools/call { name: "propose_edit_file",
|
||||
arguments: { vault, path, content } }
|
||||
→ { tool, arguments, diff, confirmation_token, expires_in }
|
||||
|
||||
2. (l'utilisateur / l'agent valide)
|
||||
|
||||
3. tools/call { name: "apply_edit_file",
|
||||
arguments: { confirmation_token } }
|
||||
→ { ok: true, data: { ... } }
|
||||
```
|
||||
|
||||
Le jeton est **signé (JWT), à usage unique et à durée de vie limitée**
|
||||
(`OBSIGATE_MCP_CONFIRMATION_TTL`, défaut 300 s). Un rejeu renvoie `token_reused`.
|
||||
|
||||
### 4.2 Resources
|
||||
|
||||
| URI | Contenu |
|
||||
|---|---|
|
||||
| `vault://<name>` | Vault accessible (métadonnées, nombre de fichiers) |
|
||||
| `vault://<name>/<path>` | Contenu d'un fichier (lecture seule, **secrets redactés**) |
|
||||
|
||||
### 4.3 Prompts
|
||||
|
||||
`summarize-directory`, `generate-note`, `find-related`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Sécurité
|
||||
|
||||
- **Permissions par vault** : `check_vault_access` est appliqué à chaque outil
|
||||
et chaque resource ; un utilisateur ne voit que ses vaults.
|
||||
- **Anti path-traversal** : `resolve_safe_path` rejette tout chemin hors du vault.
|
||||
- **Confirmation two-step** pour toute mutation (jeton signé, usage unique).
|
||||
- **Toggle par vault** `aiDestructiveTools` (défaut : activé) : le désactiver
|
||||
bloque rename/move/replace/delete tout en laissant create/edit/append.
|
||||
- **Backup automatique** avant chaque opération destructive.
|
||||
- **Rate limiting** : par jeton et par outil
|
||||
(`OBSIGATE_TOOL_RATE_LIMIT`, `OBSIGATE_TOOL_RATE_LIMIT_PER_TOOL`,
|
||||
`OBSIGATE_TOOL_RATE_WINDOW`). Une limite dépassée renvoie le code `rate_limited`.
|
||||
- **Redaction des secrets** : les résultats d'outils (lectures, diffs, extraits
|
||||
de recherche) sont nettoyés avant tout retour au client.
|
||||
- **Audit** : chaque appel est journalisé (`data/audit.log`, action
|
||||
`ai_tool_call`) avec arguments sensibles résumés.
|
||||
|
||||
### Variables d'environnement
|
||||
|
||||
| Variable | Défaut | Rôle |
|
||||
|---|---|---|
|
||||
| `OBSIGATE_MCP_CONFIRMATION_TTL` | `300` | Durée de vie (s) des jetons de confirmation |
|
||||
| `OBSIGATE_TOOL_RATE_LIMIT` | `60` | Appels d'outils max par identité et par fenêtre |
|
||||
| `OBSIGATE_TOOL_RATE_LIMIT_PER_TOOL` | = global | Appels max par outil et par fenêtre |
|
||||
| `OBSIGATE_TOOL_RATE_WINDOW` | `60` | Longueur de la fenêtre (s) |
|
||||
| `BOOKSLM_MAX_TOOL_CALLS` | `25` | Quota d'appels d'outils par run d'agent |
|
||||
| `BOOKSLM_MAX_TOOL_READ_BYTES` | `200000` | Taille max renvoyée par `read_file` |
|
||||
|
||||
---
|
||||
|
||||
## 6. Dépannage
|
||||
|
||||
| Symptôme | Cause probable / remède |
|
||||
|---|---|
|
||||
| `401 Authentification requise` | En-tête `Authorization: Bearer` absent ou jeton expiré |
|
||||
| `vault_access_denied` | Le jeton n'a pas accès à ce vault (`vaults` / `_token_vaults`) |
|
||||
| `destructive_tools_disabled` | `aiDestructiveTools=false` pour ce vault |
|
||||
| `confirmation_required` | Appeler d'abord `propose_<tool>` puis `apply_<tool>` |
|
||||
| `token_reused` / `invalid_confirmation` | Jeton déjà consommé ou expiré → refaire un `propose_` |
|
||||
| `rate_limited` | Quota dépassé ; respecter `retry_after` |
|
||||
| Le client ne se connecte pas | Vérifier le transport Streamable HTTP / le pont `mcp-remote` |
|
||||
@@ -0,0 +1,242 @@
|
||||
# 🚀 Guide de prise en main
|
||||
|
||||
Ce guide vous fait passer d'une installation fraîche à une utilisation courante
|
||||
d'ObsiGate : première connexion, découverte de l'interface, navigation dans vos
|
||||
vaults Obsidian et raccourcis essentiels.
|
||||
|
||||
> **Public :** tous les utilisateurs · **Durée de lecture :** ~10 min
|
||||
> **Voir aussi :** [Déploiement Docker](./DEPLOIEMENT_DOCKER.md) ·
|
||||
> [Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) ·
|
||||
> [API REST](./API_REST.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Qu'est-ce qu'ObsiGate ?
|
||||
|
||||
ObsiGate est une **porte d'entrée web ultra-légère** vers vos vaults Obsidian.
|
||||
Il indexe vos notes en mémoire, les rend accessibles depuis n'importe quel
|
||||
navigateur (ordinateur, tablette, téléphone) et ajoute une couche moderne :
|
||||
recherche avancée, lecture Markdown, liens `[[wikilinks]]`, images, PDF,
|
||||
Excalidraw, Mermaid, assistant IA, collaboration temps réel.
|
||||
|
||||
Points clés :
|
||||
|
||||
- **Aucune modification de vos vaults** : les volumes sont montés en lecture seule (`:ro`) par défaut.
|
||||
- **Pas de base de données** : tout l'état tient dans des fichiers JSON sous `data/`.
|
||||
- **Temps réel** : un watcher surveille le système de fichiers et met l'index à jour à chaud.
|
||||
- **Multi-vault** : plusieurs vaults peuvent être affichés et recherchés simultanément.
|
||||
|
||||
---
|
||||
|
||||
## 2. Prérequis
|
||||
|
||||
| Composant | Version | Remarque |
|
||||
|---|---|---|
|
||||
| Docker | ≥ 20.10 | ou Node/`uv` pour un lancement manuel |
|
||||
| docker-compose | ≥ 2.0 | inclus avec Docker Desktop |
|
||||
| Navigateur | récent | Chrome, Edge, Firefox, Safari |
|
||||
|
||||
Vous aurez aussi besoin du **chemin absolu** de chaque vault Obsidian sur la
|
||||
machine qui héberge Docker.
|
||||
|
||||
---
|
||||
|
||||
## 3. Lancer ObsiGate en 3 étapes
|
||||
|
||||
> La procédure complète (reverse proxy, HTTPS, mises à jour) est détaillée dans le
|
||||
> [Guide de déploiement Docker](./DEPLOIEMENT_DOCKER.md).
|
||||
|
||||
### 3.1 Cloner le dépôt
|
||||
|
||||
```bash
|
||||
git clone https://git.dracodev.net/Projets/ObsiGate.git
|
||||
cd ObsiGate
|
||||
```
|
||||
|
||||
### 3.2 Déclarer vos vaults
|
||||
|
||||
Éditez `docker-compose.yml` pour monter vos dossiers (chemins absolus, lecture seule) :
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /home/user/Documents/Obsidian-Recettes:/vaults/Recettes:ro
|
||||
- /home/user/Documents/Obsidian-IT:/vaults/IT:ro
|
||||
- ./data:/app/data # persistance auth/config
|
||||
environment:
|
||||
- VAULT_1_NAME=Recettes
|
||||
- VAULT_1_PATH=/vaults/Recettes
|
||||
- VAULT_2_NAME=IT
|
||||
- VAULT_2_PATH=/vaults/IT
|
||||
```
|
||||
|
||||
Créez le fichier de secrets à partir du modèle :
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Éditez .env (mot de passe admin, options d'auth…)
|
||||
```
|
||||
|
||||
### 3.3 Construire et démarrer
|
||||
|
||||
```bash
|
||||
chmod +x build.sh # une seule fois
|
||||
./build.sh
|
||||
```
|
||||
|
||||
`build.sh` vérifie Docker, valide les volumes, construit l'image et démarre le
|
||||
conteneur. Ouvrez ensuite **http://localhost:2020**.
|
||||
|
||||
> Options utiles : `./build.sh --help`, `./build.sh --cache` (rebuild rapide),
|
||||
> `./build.sh --build-only` (construire sans démarrer).
|
||||
|
||||
---
|
||||
|
||||
## 4. Premier accès
|
||||
|
||||
### 4.1 Si l'authentification est désactivée (défaut)
|
||||
|
||||
Vous arrivez directement sur l'interface. Toutes les fonctionnalités sont
|
||||
accessibles sans compte — **à réserver à un usage sur réseau de confiance**.
|
||||
|
||||
### 4.2 Si l'authentification est activée
|
||||
|
||||
L'écran de connexion s'affiche. Au **tout premier démarrage**, ObsiGate crée un
|
||||
compte admin et affiche le mot de passe **une seule fois dans les logs** :
|
||||
|
||||
```bash
|
||||
docker compose logs obsigate | grep -A4 "FIRST"
|
||||
```
|
||||
|
||||
Changez ce mot de passe dès la première connexion (menu → profil →
|
||||
*Changer le mot de passe*). La gestion complète des comptes, du MFA et des
|
||||
permissions est décrite dans le
|
||||
[Guide Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md).
|
||||
|
||||
---
|
||||
|
||||
## 5. Découvrir l'interface
|
||||
|
||||
L'interface se compose de trois zones principales.
|
||||
|
||||
### 5.1 L'en-tête (header)
|
||||
|
||||
| Élément | Rôle |
|
||||
|---|---|
|
||||
| 🔍 **Barre de recherche globale** | Recherche dans toutes les vaults autorisées |
|
||||
| Filtre | Restreint la recherche (type, tag, vault…) |
|
||||
| Sélecteur de vault | Bascule l'arborescence sur une vault ou « Toutes les vaults » |
|
||||
| Utilisateur | Nom du compte connecté (si auth activée) |
|
||||
| Version | Version courante d'ObsiGate |
|
||||
| ⚙️ **Options** | Configuration, thème, guide d'utilisation, administration |
|
||||
|
||||
### 5.2 La barre latérale (sidebar)
|
||||
|
||||
Elle regroupe les vues principales via des icônes :
|
||||
|
||||
- **Arborescence** — parcourt les dossiers et fichiers de la vault sélectionnée.
|
||||
- **Graphe** — vue force-directed des liens entre notes.
|
||||
- **Récents** — derniers fichiers ouverts.
|
||||
- **Signets** — vos fichiers et recherches enregistrés.
|
||||
- **Partagés** — liens de partage public que vous avez créés.
|
||||
|
||||
Un champ **« Filtrer fichiers… »** restreint l'arborescence en temps réel, et le
|
||||
bouton **Aa** ajuste l'affichage des libellés.
|
||||
|
||||
En bas de la sidebar (si l'authentification est activée), la **section compte**
|
||||
affiche votre avatar (ou vos initiales), votre nom et votre rôle ; un clic ouvre
|
||||
le profil. La photo se choisit dans **Configurations → Profil** (*Choisir une
|
||||
image* : PNG, JPG ou WEBP — recadrée en carré 256 px, affichée dans le cercle de
|
||||
la sidebar) ; le bouton *Se déconnecter* est juste à côté.
|
||||
|
||||
### 5.3 La zone de contenu
|
||||
|
||||
Elle affiche l'onglet actif : tableau de bord **Statistiques**, **Bookmarks**,
|
||||
**Récents**, **Partagés**, ou le document ouvert. Les documents s'ouvrent dans
|
||||
des **onglets** (avec possibilité de vue multi-panneaux / split view).
|
||||
|
||||
---
|
||||
|
||||
## 6. Navigation et lecture
|
||||
|
||||
1. **Déployez une vault** dans la sidebar (clic sur son nom).
|
||||
2. **Cliquez sur un dossier** pour l'ouvrir, sur un **fichier** pour l'afficher.
|
||||
3. Le **breadcrumb** en haut du document permet de remonter rapidement.
|
||||
4. Les **wikilinks** `[[note]]` sont cliquables ; les images et diagrammes
|
||||
s'affichent automatiquement.
|
||||
5. Utilisez **Ctrl + clic** sur un lien pour l'ouvrir en aperçu rapide selon le
|
||||
contexte, ou ouvrir le graphe centré sur un nœud.
|
||||
|
||||
### Créer et modifier
|
||||
|
||||
- **Bouton « Editer »** : ouvre le document dans l'éditeur Markdown (CodeMirror).
|
||||
- **Bouton « Forge »** (éditeur avancé) : ouvre la version enrichie avec
|
||||
assistant IA intégré. Voir [Assistant IA & Forge](./ASSISTANT_IA_FORGE.md).
|
||||
- **Nouveau fichier / dossier** : depuis les actions de la sidebar ou la palette
|
||||
de commandes.
|
||||
- **Sauvegarde** : `Ctrl + S` (et auto-sauvegarde dans l'éditeur IA).
|
||||
|
||||
> Selon le mode, la lecture et l'édition se remplacent : `Editer` et `Forge`
|
||||
> prennent la place de la vue lecture ; revenez avec `✓` / `×` ou `Échap`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Rechercher
|
||||
|
||||
La recherche est un point fort d'ObsiGate : index inversé TF-IDF, stemming
|
||||
français, normalisation des accents, facettes et pagination. La syntaxe complète
|
||||
(`tag:`, `#`, `vault:`, `title:`, `path:`, `ext:`, phrases exactes) est décrite
|
||||
dans le [Guide Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md).
|
||||
|
||||
Démarrage rapide :
|
||||
|
||||
- Tapez dans la barre de recherche, `Ctrl + K` pour y revenir.
|
||||
- `/` focalise la recherche hors champ de saisie.
|
||||
- `/` + `↑`/`↓` navigue dans les suggestions.
|
||||
|
||||
---
|
||||
|
||||
## 8. Apparence et confort
|
||||
|
||||
- **Thème clair/sombre** : bascule persistée en `localStorage` ; le desktop suit
|
||||
aussi le thème du système.
|
||||
- **Thèmes** : clair, sombre, contraste élevé, sépia — import/export possible.
|
||||
- **Responsive** : l'interface s'adapte au mobile (éditeur tactile, barre
|
||||
d'outils flottante).
|
||||
- **PWA** : installable comme application native, mode hors-ligne partiel.
|
||||
|
||||
Voir [PWA & mode hors-ligne](./PWA_HORS_LIGNE.md).
|
||||
|
||||
---
|
||||
|
||||
## 9. Raccourcis clavier essentiels
|
||||
|
||||
| Action | Raccourci |
|
||||
|---|---|
|
||||
| Palette de commandes | `Ctrl + Shift + Space` |
|
||||
| Palette de fichiers (navigation rapide) | `Ctrl + Alt + Space` |
|
||||
| Focus barre de recherche | `Ctrl + K` |
|
||||
| Recherche rapide (hors champ texte) | `/` |
|
||||
| Sauvegarder le fichier ouvert | `Ctrl + S` |
|
||||
| Rechercher dans le document | `Ctrl + F` |
|
||||
| Completion IA inline (éditeur) | `Ctrl + J` |
|
||||
| Insertion rapide (éditeur Forge) | `Alt + I` |
|
||||
| Fermer l'éditeur / modale | `Échap` |
|
||||
| Aide de l'éditeur Forge | `F1` |
|
||||
| Naviguer dans les suggestions | `↑` / `↓` |
|
||||
| Lancer la recherche / valider | `Entrée` |
|
||||
|
||||
> Le panneau **Raccourcis & Astuces** du tableau de bord Statistiques récapitule
|
||||
> ces raccourcis directement dans l'application.
|
||||
|
||||
---
|
||||
|
||||
## 10. Et ensuite ?
|
||||
|
||||
| Objectif | Guide |
|
||||
|---|---|
|
||||
| Mieux chercher, lire PDF et Excalidraw | [Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) |
|
||||
| Utiliser l'IA intégrée | [Assistant IA & Forge](./ASSISTANT_IA_FORGE.md) |
|
||||
| Éditer à plusieurs | [Édition & collaboration](./COLLABORATION.md) |
|
||||
| Sécuriser l'accès | [Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md) |
|
||||
| Automatiser via API/MCP | [API REST](./API_REST.md) · [MCP](./MCP.md) |
|
||||
| Installer l'application native | [Desktop (Tauri)](./DESKTOP.md) |
|
||||
@@ -0,0 +1,136 @@
|
||||
# 📱 Guide PWA & mode hors-ligne
|
||||
|
||||
ObsiGate est une **Progressive Web App (PWA)** : installez-la comme une
|
||||
application native, consultez vos notes **hors-ligne**, recevez des
|
||||
notifications et synchronisez vos modifications à la reconnexion.
|
||||
|
||||
> **Public :** tous les utilisateurs · **Guides techniques :**
|
||||
> [`PWA_GUIDE.md`](../PWA_GUIDE.md) · [`INSTALLATION_PWA.md`](../INSTALLATION_PWA.md)
|
||||
> **Voir aussi :** [Prise en main](./PRISE_EN_MAIN.md) · [Édition & collaboration](./COLLABORATION.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Qu'est-ce que la PWA d'ObsiGate ?
|
||||
|
||||
Une PWA combine le meilleur du web et du natif :
|
||||
|
||||
- **Installation** sur l'écran d'accueil, sans store.
|
||||
- **Mode hors-ligne** : interface et dernières données consultées mises en cache.
|
||||
- **Notifications** : alertes de mise à jour et Web Push.
|
||||
- **Performance** : chargement rapide via cache intelligent.
|
||||
- **Multi-plateforme** : desktop, mobile, tablette.
|
||||
|
||||
---
|
||||
|
||||
## 2. Installer la PWA
|
||||
|
||||
### Desktop (Chrome, Edge, Brave)
|
||||
|
||||
1. Ouvrez ObsiGate dans le navigateur.
|
||||
2. Cliquez sur l'icône d'installation dans la barre d'adresse (➕ / ⬇️).
|
||||
3. Cliquez sur **Installer** dans la popup.
|
||||
4. ObsiGate apparaît dans vos applications.
|
||||
|
||||
*Alternative :* menu ⋮ → **Installer ObsiGate…**
|
||||
|
||||
### Android (Chrome)
|
||||
|
||||
1. Ouvrez ObsiGate dans Chrome.
|
||||
2. Menu ⋮ → **Ajouter à l'écran d'accueil**.
|
||||
3. Confirmez.
|
||||
|
||||
### iOS / iPadOS (Safari)
|
||||
|
||||
1. Ouvrez ObsiGate dans Safari.
|
||||
2. Bouton Partager 📤 → **Sur l'écran d'accueil**.
|
||||
3. Nommez l'application puis **Ajouter**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Mode hors-ligne
|
||||
|
||||
Le **Service Worker** (`frontend/sw.js`) met en cache :
|
||||
|
||||
- l'interface (HTML, CSS, JavaScript, manifeste) ;
|
||||
- les ressources statiques (icônes, polices) ;
|
||||
- les dernières données API consultées.
|
||||
|
||||
### Stratégies de cache
|
||||
|
||||
| Ressource | Stratégie |
|
||||
|---|---|
|
||||
| Code (HTML/JS/CSS/manifest) | **Network-first** (cache en secours hors-ligne) |
|
||||
| API | **Network-first** (+ cache hors-ligne) |
|
||||
| Autres assets (images, polices) | **Stale-while-revalidate** |
|
||||
| Nettoyage | Purge des caches d'une version antérieure à l'activation |
|
||||
|
||||
> Le choix **network-first** est délibéré : les assets ne sont pas fingerprintés,
|
||||
> un cache-first servirait indéfiniment un ancien build sur mobile.
|
||||
|
||||
### File de synchronisation & conflits
|
||||
|
||||
- Les modifications faites hors-ligne sont stockées (IndexedDB) et rejouées à la
|
||||
reconnexion.
|
||||
- Les conflits éventuels sont détectés et peuvent être résolus (écran
|
||||
**Conflits**, `GET /api/conflicts`).
|
||||
|
||||
### Tester hors-ligne
|
||||
|
||||
1. DevTools (F12) → onglet **Network**.
|
||||
2. Cochez **Offline**.
|
||||
3. Rechargez : l'application doit fonctionner avec le cache.
|
||||
|
||||
---
|
||||
|
||||
## 4. Notifications (Web Push)
|
||||
|
||||
- Abonnement à partir de l'interface (permission navigateur requise).
|
||||
- Endpoints : `GET /api/push/vapid-public-key`,
|
||||
`POST /api/push/subscribe`, `DELETE /api/push/subscribe`,
|
||||
`GET /api/push/subscriptions`.
|
||||
- Les notifications sont signées **VAPID** et peuvent prévenir de changements
|
||||
(collaboration, mises à jour).
|
||||
|
||||
---
|
||||
|
||||
## 5. Mises à jour
|
||||
|
||||
- Vérification régulière des mises à jour.
|
||||
- Notification quand une nouvelle version est disponible.
|
||||
- Mise à jour en un clic, **sans perte de données**.
|
||||
- Le numéro `SW_VERSION` invalide l'ancien cache à chaque livraison.
|
||||
|
||||
### Forcer une mise à jour (console)
|
||||
|
||||
```javascript
|
||||
navigator.serviceWorker.getRegistration().then(reg => reg.update());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Débogage
|
||||
|
||||
### Vérifier l'installation
|
||||
|
||||
Chrome DevTools → onglet **Application** :
|
||||
|
||||
- **Manifest** : métadonnées ;
|
||||
- **Service Workers** : enregistrement ;
|
||||
- **Cache Storage** : contenu du cache.
|
||||
|
||||
### Désinstaller le Service Worker
|
||||
|
||||
```javascript
|
||||
navigator.serviceWorker.getRegistrations().then(regs => regs.forEach(r => r.unregister()));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Limites
|
||||
|
||||
- Le hors-ligne dépend des données déjà mises en cache.
|
||||
- Les actions d'écriture hors-ligne s'appliquent à la reconnexion (pas en temps
|
||||
réel).
|
||||
- iOS applique des contraintes spécifiques (persistance, notifications).
|
||||
|
||||
Voir [Édition & collaboration](./COLLABORATION.md) pour le temps réel.
|
||||
@@ -0,0 +1,54 @@
|
||||
# 📚 Guides d'utilisation ObsiGate
|
||||
|
||||
Bienvenue dans le répertoire des **guides utilisateur** d'ObsiGate. Chaque guide est
|
||||
autonome, écrit en français et illustré d'exemples concrets (commandes, configuration,
|
||||
captures conceptuelles).
|
||||
|
||||
> **Vous découvrez ObsiGate ?** Commencez par le **[Guide de prise en main](./PRISE_EN_MAIN.md)**.
|
||||
> Une aide rapide est aussi intégrée directement dans l'application (menu Options →
|
||||
> **Guide d'utilisation**, FR/EN, téléchargeable en Markdown et PDF).
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ Sommaire des guides
|
||||
|
||||
| Guide | Public | Contenu |
|
||||
|---|---|---|
|
||||
| 🚀 [Prise en main](./PRISE_EN_MAIN.md) | Tous | Premier lancement, interface, navigation, vaults, raccourcis |
|
||||
| 🔍 [Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) | Tous | Syntaxe de requête, recherche sémantique, lecteur PDF, diagrammes |
|
||||
| 🤖 [Assistant IA & Forge](./ASSISTANT_IA_FORGE.md) | Tous | Fournisseurs, éditeur IA, BooksLM, Forge, commandes `@` / `/` |
|
||||
| 📝 [Édition & collaboration](./COLLABORATION.md) | Tous | Édition simultanée, curseurs distants, persistance |
|
||||
| 📱 [PWA & mode hors-ligne](./PWA_HORS_LIGNE.md) | Tous | Installation PWA, cache, file de synchronisation, notifications |
|
||||
| 🔌 [API REST](./API_REST.md) | Développeurs | Authentification, clés API, endpoints, exemples `curl`, SSE |
|
||||
| 🧩 [Serveur MCP](./MCP.md) | Développeurs / IA | Brancher Claude Desktop, Cursor, Cline… sur vos vaults |
|
||||
| 🔒 [Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md) | Admin | Utilisateurs, MFA, permissions par vault, bonnes pratiques |
|
||||
| 🐳 [Déploiement Docker](./DEPLOIEMENT_DOCKER.md) | Admin / Ops | `docker-compose`, volumes, reverse proxy, mises à jour |
|
||||
| 🖥️ [Application desktop (Tauri)](./DESKTOP.md) | Tous | Installation, premier lancement, build depuis les sources |
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Par où commencer ?
|
||||
|
||||
- **Je veux juste utiliser l'application** → [Prise en main](./PRISE_EN_MAIN.md)
|
||||
- **Je veux sécuriser mon instance** → [Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md)
|
||||
- **Je veux brancher une IA** → [Assistant IA & Forge](./ASSISTANT_IA_FORGE.md) puis [MCP](./MCP.md)
|
||||
- **Je veux scripter/automatiser** → [API REST](./API_REST.md)
|
||||
- **Je veux héberger sur un serveur** → [Déploiement Docker](./DEPLOIEMENT_DOCKER.md)
|
||||
|
||||
---
|
||||
|
||||
## 📖 Documentation associée
|
||||
|
||||
| Type | Où |
|
||||
|---|---|
|
||||
| Vue d'ensemble produit | [`README.fr.md`](../../README.fr.md) · [`README.md`](../../README.md) |
|
||||
| Conception détaillée par fonctionnalité | [`docs/features/`](../features/) |
|
||||
| Standards de code | [`docs/CONTRIBUTING.md`](../CONTRIBUTING.md) |
|
||||
| Méthode de livraison (Definition of Done) | [`docs/DELIVERY_WORKFLOW.md`](../DELIVERY_WORKFLOW.md) |
|
||||
| Roadmap / travail à venir | [`docs/ROADMAP.md`](../ROADMAP.md) |
|
||||
| Historique des versions | [`CHANGELOG.md`](../../CHANGELOG.md) |
|
||||
| API interactive (Swagger / ReDoc) | `/docs` · `/redoc` (instance ObsiGate) |
|
||||
|
||||
> **Convention :** ce répertoire est la **porte d'entrée utilisateur**. Le *comment*
|
||||
> (utilisation) vit ici ; le *pourquoi* (conception technique) vit dans
|
||||
> [`docs/features/`](../features/). Ne jamais dupliquer le détail technique des fiches.
|
||||
@@ -0,0 +1,193 @@
|
||||
# 🔍 Guide Recherche, PDF & Excalidraw
|
||||
|
||||
ObsiGate va au-delà de la simple lecture : recherche puissante, rendu des
|
||||
documents riches (PDF, diagrammes) et indexation de leur contenu pour que tout
|
||||
soit retrouvable.
|
||||
|
||||
> **Public :** tous les utilisateurs
|
||||
> **Fiches techniques :** [`features/semantic-search.md`](../features/semantic-search.md) ·
|
||||
> [`features/pdf.md`](../features/pdf.md) · [`features/excalidraw.md`](../features/excalidraw.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Recherche plein texte (TF-IDF)
|
||||
|
||||
Le moteur d'ObsiGate s'appuie sur un **index inversé** et un scoring **TF-IDF**
|
||||
avec :
|
||||
|
||||
- **Boost titre** — une correspondance dans le titre pèse 3× plus.
|
||||
- **Normalisation des accents** — `resume` trouve `résumé`, `elephant` trouve `éléphant`.
|
||||
- **Stemming français** — les variantes des mots sont rapprochées.
|
||||
- **Snippets surlignés** — les termes trouvés sont mis en `<mark>` dans l'extrait.
|
||||
- **Facettes** — compteurs par vault et par tag sur les résultats.
|
||||
- **Pagination** — 50 résultats par page.
|
||||
- **Tri** — par pertinence (TF-IDF) ou par date de modification.
|
||||
- **Chips de filtres** — les filtres actifs apparaissent sous forme de puces retirables.
|
||||
- **Historique** — les 50 dernières recherches sont conservées en `localStorage`.
|
||||
|
||||
La recherche s'effectue **sans I/O disque** : le contenu est déjà en mémoire.
|
||||
|
||||
---
|
||||
|
||||
## 2. Syntaxe de requête
|
||||
|
||||
| Opérateur | Description | Exemple |
|
||||
|---|---|---|
|
||||
| `tag:<nom>` | Filtre par tag | `tag:recette docker` |
|
||||
| `#<nom>` | Raccourci de tag | `#linux serveur` |
|
||||
| `vault:<nom>` | Filtre par vault | `vault:IT kubernetes` |
|
||||
| `title:<texte>` | Filtre par titre | `title:pizza` |
|
||||
| `path:<texte>` | Filtre par chemin | `path:recettes/soupes` |
|
||||
| `ext:<type>` | Filtre par type de fichier | `ext:md kubernetes` |
|
||||
| `"phrase exacte"` | Recherche d'une phrase | `tag:"multi mots"` |
|
||||
|
||||
Les opérateurs sont **combinables** :
|
||||
|
||||
```text
|
||||
tag:linux vault:IT ext:md serveur web
|
||||
```
|
||||
|
||||
Cette requête cherche « serveur web » dans les fichiers Markdown de la vault
|
||||
`IT` portant le tag `linux`.
|
||||
|
||||
### Filtres par extension
|
||||
|
||||
| Extension | Contenu |
|
||||
|---|---|
|
||||
| `ext:md` | Notes Markdown |
|
||||
| `ext:py`, `ext:sh`, `ext:js` | Scripts et code |
|
||||
| `ext:pdf` | Documents PDF (texte extrait) |
|
||||
| `ext:excalidraw` | Diagrammes Excalidraw (texte extrait) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Autocomplétion et suggestions
|
||||
|
||||
- **`/api/suggest`** — suggère des titres de fichiers.
|
||||
- **`/api/tags/suggest`** — suggère des tags.
|
||||
- Navigation clavier : `↑` / `↓` puis `Entrée` ; `Échap` ferme les suggestions.
|
||||
|
||||
### Raccourcis de recherche
|
||||
|
||||
| Raccourci | Action |
|
||||
|---|---|
|
||||
| `Ctrl + K` / `Cmd + K` | Focaliser la barre de recherche |
|
||||
| `/` | Focaliser la recherche (hors champ texte) |
|
||||
| `↑` / `↓` | Naviguer dans les suggestions |
|
||||
| `Entrée` | Sélectionner la suggestion active ou lancer la recherche |
|
||||
| `Échap` | Fermer les suggestions / quitter la recherche |
|
||||
|
||||
Recherches sauvegardées et signets sont disponibles via l'API
|
||||
(`/api/saved-searches`, `/api/bookmarks`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Recherche sémantique (optionnelle)
|
||||
|
||||
Au classement TF-IDF peut s'ajouter un classement **par embeddings**, fusionné
|
||||
via la méthode **RRF** (Reciprocal Rank Fusion). Activation : touche `~`
|
||||
(ou `Alt + S`) dans la recherche.
|
||||
|
||||
Deux modes :
|
||||
|
||||
1. **Sans dépendance** — un *embedder* par hachage fournit une base utilisable
|
||||
immédiatement.
|
||||
2. **Embeddings réels** — installez `backend/requirements-semantic.txt` et/ou
|
||||
renseignez les variables `OBSIGATE_EMBEDDING_*` pour utiliser
|
||||
`all-MiniLM-L6-v2`.
|
||||
|
||||
Détails et configuration :
|
||||
[`features/semantic-search.md`](../features/semantic-search.md).
|
||||
|
||||
---
|
||||
|
||||
## 5. Support PDF
|
||||
|
||||
### Lecture
|
||||
|
||||
Les fichiers PDF de vos vaults s'affichent **en ligne** dans le navigateur via le
|
||||
visualiseur PDF natif (iframe + `<embed>`). Le fichier est **streamé** en HTTP
|
||||
Range (`206 Partial Content`) : les gros PDF se chargent progressivement.
|
||||
|
||||
### Recherche
|
||||
|
||||
Le texte est **extrait à l'indexation** (`pypdf` / `pymupdf`), donc le contenu
|
||||
des PDF est recherchable via la recherche plein texte. Utilisez `ext:pdf` pour
|
||||
limiter les résultats aux PDF.
|
||||
|
||||
### Métadonnées
|
||||
|
||||
`GET /api/file/{vault}/pdf/info` renvoie les métadonnées (pages, titre, auteur)
|
||||
**sans transférer** le document.
|
||||
|
||||
```bash
|
||||
curl "http://localhost:2020/api/file/Recettes/pdf/info?path=menu.pdf"
|
||||
```
|
||||
|
||||
### Limites
|
||||
|
||||
- **Pas d'OCR** : les PDF scannés (images) ne sont pas recherchables.
|
||||
- Pas d'annotation ni d'édition du PDF lui-même.
|
||||
|
||||
---
|
||||
|
||||
## 6. Diagrammes Excalidraw
|
||||
|
||||
Les fichiers `.excalidraw` et `.excalidraw.md` (dont le format compressé du
|
||||
**plugin Obsidian Excalidraw**) s'ouvrent dans un **éditeur visuel Excalidraw
|
||||
complet**, dans une iframe sandboxée.
|
||||
|
||||
- **Dessin et édition** sans quitter ObsiGate.
|
||||
- **Sauvegarde automatique** (débounce 2 s) ou `Ctrl + S`.
|
||||
- **Thème** clair/sombre suivi automatiquement.
|
||||
- **Texte indexé** : le texte des éléments du diagramme est extrait à
|
||||
l'indexation et donc recherchable (`ext:excalidraw`).
|
||||
|
||||
Fiche technique : [`features/excalidraw.md`](../features/excalidraw.md).
|
||||
|
||||
---
|
||||
|
||||
## 7. Autres contenus riches
|
||||
|
||||
### Mermaid
|
||||
|
||||
Les blocs de code ` ```mermaid ` sont rendus en diagrammes interactifs (live
|
||||
preview, thèmes, zoom, plein écran, pré-processeur compatible syntaxe Obsidian).
|
||||
|
||||
### Images Obsidian
|
||||
|
||||
Toutes les syntaxes d'images sont supportées avec résolution intelligente en
|
||||
7 stratégies :
|
||||
|
||||
1. chemin absolu ;
|
||||
2. dossier d'attachements configuré (`VAULT_N_ATTACHMENTS_PATH`) ;
|
||||
3. index de démarrage (correspondance unique) ;
|
||||
4. même répertoire que la note ;
|
||||
5. racine de la vault ;
|
||||
6. index de démarrage (correspondance la plus proche) ;
|
||||
7. repli : `[image not found: fichier.ext]`.
|
||||
|
||||
Rescan manuel des attachements :
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:2020/api/attachments/rescan/Recettes"
|
||||
```
|
||||
|
||||
### Graphe et backlinks
|
||||
|
||||
- **Graphe** : vue force-directed (Barnes-Hut), filtres (tag, type), profondeur,
|
||||
mode focus, export PNG, aperçu au survol (`Ctrl + clic`).
|
||||
- **Backlinks** : `GET /api/file/{vault}/backlinks?path=…` liste les notes
|
||||
pointant vers un document.
|
||||
|
||||
---
|
||||
|
||||
## 8. Dépannage
|
||||
|
||||
| Symptôme | Piste |
|
||||
|---|---|
|
||||
| Un PDF ne s'affiche pas | Vérifier la taille (`OBSIGATE_PDF_MAX_SIZE_MB`, défaut 50 Mo) |
|
||||
| Le texte d'un PDF scanné n'est pas trouvé | Pas d'OCR : normal |
|
||||
| Une image reste introuvable | Configurer `VAULT_N_ATTACHMENTS_PATH`, puis rescan |
|
||||
| La recherche sémantique ne s'active pas | Vérifier le toggle `~` et `OBSIGATE_EMBEDDING_*` |
|
||||
| Résultats obsolètes | Forcer une réindexation : `GET /api/index/reload` |
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
- **Projet** : ObsiGate — Porte d'entrée web pour vaults Obsidian
|
||||
- **Stack** : Python 3.11+ (backend FastAPI) · JavaScript/Vanilla (frontend) · Tauri/Rust (desktop)
|
||||
- **Dernière mise à jour** : 2026-09-17
|
||||
- **Dernière mise à jour** : 2026-09-24
|
||||
|
||||
---
|
||||
|
||||
@@ -176,7 +176,19 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *BUG-065* | [🟡 IMPORTANT] Éditeur Excalidraw : l'auto-save recharge la page en pleine édition | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/excalidraw-viewer.js`, `frontend/js/utils.js`, `frontend/excalidraw-editor.html`, `tests/frontend/excalidraw-viewer.test.mjs` | Ouvrir un `.excalidraw` puis modifier un élément : au bout de 2 s la vue se recharge | Chaque modification déclenchait un `PUT save` 2 s plus tard → SSE `index_updated` → `reloadExternalWrite` → `openFile` → **recréation de l'iframe** (refresh visible). Auto-save supprimée : sauvegarde explicite (bouton 💾 / Ctrl+S). `reloadExternalWrite` ignore le fichier si un iframe Excalidraw est ouvert (`iframe[data-excalidraw-vault/path]`). Le badge « Modified » ne réagit plus aux changements d'`appState` (resize/zoom) mais à la signature des éléments. | Vérifié Playwright : plus de refresh, badge stable après bascule plein écran. Test statique (absence de `requestSave`/`saveTimer`). |
|
||||
| *BUG-066* | [🔵 MINEUR] Configuration : icônes manquantes dans la table des matières (« Fichiers cachés », « Partages publics ») | 🟢 corrigé | P3 | 📱 frontend | IA | `frontend/locales/{fr,en}.json` | Ouvrir Configuration → observer le sommaire : les entrées « Fichiers cachés » et « Partages publics » n'ont pas d'icône | `config.section_hidden` → « 🗂️ Fichiers cachés » / « 🗂️ Hidden files », `config.section_shares` → « 📤 Partages publics » (EN avait déjà l'icône). Test : `tests/frontend/unit.test.mjs` (+1 : toutes les entrées du sommaire portent une icône FR/EN) | Les libellés du sommaire utilisent des clés i18n distinctes des titres de section (`auto.f8ba6127`, `config.section_partages-publics`) qui, elles, avaient l'icône |
|
||||
| *BUG-067* | [🔵 MINEUR] Guide d'utilisation : l'entrée « 📱 Mobile » du sommaire ne fait rien (section absente) | 🟢 corrigé | P3 | 📱 frontend | IA | `frontend/index.html` | Ouvrir le Guide → cliquer « 📱 Mobile » dans le sommaire : rien ne se passe | L'ancre `#help-mobile-editor` était présente dans la TOC mais aucune section `id="help-mobile-editor"` n'existait (l'édition mobile n'était qu'un h3 de `help-edition`). Fix #105 : section dédiée créée avec ancre + entrée de nav cohérente. | Vérifié par test statique `tests/test_guide.py::test_nav_anchors_resolve` |
|
||||
| *BUG-068* | Configuration — section « 🔒 Sécurité du compte » inachevée : boutons hors thème, QR code invisible, fiabilité des fonctions à valider | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/js/auth.js`, `frontend/style.css`, `backend/auth/router.py` | Configuration → 🔒 Sécurité du compte | `frontend/style.css` (+`config-btn-primary`/`danger` thème), `backend/auth/router.py` (`qr_data_url` segno local), `frontend/js/auth.js` (QR local + fallback, recovery WebAuthn, carte mot de passe, escapeHtml labels), locales FR/EN, `backend/requirements.txt` (+segno) ; tests `tests/test_mfa.py` (+1) + `tests/frontend/mfa-settings.test.mjs` (nouveau, 9) | pytest 1241 passed / 6 skipped, ruff 0, mypy 0, frontend unit + validate-imports verts |
|
||||
| *BUG-069* | Login 2FA bloqué sans erreur : après user+pwd corrects, la page de login reste affichée et le challenge MFA n'apparaît jamais | 🟢 corrigé | P0 | 📱 frontend | IA | `frontend/js/auth.js`, `frontend/index.html` | Activer 2FA → logout → login (bon user+pwd) | `frontend/js/auth.js` (`showMfaChallenge` → `.login-card` + erreur `mfa.challenge_unavailable` si montage impossible), locales FR/EN ; tests `tests/frontend/mfa-settings.test.mjs` (+2) | Reproduit au navigateur avant correctif (challenge jamais affiché), vérifié après : challenge affiché, code erroné → erreur, code valide (200) → app ; frontend mfa-settings 11/11, unit + validate-imports verts |
|
||||
| *BUG-070* | Activation clé physique WebAuthn impossible : « Validation du credential WebAuthn échouée » à chaque tentative | 🟢 corrigé | P0 | ⚙️ backend | IA | `backend/auth/webauthn_mfa.py`, `backend/auth/router.py` | Config → Sécurité → Ajouter une clé → cérémonie navigateur → 400 | `resolve_relying_party()` (rp_id/origines dérivés de la requête, config explicite prioritaire, forwarded si TRUST_PROXY) sur les 4 endpoints ; challenges multiples (5 derniers) acceptés ; `.env.example` ; tests `tests/test_webauthn.py` (+8) | Logs : origin `http://localhost:2020` rejetée + challenge mismatch au retry. Vérifié navigateur (authentificateur virtuel CDP) : register 200 + clé listée, clé de test retirée (admin de nouveau TOTP seul) ; pytest 1249 passed, ruff/mypy 0 |
|
||||
| *BUG-071* | Configuration « Configurations » inutilisable en mode mobile : sommaire masqué sans bouton d'accès, navigation par ancre sans JS, grilles 2 colonnes et rangées d'ajout qui débordent (≤768px) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/index.html`, `frontend/js/config.js`, `frontend/js/i18n.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json` | Mobile (≤768px) : ouvrir Configurations → aucun sommaire ni moyen d'atteindre une section ; champs « Clés IA » / jetons / webhooks débordent | `index.html` (+`#config-hamburger` `.help-hamburger`, `config.toc_toggle` FR/EN) ; `config.js` (toggle, scroll doux + actif + repli auto mobile, reset à l'ouverture) ; `i18n.js` (`data-i18n-attr` multi-paires `;`) ; `style.css` (bloc mobile `#config-modal` : sommaire haut 46vh, grilles 1fr, add-rows wrap + `!important`, items wrap, 44px) ; tests `tests/frontend/config-mobile.test.mjs` (nouveau, 11) + CI ; E2E `tests/e2e/config-mobile.spec.js` (nouveau, 3/3 projet chromium-mobile, ignoré en desktop) | pytest 1249 passed / 6 skipped, ruff 0, mypy 0, validate-imports 39 modules, unit 10/10, JSDOM ai 93/93 + sidebar 6/6 + mobile 35/35 + ai-keys 7/7 |
|
||||
| *BUG-072* | Visionneuse d'images : le plein écran et le panneau « Métadonnées » ne sont pas conservés lors de la navigation ←/→, et le panneau s'affiche sous la pellicule au lieu d'une barre latérale | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/viewer.js`, `frontend/style.css` | Ouvrir une image, activer le plein écran (ou Métadonnées), puis naviguer avec les flèches précédent/suivant | État persistant `_imageViewerState { lightbox, meta }` + drapeau `_imageViewerNavPending` posé par `go()`/pellicule : `renderFile` ne réinitialise que hors navigation image→image. Panneau reconstruit dans `.image-viewer-body` (sidebar droite, `border-left`, `width:280px; max-width:40%`) ; la règle lightbox ne masque plus que la pellicule. Boutons `image-btn-lightbox`/`image-btn-metadata` (+ `aria-pressed`), `Escape` resynchronisé. Tests : `tests/frontend/image-viewer.test.mjs` (+2), E2E `tests/e2e/image-viewer.spec.js` (+1). | Navigation → `openFile` → `renderImageViewer` recréait le conteneur : les états `lightbox`/`metaPanel` étaient perdus. Le panneau était rendu en bas (colonne) au lieu d'une sidebar droite |
|
||||
| *BUG-073* | Mobile : la barre de navigation fixe du bas masque le bas de tous les documents et pages affichés (les dernières lignes restent définitivement sous la barre) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/style.css` | Mobile (≤768px) : ouvrir un document long, défiler jusqu'au bas → la fin du contenu passe sous la barre `#mobile-toolbar` et n'est jamais atteignable | Clearance mobile retargetée de `.main-layout` (sélecteur mort, absent de `index.html`) vers `.main-body` (`calc(64px + env(safe-area-inset-bottom, 0))`) ; règle sœur morte `.editor-modal.active ~ .main-layout` supprimée ; reset `body.reading-mode .main-body { padding-bottom: 0 }` (barre masquée en mode lecture) ; `body.np-active .content-area` ramené à `76px` (dégagement dock seul, géométrie totale inchangée). Tests : `tests/frontend/mobile-toolbar.test.mjs` (7, au CI), E2E `tests/e2e/mobile-toolbar.spec.js` (2, `chromium-mobile`, skip desktop) | La règle de clearance du bloc mobile cible `.main-layout`, classe absente de `index.html` (vrai conteneur : `.main-body`) → sélecteur mort, aucun dégagement réservé |
|
||||
| | | | | | | | | | | |
|
||||
| *BUG-074* | [🟡 IMPORTANT] Assistant IA : le bloc d'étapes affiche « 1 step » sans titre alors que l'agent réalise plusieurs actions (compteur toujours à 1) | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/js/bookslm.js`, `backend/agent/loop.py` | Mode agent : demander une création multi-fichiers/dossiers → chaque message ne montre qu'« 1 étape ▶ » sans détail | `backend/agent/loop.py` + `frontend/js/bookslm.js` : compteur = actions (hors réflexions) + titre = 1re action dans le `<summary>` ; reprise de confirmation diffusée dans le **même** message (fusion des étapes). Tests : `tests/frontend/ai.test.mjs` (+3) | Le résumé `<summary>` ne porte aucun titre ; chaque reprise de confirmation crée un **nouveau** message assistant qui ne contient qu'une action ; les réflexions gonflent le compteur |
|
||||
| *BUG-075* | [🟡 IMPORTANT] Assistant IA : chaque action mutatrice demande son propre « Appliquer » — aucun résumé des actions en attente ni approbation globale | 🟢 corrigé | P1 | ⚙️ backend + 📱 frontend | IA | `backend/agent/loop.py`, `backend/bookslm_routes.py`, `frontend/js/bookslm.js` | Mode agent : demander une structure de répertoires multi-fichiers → valider une action après l'autre | `backend/agent/loop.py` (`pending.actions`, lot exécuté au resume) ; `backend/bookslm_routes.py` (`confirm_all` → `ctx.confirmed`) ; `frontend/js/bookslm.js` (carte multi-actions + « Tout approuver (N) »). Tests : `tests/test_agent_loop.py`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs` | La pause de confirmation ne capture que le **premier** appel mutateur du lot (les suivants sont `deferred`) ; carte unique sans liste ; nouveau `confirm_all` à ajouter pour autoriser la suite de l'exécution en une approbation |
|
||||
| *BUG-076* | [🟡 IMPORTANT] Assistant IA : après une action de l'agent, l'arborescence et le document ouvert ne sont pas rafraîchis dynamiquement | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : créer/supprimer un fichier ou dossier, modifier le document ouvert → l'UI ne bouge pas | `frontend/js/bookslm.js` : `MUTATING_TOOLS`/`FILE_WRITE_TOOLS`, refresh d'arborescence débouncé sur event `tool`, `_notifyFileWritten` étendu (xlsx/docx/csv/pdf). Tests : `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs` | Aucun refresh explicite sur les événements `tool` mutateurs (repose uniquement sur le watcher SSE) ; `_notifyFileWritten` ignore les créations de documents (xlsx/docx/csv/pdf) |
|
||||
| *BUG-077* | [🟡 IMPORTANT] Assistant IA : aucun bouton « Stop » pour arrêter l'exécution de l'agent à tout moment | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : lancer une longue tâche → le bouton Envoyer est désactivé, impossible d'arrêter (seule la fermeture du panneau abort) | `frontend/js/bookslm.js` + `frontend/style.css` : bouton d'envoi → Stop (`_syncSendButton`/`_stopGeneration`/`_markStopped`), i18n `ai.stop`/`ai.stopped`. Tests : `tests/frontend/ai.test.mjs` (+2) | `_abortCtrl` n'est déclenché que par `close()` ; aucun signal d'arrêt côté client pendant le stream |
|
||||
| *BUG-078* | [🟡 IMPORTANT] Fichiers de code : la coloration syntaxique (highlight.js) disparaît — les feuilles de thème sont basculées à partir de la **clé** de thème au lieu du **mode** | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/themes.js`, `frontend/js/ui.js`, `tests/frontend/unit.test.mjs` | Ouvrir un fichier `.py`/`.sh`/`.ps1`/`.yml` : le code s'affiche en texte brut, sans couleurs | `frontend/js/themes.js` : `applyTheme` bascule `hljs-theme-dark`/`hljs-theme-light` selon le **mode** (`isDark`). `frontend/js/ui.js` : `initTheme`/`applyTheme` résolvent le mode persisté (`obsigate-theme-mode`) au lieu de traiter la clé (`defaut-obsigate`) comme un mode. Test : `unit.test.mjs` (+1). | Les deux feuilles étaient désactivées car `defaut-obsigate !== "dark"` et `!== "light"` ; résultat **non déterministe** selon l'ordre `UI.initTheme()` (clé) / `Sync.init()` → `themes.initThemes()` (mode). Vérifié Playwright : 5/5 chargements colorés (`.py`), sépia/contraste élevé sur la palette claire |
|
||||
| *BUG-079* | `GET /api/diagnostics` → 500 « dictionary changed size during iteration » (stats d'index) | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` | Charger la page de diagnostic pendant une indexation : `GET /api/diagnostics` → 500 | `backend/main.py` (`api_diagnostics`) : snapshot avant itération — `list(index.items())` et `inv.word_index.copy()` (copie C atomique sous le GIL) ; test de non-régression `tests/test_api_main.py::TestConfig::test_diagnostics_concurrent_index_writes` | Le handler itérait les dicts en direct alors que l'indexeur les modifiait depuis un autre thread (rebuild initial dans `_search_executor`, hooks incrémentaux `add_document`/`remove_document`) → `RuntimeError` dans le générateur → 500. Test déterministe (`RaceDict` fait grossir le dict en cours d'itération) : échoue sans le correctif, passe avec. Vérifié : pytest 1305 passed / 6 skipped, ruff 0, mypy 0 |
|
||||
|
||||
### TODOs techniques (améliorations / nouvelles tâches)
|
||||
|
||||
@@ -248,6 +260,19 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-09-17 | #78 (complément) | UI | `frontend/excalidraw-editor.html`, `docs/features/excalidraw.md`, `CHANGELOG.md` | **#78 (complément)** : la barre d'outils de l'éditeur Excalidraw passe en **colonne d'icônes** (34×34 px, SVG seuls), **collée au bord droit** (`right: 0` ; `top: 45%` ; empilement vertical), avec `title`/`aria-label`. L'icône du bouton Save est remplacée par une coche pendant 1,2 s après une sauvegarde réussie. Badge « Modifié » réduit à une pastille. Vérifié Playwright : bord droit au bord de l'iframe, haut 45 %, 4 boutons empilés ; bascule plein écran OK, cycle d'icône Save + `PUT save` observés. | 🟢 livré (en attente vérif utilisateur) |
|
||||
| 2026-09-18 | BUG-066 | Correction | `frontend/locales/fr.json`, `frontend/locales/en.json`, `tests/frontend/unit.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-066** : la table des matières de la page de configuration n'affichait aucune icône pour « Fichiers cachés » et « Partages publics ». Les libellés du sommaire proviennent de clés i18n (`config.section_hidden`, `config.section_shares`) distinctes des titres de section qui, eux, portaient déjà l'icône. Alignement : 🗂️ / 📤 en FR **et** EN. Test de non-régression : `unit.test.mjs` vérifie que **toutes** les entrées `.help-nav-link` du sommaire portent une icône dans les deux langues (17/17). Vérifié : `unit.test.mjs` 10/10, `validate-imports` 38 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-18 | #105, BUG-067 | Documentation + correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/main.py`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **#105** : audit complet de couverture du Guide d'utilisation — 8 nouvelles sections (Architecture + diagramme Mermaid, API & intégrations, Diagrammes Mermaid & Excalidraw, Hors-ligne & synchronisation, Collaboration temps réel, Application desktop, Bibliothèque & signets, Multilingue) et compléments (recherche sémantique, MFA/WebAuthn, notifications push, exports HTML/ePub/ZIP, PDF, vue multi-panneaux, admin). Téléchargement du guide en Markdown et PDF (`GET /api/guide/download?format=md|pdf`, FR/EN, rendu par le moteur d'export existant). Guide plus large en desktop. **BUG-067** : ancre morte `#help-mobile-editor` → section dédiée créée. | 🟢 corrigé (en attente vérif utilisateur)
|
||||
| 2026-09-18 | #105 (ajustements) | Amélioration | `frontend/index.html`, `frontend/js/config.js`, `frontend/sw.js`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/pdf_export.py`, `Dockerfile`, `scripts/build_guide_diagrams.py`, `scripts/render_guide_diagram.mjs`, `scripts/guide_content.py`, `backend/assets/guide_diagrams/df7366a40db6a5a2.png`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md` | **#105 (retour utilisateur)** : 1) boutons de téléchargement du guide passés en icônes seules (tooltips i18n conservés) ; 2) le diagramme Mermaid de la section Architecture est désormais rendu en **vraie image** dans le PDF (pipeline de pré-rendu PNG Chromium+mermaid v11, PNG commité sous `backend/assets/guide_diagrams/<sha1>.png`, résolu par `diagram_png_for()` ; le Markdown garde le fenced mermaid) ; 3) emoji du PDF rendus **en couleur** au lieu de rectangles : `fonts-noto-color-emoji` ajouté au Dockerfile + `"Noto Color Emoji"` en fin de pile de polices PDF. Vérifié : pytest 1218 (test_guide ×13), ruff/mypy 0, validate-imports 38, unit 10/10 ; PDF live conteneur 2020 : 24 pages, 0 glyphes tofu, diagramme 3568x1174 embarqué. | 🟢 livré
|
||||
| 2026-09-22 | BUG-068 | Correction | `backend/auth/router.py`, `backend/requirements.txt`, `frontend/js/auth.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_mfa.py`, `tests/frontend/mfa-settings.test.mjs` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-068** : section « 🔒 Sécurité du compte » finalisée. (1) Boutons hors thème : `config-btn-primary`/`config-btn-danger` n'existaient pas en CSS → définis depuis les variables du thème (+ états disabled). (2) QR invisible : l'image tierce était bloquée par la CSP (`img-src 'self' data: blob:`) et exposait le secret TOTP → QR SVG `data:` généré en local par le backend (`qr_data_url`, segno) avec repli saisie manuelle. (3) Codes de récupération perdus à la 1re activation WebAuthn → `_showRecoveryCodes(codes, targetId)` avec repli `webauthn-flow-area`. (4) Carte « Mot de passe » ajoutée (endpoint `change-password` existant, jusque-là sans UI) + échappement des libellés de clés WebAuthn. Vérifié : pytest 1241 passed / 6 skipped, ruff 0, mypy 0 (78 fichiers), `mfa-settings.test.mjs` 9/9, unit 10/10, validate-imports 39 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-23 | BUG-069 | Correction | `frontend/js/auth.js`, `frontend/locales/{fr,en}.json`, `tests/frontend/mfa-settings.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-069** : login 2FA bloqué sans erreur — après user+pwd corrects, `showMfaChallenge` cherchait `.login-box` (inexistant dans `index.html`, marquage réel `#login-screen > .login-card`) et faisait un `return` silencieux : page de login figée, aucune erreur. Correctif : montage dans `.login-card` (repli `#login-screen`) + erreur visible `mfa.challenge_unavailable` (FR/EN) si le point de montage manque. **Reproduit au navigateur** (Playwright, instance Docker `obsigate-test`, compte jetable avec TOTP) : avant → challenge jamais affiché ; après → challenge affiché, code erroné → erreur, code valide (verify 200) → app. Tests : `mfa-settings.test.mjs` 11/11 (+2 ancrage DOM), unit 10/10, validate-imports 39 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-23 | BUG-070 | Correction | `backend/auth/webauthn_mfa.py`, `backend/auth/router.py`, `.env.example`, `tests/test_webauthn.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-070** : activation WebAuthn rejetée en 400. (1) Défauts `localhost` sans port → `resolve_relying_party()` dérive rp_id/origines de la requête (config explicite prioritaire, forwarded sous TRUST_PROXY), appliqué aux endpoints register + login. (2) Challenge single-use → 5 derniers conservés, vérification contre le challenge de la cérémonie en cours. **Vérifié au navigateur** (authentificateur virtuel CDP, instance Docker) : register 200, clé listée, clé de test retirée. Tests : `test_webauthn.py` 19/19 (+8), suite complète 1249 passed / 6 skipped, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-23 | BUG-071 | Correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/js/i18n.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs` (nouveau), `.gitea/workflows/ci.yml`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-071** : page « Configurations » inutilisable en mobile. (1) `#config-nav` masquée sous 768px sans toggle → hamburger `#config-hamburger` ajouté à l'en-tête (`.help-hamburger`, libellé `config.toc_toggle` FR/EN). (2) Ancres brutes sans JS → interception en `config.js` (scroll doux, lien actif, repli auto mobile, reset à l'ouverture). (3) Débordements 360px → bloc CSS mobile `#config-modal` (sommaire haut 46vh, grilles 1fr, add-rows wrap + largeurs inline neutralisées, items wrap, cibles 44px). `data-i18n-attr` multi-paires (`;`). Vérifié : `config-mobile.test.mjs` 11/11 (nouveau, au CI), pytest 1249 passed / 6 skipped, ruff/mypy 0, validate-imports 39 modules, unit 10/10, JSDOM ai 93/93 + ai-sidebar 6/6 + sidebar-filters 8/8 + mobile-editor 35/35 + config-ai-keys 7/7. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-23 | BUG-071 (complément E2E) | Test | `tests/e2e/config-mobile.spec.js` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-071 (complément E2E)** : spec Playwright mobile (convention `mobile-editor.spec.js` : `test.skip` hors viewport ≤768px, donc inactive sur le projet `chromium-desktop` du CI). Vérifié en local sur l'instance de test (port 2029, auth désactivée) : hamburger → sommaire, sélection → scroll + actif + repli, 0 débordement horizontal à 393px (3/3 `chromium-mobile`, 3 ignorés en desktop) ; suite `mobile-editor.spec.js` intacte (3/3). | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-23 | BUG-072 | Correction | `frontend/js/viewer.js`, `frontend/style.css`, `tests/frontend/image-viewer.test.mjs`, `tests/e2e/image-viewer.spec.js`, `scripts/run-e2e-local.ps1` (nouveau), `package.json`, `AGENTS.md`, `README.md`, `README.fr.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-072** : dans la visionneuse d'images (#108-D), le plein écran (lightbox) et le panneau « Métadonnées » étaient perdus dès qu'on changeait d'image avec ←/→ (ou la pellicule), car `openFile` → `renderFile` recrée entièrement `renderImageViewer`. (1) **Persistance** : état module `_imageViewerState { lightbox, meta }` restauré à chaque rendu ; un drapeau `_imageViewerNavPending` posé par `go()` et le clic de vignette indique à `renderFile` que le rendu suivant est une navigation image→image (pas de réinitialisation) — toute autre ouverture repart à zéro. (2) **Panneau latéral** : `.image-meta-panel` déplacé dans un nouveau `.image-viewer-body` en flex row, à droite de `.image-stage` (`border-left`, `width:280px; max-width:40%`, défilement vertical) au lieu d'une bande sous la pellicule ; la règle lightbox ne masque plus que la pellicule. Boutons stables `image-btn-lightbox`/`image-btn-metadata` + `aria-pressed`, `Escape` resynchronise l'état. Tests statiques `image-viewer.test.mjs` (+2) et E2E Playwright (+1). **Diagnostic E2E** : `npm run test:e2e` bloquait car `bash` résout vers WSL (HS, Ubuntu `Stopped`, `HCS_E_CONNECTION_TIMEOUT`) et git-bash est bloqué par App Control → lanceur PowerShell ajouté. Vérifié : `image-viewer.spec.js` 4/4, **suite `chromium-desktop` complète 103 passed / 6 skipped (10,3 min)** via `scripts/run-e2e-local.ps1`, `image-viewer.test.mjs` 12/12, unit 10/10, validate-imports 40 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-23 | BUG-073 | Correction | `frontend/style.css`, `tests/frontend/mobile-toolbar.test.mjs` (nouveau), `tests/e2e/mobile-toolbar.spec.js` (nouveau), `.gitea/workflows/ci.yml`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-073** : en mobile (≤768px), la barre fixe `#mobile-toolbar` (64px + safe-area) recouvrait le bas de tous les documents/pages — fin de contenu inaccessible. (1) **Cause** : la règle de clearance du bloc `@media (max-width: 768px)` ciblait `.main-layout`, classe absente de `index.html` (vrai conteneur : `.main-body`) → sélecteur mort, zéro dégagement ; règle sœur morte `.editor-modal.active ~ .main-layout { padding-bottom: 0 }` supprimée (overlay plein écran / nécessaire en édition inline). (2) **Correctif** : `.main-body { padding-bottom: calc(64px + env(safe-area-inset-bottom, 0)) }`, reset `body.reading-mode .main-body { padding-bottom: 0 }` (barre masquée en mode lecture), `body.np-active .content-area` ramené de `calc(64px+safe+76px)` à `76px` (dégagement dock seul — géométrie totale identique, pas de double comptage avec `.main-body`). Tests : `mobile-toolbar.test.mjs` (7 statiques, ajouté au CI), E2E `mobile-toolbar.spec.js` (géométrie + scroll fin de `ANALYSE_REVIEW.md`, skip hors viewport ≤768). Vérifié : `mobile-toolbar` 7/7, JSDOM 14 suites 0 échec, **E2E `chromium-mobile` BUG-073 2/2 + régressions mobile-editor/config-mobile 6/6**, **suite `chromium-desktop` complète 106 passed / 9 skipped (11,4 min)**, pytest 1302 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules, unit 11/11. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-24 | #114 | Feature | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs`, `tests/e2e/config-mobile.spec.js`, `docs/features/settings-mobile-114.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **#114 — Configuration, refonte mobile-responsive (≤768px)**. (1) **Drawer sommaire** : `#config-nav` en panneau `position: fixed` (`min(320px, 88vw)`, z-index 40) sous backdrop `#config-modal.config-toc-open::before` (z-index 35) ; bouton `#config-toc-close` ; backdrop/Échap ferment le drawer d'abord puis la modale ; `_setConfigNav` bascule la classe + conserve le `display` inline de reset. (2) **Modale plein écran** : `100dvh` + `padding: 0`, `border-radius: 0`. (3) **Tactile** : boutons/liens ≥44px, inputs/selects `16px` + `min-height: 44px` (anti-zoom iOS, scopé `#config-modal`), rangée `.config-actions-row` sticky column + safe-area, formulaires 1 colonne, MFA 1 colonne + code full-width, wrap webhook/token/share/diag/avatar/webauthn. (4) **Dettes HTML/i18n** : `.config-actions-row` replacée dans `#cfg-backend-settings` (`</section>` orphelin supprimé), id dupliqué `cfg-partages-publics` retiré du `<h2>`, `#plugins-settings-container` supprimé, doublons `.config-btn-add` + règle morte `.mfa-recovery-input` purgés, `#mt-explorer` → `data-i18n="settings.explorer"` ; i18n : clés mortes `settings.{backend,backend_hint,restart_badge,save,plugins}` supprimées, `settings.explorer` + `config.toc_close` ajoutées, `settings.tabs` FR = « Onglets ». Vérifié : `config-mobile.test.mjs` 27/27 (au CI), unit 11/11, validate-imports 40 modules, pytest 1302 passed / 6 skipped, ruff/mypy 0, E2E `chromium-mobile` 5/5. | ✅ livré (en attente vérif utilisateur) |
|
||||
| 2026-09-24 | BUG-074 → BUG-077 | Correction | `backend/agent/loop.py`, `backend/bookslm_routes.py`, `frontend/js/bookslm.js`, `frontend/style.css`, `frontend/index.html`, `frontend/locales/{fr,en}.json`, `frontend/sw.js`, `tests/test_agent_loop.py`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **Lot assistant IA (mode agent)** : (BUG-075) confirmation par lot — `pending.actions` regroupe toutes les mutations d'un tour LLM, un unique bouton « Tout approuver (N) » envoie `confirm_all` (`ToolContext.confirmed`) et n'interrompt plus à chaque action ; les lectures du lot s'exécutent aussitôt. (BUG-074) résumé du bloc d'étapes avec titre de la 1re action + compteur limité aux actions, reprise diffusée dans le même message (fini le « 1 étape » fragmenté). (BUG-076) refresh de l'arborescence débouncé sur les events `tool` mutateurs + `_notifyFileWritten` étendu aux documents (xlsx/docx/csv/pdf). (BUG-077) le bouton d'envoi devient « Stop » pendant le stream (abort SSE, tâche serveur annulée à la déconnexion, marqueur « Exécution arrêtée. »). Guide/i18n FR/EN + `ai.stop`/`ai.stopped`/`ai.confirm_actions`/`ai.action_apply_all` ; `SW_VERSION` v25. Vérifié : pytest 1304 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules, unit 11/11, ai 100/100, editor-inline 44/44, mobile-editor 35/35, ai-sidebar 6/6, forge 32/32, pane-manager 9/9, sw 8/8. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
|
||||
| 2026-09-24 | #115, #117, BUG-078 | Feature + correction | `frontend/js/themes.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/index.html`, `frontend/style.css`, `frontend/popout.html`, `frontend/locales/{fr,en}.json`, `frontend/icons/avatar/*` (nouveau), `tests/frontend/unit.test.mjs`, `tests/frontend/toolbar-order.test.mjs`, `tests/frontend/settings-order-avatar.test.mjs`, `docs/features/viewer-toolbar-highlight-avatars.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md` | **#115** barre d'outils de lecture épinglée : `viewer.js`/`popout.html` sortent `.file-actions` de `.file-header` dans un `.file-toolbar` enfant direct de `.content-area` (`position: sticky; top: 0`), masqué en mode lecture. **BUG-078** coloration syntaxique : le basculement des feuilles highlight.js suit le **mode** (`themes.applyTheme` + `ui.initTheme/applyTheme` lisent `obsigate-theme-mode`) au lieu de la clé de thème qui désactivait les deux feuilles. **#117** avatars prédéfinis : galerie de 12 images (`frontend/icons/avatar/`) dans `#cfg-profile`, clic → recadrage 256 px (pipeline import) + `PATCH /api/auth/me`, avatars actifs surlignés (`obsigate-avatar-preset`), import personnalisé et suppression conservés. Vérifié : Playwright (coloration 5/5 déterministe, toolbar épinglée à `barTop` constant au défilement), `unit.test.mjs` 12/12, `toolbar-order` 13/13, `settings-order-avatar` 12/12, JSDOM editor-inline/pane-manager/mobile-editor/image-viewer/pdf-viewer/config-mobile/media-viewer/excalidraw verts, pytest 1304 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-24 | BUG-079 | Correction | `backend/main.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-079** : `GET /api/diagnostics` renvoyait 500 « dictionary changed size during iteration ». Le handler itérait `inv.word_index.values()` et `index.items()` en direct alors que l'indexeur les modifiait depuis un autre thread (rebuild initial dans `_search_executor`, hooks incrémentaux `add_document`/`remove_document`) → `RuntimeError` dans le générateur. Correctif : **snapshot avant itération** (`list(index.items())`, `inv.word_index.copy()`) — copie C atomique sous le GIL, pas de verrou ajouté. Test de non-régression déterministe (`RaceDict` fait grossir le dict pendant l'itération ; échoue sans le correctif, passe avec). Vérifié : pytest 1305 passed / 6 skipped, ruff 0, mypy 0 (80 fichiers), validate-imports 40 modules, unit 12/12. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,182 +1,10 @@
|
||||
# ObsiGate — Guide MCP (Model Context Protocol)
|
||||
# Guide MCP — déplacé
|
||||
|
||||
> **Statut :** livré (#79 phase E + F) · **Dernière mise à jour :** 2026-09-11
|
||||
> **Voir aussi :** [AI_ARCHITECTURE_GUIDE.md](./AI_ARCHITECTURE_GUIDE.md) ·
|
||||
> [features/ai-tools-mcp.md](./features/ai-tools-mcp.md) · [ROADMAP.md](./ROADMAP.md)
|
||||
> Ce guide a été déplacé dans le répertoire des guides utilisateur :
|
||||
> **[docs/GUIDES/MCP.md](./GUIDES/MCP.md)**.
|
||||
|
||||
ObsiGate expose ses vaults à des **clients MCP externes** (Claude Desktop, Cursor,
|
||||
tout client compatible MCP) via un serveur **Streamable HTTP** monté sur `/mcp`.
|
||||
Les outils sont les **mêmes** que ceux de l'assistant in-app : la couche
|
||||
`backend/tools/` est la source unique de vérité.
|
||||
Le serveur MCP d'ObsiGate (`/mcp`) expose les mêmes outils que l'assistant IA à
|
||||
Claude Desktop, Cursor, Cline et tout client compatible MCP. Configuration,
|
||||
outils, resources/prompts, sécurité et dépannage s'y trouvent désormais.
|
||||
|
||||
---
|
||||
|
||||
## 1. Prérequis
|
||||
|
||||
1. Une instance ObsiGate accessible (locale ou distante).
|
||||
2. Un **jeton JWT** valide (`Authorization: Bearer <token>`), obtenu via
|
||||
`POST /api/auth/login` (ou une clé API). Le jeton porte les permissions par
|
||||
vault de l'utilisateur — l'autorisation MCP réutilise `get_current_user`.
|
||||
3. Si l'authentification est désactivée (`OBSIGATE_AUTH_ENABLED=false`), le
|
||||
serveur MCP accepte un utilisateur anonyme disposant de tous les vaults.
|
||||
|
||||
> Le transport `stdio` n'est **pas** encore supporté ; utilisez le transport
|
||||
> HTTP (un pont local type `mcp-remote` si votre client ne gère pas nativement
|
||||
> le Streamable HTTP distant).
|
||||
|
||||
---
|
||||
|
||||
## 2. Endpoint & protocole
|
||||
|
||||
| Élément | Valeur |
|
||||
|---|---|
|
||||
| URL | `https://<obsigate>/mcp` |
|
||||
| Transport | Streamable HTTP (`POST` JSON-RPC 2.0, `Accept: application/json, text/event-stream`) |
|
||||
| Auth | `Authorization: Bearer <JWT>` |
|
||||
| Protocole MCP | `2025-03-26` (négocié à l'`initialize`) |
|
||||
| Réponses | JSON (`json_response=True`) |
|
||||
|
||||
Handshake minimal :
|
||||
|
||||
```bash
|
||||
curl -sS https://obsigate.example/mcp \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
|
||||
"protocolVersion":"2025-03-26","capabilities":{},
|
||||
"clientInfo":{"name":"curl","version":"1.0"}}}'
|
||||
```
|
||||
|
||||
La réponse contient l'en-tête `Mcp-Session-Id` à réutiliser pour les appels
|
||||
suivants (`tools/list`, `tools/call`, `resources/read`, …).
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration des clients
|
||||
|
||||
### Claude Desktop (via pont `mcp-remote`)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"obsigate": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y", "mcp-remote",
|
||||
"https://obsigate.example/mcp",
|
||||
"--header", "Authorization: Bearer ${OBSIGATE_TOKEN}"
|
||||
],
|
||||
"env": { "OBSIGATE_TOKEN": "eyJ..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cursor
|
||||
|
||||
`.cursor/mcp.json` :
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"obsigate": {
|
||||
"url": "https://obsigate.example/mcp",
|
||||
"headers": { "Authorization": "Bearer eyJ..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Primitives exposées
|
||||
|
||||
### 4.1 Tools
|
||||
|
||||
Les outils de **lecture/recherche** sont exposés directement. Les outils
|
||||
**d'écriture/destructifs** sont exposés via une paire **two-step** :
|
||||
`propose_<tool>` (aperçu + jeton de confirmation, aucune modification) puis
|
||||
`apply_<tool>` (consomme le jeton et exécute).
|
||||
|
||||
| Catégorie | Outils |
|
||||
|---|---|
|
||||
| Vaults / navigation | `list_vaults`, `list_directory`, `list_all_files` |
|
||||
| Lecture | `read_file`, `read_file_raw`, `get_backlinks`, `list_backups`, `diff_backup`, `get_graph` |
|
||||
| Recherche | `search_fulltext`, `search_advanced`, `search_paths`, `list_tags`, `suggest_tags`, `list_recent` |
|
||||
| Écriture (propose/apply) | `create_file`, `create_directory`, `edit_file`, `append_to_file`, `restore_backup` |
|
||||
| Destructif (propose/apply) | `rename_file`, `rename_directory`, `move_path`, `replace_in_files`, `delete_file`, `delete_directory` |
|
||||
|
||||
Flux d'une mutation :
|
||||
|
||||
```text
|
||||
1. tools/call { name: "propose_edit_file",
|
||||
arguments: { vault, path, content } }
|
||||
→ { tool, arguments, diff, confirmation_token, expires_in }
|
||||
|
||||
2. (l'utilisateur / l'agent valide)
|
||||
|
||||
3. tools/call { name: "apply_edit_file",
|
||||
arguments: { confirmation_token } }
|
||||
→ { ok: true, data: { ... } }
|
||||
```
|
||||
|
||||
Le jeton est **signé (JWT), à usage unique et à durée de vie limitée**
|
||||
(`OBSIGATE_MCP_CONFIRMATION_TTL`, défaut 300 s). Un rejeu renvoie
|
||||
`token_reused`.
|
||||
|
||||
### 4.2 Resources
|
||||
|
||||
| URI | Contenu |
|
||||
|---|---|
|
||||
| `vault://<name>` | Vault accessible (métadonnées, nombre de fichiers) |
|
||||
| `vault://<name>/<path>` | Contenu d'un fichier (lecture seule, **secrets redactés**) |
|
||||
|
||||
### 4.3 Prompts
|
||||
|
||||
`summarize-directory`, `generate-note`, `find-related`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Sécurité
|
||||
|
||||
- **Permissions par vault** : `check_vault_access` est appliqué à chaque outil
|
||||
et chaque resource ; un utilisateur ne voit que ses vaults.
|
||||
- **Anti path-traversal** : `resolve_safe_path` rejette tout chemin hors du vault.
|
||||
- **Confirmation two-step** pour toute mutation (jeton signé, usage unique).
|
||||
- **Toggle par vault** `aiDestructiveTools` (défaut : activé) : le désactiver
|
||||
bloque rename/move/replace/delete tout en laissant create/edit/append.
|
||||
- **Backup automatique** avant chaque opération destructive.
|
||||
- **Rate limiting** : par jeton et par outil
|
||||
(`OBSIGATE_TOOL_RATE_LIMIT`, `OBSIGATE_TOOL_RATE_LIMIT_PER_TOOL`,
|
||||
`OBSIGATE_TOOL_RATE_WINDOW`). Une limite dépassée renvoie le code
|
||||
`rate_limited`.
|
||||
- **Redaction des secrets** : les résultats d'outils (lectures, diffs,
|
||||
extraits de recherche) sont nettoyés avant tout retour au client.
|
||||
- **Audit** : chaque appel est journalisé (`data/audit.log`, action
|
||||
`ai_tool_call`) avec arguments sensibles résumés.
|
||||
|
||||
### Variables d'environnement
|
||||
|
||||
| Variable | Défaut | Rôle |
|
||||
|---|---|---|
|
||||
| `OBSIGATE_MCP_CONFIRMATION_TTL` | `300` | Durée de vie (s) des jetons de confirmation |
|
||||
| `OBSIGATE_TOOL_RATE_LIMIT` | `60` | Appels d'outils max par identité et par fenêtre |
|
||||
| `OBSIGATE_TOOL_RATE_LIMIT_PER_TOOL` | = global | Appels max par outil et par fenêtre |
|
||||
| `OBSIGATE_TOOL_RATE_WINDOW` | `60` | Longueur de la fenêtre (s) |
|
||||
| `BOOKSLM_MAX_TOOL_CALLS` | `25` | Quota d'appels d'outils par run d'agent |
|
||||
| `BOOKSLM_MAX_TOOL_READ_BYTES` | `200000` | Taille max renvoyée par `read_file` |
|
||||
|
||||
---
|
||||
|
||||
## 6. Dépannage
|
||||
|
||||
| Symptôme | Cause probable / remède |
|
||||
|---|---|
|
||||
| `401 Authentification requise` | En-tête `Authorization: Bearer` absent ou jeton expiré |
|
||||
| `vault_access_denied` | Le jeton n'a pas accès à ce vault (`vaults` / `_token_vaults`) |
|
||||
| `destructive_tools_disabled` | `aiDestructiveTools=false` pour ce vault |
|
||||
| `confirmation_required` | Appeler d'abord `propose_<tool>` puis `apply_<tool>` |
|
||||
| `token_reused` / `invalid_confirmation` | Jeton déjà consommé ou expiré → refaire un `propose_` |
|
||||
| `rate_limited` | Quota dépassé ; respecter `retry_after` |
|
||||
| Le client ne se connecte pas | Vérifier le transport Streamable HTTP / le pont `mcp-remote` |
|
||||
Sommaire des guides : [docs/GUIDES/README.md](./GUIDES/README.md).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.13.0 | **Dernière mise à jour :** 2026-09-18
|
||||
> **Version :** 2.28.1 | **Dernière mise à jour :** 2026-09-26
|
||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||
> vers les fonctionnalités livrées.
|
||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||
@@ -37,7 +37,7 @@
|
||||
- **Reste à faire :**
|
||||
- [x] **Signature de l'updater Tauri** (gratuit) : paire de clés générée, `pubkey` renseignée, `createUpdaterArtifacts` activé, secrets CI câblés
|
||||
- [x] **Manifeste `latest.json`** généré par `scripts/updater_manifest.py` (intégré à `publish_release.py`), endpoint updater pointé sur `main`
|
||||
- [ ] **Signature de code Windows** : non retenue (pas de certificat) — alternatives : livrer non signé, SignPath.io (OSS gratuit), Certum OSS, Azure Trusted Signing, certificat EV
|
||||
- [ ] **Signature de code Windows** : **non retenue — décision confirmée le 2026-09-26** : livraison non signée + documentation SmartScreen (« Exécuter quand même »). Alternatives écartées sauf retour utilisateur : SignPath.io (OSS gratuit), Certum OSS, Azure Trusted Signing, certificat EV
|
||||
- [ ] Exécuter les 6 tests E2E **manuels** — protocole documenté : [DESKTOP_E2E_CHECKLIST.md](./DESKTOP_E2E_CHECKLIST.md)
|
||||
|
||||
---
|
||||
@@ -47,6 +47,7 @@
|
||||
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
|
||||
|
||||
- **Effort :** 6-8 jours | **Impact :** 🟢
|
||||
- **Décision 2026-09-26 : reporté (P4)** — axe prioritaire = dette & sécurité (#85/#87) ; #73 hors chemin critique. Si réactivé : partir d'un MVP export/hash/LWW adossé à #59 (PWA offline) + #62 (collab Yjs/CRDT) plutôt qu'un protocole parallèle.
|
||||
- **Description :** Synchronisation des vaults entre plusieurs instances d'ObsiGate via un protocole de synchronisation décentralisé ou compatible Obsidian Sync. Alternative self-hosted à Obsidian Sync.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Protocole : évaluation CRDT vs OT vs diff/patch pour fichiers markdown
|
||||
@@ -60,69 +61,18 @@
|
||||
|
||||
---
|
||||
|
||||
## ⚪ Backlog — Priorité 2 (P2)
|
||||
|
||||
### 83. Barre d'outils d'édition mobile — style Obsidian Android
|
||||
|
||||
- **Effort :** 3-5 jours | **Impact :** 🟡 | **Zone :** frontend (mobile)
|
||||
- **Statut :** ✅ livré — ruban horizontal défilable ancré au-dessus du clavier, commandes étendues et personnalisation persistée. Détail : [archive/COMPLETED_v1-v2.md](./archive/COMPLETED_v1-v2.md) (section #83).
|
||||
- **Description :** remplacer la barre de mise en forme Markdown actuelle par un **ruban horizontal
|
||||
défilable** ancré juste au-dessus du clavier virtuel, reprenant l'ergonomie de l'app Android
|
||||
Obsidian : fond anthracite aux coins arrondis, insertion/enrobage de la syntaxe au curseur ou sur
|
||||
la sélection, et personnalisation des commandes via une icône clé à molette.
|
||||
- **Sous-tâches :**
|
||||
- [x] Ruban horizontal défilable (glissement tactile gauche/droite) ancré au-dessus du clavier
|
||||
- [x] Actions rapides : annuler, refaire, `[[ ]]` (lien interne), modèle/fichiers, tag `#`, pièce jointe
|
||||
- [x] Formatage : H1–H6, gras, italique, barré (`~~`), surligné (`==`), code en ligne/bloc, citation (`>`)
|
||||
- [x] Liens externes, listes à puces/numérotées, case à cocher (`- [ ]`), indenter / désindenter
|
||||
- [x] Personnalisation (clé à molette) : ajouter / supprimer / réordonner les commandes
|
||||
- [x] i18n FR/EN + tests frontend (helpers purs) + E2E mobile
|
||||
|
||||
---
|
||||
|
||||
## ⚪ Backlog — Sécurité, architecture & performance (P0/P1)
|
||||
|
||||
### 84. Consolidation & sécurité — revue statique 2026-09-13 (phase 1)
|
||||
|
||||
- **Effort :** 6-9 jours | **Impact :** 🔴 | **Zone :** backend + frontend | **Référence :** [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md) BUG-021 → BUG-034
|
||||
- **Statut :** 🟢 livré (phase 1) — sanitizer XSS, rate-limit/lockout MFA, isolation vaults, ReDoS, SSRF webhooks, cycle de vie des sessions, politique de mot de passe, verrous `users.json`, audits IP, rate-limit par compte, symlinks, recherche via inverted index, token en cookie HttpOnly. Détail : [archive/COMPLETED_v1-v2.md](./archive/COMPLETED_v1-v2.md) (section #84).
|
||||
- **Description :** traiter toutes les vulnérabilités critiques et importantes issues de la revue statique : XSS markdown (`escape=False`) et page publique de partage, brute-force MFA, isolation des vaults (`resolve_safe_path`), ReDoS, SSRF webhooks, cycle de vie des sessions, politique de mot de passe, races `users.json`, audits IP, rate-limit partagé, indexation symlinks.
|
||||
- **Sous-tâches :**
|
||||
- [x] Assainir le rendu markdown (sanitizer serveur en whitelist) et la page de partage (échappement `title`/frontmatter) — *DOMPurify client non ajouté (défense en profondeur serveur suffisante)*
|
||||
- [x] Rate-limit + lockout sur les endpoints MFA (`totp/verify`, `recovery`, `webauthn/verify`)
|
||||
- [x] Corriger `resolve_safe_path` (comparaison de chemin stricte par segment) + test de régression
|
||||
- [x] Rotation du refresh token, révocation de l'access token au logout, persistance des JTI révoqués
|
||||
- [x] Valider la politique de mot de passe à la création ; bloquer le SSRF des webhooks et externaliser les secrets
|
||||
- [x] Verrous sur les mutations `users.json` ; consigner l'adresse IP réelle dans les audits
|
||||
- [x] Ignorer les symlinks de l'index ; caps CPU/timeout regex (ReDoS)
|
||||
- [~] Durcir la CSP — *partiel* : directives `object-src`/`base-uri`/`form-action`/`frame-ancestors` ajoutées et token retiré de `sessionStorage` ; migration **nonce** restante (nécessite la conversion des gestionnaires d'événements inline)
|
||||
|
||||
### 85. Refonte architecturale — découpage du monolithe & persistance d'état (phase 2)
|
||||
|
||||
- **Effort :** 8-12 jours | **Impact :** 🟡 | **Zone :** backend
|
||||
- **Description :** extraire le monolithe `backend/main.py` (~4 260 lignes) en routers FastAPI par domaine et rendre persistant l'état qui ne l'est pas (index de recherche, JTI révoqués, compteurs de rate-limit) pour préparer le multi-nœuds.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai
|
||||
- [ ] Centraliser le contrat d'outils IA sur `tools/registry.py` (permissions, quotas, redaction)
|
||||
- [ ] Persister index, JTI révoqués et compteurs de rate-limit (SQLite/Redis)
|
||||
- [ ] Verrous asyncio autour de l'index global et des stores JSON ; service de partage public (expiration, révocation, quotas)
|
||||
|
||||
### 86. Optimisation globale des performances (phase 3)
|
||||
|
||||
- **Effort :** 4-6 jours | **Impact :** 🟡 | **Zone :** backend (`search.py`, `indexer.py`, `mutations.py`)
|
||||
- **Description :** brancher l'inverted index (déjà construit) sur la recherche simple et le tool IA `search_fulltext`, indexation incrémentale, extraction PDF lazy.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Recherche simple + tool IA via l'inverted index (suppression du balayage O(N) en mémoire)
|
||||
- [ ] Indexation incrémentale + scan différentiel au démarrage (remplace le `rglob` complet)
|
||||
- [ ] Extraction PDF/excalidraw différée (hors scan) ; caps CPU sur les opérations regex
|
||||
|
||||
### 87. Amélioration continue — tests, CI/CD, revues de sécurité (phase 4)
|
||||
|
||||
- **Effort :** 3-5 jours | **Impact :** 🟡 | **Zone :** `.gitea/workflows/`, `tests/`
|
||||
- **Description :** renforcer le pipeline (`.gitea/workflows/ci.yml`, `desktop-build.yml`) pour le rendre bloquant par défaut et accompagner les phases 1 → 3.
|
||||
- **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).**
|
||||
- **Statut :** 🔵 en cours depuis 2026-09-26 — par tranches. **T1 livrée (v2.28.1) :** bandit bloquant (`nosec` justifiés B324/B404/B603/B607/B406, B105 exclu comme `pyproject`), `npm audit` bloquant (0 vulnérabilité), 5 suites frontend intégrées au CI (`upload`, `pretty`, `media-viewer`, `mfa-settings`, `config-ai-keys`). pip-audit reste consultatif (montées starlette/weasyprint à qualifier).
|
||||
- **Description :** renforcer le pipeline (`.gitea/workflows/ci.yml`, `desktop-build.yml`) pour le rendre bloquant par défaut et accompagner les phases 1 → 3. Constat 2026-09-26 : job `security` non bloquant (`bandit`/`pip-audit` en `|| echo`, ni semgrep ni trivy), E2E limité à `chromium-desktop`, 5 suites frontend hors CI.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Jobs CI sécurité (bandit/semgrep/trivy, audits pip/npm) + tests E2E XSS (page de partage + lecteur markdown)
|
||||
- [ ] Tests de concurrence (`users.json`), fuzzing de timing regex, couverture des composants critiques
|
||||
- [ ] Jobs CI sécurité **bloquants** (bandit/semgrep/trivy, audits pip/npm) + tests E2E XSS (page de partage + lecteur markdown)
|
||||
- [ ] Tests de concurrence (`users.json`), fuzzing de timing regex, couverture des composants critiques ; intégrer au CI les 5 suites frontend hors CI (`upload`, `pretty`, `media-viewer`, `mfa-settings`, `config-ai-keys`)
|
||||
- [ ] Finir BUG-034 (migration CSP **nonce**, conversion des handlers inline), `Secure` cookies à `true` par défaut, politique CORS same-origin explicite ; confirmer la rotation de la clé DeepSeek (BUG-006, clé dans l'historique Git)
|
||||
- [ ] Revue périodique des dépendances ; documentation utilisateur FR/EN synchronisée ; contrôle automatisé de la conformité au DoD
|
||||
|
||||
---
|
||||
@@ -135,6 +85,7 @@
|
||||
|
||||
| # | Domaine / fonctionnalité | Version | Détails |
|
||||
|---|---|---|---|
|
||||
| 152 | Viewer XLSX — affichage multi-feuilles, édition des cellules, téléchargement | 2.27.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
| BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) |
|
||||
| 90 | Barre d'actions du document — regroupement fonctionnel + spacers | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
| 89 | Drag & drop complet de fichiers/dossiers & intégration Assistant IA | 2.3.0 | [features/drag-and-drop-ai.md](./features/drag-and-drop-ai.md) |
|
||||
@@ -188,6 +139,20 @@
|
||||
| 103 | Configuration — clés utilisateur des sources connectées & recherche à clé (page Configurations, `data/api_keys.json`, priorité sur l'env) | 2.11.0 | [features/ai-tools-roadmap.md](./features/ai-tools-roadmap.md) |
|
||||
| 104 | Configuration — Redesign UI de la section « Clés API IA » : recherche fournisseurs, carte défaut 2 colonnes + badges de capacités, cartes dépliables, footer d'actions sticky | 2.12.0 | [features/ai-keys-ui.md](./features/ai-keys-ui.md) |
|
||||
| 105 | Guide d'utilisation — audit de couverture complet, téléchargement Markdown/PDF, guide desktop élargi, section Architecture (Mermaid) + BUG-067 | 2.13.0 | [features/guide-coverage-105.md](./features/guide-coverage-105.md) |
|
||||
| 106 | Assistant IA — Actions instantanées contextuelles, catalogue « Toutes les actions » & frontmatter complet | 2.14.0 | [features/ai-quick-actions.md](./features/ai-quick-actions.md) |
|
||||
| 107 | Configuration — Gestion des clés API & MCP : création/révocation de jetons longue durée (1 j, 1 mois, 6 mois, 1 an, sans fin), une seule clé pour l'API REST et le serveur MCP, « dernière utilisation », store `data/api_tokens.json` sans secret persisté | 2.15.0 | [features/api-mcp-tokens-107.md](./features/api-mcp-tokens-107.md) |
|
||||
| 86 | Optimisation globale des performances (phase 3) — scan différentiel, excalidraw différé, garde-fou `replace` (inverted index / PDF lazy / caps regex déjà livrés via BUG-033/040/025) | 2.16.0 | [features/perf-phase3-86.md](./features/perf-phase3-86.md) |
|
||||
| 108 | Support complet des images — arborescence, visionneuse (zoom/pan/navigation/miniatures), indexation nom+métadonnées, `media_types.py`, filtre `ext:`, SVG sandbox | 2.17.0 | [features/image-support.md](./features/image-support.md) |
|
||||
| 109 | Support audio & vidéo — lecteurs HTML5 intégrés, streaming HTTP Range (`/api/media`), fallback codec/taille | 2.18.0 | [features/media-viewers-109.md](./features/media-viewers-109.md) |
|
||||
| 110 | Lecteur média persistant « Now Playing » — élément partagé téléporté (inline ⇄ dock), Media Session, mini-vidéo PiP, mobile, reprise | 2.19.0 | [features/media-viewers-109.md](./features/media-viewers-109.md) |
|
||||
| 111 | Visionneuse d'images — navigation fluide : image ajustée au cadre, navigation en place (cache annuaire + préchargement), pellicule persistante, flèches latérales au survol | 2.20.0 | [features/image-navigation-111.md](./features/image-navigation-111.md) |
|
||||
| 112 | En-tête allégé & compte en sidebar — version dans le menu Options, retrait utilisateur/déconnexion du header, section compte en bas de la sidebar, pellicule d'images défilable (molette + flèches) | 2.21.0 | [features/header-user-sidebar-112.md](./features/header-user-sidebar-112.md) |
|
||||
| 113 | Configuration — ordre naturel des sections (Profil 1er, À propos dernier, TOC = page) & avatar utilisateur (import PNG/JPG/WEBP, persistance serveur, cercle sidebar) | 2.22.0 | [features/settings-order-avatar-113.md](./features/settings-order-avatar-113.md) |
|
||||
| 114 | Configuration — refonte mobile-responsive (modale plein écran 100dvh, sommaire en drawer coulissant, cibles tactiles ≥ 44 px, inputs 16 px anti-zoom, sauvegarde sticky, MFA 1 colonne, purge i18n/HTML) | 2.23.0 | [features/settings-mobile-114.md](./features/settings-mobile-114.md) |
|
||||
| BUG-078 | Fichiers de code — coloration syntaxique restaurée (feuilles highlight.js basculées sur le mode de thème et non la clé) | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
|
||||
| 115 | Viewer — barre d'outils de lecture épinglée au défilement | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
|
||||
| 117 | Configuration — avatars prédéfinis dans le profil utilisateur (12 images) | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
|
||||
| 85 | Refonte architecturale — découpage du monolithe (14 routers, `main.py` 4 827 → ~750 lignes), stores JSON verrouillés, rate-limit SQLite optionnel | 2.27.2→2.27.13 | [features/archi-refonte-85.md](./features/archi-refonte-85.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -195,16 +160,18 @@
|
||||
|
||||
| Priorité | Items | Effort total estimé |
|
||||
|---|---|---|
|
||||
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93, #94–100, #102–104, #92 | ~115 jours réalisés |
|
||||
| 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour |
|
||||
| ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
|
||||
| ⚪ P0/P1 restant | #85-87 Refonte architecturale, performance, CI/CD (BUG-035 → BUG-040 corrigés) | ~15-23 jours |
|
||||
| **Total restant** | **7 items + finitions** | **~27-42 jours** |
|
||||
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–86, #88–93, #94–100, #102–115, #117, #92 | ~141 jours réalisés |
|
||||
| 🔵 Finitions | #77 Desktop : 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) — signature Windows non retenue (décision 2026-09-26) | ~0,5-1 jour |
|
||||
| ⚪ P4 reporté | #73 Sync — **reporté (décision 2026-09-26)**, hors chemin critique | 6-8 jours si réactivé |
|
||||
| ⚪ P0/P1 prioritaire | #87 CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~3-5 jours |
|
||||
| **Total chemin critique** | **#77 fin + #87** | **~4-6 jours** |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Décisions 2026-09-26 :** axe prioritaire = dette & sécurité (#85/#87) ; #73 Sync reporté (P4, hors chemin critique) ; desktop livré non signé + doc SmartScreen.
|
||||
- **Clôture #85 (v2.27.13) :** monolithe découpé (T1→T9), stores verrouillés + rate-limit SQLite (T10), fiche `docs/features/archi-refonte-85.md`.
|
||||
- Les items P3/P4 ne sont pas ordonnés par priorité interne — à raffiner selon les retours utilisateurs.
|
||||
- L'effort inclut le développement + tests unitaires + intégration CI, mais pas la documentation utilisateur.
|
||||
- Les items marqués 🟢 (nice-to-have) sont de bons candidats pour des contributions externes.
|
||||
|
||||
@@ -404,6 +404,21 @@ Deux compléments au bouton « Ajouter » de l'assistant IA.
|
||||
|
||||
---
|
||||
|
||||
## #152 — Viewer XLSX : affichage, édition, téléchargement ✅ TERMINÉ
|
||||
|
||||
Les fichiers `.xlsx` s'ouvrent dans un dédié : un tableau HTML par feuille (onglets en cas de
|
||||
multi-feuilles, en-têtes A1, cellules `contenteditable`), bouton **Enregistrer** actif dès la
|
||||
première modification et téléchargement du fichier d'origine.
|
||||
|
||||
| Aspect | Détail |
|
||||
|---|---|
|
||||
| Lecture | `backend/xlsx_reader.py` — openpyxl `read_only`, formules affichées comme texte, plafond 500×40 cellules par feuille |
|
||||
| Écriture | `PUT /api/file/{vault}/xlsx/save` → `services/mutations.edit_xlsx_cells` (backup avant écriture, refs A1 validées, `str`→`int`/`float`, 500 cellules max par requête) |
|
||||
| Frontend | `renderXlsxViewer` dans `frontend/js/viewer.js` (onglets, cellules sales, Entrée/Échap, collage monoligne) |
|
||||
| Limite connue | Le round-trip openpyxl conserve valeurs/formules/styles mais perd graphiques, images et tableaux croisés |
|
||||
|
||||
---
|
||||
|
||||
## Grosses fonctionnalités — fiches dédiées
|
||||
|
||||
| # | Feature | Version | Fiche |
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# #106 — Assistant IA : actions instantanées contextuelles & catalogue de prompts
|
||||
|
||||
> **Statut :** livré en 2.14.0 · **Domaine :** Assistant IA (frontend) ·
|
||||
> **Fichiers :** `frontend/js/ai-quick-actions.js`, `frontend/js/bookslm.js`,
|
||||
> `frontend/style.css`, `frontend/locales/{fr,en}.json`,
|
||||
> `tests/frontend/ai-quick-actions.test.mjs`
|
||||
|
||||
## Problème
|
||||
|
||||
La zone d'accueil de l'assistant affichait 3 suggestions **statiques** par mode
|
||||
(résumé / thèmes / contradictions), sans lien avec ce que l'utilisateur regarde
|
||||
réellement (un fichier de code ? plusieurs documents ? une sélection dans
|
||||
l'éditeur ?), et sans point d'accès au reste des prompts utiles.
|
||||
|
||||
## Solution
|
||||
|
||||
### Catalogue (`frontend/js/ai-quick-actions.js`, module pur sans DOM)
|
||||
|
||||
25 actions en 6 catégories, chacune = `{id, cat, icon (lucide), labelKey,
|
||||
promptKey, agent?}` — libellés **et** prompts i18n FR/EN (clés `qa.*`) :
|
||||
|
||||
| Catégorie | Actions |
|
||||
|---|---|
|
||||
| 📝 Synthèse & Analyse | Résumer en 3 points clés · Frictions/contradictions · Vulgariser · FAQ |
|
||||
| ✅ Productivité & Structuration | Checklist d'actions · Plan d'action · Mémo exécutif · **Générer le frontmatter YAML** · **Mettre à jour le frontmatter** · Liens/backlinks · Sections hiérarchiques |
|
||||
| 💻 Code & Scripts | Expliquer · Bugs & failles · Docstrings/types · Tests unitaires |
|
||||
| 🔗 Cross-documents | Comparer · Fusionner en note de synthèse · Chronologie |
|
||||
| ✍️ Édition & Reformulation | Concis · Corriger le style · Reformuler · Traduire · Expliquer la sélection |
|
||||
| ❔ Assistant (général) | Que sais-tu faire · Rechercher efficacement · Créer une note de réunion |
|
||||
|
||||
Les deux actions frontmatter sont marquées `agent: true` : un clic bascule
|
||||
transparentement le panneau en **mode agent** (comme Deep Research) puis envoie
|
||||
le prompt — l'assistant lit le document, propose la mutation via l'outil
|
||||
`edit_file`/`append_to_file`, et la **carte de confirmation** (#79) applique le
|
||||
nouveau bloc en tête du fichier.
|
||||
|
||||
Le prompt « Générer le frontmatter YAML » demande le format complet du vault de
|
||||
Bruno (titre, auteur, creation_date/modification_date ISO-8601 avec fuseau,
|
||||
catégorie, tags en liste inline, aliases, status, publish, favoris, template,
|
||||
task, archive, draft, private, NomDeVoute, Description). « Mettre à jour le
|
||||
frontmatter » **conserve les champs existants** : actualise
|
||||
`modification_date`, recalcule titre/tags/aliases/catégorie/NomDeVoute/Description
|
||||
d'après le contenu, complète les champs manquants.
|
||||
|
||||
### Sélection contextuelle (triage par défaut dans l'UI)
|
||||
|
||||
`detectContext({mode, docCount, currentPath, hasSelection})` — précédence :
|
||||
**sélection > code > multi-docs > doc unique > répertoire > général**.
|
||||
|
||||
| Contexte | Boutons suggérés |
|
||||
|---|---|
|
||||
| 1 doc texte | Résumer 3 points · Checklist · Générer le frontmatter · Mettre à jour le frontmatter |
|
||||
| 2+ docs | Fusionner · Comparer · Frictions/contradictions |
|
||||
| Fichier de code (.py, .ts, .sh…) | Expliquer · Bugs & failles · Tests unitaires |
|
||||
| Sélection dans l'éditeur | Concis · Corriger le style · Expliquer la sélection |
|
||||
| Répertoire | Résumer le répertoire · Thèmes · Checklist |
|
||||
| Général | Que sais-tu faire · Rechercher · Créer une note |
|
||||
|
||||
### Présentation (calquée sur le POC `ai_assistant_quick_actions_poc.html`)
|
||||
|
||||
- Badge de contexte dans l'en-tête (« 1 doc ouvert », « Fichier de code »,
|
||||
« Sélection active », « 3 docs ouverts »…).
|
||||
- Ligne « Actions suggérées » + bouton **« Toutes les actions »** ouvrant un
|
||||
**tiroir bottom-sheet** : recherche instantanée (insensible aux accents/casse
|
||||
via `normalizeSearch`) + catalogue groupé par catégorie, Échap/backdrop
|
||||
pour fermer.
|
||||
- Boutons icône + libellé + flèche au survol ; clic = **envoi immédiat** du
|
||||
prompt dans le composer (chemin `_sendMessage()` : skills, images et mode
|
||||
agent continuent de fonctionner).
|
||||
- La rangée se masque dès le premier message du fil (comportement historique) ;
|
||||
le rafraîchissement du contexte à la sélection éditeur est throttled 250 ms
|
||||
(`selectionchange` + `mouseup`/`keyup`, CodeMirror n'émettant pas
|
||||
`selectionchange` natif).
|
||||
- CSS 100 % variables de thème (`--surface`, `--accent`, `--accent-bg`…) →
|
||||
conforme aux 15 thèmes ; transitions désactivées en `prefers-reduced-motion`.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/frontend/ai-quick-actions.test.mjs` (13 tests, câblé au job lint CI) :
|
||||
précédence des contextes, presets 3-4 actions résolvables, table de conception,
|
||||
agent-flag des actions frontmatter, **complétude FR+EN** de chaque clé
|
||||
catalogue/badge contre les vrais JSON de locales.
|
||||
|
||||
## Vérification live (obsigate-test :2020, Playwright)
|
||||
|
||||
badge « 1 doc ouvert », 4 suggestions dont les 2 frontmatter, tiroir à 6
|
||||
catégories / 25 actions, recherche « frontmatter » → 2 résultats, clic →
|
||||
`aria-pressed=true` sur le bouton agent + message envoyé avec le nouveau
|
||||
prompt + réponse de l'assistant.
|
||||
@@ -20,6 +20,7 @@
|
||||
- [x] **B3.** Fallback : retry sans `tools` si le provider rejette les tools (400/404/422) → chat simple ; protocole texte `obsigate-action` conservé côté frontend **pour le chat classique uniquement** (BUG-053 : en mode agent, le prompt impose les outils natifs et interdit les blocs `obsigate-action`)
|
||||
- [x] **B4.** SSE réellement streaming — `ai_chat.stream_completion` (`_openai_stream` + `_gemini_stream`) alimente `/api/ai/bookslm/chat` token par token ; le middleware GZip laisse passer les endpoints SSE BooksLM.
|
||||
- [x] **B5.** Confirmations UI : toggle « mode agent » (front → `/agent`), événements `tool`/`confirmation`, carte Apply + aperçu diff (LCS) pour les mutations, reprise `confirm`/`confirm_messages` côté backend. *S'active dès que la phase D enregistre des outils `write`.*
|
||||
- **Complément (BUG-074 → BUG-077)** : la pause de confirmation **regroupe toutes les mutations** d'un même tour LLM (`pending.actions`, chacune avec son libellé `step` et son diff) et la carte n'offre plus qu'un seul bouton « **Tout approuver (N)** » ; la reprise envoie `confirm_all` et le backend arme `ToolContext.confirmed` pour le reste du run (plus d'approbation action par action). Le bloc d'étapes affiche un **titre** (1re action) et ne compte que les **actions** (hors réflexions) ; la reprise diffuse dans le **même message** (« N étapes » cumulées). L'arborescence et le document affiché sont **rafraîchis** dès une action mutatrice (refresh débouncé + `obsigate:file-written`, documents xlsx/docx/csv/pdf inclus). Le bouton d'envoi devient « **Stop** » pendant le stream (abort SSE, tâche serveur annulée à la déconnexion, marqueur « Exécution arrêtée. »).
|
||||
- [x] **B6.** Outils de navigation in-app : `open_file`, `reveal_in_tree` (événement `obsigate:open-file`) — livré via les liens cliquables de l'assistant (#80, [ai-assistant-ux.md](./ai-assistant-ux.md))
|
||||
- [x] **B7.** Tests : agent loop LLM mocké (`tests/test_agent_loop.py`), providers (`tests/test_ai_chat.py`), endpoint (`tests/test_bookslm.py`)
|
||||
|
||||
@@ -67,7 +68,7 @@
|
||||
`call_tool` (couvre les diffs, extraits de recherche et lectures non pré-redactées).
|
||||
- [x] **F3.** Documentation OpenAPI + guide MCP — `backend/openapi_docs.py` : tag `MCP`,
|
||||
règle `/mcp`, injection du path `/mcp` (Streamable HTTP, JSON-RPC) dans le schéma ;
|
||||
nouveau [`docs/MCP_GUIDE.md`](../MCP_GUIDE.md) (endpoint, auth, config Claude Desktop /
|
||||
nouveau [`docs/GUIDES/MCP.md`](../GUIDES/MCP.md) (endpoint, auth, config Claude Desktop /
|
||||
Cursor, tools/resources/prompts, sécurité, variables, dépannage).
|
||||
- [x] **F4.** Tests E2E de bout en bout — `tests/test_ai_e2e.py` : agent in-app
|
||||
read→confirmation→write, quota d'outils, rate limiting, redaction, et flux MCP complet
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# #107 — Clés API & MCP (panneau de configuration)
|
||||
|
||||
**Version : 2.15.0 — statut : complété (septembre 2026)**
|
||||
|
||||
## Problème
|
||||
|
||||
Pour brancher un client MCP externe (Claude Desktop, Cursor…) ou scripter
|
||||
l'API REST, il fallait soit se connecter et voler le JWT de session de 1 h
|
||||
dans le navigateur, soit générer un token à la main via `docker exec`
|
||||
(`generer_access_token.sh`) — sans expiration maîtrisable ni révocation.
|
||||
|
||||
## Décision : une seule clé pour l'API ET le MCP
|
||||
|
||||
Le serveur MCP (`/mcp`, `backend/mcp/server.py::_authenticate`) résout
|
||||
l'appelant via la même dépendance `get_current_user()` que l'API REST.
|
||||
Un jeton HS256 `type=access` authentifie donc **les deux surfaces** — il
|
||||
n'y a pas de famille de clés séparée à exposer dans l'UI. C'est dit
|
||||
explicitement dans la section (« la même clé fonctionne pour les deux »)
|
||||
et verrouillé par tests (REST 200 + MCP initialize 200 avec la même clé ;
|
||||
révocation → 401 des deux côtés).
|
||||
|
||||
## Conception
|
||||
|
||||
### Store — `data/api_tokens.json`
|
||||
|
||||
```json
|
||||
{"version": 1, "tokens": {"<jti>": {
|
||||
"name": "Claude Desktop", "username": "admin",
|
||||
"created_at": 1790000000, "expires_at": 1792592000,
|
||||
"expiry_key": "30d", "last_used_at": null
|
||||
}}}
|
||||
```
|
||||
|
||||
Le JWT brut n'est **jamais persisté** : affiché une seule fois à la
|
||||
création, sinon perdu (pattern GitHub). La révocation fonctionne par
|
||||
`jti` : le JWT présenté est rejeté par `is_token_revoked` même s'il est
|
||||
encore valide dans sa signature.
|
||||
|
||||
### Expirations (choix UI)
|
||||
|
||||
| Clé | Durée | `exp` dans le JWT |
|
||||
|---|---|---|
|
||||
| `1d` | 1 jour | iat + 86 400 |
|
||||
| `30d` | 1 mois | iat + 2 592 000 |
|
||||
| `180d` | 6 mois | iat + 15 552 000 |
|
||||
| `365d` | 1 an | iat + 31 536 000 |
|
||||
| `never` | sans fin | **aucun claim exp** |
|
||||
|
||||
Plafond : 50 tokens actifs par utilisateur (`API_TOKEN_MAX_PER_USER`).
|
||||
|
||||
### Correction induite — révocation longue durée
|
||||
|
||||
L'ancien `revoked_tokens.json` bornait toute entrée à 7 jours ; une clé
|
||||
1 an révoquée aurait « repris vie » au nettoyage suivant. Le store devient
|
||||
un dict `{jti: valid_until}` calé sur l'expiration réelle du jeton
|
||||
(« sans fin » → 100 ans). Session tokens inchangés (7 j).
|
||||
|
||||
### « Dernière utilisation »
|
||||
|
||||
`get_current_user` appelle `maybe_touch_api_token(jti)` pour les jetons
|
||||
`api: true` — écriture disque throttlée à 1 h par jti, silencieuse si le
|
||||
dossier est en lecture seule ; ne fait jamais échouer une requête.
|
||||
|
||||
## Endpoints (`/api/auth`, auth requise, périmètre = l'appelant)
|
||||
|
||||
- `GET /api/auth/tokens` → `{tokens: [...], expiry_choices: [...]}`
|
||||
- `POST /api/auth/tokens` `{name, expiry}` → `{token, ...record}` (secret unique)
|
||||
- `DELETE /api/auth/tokens/{jti}` → révocation immédiate API + MCP
|
||||
- Audit : `config_change` / `api_token_create|revoke`.
|
||||
|
||||
## UI — panneau Configuration
|
||||
|
||||
Nouvelle section `#cfg-tokens` « 🔑 Clés API & MCP » (après Sécurité) :
|
||||
liste (badge Active/Expirée, créée/expire/dernière utilisation), champ
|
||||
nom + sélecteur d'expiration + Créer, zone secrète en tirets avec Copier,
|
||||
bloc « Utilisation » : header `Authorization: Bearer <clé>` + exemple de
|
||||
config MCP avec headers sur `<base>/mcp`. 28 clés i18n FR/EN, SW v24.
|
||||
|
||||
## Tests — `tests/test_api_tokens.py` (13)
|
||||
|
||||
Création/liste (secret jamais restitué), les 5 durées dont l'absence
|
||||
d'`exp` pour `never`, expiration invalide → 400, clé identique acceptée
|
||||
par REST **et** `/mcp`, refus MCP anonyme, isolation par utilisateur,
|
||||
révocation → 401 immédiat des deux côtés, survie de la révocation
|
||||
longue durée après reload disque, drapeau `expired`, migration de format
|
||||
`revoked_tokens.json`. Suite auth + MCP complète verte (77).
|
||||
|
||||
## Config MCP externe (exemple)
|
||||
|
||||
```json
|
||||
{"mcpServers": {"obsigate": {
|
||||
"url": "http://localhost:2020/mcp",
|
||||
"headers": {"Authorization": "***"}
|
||||
}}}
|
||||
```
|
||||
@@ -0,0 +1,77 @@
|
||||
# #85 — Refonte architecturale : découpage du monolithe & persistance d'état (phase 2)
|
||||
|
||||
> **Statut :** livré (T1→T10) — `backend/main.py` 4 827 → ~750 lignes, 14 routers,
|
||||
> persistance partielle (stores verrouillés + rate-limit SQLite optionnel).
|
||||
> Méthode : tranches à impact minimal, comportement inchangé, un domaine par
|
||||
> commit, suite complète verte à chaque commit (1320 passed / 6 skipped).
|
||||
|
||||
## 1. Découpage du monolithe (T1→T9, comportement inchangé)
|
||||
|
||||
Chaque tranche déplace un domaine vers `backend/routers/` (handlers verbatim,
|
||||
mêmes chemins/modèles/auth/tags OpenAPI), les modèles vers `backend/schemas.py`,
|
||||
et ne committe que sur suite verte + `test_version` vert.
|
||||
|
||||
| Tranche | Domaine | Nouveau module | Version |
|
||||
|---|---|---|---|
|
||||
| T1 | health (`/api/health*`) | `routers/health.py` (+ `HealthResponse` → schemas) | 2.27.2 |
|
||||
| T2 | webhooks CRUD | `routers/webhooks.py` | 2.27.3 |
|
||||
| T3 | sharing (`/api/share*`, `/s/*`) | `routers/sharing.py` | 2.27.4 |
|
||||
| T4 | backups (9 routes) | `routers/backups.py` (+ `Diff/Restore*` → schemas, `backend/sse.py`) | 2.27.5 |
|
||||
| T5 | search (11 routes) | `routers/search.py` (+ modèles → schemas, `backend/search_executor.py`) | 2.27.6 |
|
||||
| T6a | lecture fichiers | `routers/files_read.py` (+ modèles, `routers/helpers.py`) | 2.27.7 |
|
||||
| T6b | mutations fichiers/dossiers | `routers/files_write.py` (+ 15 modèles → schemas) | 2.27.8 |
|
||||
| T6c | media/pdf/export/guide | `routers/files_media.py` (Range helper → `helpers.py`) | 2.27.9 |
|
||||
| T7 | config (12 routes) | `routers/config.py` (`_FALLBACK_MODELS` déplacé) | 2.27.10 |
|
||||
| T8 | vaults + history + conflicts (13 routes) | `routers/vaults.py`, `history.py`, `conflicts.py` (+ `backend/watcher_state.py`) | 2.27.11 |
|
||||
| T9 | realtime + render | `routers/realtime.py` (SSE + collab WS), `backend/render.py` | 2.27.12 |
|
||||
|
||||
`main.py` ne contient plus que l'assemblage : lifespan, middlewares, montage
|
||||
des routers, racine `/api`, statique/SPA, 4 cales de compatibilité testées
|
||||
(`_resolve_safe_path`, `_backup_file`, `_check_vault_writable`, `_get_backup_dir`).
|
||||
|
||||
Correctifs au passage : décorateur orphelin `/s/{token}` (double-enregistrement
|
||||
de `/api/conflicts`), tag OpenAPI `media` inexistant (assignation par chemin
|
||||
conservée), tests statiques frontend réalignés (`image-viewer`, `media-viewer`),
|
||||
tests repointés vers les modules canoniques (`test_ai_models`, `test_api_main`).
|
||||
|
||||
## 2. Persistance d'état (T10)
|
||||
|
||||
| État | Avant | Après |
|
||||
|---|---|---|
|
||||
| JTI révoqués (`revoked_tokens.json`) | persisté, **sans verrou** | `RLock` (load/save/revoke/check) |
|
||||
| `shares.json` | persisté, **sans verrou** | `RLock` (4 mutateurs) |
|
||||
| `webhooks.json` + secrets | persistés, **sans verrou** | `RLock` (create/update/delete/secrets) |
|
||||
| `api_keys.json` (tool-secrets) | persisté, **sans verrou** | `RLock` (set/delete) |
|
||||
| Rate-limit auth | mémoire, mono-process | **inchangé par défaut** + option `OBSIGATE_RATELIMIT_DB` (SQLite WAL : mêmes fenêtres/budgets, partagé multi-workers, survit au redémarrage) |
|
||||
| Index de recherche | mémoire, rebuild au démarrage | **conservé** (voir §3) |
|
||||
| `users.json`, `api_tokens.json`, `vault_settings.json` | déjà verrouillés (BUG-029, #107) | inchangé |
|
||||
|
||||
Tests : `tests/test_store_locks.py` (4 — concurrence threads, pertes prouvées
|
||||
sans verrou : 25/200 partages), `tests/test_ratelimit_store.py` (7 —
|
||||
sémantique SQLite identique, persistance, concurrence 200/200).
|
||||
|
||||
Déjà existants et vérifiés (pas de code) : verrous `threading` + `asyncio`
|
||||
de l'indexeur (`_index_lock`, `_async_index_lock`), contrat central des
|
||||
outils IA — `backend/tools/registry.py` couvre déjà permissions
|
||||
(`requires_vault`, `require_destructive_allowed`), quotas
|
||||
(`check_and_record` par outil) et redaction (`redact_payload`) pour les
|
||||
35 outils enregistrés via `@tool(`.
|
||||
|
||||
## 3. Décisions assumées (non fait, et pourquoi)
|
||||
|
||||
- **Index non persisté sur disque.** Le rebuild différentiel (#86 : réutilise
|
||||
les entrées inchangées `size` + `mtime`) rend le démarrage rapide ; un
|
||||
snapshot introduirait des risques de staleness/drift de format sans gain
|
||||
mesuré. Réévaluer si le démarrage devient lent (vaults 50k+ fichiers).
|
||||
- **Redis exclu.** SQLite WAL couvre le multi-workers mono-hôte sans nouvelle
|
||||
infra ; Redis reste l'option multi-nœuds documentée (cf. `ratelimit.py`).
|
||||
- **`.gitignore` (`_*.py` ignore les `__init__.py`).** Contourné par
|
||||
`git add -f` comme les packages existants ; assainir la règle à part.
|
||||
- Noms en `_` conservés (`backend/render.py`, stores) : déplacement verbatim,
|
||||
zéro churn d'appels.
|
||||
|
||||
## 4. Reste connu (hors #85)
|
||||
|
||||
- CSP `unsafe-inline` (migration nonce, BUG-034 partiel) et `Secure` cookies → #87.
|
||||
- `main.py` (~750 lignes) : lifespan, middlewares, statique/SPA — cible
|
||||
d'extraction ultérieure si besoin, non bloquant.
|
||||
@@ -47,7 +47,8 @@ le viewer ne rend que les vues document, la modale est donc enrichie ici. Si le
|
||||
CDN Mermaid n'est pas prêt, le bloc reste du code lisible et le rendu est
|
||||
retenté à l'ouverture suivante (`data-mermaid-rendered` ne se pose qu'après
|
||||
succès). Le MD exporté embarque le diagramme en fenced block ` ```mermaid `
|
||||
(copiable, rendu par GitHub/VS Code/Obsidian) ; le PDF contient le même bloc.
|
||||
(copiable, rendu par GitHub/VS Code/Obsidian) ; le PDF affiche le PNG pré-rendu
|
||||
(section 4 — WeasyPrint n'exécute pas Mermaid).
|
||||
|
||||
## 4. Téléchargement (MD + PDF)
|
||||
|
||||
@@ -63,13 +64,26 @@ nu) — même stratégie que le bouton « PDF » des documents (#92).
|
||||
`index.html` et `fr.json`) pour éviter de re-générer à chaque requête.
|
||||
|
||||
Endpoint `GET /api/guide/download?format=md|pdf&lang=fr|en`
|
||||
(`require_auth`, tag OpenAPI **« Guide »**), `Content-Disposition: attachment`.
|
||||
Côté UI : boutons « Markdown » / « PDF » dans l'en-tête de la modale
|
||||
(`#help-download-md`/`#help-download-pdf`), handler `downloadGuide()` dans
|
||||
(`require_auth`, tag OpenAPI « Guide »), `Content-Disposition: attachment`.
|
||||
Côté UI : boutons **icônes seules** 📄/⬇ dans l'en-tête de la modale
|
||||
(`#help-download-md`/`#help-download-pdf`, tooltip i18n), handler `downloadGuide()` dans
|
||||
`frontend/js/config.js` — fetch avec `AuthManager.getAuthHeaders()` +
|
||||
credentials (identique à `viewer.downloadExport()`), blob → lien
|
||||
téléchargeable, toasts i18n réutilisés (`viewer.export_*`).
|
||||
|
||||
Diagramme d'architecture dans le PDF : WeasyPrint n'exécute pas Mermaid → le code
|
||||
Mermaid est **pré-rendu en PNG** (`scripts/build_guide_diagrams.py` +
|
||||
`scripts/render_guide_diagram.mjs`, Chromium + mermaid v11 CDN, scale 2) et le PNG
|
||||
est **commité** dans `backend/assets/guide_diagrams/<sha1>.png` ; `diagram_png_for()`
|
||||
(hash sha1[:16] du code normalisé, même algorithme des deux côtés) remplace le
|
||||
bloc par `<img src="file:///…">` dans le HTML d'export. Après modification du
|
||||
diagramme dans `guide_content.py` + réinsertion : relancer
|
||||
`python scripts/build_guide_diagrams.py` et committer le nouveau PNG.
|
||||
Emoji : l'image Docker installe `fonts-noto-color-emoji` et la pile de polices
|
||||
PDF finit par `"Noto Color Emoji"` — sans cela les emoji pleine chasse des titres
|
||||
de section s'affichent en rectangles (la TOC n'était pas touchée car elle passe
|
||||
par DejaVu/Sans).
|
||||
|
||||
## 5. Guide desktop élargi
|
||||
|
||||
`frontend/js/desktop.js::initDesktopIntegration()` ajoute `body.desktop-mode`
|
||||
@@ -106,14 +120,15 @@ même layout est accordé aux viewports ≥1400 px web via media-query (contenu
|
||||
|
||||
## 7. Tests & vérifications
|
||||
|
||||
- `tests/test_guide.py` (11) : ancre TOC→sections (garde-fou BUG-067),
|
||||
- `tests/test_guide.py` (13) : ancre TOC→sections (garde-fou BUG-067),
|
||||
sections #105 présentes, parité/présence des clés `guide105.*` FR=EN,
|
||||
Markdown FR/EN (titres, mermaid, absence de balises résiduelles), PDF
|
||||
(`%PDF`, taille), metadata `get_guide_document`, endpoint MD/PDF/400 via
|
||||
TestClient, OpenAPI (path + tag « Guide »).
|
||||
- Suite backend pytest : 1216 passed · ruff/mypy 0 erreur ·
|
||||
(`%PDF`, taille), **diagramme Architecture rendu en `<img>` PNG dans le HTML
|
||||
d'export**, MD garde le fenced mermaid, metadata `get_guide_document`,
|
||||
endpoint MD/PDF/400 via TestClient, OpenAPI (path + tag « Guide »).
|
||||
- Suite backend pytest : 1218 passed · ruff/mypy 0 erreur ·
|
||||
`validate-imports` 38 modules · `unit.test.mjs` 10/10 · E2E complet CI.
|
||||
- SW cache busting : `SW_VERSION` v21→v22.
|
||||
- SW cache busting : `SW_VERSION` v21→v23 (guides + boutons icônes).
|
||||
|
||||
## 8. Limites assumées
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# #112 — En-tête allégé & compte en sidebar
|
||||
|
||||
> **Version livrée :** 2.21.0 · **Statut :** 🟢 · **Impact :** 🟡
|
||||
> **Zone :** frontend (`index.html`, `frontend/js/auth.js`, `frontend/js/viewer.js`,
|
||||
> `frontend/style.css`, i18n FR/EN) + CI (`.gitea/workflows/ci.yml`).
|
||||
|
||||
## Contexte
|
||||
|
||||
Le header concentrait plusieurs éléments redondants ou peu lisibles :
|
||||
|
||||
- la **version** était affichée en permanence à côté du bouton Options ;
|
||||
- un **bouton de déconnexion** et le **nom de l'utilisateur** occupaient la zone
|
||||
droite du header, sans regroupement logique ;
|
||||
- la pellicule de miniatures de la visionneuse d'images ne pouvait être
|
||||
parcourue qu'au glisser (pas de molette horizontale ni de flèches dédiées).
|
||||
|
||||
## Ce qui a été livré
|
||||
|
||||
### A. Header allégé
|
||||
|
||||
- **Version déplacée** du header vers le menu **Options** : nouvelle ligne
|
||||
non-interactive « Version » (`#version-badge` conservé pour `populateVersions`).
|
||||
- **Suppression** du bloc `#user-menu` (nom + bouton « sign out ») et de la
|
||||
ligne « Déconnexion » du menu Options. Le header droit ne garde que l'indicateur
|
||||
hors-ligne, le contexte vault et le bouton Options.
|
||||
|
||||
### B. Section compte en bas de la sidebar
|
||||
|
||||
- Nouveau bloc `#sidebar-user` épinglé au bas de la sidebar (`flex-shrink: 0`,
|
||||
bordure supérieure), à la manière d'un site web professionnel :
|
||||
- initiales dans un **avatar** circulaire (dégradé d'accent) ;
|
||||
- **nom** (`#sidebar-user-name`, classe `.user-display-name` conservée pour la
|
||||
réutilisation par la collaboration) et **rôle** localisé
|
||||
(Administrateur / Utilisateur) ;
|
||||
- **bouton déconnexion** (`#sidebar-user-logout`) ;
|
||||
- clic sur l'identité → **Profil** (ouvre la section `#cfg-profile`).
|
||||
- `AuthManager.renderUserSection()` peuple le bloc ; il reste masqué quand
|
||||
l'authentification est désactivée (`#sidebar-user[hidden]`). `renderUserMenu()`
|
||||
est conservé comme alias rétro-compatible.
|
||||
- La règle mobile qui masquait `.user-display-name` est limitée au header
|
||||
(`.header-right .user-display-name`) pour que le nom reste visible dans la
|
||||
sidebar mobile.
|
||||
|
||||
### C. Pellicule d'images défilable (#112, complément de #111)
|
||||
|
||||
- Nouveau conteneur `.image-nav` autour de la pellicule, avec deux **flèches
|
||||
translucides** fixes (`.image-strip-arrow-prev/next`) révélées au survol.
|
||||
- **Molette** au-dessus de la pellicule → défilement horizontal
|
||||
(`strip.scrollLeft += deltaY`), conversion des deltas verticaux.
|
||||
- Les flèches font défiler d'une « page » (`strip.scrollBy`, défilement doux).
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/frontend/unit.test.mjs` (+1) : header nettoyé (plus de `#user-menu`,
|
||||
`#logout-btn`, ni version dans `.header-right`), version dans le menu Options,
|
||||
présence de `#sidebar-user`, styles `.sidebar-user`/`.menu-list-version`, clés
|
||||
i18n FR/EN.
|
||||
- `tests/frontend/image-viewer.test.mjs` (+1) : wrapper `.image-nav`, flèches de
|
||||
pellicule, molette et `scrollBy`.
|
||||
- `tests/e2e/header-sidebar.spec.js` (nouveau) : test header/version exécuté
|
||||
partout ; test compte en sidebar conditionné à l'auth (skip sinon).
|
||||
- CI : `image-viewer.test.mjs` ajouté au job `lint` (il n'y était pas depuis #108).
|
||||
|
||||
## Limitations connues
|
||||
|
||||
- La section compte n'apparaît que si l'authentification est activée.
|
||||
- `doLogoutFallback` (script inline supprimé avec la ligne du menu) reste appelé
|
||||
par la section Profil uniquement en repli ; `window.handleLogout` est toujours
|
||||
défini par `auth.js`, donc le repli n'est jamais atteint.
|
||||
@@ -0,0 +1,80 @@
|
||||
# #111 — Visionneuse d'images : navigation fluide
|
||||
|
||||
> **Version livrée :** 2.20.0 · **Statut :** 🟢 · **Impact :** 🟡
|
||||
> **Zone :** frontend (`frontend/js/viewer.js`, `frontend/style.css`, i18n FR/EN).
|
||||
> **Améliore :** [#108](./image-support.md) (visionneuse livrée en 2.17.0).
|
||||
|
||||
## Contexte
|
||||
|
||||
La visionneuse d'images de #108 fonctionnait mais restait perfectible sur l'usage
|
||||
quotidien :
|
||||
|
||||
- certaines images s'affichaient **plus grandes que le cadre** de présentation ;
|
||||
- changer d'image (←/→, flèches, vignette) appelait `openFile` → `renderFile` →
|
||||
`renderImageViewer`, soit **un rechargement complet** de la vue **et** un
|
||||
nouvel appel `/api/browse` à *chaque* image — sensible dès qu'un dossier en
|
||||
contient beaucoup ;
|
||||
- la **pellicule de miniatures disparaissait** le temps du re-rendu ;
|
||||
- aucune zone de clic latérale ne permettait de changer d'image.
|
||||
|
||||
## Ce qui a été livré
|
||||
|
||||
### A. Ajustement au cadre
|
||||
|
||||
- La zone de contenu devient un conteneur **flex** dédié à la visionneuse
|
||||
(`.content-area:has(> .image-viewer-container) { padding: 0; overflow: hidden }`) :
|
||||
plus de défilement de page, la visionneuse occupe tout l'espace disponible.
|
||||
- `.image-stage` gagne `min-height: 0`, un `padding` de 16 px (cadre) et
|
||||
`box-sizing: border-box` ; `.image-main` conserve `max-width/height: 100%`
|
||||
avec `width/height: auto`. L'image est donc **toujours redimensionnée dans
|
||||
l'espace disponible**, y compris quand le panneau « Métadonnées » s'ouvre
|
||||
(la scène se réduit, l'image suit).
|
||||
|
||||
### B. Navigation « en place » (performance)
|
||||
|
||||
- `renderImageViewer` ne se contente plus de rendre une image : il gère un état
|
||||
mutable (`currentPath`, `currentTitle`, `imgUrl`, `currentMeta`) et expose
|
||||
`showSibling(index)` qui **remplace le `src` du `<img>`** sans reconstruire le
|
||||
DOM. Les flèches, le clavier ←/→ et les miniatures passent tous par là.
|
||||
- Conséquences : plus de `openFile`, plus de re-rendu, **plus de refetch du
|
||||
fichier ni de `/api/browse`** à chaque image.
|
||||
- **Cache annuaire** `_imageDirCache` (`Map`, TTL 15 s) : la liste des images
|
||||
d'un dossier n'est récupérée qu'une fois par courte fenêtre.
|
||||
- **Préchargement** des images voisines (`new Image()`), avec un `Set` pour
|
||||
éviter les doublons.
|
||||
|
||||
### C. Pellicule persistante
|
||||
|
||||
- La pellicule de miniatures n'est plus recréée à chaque navigation ; la
|
||||
vignette active est simplement re-marquée (`.active`) et **amenée dans la vue
|
||||
par défilement horizontal du film uniquement** (jamais la page).
|
||||
- Miniatures en `loading="lazy"` + `decoding="async"`.
|
||||
|
||||
### D. Flèches latérales translucides
|
||||
|
||||
- Deux boutons superposés `.image-nav-arrow` (prev/next) longent les bords du
|
||||
cadre, avec une icône `chevron` et une ombre portée pour rester lisibles sur
|
||||
toute image.
|
||||
- Opacité quasi nulle au repos, révélée au **survol du cadre** (`0.4`) puis du
|
||||
bouton (`1`, avec dégradé sombre). Toujours visibles (opacité moyenne) sur les
|
||||
appareils tactiles (`@media (hover: none)`).
|
||||
- Le `pointerdown`/`dblclick` des flèches n'est pas propagé à la scène : le
|
||||
pan (glisser) et le double-clic de réinitialisation du zoom restent intacts.
|
||||
- Un **compteur** `n / total` est ajouté à la barre d'outils.
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/frontend/image-viewer.test.mjs` : helpers purs inchangés + vérifications
|
||||
statiques de la navigation en place (`showSibling`, absence de
|
||||
`_imageViewerNavPending`, cache annuaire), des flèches et du CSS
|
||||
(`:has(> .image-viewer-container)`, `max-width/height`, `opacity`).
|
||||
- `tests/e2e/image-viewer.spec.js` (+1) : image contenue dans le cadre, flèche
|
||||
superposée révélée au survol, titre mis à jour **sans recréer le conteneur**
|
||||
(marqueur `data-inplace`), pellicule toujours visible et compteur affiché.
|
||||
|
||||
## Limitations connues
|
||||
|
||||
- Le cache annuaire a un TTL court : une image ajoutée puis ouverte dans les
|
||||
quelques secondes peut ne pas apparaître tout de suite dans la pellicule.
|
||||
- Les flèches latérales n'apparaissent pas en mode lightbox plein écran
|
||||
(navigation clavier ←/→ et pellicule masquée conservées).
|
||||
@@ -0,0 +1,96 @@
|
||||
# #108 — Support complet des images (arborescence, visionneuse, indexation)
|
||||
|
||||
> **Version livrée :** 2.17.0 · **Statut :** ✅ · **Impact :** 🟡
|
||||
> **Zone :** backend (`indexer`, `main`, `media_types`, `media_thumbs`) + frontend
|
||||
> (`viewer.js`, `utils.js`, `style.css`).
|
||||
|
||||
## Contexte
|
||||
|
||||
Seul l'affichage *inline* dans un document markdown (`![[image.png]]`) fonctionnait.
|
||||
L'image isolée était **invisible dans l'arborescence** (filtrée par
|
||||
`SUPPORTED_EXTENSIONS`) et son **affichage standalone était cassé** : le `<img>`
|
||||
généré par `api_file_view()` pointait vers `/api/file/{vault}/raw`, un endpoint qui
|
||||
renvoie du **JSON** (`FileRawResponse`) et non des octets d'image.
|
||||
|
||||
## Ce qui a été livré
|
||||
|
||||
### A. Arborescence & indexation
|
||||
|
||||
- **`backend/media_types.py`** (nouveau) : source unique des extensions
|
||||
`IMAGE_EXTENSIONS`, `AUDIO_EXTENSIONS`, `VIDEO_EXTENSIONS` (+ helpers
|
||||
`is_image`/`is_audio`/`is_video`/`is_media`/`media_mime_type`). Socle réutilisé
|
||||
par #109. `attachment_indexer.py` et `api_file_view()` ne dupliquent plus la
|
||||
liste.
|
||||
- Les extensions image sont intégrées à `SUPPORTED_EXTENSIONS`
|
||||
(`indexer.py`) **avec une branche binaire** : `_scan_vault` et
|
||||
`_index_single_file_sync` indexent **nom / taille / mtime** et ne lisent
|
||||
**jamais** les octets (`content: ""`, `content_preview: ""`). Le TF-IDF reste
|
||||
donc propre et aucune `UnicodeDecodeError` ne pollue les logs.
|
||||
- Le reindex **watchdog** suit automatiquement (même filtre d'extensions).
|
||||
- Le filtre `ext:png` / `ext:jpg` de la recherche avancée est opérationnel dès
|
||||
lors que les images entrent dans l'index.
|
||||
- `/api/dashboard` expose `image_count` (par vault) et `total_images` (global),
|
||||
séparés du `file_count` général.
|
||||
|
||||
### B. Affichage standalone (correctif)
|
||||
|
||||
- `api_file_view()` génère désormais
|
||||
`src="/api/image/{vault}?path=…"` (chemin URL-encodé) au lieu de `/raw`.
|
||||
- `viewer.js` utilise le même endpoint (plus de bouton « Plein écran » cassé).
|
||||
- **Sécurité SVG** : `/api/image` (et le repli de `/api/media/.../thumb`) ajoute
|
||||
`Content-Security-Policy: sandbox` pour les `.svg`, ce qui empêche
|
||||
l'exécution du JavaScript embarqué quand le fichier est ouvert directement
|
||||
dans un onglet (XSS same-origin). Dans une balise `<img>`, l'en-tête est sans
|
||||
effet. Le middleware de sécurité ne remplace plus une politique stricte posée
|
||||
par une route.
|
||||
|
||||
### C. Miniatures
|
||||
|
||||
- `GET /api/media/{vault}/thumb?path=…&size=…` : miniature **WebP** générée
|
||||
avec `pillow>=10.0`, mise en cache sous
|
||||
`<OBSIGATE_DATA_DIR>/.obsigate-cache/thumbs/{sha1}.webp`. La clé de cache
|
||||
embarque **mtime + taille**, donc toute édition invalide naturellement la
|
||||
vignette.
|
||||
- Génération dans un thread (`run_in_executor`) avec **timeout 2 s** ; repli sur
|
||||
l'original en cas d'échec. SVG : l'original est servi tel quel (Pillow ne
|
||||
décode pas le SVG) ; GIF/WebP animés : première frame.
|
||||
|
||||
### D. Visionneuse
|
||||
|
||||
`renderImageViewer()` (`frontend/js/viewer.js`) remplace l'ancien rendu minimal :
|
||||
|
||||
- image centrée `object-fit: contain` ; **zoom molette 0,1×–8×**, **pan au
|
||||
glisser** (Pointer Events), **double-clic = réinitialisation**, raccourcis
|
||||
`+` / `-` / `0` ;
|
||||
- boutons +/−/reset et **badge de zoom** ;
|
||||
- **navigation ←/→** entre les images du même dossier (via `/api/browse`) et
|
||||
**pellicule de miniatures** (`/api/media/.../thumb`, `loading="lazy"`) ;
|
||||
- barre d'outils : « Ouvrir l'original » (nouvel onglet `/api/image`),
|
||||
téléchargement, **panneau métadonnées** repliable (dimensions via
|
||||
`naturalWidth/Height`, taille, type MIME, chemin, date), **lightbox** plein
|
||||
écran (fond `rgba(0,0,0,.9)`, `Échap` pour quitter) ;
|
||||
- `EXT_ICONS` : extensions image → icône Lucide `image` ;
|
||||
- compatible Split View (#75) : rendu dans `getContentArea()` du panneau actif.
|
||||
|
||||
### E. Tests
|
||||
|
||||
- `tests/test_image_api.py` : octets + MIME sur `/api/image`, en-tête `sandbox`
|
||||
des SVG, URL `/api/image` dans le HTML de `api_file_view`, encodage des
|
||||
chemins accentués, miniatures WebP + repli SVG + refus non-image.
|
||||
- `tests/test_image_indexing.py` : image présente dans `list_directory` et
|
||||
`path_index`, indexée avec `content == ""`, pertinence watchdog, compteurs
|
||||
dashboard, filtre `ext:png`.
|
||||
- `tests/frontend/image-viewer.test.mjs` : helpers purs (`clampImageZoom`,
|
||||
`isImagePath`, `buildImageUrl`) + vérifications statiques (zoom/pan/nav,
|
||||
absence de `/raw` dans la visionneuse, CSS, icônes).
|
||||
- `tests/e2e/image-viewer.spec.js` : ouverture d'une image depuis
|
||||
l'arborescence, réponse `/api/image` en `image/png`, zoom molette, navigation
|
||||
par la pellicule (fixtures `test_vault/sample-image.png` +
|
||||
`sample-vector.svg`).
|
||||
|
||||
## Limitations connues
|
||||
|
||||
- **HEIC/HEIF** (iPhone) : non décodables par les navigateurs → hors scope ;
|
||||
`pillow-heif` envisagé en v2.
|
||||
- Le SVG passe par l'original (pas de rendu bitmap côté serveur) : les
|
||||
miniatures de dossiers SVG ne sont pas générées.
|
||||
@@ -0,0 +1,187 @@
|
||||
# #109 — Support audio & vidéo (lecteurs HTML5 intégrés)
|
||||
|
||||
> **Version livrée :** 2.18.0 · **Statut :** ✅ · **Impact :** 🟡
|
||||
> **Zone :** backend (`main`, `indexer`, `media_types`, `bookslm`) + frontend
|
||||
> (`viewer.js`, `utils.js`, `style.css`, `sw.js`).
|
||||
|
||||
## Contexte
|
||||
|
||||
Le socle média de #108 (`backend/media_types.py`) exposait déjà
|
||||
`AUDIO_EXTENSIONS` / `VIDEO_EXTENSIONS`, mais ces fichiers n'étaient ni indexés
|
||||
ni affichables : ils tombaient dans le chemin binaire « Ce fichier est binaire
|
||||
et ne peut pas être affiché » + bouton download.
|
||||
|
||||
## Ce qui a été livré
|
||||
|
||||
### A. Backend
|
||||
|
||||
- **Indexation** : `AUDIO_EXTENSIONS` et `VIDEO_EXTENSIONS` sont intégrées à
|
||||
`SUPPORTED_EXTENSIONS` (`indexer.py`). La branche `is_media(ext)` existante
|
||||
indexe **nom / taille / mtime** sans jamais lire les octets (`content: ""`),
|
||||
donc le TF-IDF et les logs restent propres. Le watcher et le filtre `ext:`
|
||||
suivent automatiquement.
|
||||
- **Streaming** : le helper Range de `pdf/stream` a été extrait en
|
||||
`_stream_file_with_range(file_path, request, media_type)` (206 +
|
||||
`Content-Range` + `Accept-Ranges`, `416` sur plage invalide, `FileResponse`
|
||||
simple sinon, lectures offloadées via `asyncio.to_thread`). `pdf/stream`
|
||||
l'utilise désormais aussi (comportement inchangé, tests de régression).
|
||||
- **Nouvel endpoint `GET /api/media/{vault}?path=…`** : sert audio/vidéo avec le
|
||||
MIME `media_types.media_mime_type` (surcharges `.m4a→audio/mp4`,
|
||||
`.opus/.oga→audio/ogg`, `.mov→video/quicktime`, `.m4v→video/mp4`).
|
||||
- **Gardes-fous** : `_resolve_safe_path`, `check_vault_access`, et
|
||||
`OBSIGATE_MEDIA_MAX_INLINE_MB` (défaut **500 Mo**) — au-delà, l'endpoint
|
||||
renvoie `413` et la vue fichier bascule sur l'UI de téléchargement.
|
||||
- **`api_file_view()`** renvoie, avant tout `read_text()`, `is_audio` /
|
||||
`is_video` / `stream_url` / `media_mime` / `size_bytes`. Au-delà de la limite :
|
||||
`unsupported: true` + `media_too_large: true`.
|
||||
|
||||
### B/C. Frontend — lecteurs
|
||||
|
||||
- `viewer.js` dispatche `data.is_audio` → `renderAudioViewer()` et
|
||||
`data.is_video` → `renderVideoViewer()`.
|
||||
- **Audio** : `<audio controls preload="metadata">` pleine largeur, artwork
|
||||
placeholder (icône Lucide `audio-lines`), durée lue via `loadedmetadata`,
|
||||
toolbar (titre, voûte + taille, badge durée, ouvrir l'original, télécharger).
|
||||
- **Vidéo** : `<video controls playsinline preload="metadata">` centrée sur une
|
||||
scène noire letterboxée (`max-height: calc(100vh - 180px)`), même toolbar
|
||||
avec durée + résolution.
|
||||
- `renderMediaFallback()` : sur l'événement `error` de l'élément média (codec
|
||||
hors web-natif : `.mkv`, `.avi`, HEVC…) ou si le fichier est trop volumineux,
|
||||
remplace le lecteur par l'UI binaire + message
|
||||
`viewer.media_unsupported` / `viewer.media_too_large` + **Télécharger** /
|
||||
**Ouvrir dans un nouvel onglet**.
|
||||
- **Pause** au changement de vue : `_mediaViewerCleanup` met en pause et détache
|
||||
la source quand `renderFile()` re-rend la zone (changement d'onglet, navigation)
|
||||
— pas de lecture persistante en v1 (cohérence #75).
|
||||
- `EXT_ICONS` (`utils.js`) : audio → `audio-lines`, vidéo → `video`.
|
||||
- i18n FR/EN (`viewer.media_unsupported`, `viewer.media_too_large`).
|
||||
|
||||
### D. Recherche & intégrations
|
||||
|
||||
- Filtres `ext:mp3`, `ext:mp4`, etc. opérationnels (les médias entrent dans
|
||||
l'index).
|
||||
- Récents / dashboards : previews vides (aucun texte extrait) — comportement
|
||||
naturel de la branche binaire.
|
||||
- **BooksLM** : `_file_entry()` ignore désormais tout média (`is_media`) — les
|
||||
octets ne sont jamais envoyés au modèle ; les images restent gérées à part via
|
||||
`load_vault_image_data_url` (vision).
|
||||
- **Hors scope v1** (porte notée) : transcription audio via Whisper.
|
||||
|
||||
### E. Mobile & PWA
|
||||
|
||||
- Le service worker ne met **jamais** en cache le flux média
|
||||
(`/api/media/{vault}`), tout en conservant le cache des miniatures
|
||||
(`/api/media/{vault}/thumb`) et la stratégie Network First pour le reste. Les
|
||||
requêtes `Range` étaient déjà exclues.
|
||||
|
||||
### F. Tests
|
||||
|
||||
- `tests/test_media_stream.py` : 206 + `Content-Range` + 1024 octets, `416` hors
|
||||
borne, `200` + `Accept-Ranges` sans Range, suffix range, `413` au-delà de la
|
||||
limite, `403` path traversal, `403` vault sans accès, `400` non-média, MIME
|
||||
`.mov`, régression `pdf/stream`.
|
||||
- `tests/test_media_indexing.py` : `.mp3`/`.mp4`/`.flac` dans l'arborescence et
|
||||
l'index, `content == ""`, watcher pertinent, filtre `ext:mp3`.
|
||||
- `tests/frontend/media-viewer.test.mjs` : helpers purs (`formatMediaDuration`,
|
||||
`buildMediaUrl`) + vérifications statiques (dispatch, `<audio>`/`<video>`,
|
||||
fallback, CSS, icônes, i18n, service worker, endpoints backend).
|
||||
- `tests/e2e/media-viewer.spec.js` : lecture `<audio>` et `<video>` via
|
||||
`/api/media` + réponse `206` sur requête `Range`. Fixtures :
|
||||
`test_vault/sample-audio.mp3` (sine 1 s) et `test_vault/sample-video.webm`
|
||||
(VP8 64×64).
|
||||
|
||||
## Limitations connues
|
||||
|
||||
- Formats hors web-natifs (`.mkv`, `.avi`, HEVC, AC-4) : non lisibles sans
|
||||
transcodage (ffmpeg hors scope) → repli téléchargement / lecteur de l'OS.
|
||||
- HLS, sous-titres `<track src=".vtt">` et vignettes vidéo : hors scope v1.
|
||||
- Gros médias (> 500 Mo par défaut) : pas de lecture intégrée (protège le worker
|
||||
uvicorn unique) ; ajustable via `OBSIGATE_MEDIA_MAX_INLINE_MB`.
|
||||
|
||||
---
|
||||
|
||||
## #110 — Lecteur média persistant « Now Playing »
|
||||
|
||||
> **Version livrée :** 2.19.0 · **Statut :** ✅ · **Impact :** 🟡
|
||||
> **Zone :** frontend (`now-playing.js`, `viewer.js`, `ui.js`, `pane-manager.js`,
|
||||
> `app.js`, `style.css`, `index.html`, locales).
|
||||
|
||||
### Principe : un seul média, téléporté
|
||||
|
||||
`frontend/js/now-playing.js` est un contrôleur singleton qui possède **l'unique
|
||||
élément `<audio>`/`<video>`** de l'application. Il est déplacé par `appendChild`
|
||||
(sans recréation, donc sans couper la lecture) entre :
|
||||
|
||||
- la **vue inline** — l'onglet/panneau du média, via la surface
|
||||
`NowPlaying.attachInline(area, data)` (appelée par `renderAudioViewer` /
|
||||
`renderVideoViewer` de `viewer.js`) ; et
|
||||
- le **dock global** — un enfant direct de `<body>` (`#now-playing-host`), monté
|
||||
hors de `.content-wrapper` pour survivre à `renderFile()`, aux onglets, aux
|
||||
panneaux et à la reconstruction de la grille split.
|
||||
|
||||
`renderFile()` appelle `NowPlaying.handleRender(area, data)` : si la zone qui va
|
||||
être réécrite contient l'élément média, celui-ci est renvoyé au dock. Les autres
|
||||
points qui vident le contenu (dashboard `_showDashboard`, `showWelcome`,
|
||||
`PaneManager._buildGrid` / `_collapseToSingle`) appellent le même hook via le
|
||||
global `window.NowPlaying`.
|
||||
|
||||
### Surfaces et ergonomie
|
||||
|
||||
- **Dock audio (desktop)** : pilule flottante verre dépoli centrée en bas
|
||||
(`.np-dock--audio`) — artwork, titre, voûte, durée, play/pause,
|
||||
précédent/suivant, barre de progression (seek), volume, **revenir au média**,
|
||||
agrandir, fermer.
|
||||
- **Panneau étendu** : carte centrale (bottom-sheet sur mobile) avec artwork,
|
||||
scrub large, volume, vitesse 0,5–2×, précédent/suivant et actions
|
||||
ouvrir/télécharger/fermer.
|
||||
- **Mini-vidéo flottante** (`.np-dock--video`) : déplaçable **librement** depuis
|
||||
n'importe quel point de la fenêtre (position absolue mémorisée, centre autorisé
|
||||
— aucune aimantation aux bords) et redimensionnable, géométrie persistée ; sur
|
||||
mobile elle se fixe au-dessus de la barre d'outils.
|
||||
- **Mobile** : mini-player au-dessus de la barre 64 px
|
||||
(`bottom: calc(64px + env(safe-area-inset-bottom))`), `viewport-fit=cover`
|
||||
ajouté, et `body.np-active` ajoute le décalage du contenu. La barre audio passe
|
||||
en grille (progression sur sa propre ligne) et masque les actions secondaires
|
||||
pour éviter tout chevauchement.
|
||||
|
||||
### Comportements
|
||||
|
||||
- Naviguer (onglet, panneau, dashboard, split) **ne coupe pas** la lecture ; le
|
||||
dock apparaît.
|
||||
- **Revenir au média** : `NowPlaying.focus()` rouvre/focalise l'onglet
|
||||
`vault::path` (`window.getActiveTabManager().open`).
|
||||
- **Fermer** : `NowPlaying.stop()` met en pause, libère l'élément et masque le
|
||||
dock.
|
||||
- **Fermer l'onglet** du média en cours : la lecture continue et un toast
|
||||
`player.continues` le signale.
|
||||
- **Media Session** : métadonnées (`MediaMetadata`) + actions
|
||||
play/pause/stop/seek/nexttrack/previoustrack → écran verrouillé, casque
|
||||
Bluetooth, touches média, **Windows SMTC** (WebView2).
|
||||
- **Picture-in-Picture** natif pour la vidéo (`requestPictureInPicture`), bouton
|
||||
masqué si non supporté.
|
||||
- **Reprise après rechargement** : état (fichier, position, pause, préférences
|
||||
volume/vitesse) persisté en `localStorage` (`obsigate-now-playing`,
|
||||
`obsigate-player-prefs`, `obsigate-player-pos`).
|
||||
|
||||
### Fichiers modifiés / ajoutés
|
||||
|
||||
- Nouveau : `frontend/js/now-playing.js` (contrôleur, dock, session, PiP,
|
||||
persistance), `tests/e2e/media-viewer.spec.js` (dock/retour/fermeture/mini-vidéo).
|
||||
- Modifiés : `viewer.js` (délégation inline + `handleRender`), `ui.js`
|
||||
(dashboard + toast de fermeture d'onglet), `pane-manager.js` (grille/collapse),
|
||||
`app.js` (`initNowPlaying`), `style.css` (dock/étendu/vidéo/mobile, et
|
||||
correction des variables `--surface1`/`--text-dim` non définies),
|
||||
`index.html` (`viewport-fit=cover`), locales FR/EN (`player.*`).
|
||||
|
||||
### Correctifs annexes
|
||||
|
||||
- Les variables CSS `--surface1` et `--text-dim`, utilisées mais **jamais
|
||||
définies** depuis #108/#109, sont remplacées par `--surface` et
|
||||
`--text-secondary` (+ `--text-dim` dans toute la feuille).
|
||||
|
||||
### Hors scope (porte notée)
|
||||
|
||||
- Fenêtre vidéo détachée **native** Tauri (`WebviewWindowBuilder` +
|
||||
`always_on_top`) : non implémentée, à faire dans une itération dédiée (Rust,
|
||||
capabilities, route `/player`).
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# #86 — Optimisation globale des performances (phase 3)
|
||||
|
||||
> **Statut :** ✅ livré | **Zone :** backend (`indexer.py`, `mutations.py`)
|
||||
> **Prérequis déjà livrés :** BUG-033 (recherche via inverted index), BUG-040 (PDF lazy),
|
||||
> BUG-025 (caps regex).
|
||||
|
||||
## Contexte
|
||||
|
||||
La roadmap #86 demandait : recherche simple + tool IA via l'inverted index, indexation
|
||||
incrémentale + scan différentiel au démarrage, extraction PDF/excalidraw différée et caps
|
||||
CPU sur les opérations regex. Trois de ces cinq points étaient déjà couverts par des
|
||||
correctifs antérieurs ; cette livraison ferme les deux points restants.
|
||||
|
||||
## Ce qui était déjà livré (rappel)
|
||||
|
||||
| Point #86 | Livré par | État |
|
||||
|---|---|---|
|
||||
| Recherche simple + `search_fulltext` via inverted index | BUG-033 | `search()` récupère ses candidats via l'inverted index (intersection + expansion de préfixes), repli scan pendant la construction |
|
||||
| Extraction PDF différée | BUG-040 | `_scan_vault` ne lit que les métadonnées ; `enrich_pdf_texts()` extrait après index |
|
||||
| Caps CPU regex | BUG-025 | `validate_regex` (longueur ≤ 500, rejet quantificateurs imbriqués), contenu tronqué à 200 kio, matchs plafonnés à 1 000 |
|
||||
|
||||
## Livré ici
|
||||
|
||||
### 1. Scan différentiel au démarrage (`backend/indexer.py`)
|
||||
|
||||
- `_scan_vault(..., previous_files)` : quand un snapshot `{relative_path: file_info}` est
|
||||
fourni, toute entrée dont `size` **et** `modified` sont inchangés est réutilisée sans
|
||||
lecture disque ni re-parse (copie du dict, tags recomptés, wikilinks ré-enregistrés
|
||||
depuis le contenu caché pour reconstruire l'index de backlinks).
|
||||
- `build_index()` capture le snapshot précédent avant le `clear()` et le transmet à
|
||||
chaque scan de vault (via `functools.partial` pour l'executor) ; `reload_single_vault()`
|
||||
fait de même pour son vault. Seuls le `os.walk` + `stat` (bon marché) tournent à
|
||||
chaque passe ; le retour inclut `reused` (hits différentiels, loggé par vault).
|
||||
- Premier démarrage (aucun snapshot) : comportement identique à avant.
|
||||
|
||||
### 2. Extraction excalidraw différée (`backend/indexer.py`)
|
||||
|
||||
- Le scan ne lit plus les `.excalidraw` / `.excalidraw.md` : titre dérivé du nom de
|
||||
fichier, `content` vide, flag `excalidraw_text_pending` (plus de décompression
|
||||
lz-string pendant le scan).
|
||||
- `enrich_pdf_texts()` traite désormais les deux flags (`pdf` + `excalidraw`) via
|
||||
`_read_excalidraw_indexable_text()` exécuté dans l'executor, avec notification du hook
|
||||
d'index incrémental comme pour les PDF. Nom conservé pour compatibilité (tests,
|
||||
`main.py`, `reload_index`, `reload_single_vault` inchangés côté appel).
|
||||
- Le chemin incrémental fichier-à-fichier (`_index_single_file_sync`, watcher) reste
|
||||
immédiat : un seul fichier ne justifie pas le différé.
|
||||
|
||||
### 3. Garde-fou taille sur `replace_in_files` (`backend/services/mutations.py`)
|
||||
|
||||
- Nouvelle constante `MAX_REPLACE_FILE_BYTES` (5 Mio) : tout fichier dépassant le plafond
|
||||
est sauté (warning loggé) au lieu d'être lu intégralement puis balayé par le pattern
|
||||
utilisateur. Complète les caps BUG-025 (qui couvrent la recherche, pas le remplacement
|
||||
qui opère sur le contenu disque complet par nature).
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_perf_phase3.py` (9 tests) : différé excalidraw au scan (`.excalidraw` +
|
||||
`.excalidraw.md`), remplissage par l'enrichissement, `reused == 0` au premier scan,
|
||||
réutilisation à l'identique, re-parse du fichier modifié, ajout/suppression, skip
|
||||
`replace` sur fichier surdimensionné + cas passant nominal.
|
||||
|
||||
## Limites connues
|
||||
|
||||
- Pas de persistance disque de l'index : le différentiel joue sur les rebuilds dans le
|
||||
même processus (`reload_index`, `reload_single_vault`), pas entre deux redémarrages
|
||||
(persistance = #85, phase 2).
|
||||
- La comparaison `size + mtime` ne détecte pas une modification qui conserverait taille
|
||||
et mtime à la milliseconde près (cas pathologique, le watcher temps réel couvre les
|
||||
modifications en cours d'exécution).
|
||||
@@ -0,0 +1,172 @@
|
||||
# #114 — Configuration — refonte mobile-responsive de la section Settings
|
||||
|
||||
> **Statut :** 🟢 · **Impact :** 🟡 · **Zone :** frontend (mobile, ≤ 768 px)
|
||||
> **Fichiers :** `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`,
|
||||
> `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs`,
|
||||
> `tests/e2e/config-mobile.spec.js`.
|
||||
|
||||
## Contexte
|
||||
|
||||
La page **Configurations** (`#config-modal`) était utilisable en mobile seulement
|
||||
partiellement (correctifs BUG-071) : le sommaire s'ouvrait en bloc haut, la modale
|
||||
n'était pas plein écran, les cibles tactiles étaient sous 44 px, le clavier virtuel
|
||||
iOS zoomait les champs, la rangée « Sauvegarder » disparaissait au scroll et plusieurs
|
||||
dettes HTML/i18n étaient restées en place.
|
||||
|
||||
## Ce qui a été livré
|
||||
|
||||
### A. Sommaire en drawer coulissant
|
||||
|
||||
- `#config-nav` devient un **panneau coulissant gauche** (`position: fixed`,
|
||||
`width: min(320px, 88vw)`, `z-index: 40`) sous un fond assombri
|
||||
(`#config-modal.config-toc-open::before`, `z-index: 35`).
|
||||
- Ouverture/fermeture pilotée par la classe **`.config-toc-open`** sur `#config-modal`
|
||||
(JS `_setConfigNav`) + un `display` inline conservé pour le contrat de reset.
|
||||
- Bouton **`#config-toc-close`** (classe `.help-toc-close`, `aria-label`
|
||||
`config.toc_close`) dans l'en-tête du drawer ; visible uniquement dans le drawer
|
||||
de la config (masqué sur desktop où la nav est toujours visible).
|
||||
- **Backdrop** : un tap hors du drawer ferme d'abord le drawer, pas la modale
|
||||
(`e.target === modal` → `config-toc-open` présent → `_setConfigNav(false)`).
|
||||
- **Échap** : ferme le drawer d'abord, puis la modale.
|
||||
- Fermeture de la modale (`closeConfigModal`) nettoie toujours la classe
|
||||
`config-toc-open` et le `display` inline (aucun fond ne subsiste).
|
||||
- Animation `config-toc-slide-in` (translateX) à l'ouverture.
|
||||
|
||||
### B. Modale plein écran
|
||||
|
||||
- `#config-modal` : `padding: 0`, `.editor-container` en `100vw × 100dvh`
|
||||
(`100dvh` = hauteur du viewport dynamique, tient compte de la barre du navigateur
|
||||
mobile), `border-radius: 0`, `border: none`.
|
||||
- Le contenu (`#config-scroll`) garde son scroll propre ; le sous-bloc
|
||||
`.config-content` reçoit un `padding-bottom: 80 px` pour ne jamais passer sous la
|
||||
rangée sticky.
|
||||
|
||||
### C. Cibles tactiles ≥ 44 px & anti-zoom iOS
|
||||
|
||||
- **Champs** : `.config-input`, `.config-select`, `.help-nav-search`,
|
||||
`.profile-field .config-input/.config-select` → `min-height: 44px` +
|
||||
`font-size: 16px` (**anti-zoom iOS** : un `font-size < 16px` déclenche le zoom
|
||||
automatique au focus). La règle est **scoppée `#config-modal`** pour ne pas écraser
|
||||
`.mfa-code-input` (qui a sa propre typographie).
|
||||
- **Boutons** : `.config-btn-save`, `.config-btn-secondary`, `.config-btn-primary`,
|
||||
`.config-btn-danger`, `.config-btn-add`, `.config-btn-sm`, `.mfa-link-btn`,
|
||||
`.theme-action-btn`, `.profile-avatar-actions .config-btn-secondary`,
|
||||
`.editor-btn` (fermer), `#config-hamburger`, `#config-toc-close`,
|
||||
`.help-search-clear` → `min-height/min-width: 44px`.
|
||||
- **Liens du sommaire** : `.help-nav-link` → `min-height: 44px` (ligne tactile
|
||||
confortable).
|
||||
- `.help-hamburger` passe de 36 px à **44 px** en mobile (règle partagée avec le
|
||||
modal d'aide).
|
||||
|
||||
### D. Rangée « Sauvegarder » sticky
|
||||
|
||||
- `.config-actions-row` (dans `#cfg-backend-settings`) → `position: sticky;
|
||||
bottom: 0`, empilée verticalement (`flex-direction: column`), boutons pleine
|
||||
largeur 44 px, fond opaque + bordure, `padding-bottom` avec
|
||||
`env(safe-area-inset-bottom)` (barre home iOS).
|
||||
- La rangée reste visible pendant le scroll de la section backend ; le
|
||||
`padding-bottom: 80px` de `.config-content` garantit qu'elle ne masque jamais les
|
||||
derniers contrôles.
|
||||
|
||||
### E. Formulaires 1 colonne & grilles
|
||||
|
||||
- `.config-row` → 1 colonne (`grid-template-columns: 1fr`), `.config-input--num`
|
||||
pleine largeur, `text-align: left`.
|
||||
- `.ai-default-grid` / `.ai-provider-fields` → 1 colonne.
|
||||
- Add-rows (`.config-add-row`, `.config-add-pattern`) → wrap + largeurs inline
|
||||
(`180/140/100px`) neutralisées (`width: auto !important`).
|
||||
- Items webhook/token/share → wrap ; URLs/méta sur leur propre ligne
|
||||
(`overflow-wrap: anywhere`) ; boutons de suppression 44 px.
|
||||
- `.hidden-files-add-row` → wrap, input pleine largeur.
|
||||
- `.config-diag-row` → wrap.
|
||||
- `.profile-avatar-row` → wrap ; `.profile-form` → `max-width: 100%`.
|
||||
- `.webauthn-key-item` → wrap ; `.webauthn-key-label` → pleine largeur.
|
||||
- `.theme-grid` reste en `auto-fill minmax(160px, 1fr)` (déjà responsive).
|
||||
|
||||
### F. MFA & sécurité
|
||||
|
||||
- `.mfa-recovery-list` → **1 colonne** en mobile (2 colonnes illisibles à 360 px).
|
||||
- `.mfa-verify-section`, `.mfa-recovery-actions`, `.mfa-disable-actions` → wrap ;
|
||||
champs/boutons enfants en pleine largeur.
|
||||
- `.mfa-code-input` → `width: 100%`, `max-width: 320px`, `letter-spacing: 6px`
|
||||
(au lieu de 12 px qui débordait), `min-height: 52px`.
|
||||
- `.mfa-secret-code` → `word-break: break-all` (secret TOTP long).
|
||||
- `.mfa-code-input-group` → wrap.
|
||||
- Règle morte **`.mfa-recovery-input`** supprimée (auth.js utilise
|
||||
`mfa-code-input recovery-input`).
|
||||
|
||||
### G. Dettes HTML corrigées
|
||||
|
||||
- `.config-actions-row` (Sauvegarder / Réindexer / Réinitialiser) déplacée
|
||||
**dans** `#cfg-backend-settings` (elle était hors de toute section → le sticky
|
||||
n'avait pas de conteneur de scroll fiable) ; `</section>` orphelin supprimé.
|
||||
- Id dupliqué **`cfg-partages-publics`** retiré du `<h2>` (l'id reste sur la
|
||||
`<section>`, cf. #113).
|
||||
- Conteneur mort **`#plugins-settings-container`** supprimé (le rendu réel est
|
||||
`#cfg-plugins` via `plugins.js`).
|
||||
- Sections plugins/about ré-indentées.
|
||||
- **`#mt-explorer`** : libellé brut `settings.search` remplacé par
|
||||
`<span data-i18n="settings.explorer">` (i18n correct, FR « Explorateur » / EN
|
||||
« Files »).
|
||||
- Doublons CSS **`.config-btn-add`** (3 définitions) réduits à la définition de
|
||||
référence.
|
||||
|
||||
### H. i18n FR/EN
|
||||
|
||||
- **Purge des clés mortes** (aucune référence HTML/JS/tests/backend) :
|
||||
`settings.backend`, `settings.backend_hint`, `settings.restart_badge`,
|
||||
`settings.save`, `settings.plugins`.
|
||||
- **Ajouts** : `settings.explorer` (FR « Explorateur » / EN « Files »),
|
||||
`config.toc_close` (FR « Fermer le sommaire » / EN « Close contents »).
|
||||
- **Correctif** : `settings.tabs` en FR était le mot anglais « Tabs » →
|
||||
**« Onglets »** (EN reste « Tabs »).
|
||||
- Clés vivantes conservées : `settings.reindex`, `settings.no_restart_badge`,
|
||||
`settings.backend_section`, `settings.security`, `settings.search`,
|
||||
`settings.tabs`, `settings.search_placeholder`.
|
||||
|
||||
## Tests
|
||||
|
||||
### Statics — `tests/frontend/config-mobile.test.mjs` (27, au CI)
|
||||
|
||||
- BUG-071a–e (hamburger, toggle JS, grilles/wrap, ancres mortes,
|
||||
`data-i18n-attr` multi-paires) — conservés et adaptés au drawer.
|
||||
- **#114a** : `#config-toc-close` présent + i18n ; `_setConfigNav` bascule
|
||||
`.config-toc-open` ; backdrop `::before` (z-index 35) ; `.help-toc-close`
|
||||
masqué sur desktop / visible dans le drawer ; Échap et backdrop ferment le
|
||||
drawer d'abord ; `closeConfigModal` nettoie la classe.
|
||||
- **#114b** : modale `100dvh` + `padding: 0` ; inputs/selects `16px` +
|
||||
`44px` ; boutons `44px` ; rangée sticky (`position: sticky` +
|
||||
`flex-direction: column` + safe-area) ; MFA 1 colonne + wrap + code
|
||||
full-width ; `.config-actions-row` bien dans `#cfg-backend-settings`.
|
||||
- **#114i18n** : clés mortes purgées ; `settings.explorer` FR/EN ;
|
||||
`settings.tabs` FR = « Onglets » ; `#mt-explorer` porte
|
||||
`data-i18n="settings.explorer"` ; `#plugins-settings-container` absent.
|
||||
|
||||
### E2E — `tests/e2e/config-mobile.spec.js` (5, projet `chromium-mobile`)
|
||||
|
||||
1. Hamburger → drawer `position: fixed` + classe `config-toc-open` sur la modale.
|
||||
2. Bouton `#config-toc-close` et tap backdrop ferment le drawer **sans** fermer la
|
||||
modale.
|
||||
3. Sélection d'une section → scroll doux + lien actif + repli du drawer.
|
||||
4. Modale plein écran (largeur/hauteur ≈ viewport) + `#config-hamburger`,
|
||||
`#config-close` et `#cfg-save-backend` ≥ 44 px.
|
||||
5. Aucun débordement horizontal à 393 px (sections IA / tokens / webhooks /
|
||||
partages).
|
||||
|
||||
## Détails d'implémentation notables
|
||||
|
||||
- **Spécificité CSS** : les règles `#config-modal #config-nav` (2 ids) priment sur
|
||||
les règles génériques `.help-nav` / `body .help-nav` qui masquent la nav en
|
||||
mobile — pas besoin de `!important`.
|
||||
- **Backdrop = pseudo-élément** : les clics sur `::before` sont attribués à
|
||||
l'élément or (`#config-modal`), donc le handler `e.target === modal` existant
|
||||
fonctionne sans node supplémentaire.
|
||||
- **Contrat de reset conservé** : `configNavOnOpen.style.display = ''` à
|
||||
l'ouverture (test statique BUG-071b) — le CSS reprend la main (drawer masqué par
|
||||
défaut sur mobile).
|
||||
- **`100dvh` avec repli `100vh`** : les navigateurs sans support `dvh` gardent le
|
||||
comportement précédent.
|
||||
- La règle `.help-hamburger { display: inline-flex }` du bloc mobile du modal
|
||||
d'aide est **partagée** (44 px) ; le drawer de la config double avec
|
||||
`#config-modal .help-hamburger` pour rester robuste à un réordonnancement des
|
||||
règles.
|
||||
@@ -0,0 +1,94 @@
|
||||
# #113 — Ordre naturel des sections Configurations & avatar utilisateur
|
||||
|
||||
> **Version livrée :** 2.22.0 · **Statut :** 🟢 · **Impact :** 🟡
|
||||
> **Zone :** frontend (`index.html`, `frontend/js/config.js`, `frontend/js/auth.js`,
|
||||
> `frontend/style.css`, i18n FR/EN) + backend (`backend/auth/router.py`,
|
||||
> `backend/auth/user_store.py`) + CI (`.gitea/workflows/ci.yml`).
|
||||
|
||||
## Contexte
|
||||
|
||||
Deux irritants sur la page **Configurations** :
|
||||
|
||||
- l'ordre des sections était historique et peu naturel (Recherche en tête, Profil
|
||||
noyé en position 11, À propos au milieu) ;
|
||||
- la section **Profil** ne permettait pas de personnaliser l'image affichée dans le
|
||||
cercle du compte en bas de la sidebar (initiales uniquement).
|
||||
|
||||
## Ce qui a été livré
|
||||
|
||||
### A. Ordre naturel des sections (TOC = page)
|
||||
|
||||
Nouvel ordre, appliqué **à la liste `<ul class="help-nav-list">` (TOC) et aux
|
||||
`<section>` de la page**, dans le même ordre :
|
||||
|
||||
1. **Profil** (`#cfg-profile`) — en premier
|
||||
2. Sécurité du compte (`#cfg-security`)
|
||||
3. Thèmes (`#cfg-themes`)
|
||||
4. Paramètres de recherche (`#cfg-search`)
|
||||
5. Historique récent (`#cfg-recent`)
|
||||
6. Filtrage de tags (`#cfg-tags`)
|
||||
7. Fichiers cachés (`#cfg-hidden-files`)
|
||||
8. Synchronisation (`#cfg-sync`)
|
||||
9. Paramètres backend (`#cfg-backend-settings`)
|
||||
10. Diagnostics (`#cfg-diags`)
|
||||
11. Clés API IA (`#cfg-ai`)
|
||||
12. Sources connectées (`#cfg-sources`)
|
||||
13. Clés API & MCP (`#cfg-tokens`)
|
||||
14. Notifications push (`#cfg-push`)
|
||||
15. Webhooks (`#cfg-webhooks`)
|
||||
16. Partages publics (`#cfg-partages-publics`)
|
||||
17. Plugins (`#cfg-plugins`)
|
||||
18. **À propos** (`#cfg-about`) — en dernier
|
||||
|
||||
Regroupement retenu : **Compte & apparence → Navigation & contenu → Système →
|
||||
Intégrations & notifications → À propos**.
|
||||
|
||||
Les ancres `cfg-tags` et `cfg-partages-publics` étaient posées sur un `<h2>` à
|
||||
l'intérieur d'une `<section>` sans id : elles sont désormais portées par la
|
||||
`<section>` elle-même, pour que le scroll vise le haut de la section (comme toutes
|
||||
les autres).
|
||||
|
||||
### B. Avatar utilisateur ( Profil )
|
||||
|
||||
- **Import d'image** : bouton « Choisir une image » + overlay caméra au survol de
|
||||
l'aperçu circulaire (88 px) → `<input type="file" accept="image/png,image/jpeg,image/webp">`.
|
||||
- **Traitement client** (`config.js`) : garde type (PNG/JPEG/WEBP) et taille brute
|
||||
(8 Mo), **recadrage carré central** et redimensionnement à **256 px** via canvas,
|
||||
export JPEG qualitée 0,85 (fond blanc pour les PNG transparents).
|
||||
- **Persistance serveur** : `PATCH /api/auth/me` avec `{"avatar": "<data-url>"}` ;
|
||||
`""` supprime. Validation stricte dans `backend/auth/router.py`
|
||||
(`_validate_avatar`) : data-URL PNG/JPEG/WebP uniquement (**SVG refusé** — surface
|
||||
XSS), plafond 400 000 caractères, base64 valide et **octets magiques** contrôlés.
|
||||
Champ `avatar` ajouté à `data/users.json` (`create_user`), renvoyé par
|
||||
`GET/PATCH /api/auth/me` et par le payload `user` de login.
|
||||
- **Affichage sidebar** (`auth.js`) : `renderUserSection()` insère un
|
||||
`<img class="sidebar-user-avatar-img">` dans `#sidebar-user-avatar` quand
|
||||
`user.avatar` est défini, sinon les initiales (repli inchangé). Helpers
|
||||
`AuthManager.updateCachedUser()` et `AuthManager.isAuthEnabled()`.
|
||||
- **Suppression** : bouton « Supprimer la photo » (stylisté danger), revenu aux
|
||||
initiales.
|
||||
- **Auth désactivée** : le bloc avatar est masqué (pas de compte, sidebar masquée).
|
||||
- Feedback : toasts `config.avatar_updated` / `config.avatar_removed`, erreurs en
|
||||
ligne (`config.avatar_invalid_type`, `config.avatar_too_large`,
|
||||
`config.avatar_upload_failed`).
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/test_auth_api.py` — classe `TestAvatar` (+8) : GET/PATCH exposent
|
||||
l'avatar, data-URL PNG acceptée, `""` efface, SVG refusé, payload non-image refusé,
|
||||
trop grand refusé, base64 invalide refusé, avatar présent dans le payload de login.
|
||||
- `tests/frontend/settings-order-avatar.test.mjs` (nouveau, ajouté au job `lint`) —
|
||||
9 tests : ordre TOC (Profil 1er, À propos dernier), ordre de page strictement
|
||||
identique à la TOC, aucune ancre morte / section orpheline, présence de l'UI avatar
|
||||
dans `#cfg-profile`, flux `config.js` (types, taille, resize, PATCH, rafraîchissement
|
||||
sidebar), rendu `auth.js`, règles CSS, clés i18n FR/EN, validation backend.
|
||||
- Vérifications locales : pytest 1302 passed, ruff/mypy 0 erreur, tests frontend
|
||||
statiques + JSDOM verts.
|
||||
|
||||
## Limitations connues
|
||||
|
||||
- L'avatar est stocké en data-URL dans `data/users.json` (adéquat pour un usage
|
||||
personnel ; un stockage fichier dédié restera possible si les comptes se multiplient).
|
||||
- La conversion GIF/animé n'est pas prise en charge (types PNG/JPEG/WEBP uniquement).
|
||||
- Le nom d'affichage du profil reste local (`localStorage`) et n'est pas poussé au
|
||||
serveur (comportement antérieur conservé).
|
||||
@@ -0,0 +1,122 @@
|
||||
# #115 / BUG-078 / #117 — Barre d'outils épinglée, coloration syntaxique des fichiers de code & avatars prédéfinis
|
||||
|
||||
> **Statut :** 🟢 livré (en attente vérification utilisateur)
|
||||
> **Impact :** 🟡 (#115, BUG-078) · 🟢 (#117)
|
||||
> **Zone :** frontend (`frontend/js/viewer.js`, `frontend/js/themes.js`,
|
||||
> `frontend/js/ui.js`, `frontend/js/config.js`, `frontend/index.html`,
|
||||
> `frontend/style.css`, `frontend/popout.html`, `frontend/locales/fr.json`,
|
||||
> `frontend/locales/en.json`, `frontend/icons/avatar/*`)
|
||||
|
||||
Trois demandes traitées dans la même livraison, toutes côté frontend.
|
||||
|
||||
## #115 — Barre d'outils de lecture toujours visible
|
||||
|
||||
### Problème
|
||||
|
||||
La barre d'actions d'un document (pop-out, bookmark, Editer, Source, Copier, PDF, Export,
|
||||
Partager…) était rendue **dans** `.file-header`, un conteneur court placé en haut de la
|
||||
zone de lecture. Or un élément `position: sticky` reste borné par son parent : dès que
|
||||
`.file-header` sortait de l'écran au défilement d'un document long, la barre disparaissait.
|
||||
|
||||
### Correctif
|
||||
|
||||
- `frontend/js/viewer.js` et `frontend/popout.html` sortent la barre d'actions de
|
||||
`.file-header` et la placent dans un nouveau conteneur `.file-toolbar`, **enfant direct
|
||||
de `.content-area`** (le conteneur de défilement). La barre peut donc se coller au haut
|
||||
de la zone de lecture pour toute la hauteur du document.
|
||||
- `frontend/style.css` :
|
||||
|
||||
```css
|
||||
.file-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
margin: 0 0 16px;
|
||||
padding: 8px 0;
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
```
|
||||
|
||||
Le fond est opaque pour qu'aucun texte ne transparaisse dessous.
|
||||
- `body.reading-mode .file-toolbar { display: none }` : la barre reste masquée en mode
|
||||
lecture, comme les autres actions.
|
||||
|
||||
## BUG-078 — Coloration syntaxique des fichiers de code
|
||||
|
||||
### Problème
|
||||
|
||||
Les fichiers `.py`, `.sh`, `.ps1`, `.yml`, `.json`… (et les blocs de code Markdown)
|
||||
s'affichaient en **texte brut**, sans couleurs. Le code était pourtant correctement
|
||||
généré côté backend (`<pre><code class="language-python">…`) et `safeHighlight()` appelait
|
||||
bien `hljs.highlightElement()`.
|
||||
|
||||
La cause était ailleurs : les deux feuilles de style de highlight.js (`#hljs-theme-dark`
|
||||
et `#hljs-theme-light`, chargées depuis le CDN dans `index.html`) étaient basculées à
|
||||
partir de la **clé de thème** persistée (`obsigate-theme` = `defaut-obsigate`, …) :
|
||||
|
||||
```js
|
||||
darkSheet.disabled = theme !== "dark"; // "defaut-obsigate" !== "dark" → true
|
||||
lightSheet.disabled = theme !== "light"; // "defaut-obsigate" !== "light" → true
|
||||
```
|
||||
|
||||
Les deux feuilles finissaient donc désactivées, privant tous les tokens de leurs couleurs.
|
||||
Le résultat dépendait de l'ordre de deux initialisations concurrentes — `UI.initTheme()`
|
||||
(clé de thème) au démarrage et `themes.initThemes()` (mode) via `Sync.init()` — d'où un
|
||||
comportement **non déterministe** (parfois coloré, le plus souvent non).
|
||||
|
||||
### Correctif
|
||||
|
||||
- `frontend/js/themes.js` — `applyTheme(themeKey, mode)` bascule désormais les feuilles
|
||||
highlight.js selon le **mode** :
|
||||
|
||||
```js
|
||||
var isDark = mode === 'dark';
|
||||
darkSheet.disabled = !isDark;
|
||||
lightSheet.disabled = isDark;
|
||||
```
|
||||
|
||||
(`sepia` et `high-contrast` réutilisent la palette claire.)
|
||||
- `frontend/js/ui.js` — `initTheme()` lit le **mode** persisté (`obsigate-theme-mode`) et
|
||||
`applyTheme()` résout un mode avant de fixer `data-theme` et de basculer les feuilles,
|
||||
au lieu de comparer la clé de thème à `"dark"`/`"light"`.
|
||||
|
||||
Le basculement est ainsi **déterministe** dès le premier rendu et à chaque changement de
|
||||
mode.
|
||||
|
||||
## #117 — Avatars prédéfinis dans le profil
|
||||
|
||||
### Ce qui a été livré
|
||||
|
||||
- **Galerie de 12 avatars** dans la section `Profil` (`#cfg-profile`), servis depuis
|
||||
`frontend/icons/avatar/` (`/static/icons/avatar/<fichier>.jpg`) : Chat, chien, elephan,
|
||||
hibou, koala, lapin, lion, ours, penda, pingouin, raton, tigre.
|
||||
- Un clic charge l'image, la fait passer par le **même pipeline que l'import** (recadrage
|
||||
carré central, redimensionnement 256 px, export JPEG 0,85) et l'enregistre via
|
||||
`PATCH /api/auth/me` — aucune modification backend n'a été nécessaire, la validation
|
||||
data-URL PNG/JPEG/WebP existante (BUG-113) s'applique telle quelle.
|
||||
- L'avatar actif est **surligné** ; le choix est mémorisé dans `localStorage`
|
||||
(`obsigate-avatar-preset`) et purgé dès qu'on importe une photo personnalisée ou qu'on
|
||||
supprime l'avatar.
|
||||
- L'import d'une photo et la suppression restent disponibles.
|
||||
- i18n : nouvelle clé `config.avatar_presets_label` (FR/EN).
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/frontend/unit.test.mjs` — `syntax highlight theme` (BUG-078) : `themes.applyTheme`
|
||||
bascule les feuilles selon le mode et `ui.js` ne compare plus la clé au mode.
|
||||
- `tests/frontend/toolbar-order.test.mjs` — #115 : `.file-toolbar` dans `viewer.js` et
|
||||
`popout.html`, règle CSS `position: sticky; top: 0`, masquage en mode lecture.
|
||||
- `tests/frontend/settings-order-avatar.test.mjs` — #117 : 12 avatars présents dans
|
||||
`#cfg-profile`, fichiers d'images existants, pipeline `config.js`, règles CSS, clé i18n
|
||||
FR/EN.
|
||||
- Vérification Playwright (instance locale, auth désactivée) : coloration déterministe sur
|
||||
5 chargements successifs d'un `.py` ; `.file-toolbar` dont le `top` ne bouge plus après
|
||||
défilement (épinglage effectif).
|
||||
|
||||
## Limitations
|
||||
|
||||
- L'avatar reste stocké en data-URL 256 px dans `data/users.json` (comportement #113
|
||||
conservé).
|
||||
- Le mode `high-contrast`/`sepia` utilise le thème highlight.js **clair** (pas de palette
|
||||
dédiée).
|
||||
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 159 KiB |