Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
fb2d83e9e3 | ||
|
|
82f6b4a791 | ||
|
|
7e1f5d6852 | ||
|
|
ab766862a3 | ||
|
|
2bd9dd7535 | ||
|
|
7f0f64a42e | ||
|
|
f7e068baed | ||
|
|
2e2a33cef3 | ||
|
|
2c460022f8 | ||
|
|
133644a0ba | ||
|
|
ba0ec3d1fa | ||
|
|
22e9240e4f | ||
|
|
6a58a59a11 | ||
|
|
26328fadeb | ||
|
|
c0eea526de | ||
|
|
94ea5909f4 | ||
|
|
3758db2861 |
@@ -7,11 +7,16 @@ OBSIGATE_AUTH_ENABLED=true
|
||||
OBSIGATE_ADMIN_USER=admin
|
||||
OBSIGATE_ADMIN_PASSWORD=chab30
|
||||
|
||||
# DANGER : si OBSIGATE_AUTH_ENABLED=false, toute requête devient un admin
|
||||
# anonyme. Le serveur REFUSE de démarrer sur une adresse non-loopback
|
||||
# (ex. 0.0.0.0) sauf si l'on force l'opt-in ci-dessous. À réserver au local.
|
||||
# OBSIGATE_ALLOW_INSECURE=false
|
||||
|
||||
# Sécurité des cookies (activer si derrière HTTPS)
|
||||
# OBSIGATE_SECURE_COOKIES=false
|
||||
|
||||
# Tokens TTL en secondes
|
||||
# OBSIGATE_ACCESS_TOKEN_TTL=900
|
||||
# OBSIGATE_ACCESS_TOKEN_TTL=31536000000 # 1000 ans
|
||||
# OBSIGATE_REFRESH_TOKEN_TTL=604800
|
||||
|
||||
# Rate limiting
|
||||
@@ -46,7 +51,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
|
||||
@@ -74,3 +82,19 @@ DEEPSEEK_MODEL=deepseek-chat
|
||||
# Chaîne de repli sans clé (DuckDuckGo puis Bing) si SearXNG ne remonte rien
|
||||
# OBSIGATE_WEB_FALLBACK=1
|
||||
# OBSIGATE_WEB_TIMEOUT=10
|
||||
# Fournisseurs à clé (#92), essayés avant SearXNG — injecter via Infisical en prod
|
||||
# OBSIGATE_TAVILY_API_KEY=
|
||||
# OBSIGATE_BRAVE_API_KEY=
|
||||
# OBSIGATE_SERPAPI_API_KEY=
|
||||
# OBSIGATE_EXA_API_KEY=
|
||||
# Ordre des fournisseurs (sinon : clés présentes puis SearXNG puis replis)
|
||||
# OBSIGATE_WEB_PROVIDERS=brave,searxng
|
||||
# Réessais réseau (backoff maison) + cache SQLite des résultats web
|
||||
# OBSIGATE_WEB_RETRY=1
|
||||
# OBSIGATE_WEB_CACHE_TTL=900 # secondes ; 0 = cache désactivé
|
||||
# Rendu dynamique (pages SPA) — dépendance optionnelle :
|
||||
# pip install playwright && playwright install chromium
|
||||
# ── Assistant IA — sources connectées (Gitea / GitHub) ──
|
||||
# OBSIGATE_GITEA_URL=https://git.example.net
|
||||
# OBSIGATE_GITEA_TOKEN=
|
||||
# OBSIGATE_GITHUB_TOKEN=
|
||||
|
||||
@@ -38,7 +38,12 @@ 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
|
||||
|
||||
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition)
|
||||
run: |
|
||||
@@ -57,6 +62,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
|
||||
@@ -73,6 +79,7 @@ jobs:
|
||||
node desktop.test.mjs
|
||||
node toolbar-order.test.mjs
|
||||
node editor-inline.test.mjs
|
||||
node ai-quick-actions.test.mjs
|
||||
fi
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────
|
||||
@@ -196,6 +203,7 @@ jobs:
|
||||
-e DIR_1_NAME=TestDir \
|
||||
-e DIR_1_PATH=/vaults/TestDir \
|
||||
-e OBSIGATE_AUTH_ENABLED=false \
|
||||
-e OBSIGATE_ALLOW_INSECURE=true \
|
||||
obsigate:ci
|
||||
# Docker-in-docker : le bind mount $(pwd)/... pointe sur un chemin
|
||||
# du job container, inexistant sur l'hôte → montage vide. Les -v
|
||||
|
||||
@@ -1,47 +1,101 @@
|
||||
# AGENTS.md — Instructions obligatoires du dépôt ObsiGate
|
||||
|
||||
> Ces instructions s'appliquent à **toute** intervention (humaine ou IA) sur ce dépôt.
|
||||
> Documentation et réponses en **français**.
|
||||
|
||||
## Règle n°1 — Méthode de livraison unique
|
||||
|
||||
Avant toute tâche (fonctionnalité, bug, refactor), **lire et appliquer**
|
||||
[`docs/DELIVERY_WORKFLOW.md`](./docs/DELIVERY_WORKFLOW.md) (Definition of Done).
|
||||
Aucune tâche n'est terminée avant que sa checklist soit complète **et le CI vert**.
|
||||
Aucune tâche n'est terminée avant que sa checklist soit complète **et le CI vert**
|
||||
(jobs `lint`, `test`, `security`, `build`, `e2e` de `.gitea/workflows/ci.yml`).
|
||||
|
||||
## Avant de commencer
|
||||
|
||||
1. Lire [`docs/ROADMAP.md`](./docs/ROADMAP.md) (travail à venir + index) et
|
||||
[`docs/ISSUES_TODOLIST.md`](./docs/ISSUES_TODOLIST.md) (bugs).
|
||||
2. Identifier ou créer l'**ID stable** (`#NN` pour une feature, `BUG-NNN` pour un bug)
|
||||
et passer son statut à « en cours » **avant** de coder.
|
||||
2. Identifier ou créer l'**ID stable** (`#NN` pour une feature, `BUG-NNN` pour un bug —
|
||||
jamais réutilisé) et passer son statut à « en cours » **avant** de coder.
|
||||
|
||||
## Architecture (ce qui n'est pas obvious)
|
||||
|
||||
- **Backend** : FastAPI/Python 3.11, point d'entrée `backend/main.py` (endpoints + rendu
|
||||
markdown), index en mémoire (`indexer.py`, `search.py`), watcher (`watcher.py`),
|
||||
auth dans `backend/auth/`. Pas de base de données : JSON dans `data/`.
|
||||
- **Frontend** : vanilla JS **zéro framework, zéro build npm** (`frontend/app.js`,
|
||||
`index.html`, `style.css`). Ne pas ajouter de dépendances npm ni d'étape de build.
|
||||
- **Desktop** : Tauri (Rust) dans `desktop/` ; `tauri.conf.json` embarque `backend/**` et
|
||||
`frontend/**` depuis `desktop/` — les scripts de build font le **staging** (copie) avant
|
||||
`cargo tauri build`, sinon le build échoue.
|
||||
- **i18n** : tout texte d'interface doit exister en FR **et** EN
|
||||
(`frontend/locales/fr.json` + `en.json`).
|
||||
|
||||
## Vérifications locales (pwsh, à faire passer avant tout commit/push)
|
||||
|
||||
```powershell
|
||||
# Backend (venv à la racine)
|
||||
.\.venv\Scripts\python.exe -m pytest tests/
|
||||
.\.venv\Scripts\python.exe -m ruff check backend/
|
||||
.\.venv\Scripts\python.exe -m mypy backend/ --ignore-missing-imports
|
||||
|
||||
# Frontend : scripts Node à exécuter directement (pas de runner)
|
||||
node tests/frontend/validate-imports.mjs
|
||||
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, ~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`.
|
||||
- **Sélection E2E** : vérifier chaque sélecteur dans le DOM réel avant de l'utiliser dans un
|
||||
test ; tout test nouveau/modifié doit passer en local avant push ; pas de contournement
|
||||
qui masque la flakiness (`waitForTimeout` arbitraires, fallbacks silencieux).
|
||||
- La suite E2E doit finir à **100 %** sans s'appuyer sur les retries. Jamais de `git push`
|
||||
avant que les 5 étapes locales soient vertes.
|
||||
|
||||
## Version & hooks (pièges)
|
||||
|
||||
- `VERSION` (racine) = **source unique de vérité** (SemVer), incrémenté **automatiquement à
|
||||
chaque commit** par le hook `.githooks/prepare-commit-msg` — `feat` → mineur,
|
||||
`!:` / `BREAKING CHANGE` → majeur, sinon correctif. Le même commit resynchronise
|
||||
`package.json`, le desktop Tauri, `README.md`/`README.fr.md`, `docs/ROADMAP.md` et publie
|
||||
la section `[Unreleased]` du `CHANGELOG.md` en `[X.Y.Z] — date` ; tag `vX.Y.Z` créé au
|
||||
commit, publié au push (`push.followTags`).
|
||||
- Hooks **obligatoires**, à installer une fois par clone : `scripts/install-hooks.sh`
|
||||
(sinon la version ne suit plus et le CI échoue via le garde-fou `tests/test_version.py`).
|
||||
- Le rattachement des fichiers de bump se fait par un `--amend` immédiat : **le SHA affiché
|
||||
par `git commit` change** — ne pas s'y fier.
|
||||
- Commit sans incrément (exceptionnel) : `SKIP_VERSION_BUMP=1 git commit …`.
|
||||
- Ne jamais réécrire une version déjà publiée dans le CHANGELOG ; jamais de détail dupliqué
|
||||
entre Roadmap et CHANGELOG.
|
||||
|
||||
## À la fin de chaque tâche (obligatoire)
|
||||
|
||||
- Ajouter/mettre à jour les **tests unitaires**.
|
||||
- Vérifications locales vertes : `pytest`, `ruff`, `mypy`, tests frontend (`E2E` si UI).
|
||||
- Mettre à jour la documentation requise : `CHANGELOG.md` (`[Unreleased]`), `docs/ROADMAP.md`
|
||||
(statut + index), fiche `docs/features/` **ou** `docs/archive/`, `docs/ISSUES_TODOLIST.md`,
|
||||
guide utilisateur i18n FR/EN + README si impact utilisateur.
|
||||
- **Commit** conventionnel référençant l'ID, puis **push**.
|
||||
- Version : le fichier VERSION (racine du dépôt) est la **source unique de
|
||||
vérité (MAJEUR.MINEUR.CORRECTIF), incrémenté automatiquement à chaque commit** par le hook
|
||||
.githooks/prepare-commit-msg — feat → mineur, !: / BREAKING CHANGE → majeur, sinon
|
||||
correctif. Le même commit resynchronise package.json, le desktop Tauri, README.md/
|
||||
README.fr.md, docs/ROADMAP.md et publie la section [Unreleased] du CHANGELOG.md en
|
||||
[X.Y.Z] — date ; le tag vX.Y.Z est créé au commit et publié au push (push.followTags).
|
||||
Hooks à installer une fois par clone : scripts/install-hooks.sh. Garde-fou :
|
||||
tests/test_version.py (détail : docs/DELIVERY_WORKFLOW.md §7).
|
||||
- Vérifier le **CI Gitea vert** (jobs `lint`, `test`, `security`, `build`, `e2e`).
|
||||
- Tests unitaires ajoutés/mis à jour (correctif sans test de non-régression = pas terminé).
|
||||
- Toutes les vérifications locales ci-dessus vertes (`E2E` si UI).
|
||||
- Documentation mise à jour : `CHANGELOG.md` (`[Unreleased]`), `docs/ROADMAP.md` (statut +
|
||||
index), fiche `docs/features/` **ou** `docs/archive/`, `docs/ISSUES_TODOLIST.md` (si bug),
|
||||
guide utilisateur i18n FR/EN + README si impact utilisateur, docstrings +
|
||||
`response_model` si API.
|
||||
- **Commit** conventionnel référençant l'ID (`feat: … #12`), puis **push** et **CI vert**.
|
||||
|
||||
## Cartographie documentaire
|
||||
|
||||
| Sujet | Fichier |
|
||||
|---|---|
|
||||
| Méthode de livraison / DoD | `docs/DELIVERY_WORKFLOW.md` |
|
||||
| Version livrée (source unique) | VERSION + scripts/bump_version.py |
|
||||
| Version livrée (source unique) | `VERSION` + `scripts/bump_version.py` |
|
||||
| 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` |
|
||||
@@ -50,5 +104,8 @@ Aucune tâche n'est terminée avant que sa checklist soit complète **et le CI v
|
||||
## Conventions
|
||||
|
||||
- Commits : `type: description` — `feat`, `fix`, `perf`, `refactor`, `docs`, `style`, `chore`, `test`.
|
||||
- **Ne jamais** committer de secrets, clés ou tokens.
|
||||
- Réponses et documentation en **français** ; respecter le style du code existant.
|
||||
- Sécurité : tout chemin fichier fourni par l'utilisateur passe par `_resolve_safe_path()`.
|
||||
- **Ne jamais** committer de secrets, clés ou tokens (`.env` jamais committé ; secrets dans
|
||||
`data/api_keys.json` ou variables `OBSIGATE_*`).
|
||||
- Respecter le style du code existant (ruff/mypy 0 erreur ; CSS variables, pas de couleurs
|
||||
hardcodées ; `safeCreateIcons()` plutôt que `lucide.createIcons()` direct).
|
||||
|
||||
@@ -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.8.2**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.27.2**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,930 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.27.2] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#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é
|
||||
|
||||
- **#105 - Guide d'utilisation : audit de couverture, téléchargement Markdown/PDF,
|
||||
section Architecture** : le guide intégré est aligné sur l'application réelle après
|
||||
~35 features livrées. Huit nouvelles sections — **🏗️ Architecture** (diagramme Mermaid
|
||||
des grandes composantes : clients SPA/PWA/Tauri → serveur FastAPI REST, index de
|
||||
recherche, rendu markdown, IA, MCP, WebSocket, webhooks → vaults, `data/*.json`,
|
||||
backups), **📊 Diagrammes** (Mermaid : zoom, plein écran, copie SVG/code, thèmes),
|
||||
**⭐ Bibliothèque** (signets, recherches sauvegardées, backlinks & graphe, conflits
|
||||
Syncthing, pièces jointes), **📴 Hors-ligne** (PWA, file d'attente IndexedDB, replay,
|
||||
watcher), **👥 Collaboration** (Yjs/CRDT, curseurs awareness), **🖥️ Desktop** (Tauri,
|
||||
updater signé, assistant premier lancement), **🔌 API** (OpenAPI 3.1 : `/docs`,
|
||||
`/redoc`, `/api`, `/openapi.json`, auth Bearer/cookie, serveur MCP, automatisation)
|
||||
et **🌍 Multilingue** — plus des compléments dans les sections existantes (recherche
|
||||
sémantique hybride, notifications web push, export PDF, export HTML/ePub/ZIP,
|
||||
anti-doublons d'upload, vue multi-panneaux, MFA TOTP/WebAuthn, tableau de bord
|
||||
admin). Nouveau **`GET /api/guide/download?format=md|pdf&lang=fr|en`** (tag OpenAPI
|
||||
« Guide ») : le document est généré depuis la modale d'aide réelle et les locales,
|
||||
il reflète donc exactement le guide affiché, dans la langue de l'utilisateur ;
|
||||
boutons **Markdown** et **PDF** ajoutés dans l'en-tête du guide. En **desktop**
|
||||
(Tauri, `body.desktop-mode`) et sur grand écran web, le guide s'élargit
|
||||
(conteneur jusqu'à 1760 px, contenu 1280–1440 px) pour une lecture confortable.
|
||||
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
|
||||
`#help-mobile-editor`, section inexistante (l'édition mobile n'était qu'un `<h3>`
|
||||
de la section Édition). Le bloc est devenu une section dédiée ancrée ; l'ancre
|
||||
`#help-ia` (également morte) a été corrigée en `#help-ai` et un attribut
|
||||
`data-i18n-placeholder` dupliqué nettoyé. Garde-fou : `test_guide_nav_has_no_dead_anchors`.
|
||||
|
||||
---
|
||||
|
||||
## [2.12.2] — 2026-09-18
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-066 — Configuration : icônes manquantes dans la table des matières** :
|
||||
les entrées « Fichiers cachés » et « Partages publics » du sommaire de la page de
|
||||
configuration n'affichaient pas d'icône (les titres de section, eux, en avaient une).
|
||||
Les libellés i18n `config.section_hidden` (🗂️) et `config.section_shares` (📤) sont
|
||||
alignés sur leurs titres de section, en FR **et** EN. Test de non-régression ajouté
|
||||
dans `tests/frontend/unit.test.mjs` (toutes les entrées du sommaire doivent porter une
|
||||
icône dans les deux langues).
|
||||
|
||||
---
|
||||
|
||||
## [2.12.1] — 2026-09-18
|
||||
|
||||
---
|
||||
|
||||
## [2.12.0] — 2026-09-18
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#104 - Configuration : redesign UI de la section « 🤖 Clés API Intelligence Artificielle »** :
|
||||
la longue liste plate de champs devient une interface structurée et dépliable —
|
||||
1. **En-tête de section** : titre + sous-titre explicatifs et une **barre de recherche**
|
||||
(`#cfg-ai-search`) pour filtrer les fournisseurs (normalisée, insensible à la casse et aux
|
||||
accents), avec état vide « Aucun fournisseur ne correspond ».
|
||||
2. **Carte « Configuration par défaut »** visuellement distincte : grille **2 colonnes**
|
||||
fournisseur / modèle par défaut, et **capacités du modèle rendues en badges colorés**
|
||||
(nouveau `renderCapabilityBadges()` dans `frontend/js/ai.js`, uniquement les capacités
|
||||
actives) au lieu des cases à cocher.
|
||||
3. **Fournisseurs d'API en cartes dépliables** (rendu dynamique depuis `AI_PROVIDER_NAMES` par
|
||||
`_renderAIProviderCards()`, `frontend/js/config.js`) : état replié = logo (initiale), nom,
|
||||
**badge de statut** (« Configuré » vert / « Non configuré » gris) et **corbeille discrète**
|
||||
pour supprimer la clé (visible uniquement si configurée, confirmation conservée) ; état
|
||||
déplié = libellés au-dessus des champs, **API key à 60 % / modèle à 40 %** (grille `3fr 2fr`).
|
||||
Les boutons « Configuré/Supprimer » redondants à l'intérieur des champs disparaissent — le
|
||||
statut vit désormais uniquement dans l'en-tête de la carte.
|
||||
4. **Barre d'actions** : « Sauvegarder » (primaire) et « Tester » (outline) dans un footer
|
||||
**sticky** en bas de section, toujours accessible pendant le défilement, avec le statut de
|
||||
test à côté.
|
||||
Styles `.ai-keys-*` / `.ai-provider-*` en variables CSS (profondeur fond/carte, espacement
|
||||
généreux, chevron animé, focus visible, responsive 1 colonne < 600 px) ; textes i18n FR/EN
|
||||
(`config.ai_*`). Les ID d'éléments (`cfg-<provider>-key/model/badge/delete`) et le
|
||||
comportement de sauvegarde/test/suppression restent inchangés (rétrocompatible pickers IA).
|
||||
Tests : `tests/frontend/config-ai-keys.test.mjs` (7 — rendu, badges, bascule, filtre, save,
|
||||
suppression, badges de capacités).
|
||||
|
||||
---
|
||||
|
||||
## [2.11.6] — 2026-09-18
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-064 - Éditeur Excalidraw : le diagramme ne s'affiche jamais (canvas vide)** — deux
|
||||
causes cumulées :
|
||||
1. **La feuille de style d'Excalidraw n'était jamais chargée** (`@excalidraw/excalidraw` exige
|
||||
un import CSS explicite). Sans elle, l'éditeur est non stylisé et `.excalidraw` n'a aucune
|
||||
hauteur fixe : la boucle de redimensionnement d'Excalidraw fait grossir le canvas jusqu'au
|
||||
plafond codé en dur de `2^25` (33 554 432 px), que le navigateur ne peut pas dessiner → scène
|
||||
blanche. Correctifs : `<link>` vers `…/@excalidraw/[email protected]/dist/prod/index.css` dans
|
||||
`frontend/excalidraw-editor.html` et ajout de `https://esm.sh` à `style-src` de la CSP
|
||||
(`backend/main.py`).
|
||||
2. `appState.collaborators` est une `Map` sérialisée en objet JSON (`{}`) par l'app Excalidraw /
|
||||
le plugin Obsidian ; réinjectée via `initialData`, Excalidraw 0.18 appelait `.forEach()` dessus
|
||||
et plantait (`e.appState.collaborators.forEach is not a function`). `sanitizeAppState()` la
|
||||
reconvertit en `Map` et écarte la géométrie de viewport importée (`width`, `height`,
|
||||
`offsetLeft`, `offsetTop`) pour les deux formats (`.excalidraw` et `.excalidraw.md`).
|
||||
Tests de non-régression : `tests/frontend/excalidraw-viewer.test.mjs` (CSS lié + CSP),
|
||||
`tests/test_security_hardening.py::TestCspExcalidrawStylesheet`, `tests/e2e/excalidraw.spec.js`
|
||||
(hauteur de canvas bornée) + fixtures `test_vault/diagram-app-export.excalidraw`.
|
||||
- **BUG-064 (complément) - Éditeur Excalidraw : pleine largeur quand la navigation est masquée** :
|
||||
la règle `.sidebar.hidden ~ .content-wrapper .content-area { max-width: 1200px }` (colonne de
|
||||
lecture centrée) s'appliquait aussi au viewer Excalidraw. Ajout de
|
||||
`.content-area:has(iframe[src*="excalidraw-editor.html"])` en `max-width: none; margin: 0`,
|
||||
comme pour les viewers PDF/image (BUG-062). Fichier : `frontend/style.css`.
|
||||
- **BUG-065 - Excalidraw : l'auto-save rechargeait la page en pleine édition** : chaque
|
||||
modification déclenchait, 2 s plus tard, un `PUT /api/file/…/save` ; l'écriture émettait
|
||||
`index_updated` (SSE) qui re-rendait la vue et **recréait l'iframe** — un refresh visible qui
|
||||
interrompait le dessin. L'auto-save est supprimée (`frontend/js/excalidraw-viewer.js`) :
|
||||
sauvegarde explicite par le bouton « 💾 Save » ou `Ctrl+S`. En complément, `reloadExternalWrite`
|
||||
ne re-rend plus la vue quand un iframe Excalidraw est déjà ouvert sur le fichier
|
||||
(`iframe[data-excalidraw-*]`), et le badge « Modified » ne se déclenche plus sur les
|
||||
changements d'`appState` (resize, zoom) mais uniquement sur le contenu (signature des éléments).
|
||||
- **#78 (complément) - Bouton plein écran pour les diagrammes Excalidraw** : nouveau bouton
|
||||
`#btn-fullscreen` dans la barre d'outils de l'éditeur (`frontend/excalidraw-editor.html`) qui
|
||||
bascule le mode plein écran natif ; l'iframe est créée avec `allow="fullscreen" allowfullscreen`
|
||||
(`frontend/js/excalidraw-viewer.js`). Fichiers : `frontend/excalidraw-editor.html`,
|
||||
`frontend/js/excalidraw-viewer.js`, `tests/frontend/excalidraw-viewer.test.mjs`.
|
||||
- **#78 (complément) - Barre d'outils Excalidraw en icônes, verticale à droite** : les boutons
|
||||
Save / PNG / SVG / plein écran passent en **icônes seules** (34×34 px) dans une colonne
|
||||
**collée au bord droit** (`right: 0`), débutant à `45%` de la hauteur, empilée verticalement,
|
||||
avec infobulles et `aria-label`. L'icône du bouton Save devient une coche après une sauvegarde
|
||||
réussie. Fichier : `frontend/excalidraw-editor.html`.
|
||||
|
||||
---
|
||||
|
||||
## [2.11.5] — 2026-09-17
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-063 (complément) — Viewer PDF : la table des matières ne déplaçait toujours pas la
|
||||
page** : le premier correctif réassignait `iframe.src` avec le seul fragment `#page=N`, ce qui
|
||||
ne change que le fragment → navigation *same-document* que le lecteur PDF natif ignore (il
|
||||
n'applique `#page=N` qu'au chargement). Diagnostic en Chrome *headful* par comparaison de
|
||||
captures. `navigatePdfToPage()` ajoute désormais un paramètre de query horodaté
|
||||
(`&_pdfpage=<ts>#page=N`) pour forcer un vrai rechargement de l'iframe. Fichiers :
|
||||
`frontend/js/viewer.js`, `tests/frontend/pdf-viewer.test.mjs`, `tests/e2e/pdf-viewer.spec.js`.
|
||||
|
||||
---
|
||||
|
||||
## [2.11.4] — 2026-09-17
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-061 — Assistant IA : le bouton « Plein écran » n'agrandissait plus le panneau** : la
|
||||
largeur du panneau est écrite en style inline par la poignée de redimensionnement (et par la
|
||||
largeur persistée en `localStorage`) ; cet inline l'emportait sur la règle
|
||||
`.bookslm-panel.fullscreen { width: 100vw }`, donc le panneau restait à sa largeur courante.
|
||||
La règle plein écran est désormais prioritaire (`!important`). Fichier : `frontend/style.css`.
|
||||
- **BUG-062 — Viewer PDF : largeur incomplète quand la navigation est masquée** : la règle de
|
||||
colonne de lecture centrée (`.sidebar.hidden … { max-width: 1200px }`) s'appliquait aussi aux
|
||||
viewers plein cadre. Les conteneurs PDF et image sont maintenant exemptés
|
||||
(`:has(.pdf-viewer-container)` / `:has(.image-viewer-container)` → `max-width: none`). Fichier :
|
||||
`frontend/style.css`.
|
||||
- **BUG-063 — Viewer PDF : la table des matières ne naviguait pas** : les liens faisaient
|
||||
`contentWindow.location.hash = 'page=N'`, mais le lecteur PDF natif vit dans une fenêtre
|
||||
`about:blank` et l'affectation n'atteignait jamais le document. Nouveau helper
|
||||
`navigatePdfToPage()` qui recharge l'iframe avec le fragment `#page=N` ; les entrées portent un
|
||||
`data-page` et sont câblées par des écouteurs (plus d'`onclick` inline). Fichiers :
|
||||
`frontend/js/viewer.js`, `frontend/style.css`.
|
||||
- **Tests** : `tests/frontend/ai.test.mjs` (+1), `tests/frontend/pdf-viewer.test.mjs` (TOC, plein
|
||||
largeur), `tests/e2e/pdf-viewer.spec.js` (TOC `#page=N`, largeur, fixture
|
||||
`test_vault/sample-pdf-toc.pdf`).
|
||||
|
||||
---
|
||||
|
||||
## [2.11.3] — 2026-09-17
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-035 — `secret_redactor` : faux positifs sur les hashs hex** : la règle qui masquait
|
||||
tout jeton hexadécimal de 40 à 64 caractères mutilait les hashs git/SHA légitimes des notes.
|
||||
Le masquage des chaînes hexadécimales n'a désormais lieu que si un mot-clé de secret
|
||||
(`secret`, `token`, `key`, `password`, `bearer`…) figure dans les 60 caractères précédents ;
|
||||
un contexte de hash (`commit`, `sha256`, `hash`, `checksum`, `git`, `etag`…) exempte
|
||||
explicitement la chaîne. Fichier : `backend/secret_redactor.py`.
|
||||
- **BUG-036 — Collaboration WebSocket : jeton accepté en query string** : le JWT n'est plus lu
|
||||
depuis `?token=` (URLs journalisées par les proxies et l'historique navigateur). Le cookie
|
||||
HttpOnly `access_token`, envoyé automatiquement par le navigateur lors du handshake
|
||||
same-origin, est le seul transport supporté ; les trames brutes dépassant
|
||||
`MAX_MESSAGE_CHARS` (16 Mio) sont rejetées avant analyse. Fichier : `backend/collab.py`.
|
||||
- **BUG-037 — Mode sans authentification** : au démarrage, un avertissement explicite est
|
||||
journalisé quand `OBSIGATE_AUTH_ENABLED=false`. Le serveur **refuse désormais de démarrer**
|
||||
s'il est lié à une adresse non-loopback sans l'opt-in explicite `OBSIGATE_ALLOW_INSECURE=true`,
|
||||
pour empêcher l'exposition publique d'une instance sans authentification (admin anonyme).
|
||||
Fichiers : `backend/auth/middleware.py`, `backend/main.py`.
|
||||
- **BUG-038 — Argon2 : coût mémoire recalibré** : `memory_cost` passe de 64 Mio à 19 Mio
|
||||
(`m=19456 Kio, t=2, p=1`, recommandation OWASP actuelle) pour supprimer le risque
|
||||
d'épuisement mémoire sous connexions simultanées ; les anciens hachages restent valides et
|
||||
sont migrés automatiquement (`needs_rehash`). Fichier : `backend/auth/password.py`.
|
||||
- **BUG-039 — Énumération de comptes au login** : les comptes inconnus, désactivés, verrouillés
|
||||
et limités par le budget par compte répondent tous un `401 Identifiants invalides` avec un
|
||||
temps équivalent (hachage factice), au lieu d'un `429`/`403` distinctif ; seul le rate-limit
|
||||
par IP (non lié à un compte) conserve le `429`. Fichier : `backend/auth/router.py`.
|
||||
- **BUG-040 — Extraction PDF différée au scan** : `_scan_vault` ne lit plus que les métadonnées
|
||||
des PDF ; l'extraction de texte intégrale (100 kio) est déléguée à `enrich_pdf_texts()`,
|
||||
exécutée après la construction de l'index/inverted index (démarrage) et après chaque
|
||||
réindexation. Un vault contenant de nombreux/gros PDF démarre sans être bloqué ; le texte
|
||||
reste recherchable une fois l'enrichissement terminé. Fichiers : `backend/indexer.py`,
|
||||
`backend/main.py`.
|
||||
- **Tests** : `tests/test_api_main.py` (redactor hex), `tests/test_auth.py` (coût Argon2,
|
||||
garde-fou d'instance non authentifiée), `tests/test_auth_api.py` (login uniforme),
|
||||
`tests/test_collab.py` (jeton query rejeté, trame surdimensionnée), `tests/test_pdf.py`
|
||||
(scan différé + enrichissement).
|
||||
|
||||
---
|
||||
|
||||
## [2.11.2] — 2026-09-17
|
||||
|
||||
### Modifié
|
||||
|
||||
- **Tests E2E PDF (BUG-060)** : le helper d'ouverture de fichier de
|
||||
`tests/e2e/pdf-viewer.spec.js` étend désormais le vault dans l'arborescence s'il est replié
|
||||
(cas d'une instance avec authentification activée) au lieu de supposer le vault déjà déplié.
|
||||
|
||||
---
|
||||
|
||||
## [2.11.1] — 2026-09-17
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-060 — Viewer PDF : l'affichage des pages ne fonctionnait pas** : au clic sur un fichier
|
||||
`.pdf`, seule la barre d'outils « PDF — N pages » s'affichait, le corps restant vide. La CSP
|
||||
durcie en BUG-034 pose `object-src 'none'`, directive qui gouverne `<embed>`/`<object>`, alors
|
||||
que le viewer rendait le PDF via `<embed type="application/pdf">` : le lecteur natif était
|
||||
bloqué. Le rendu passe désormais par une `<iframe>` (autorisée par `frame-src 'self'`, le stream
|
||||
`/api/file/{vault}/pdf/stream` étant same-origin) ; `object-src 'none'` est conservé. Fichiers :
|
||||
`frontend/js/viewer.js`, `tests/frontend/pdf-viewer.test.mjs` (nouveau),
|
||||
`tests/e2e/pdf-viewer.spec.js` (nouveau, fixture `test_vault/sample-pdf.pdf`).
|
||||
|
||||
---
|
||||
|
||||
## [2.11.0] — 2026-09-17
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#103 — Clés des sources connectées configurables depuis la page Configurations** : nouvelle
|
||||
section « 🔗 Sources connectées & recherche » (admin) permettant de saisir ses propres jetons
|
||||
sans toucher aux variables d'environnement : clés de recherche web à clé (Tavily, Brave,
|
||||
SerpAPI, Exa), URL Gitea + token, token GitHub. Stockage dans `data/api_keys.json` (même
|
||||
fichier que les clés des fournisseurs IA) via les endpoints
|
||||
`GET/POST/DELETE /api/config/tool-keys` (GET masque les secrets, URLs en clair ; liste
|
||||
blanche stricte ; admin requis). Les outils lisent la valeur stockée **en priorité** puis
|
||||
l'environnement (Infisical) — `backend/tools/secrets.py`. Fichiers :
|
||||
`backend/tools/{secrets,web,connected}.py`, `backend/main.py`, `frontend/index.html`,
|
||||
`frontend/js/config.js`, `frontend/locales/{fr,en}.json`, `tests/test_tool_keys.py` (nouveau),
|
||||
`tests/test_connected_tools.py`.
|
||||
|
||||
---
|
||||
|
||||
## [2.10.1] — 2026-09-17
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-059 — Assistant IA : un clic dans la conversation faisait sauter le texte au bas de la
|
||||
fenêtre** : dans une conversation ouverte (post ancré en haut), tout clic — lien de fichier,
|
||||
étapes, sélection de texte — dépinait l'ancre via le gestionnaire `mousedown` et retirait le
|
||||
padding d'ancre, bornant le scroll à la nouvelle hauteur max (saut au bas). Le dépintage est
|
||||
désormais réservé aux vrais gestes de scroll : molette, tactile et poignée de scroll
|
||||
uniquement (`isScrollbarPress`). Fichiers : `frontend/js/bookslm.js`,
|
||||
`tests/frontend/ai.test.mjs`.
|
||||
- **#92 — `create_pdf` de l'Assistant : tableaux mal formatés** : l'outil `create_pdf`
|
||||
(génération PDF via l'assistant) utilisait un rendu reportlab simplifié sans support des
|
||||
tableaux. Il passe désormais par le même pipeline que le bouton « Télécharger PDF » de la
|
||||
page document (mistune + plugin `table` + WeasyPrint), avec repli automatique sur le rendu
|
||||
simple si WeasyPrint n'est pas disponible (GTK absent). Fichiers :
|
||||
`backend/tools/documents.py`, `tests/test_document_tools.py`.
|
||||
|
||||
---
|
||||
|
||||
## [2.10.0] — 2026-09-17
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#92 — Assistant IA : écosystème d'outils phase 2 (web étendu, sources connectées, documents)** :
|
||||
- **Recherche web à clé** : fournisseurs optionnels essayés avant SearXNG — Tavily, Brave
|
||||
Search, SerpAPI (Google) et Exa (`OBSIGATE_TAVILY_API_KEY`, `OBSIGATE_BRAVE_API_KEY`,
|
||||
`OBSIGATE_SERPAPI_API_KEY`, `OBSIGATE_EXA_API_KEY`), avec ordre personnalisable via
|
||||
`OBSIGATE_WEB_PROVIDERS`.
|
||||
- **Transverse** : réessais réseau avec backoff maison (`OBSIGATE_WEB_RETRY`) et cache SQLite
|
||||
des résultats web avec TTL (`OBSIGATE_WEB_CACHE_TTL`, 900 s par défaut, `OBSIGATE_WEB_CACHE_PATH`).
|
||||
- **Rendu dynamique** : `fetch_url(render=True)` délègue les pages SPA à un worker Playwright
|
||||
isolé (dépendance optionnelle, dégradation propre si non installée).
|
||||
- **Crawl de site** : `crawl_site` (WRITE, confirmation) capture jusqu'à 20 pages d'un même
|
||||
hôte et enregistre un condensé Markdown dans un vault.
|
||||
- **Sources connectées** : Gitea (`OBSIGATE_GITEA_URL`/`OBSIGATE_GITEA_TOKEN`) et GitHub
|
||||
(`OBSIGATE_GITHUB_TOKEN`) — `git_list_repos`, `git_search_issues`, `git_get_file` (READ,
|
||||
rate-limités, audités). Les drives cloud (Google Drive / OneDrive) restent orientés serveur
|
||||
MCP externe (#79), conformément à la feuille de route.
|
||||
- **Production de documents** : `create_xlsx` (openpyxl), `create_docx` (python-docx),
|
||||
`create_csv`, `create_pdf` (reportlab) — outils WRITE avec confirmation et sauvegarde
|
||||
dans le vault (backup avant écrasement).
|
||||
- Chaque outil : libellé `labels.py` + clés i18n `ai.step.*` FR/EN + tests unitaires mockés
|
||||
(httpx). Dépendances ajoutées : `openpyxl`, `python-docx`, `reportlab`.
|
||||
Fichiers : `backend/tools/{webcache,webrender,connected,crawler,documents,web,schemas,labels}.py`,
|
||||
`backend/services/mutations.py`, `tests/test_{web_cache,web_search_providers,webrender,connected_tools,document_tools,crawler}.py`.
|
||||
|
||||
---
|
||||
|
||||
## [2.9.1] — 2026-09-17
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-058 — Éditeur « Editer » : la barre de numérotation de ligne ne suit pas le thème** :
|
||||
CodeMirror peint son gutter (colonne des numéros de ligne) avec des valeurs claires codées
|
||||
en dur (`#f5f5f5`, bordure `#ddd`), si bien qu'en thème sombre la barre restait gris clair
|
||||
alors que le fond de l'éditeur suivait le thème. Le gutter est désormais dérivé des
|
||||
variables CSS du thème ObsiGate (`color-mix(in srgb, var(--text-primary) …)` pour un fond
|
||||
subtil, `--text-secondary` pour les numéros, `--border` pour la séparation), ce qui le fait
|
||||
suivre tous les thèmes et modes (sombre, clair, contraste élevé, sépia). Fichiers :
|
||||
`frontend/style.css`. Tests : `tests/frontend/editor-inline.test.mjs`.
|
||||
|
||||
---
|
||||
|
||||
## [2.9.0] — 2026-09-17
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#102 — Assistant IA : bouton « Ajouter la section » par bloc de code** : chaque bloc
|
||||
de code d'une réponse de l'assistant (par ex. une section ```markdown```) affiche un
|
||||
bouton discret « Ajouter la section » qui insère **uniquement ce bloc** dans le document
|
||||
ouvert (sans les délimiteurs de code), au lieu de la réponse complète. Fichiers :
|
||||
`frontend/js/bookslm.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`.
|
||||
Tests : `tests/frontend/ai.test.mjs`.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-057 — Assistant IA : bouton « Ajouter » inopérant dans l'éditeur Forge** : le
|
||||
bouton ne ciblait que `state.editorView` (CodeMirror de « Editer ») et affichait « Aucun
|
||||
document ouvert dans l'éditeur » en Forge. `_insertIntoEditor()` prend désormais en
|
||||
charge les trois surfaces : CodeMirror, l'iframe Forge (délégation par
|
||||
`postMessage({ type: 'parent-insert' })` → `insertAtCursor` dans `editor-poc.html`) et le
|
||||
textarea de repli. Fichiers : `frontend/js/bookslm.js`, `frontend/editor-poc.html`.
|
||||
Tests : `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs`.
|
||||
|
||||
---
|
||||
|
||||
## [2.8.4] — 2026-09-17
|
||||
|
||||
---
|
||||
|
||||
## [2.8.3] — 2026-09-17
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-056 - Éditeur Forge en plein écran : l'Assistant IA s'ouvre en arrière-plan** :
|
||||
le panneau de l'assistant est monté dans le document parent, alors que le plein écran
|
||||
Forge porte sur l'iframe — l'API Fullscreen n'affichant que l'élément plein écran et ses
|
||||
descendants, le panneau restait invisible. Le plein écran est désormais quitté avant
|
||||
d'ouvrir le panneau : côté **parent** (`sync.js`, sur `forge-open-ai` — le plein écran
|
||||
peut appartenir au document parent et non à l'iframe) **et** côté iframe
|
||||
(`editor-poc.html`, `openAssistant`), la demande d'ouverture étant émise une fois la
|
||||
sortie effective. Fichiers : `frontend/editor-poc.html`, `frontend/js/sync.js`.
|
||||
Tests : `tests/frontend/forge-completion.test.mjs` (+1),
|
||||
`tests/frontend/editor-inline.test.mjs` (+1).
|
||||
|
||||
---
|
||||
|
||||
## [2.8.2] — 2026-09-17
|
||||
|
||||
### Corrigé
|
||||
|
||||
@@ -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,62 +1,77 @@
|
||||
# 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))
|
||||
- **🗺️ Vue graphe interactive** — Canvas force-directed avec Barnes-Hut O(n log n), filtres (tag, type), profondeur, mode focus, historique de navigation ←→↑, export PNG, aperçu au survol (Ctrl+click)
|
||||
- **🗂️ Multi-vault** : Visualisez plusieurs vaults Obsidian simultanément
|
||||
@@ -68,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
|
||||
@@ -281,7 +298,18 @@ 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`) | — |
|
||||
| `OBSIGATE_WEB_RETRY` | Réessais réseau des outils web (backoff maison) | `1` |
|
||||
| `OBSIGATE_WEB_CACHE_TTL` | Durée du cache SQLite des résultats web (secondes, `0` = off) | `900` |
|
||||
| `OBSIGATE_GITEA_URL` / `OBSIGATE_GITEA_TOKEN` | Source connectée Gitea (outil `git_list_repos`…) | — |
|
||||
| `OBSIGATE_GITHUB_TOKEN` | Jeton GitHub (outil `git_list_repos`…) | — |
|
||||
|
||||
> Ces clés peuvent aussi être saisies **depuis l'interface** (menu → Configurations →
|
||||
> « Sources connectées & recherche ») : la valeur saisie est stockée dans `data/api_keys.json`
|
||||
> et prime sur la variable d'environnement.
|
||||
|
||||
### Volume pour la persistance
|
||||
|
||||
@@ -381,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
|
||||
@@ -401,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é.
|
||||
@@ -560,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
|
||||
@@ -579,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 |
|
||||
@@ -606,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 |
|
||||
|
||||
@@ -626,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 |
|
||||
@@ -747,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)
|
||||
@@ -822,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`)
|
||||
|
||||
@@ -845,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.
|
||||
@@ -916,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.8.2).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.2).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.8.2 | Dernière mise à jour : Juin 2026*
|
||||
*Projet : ObsiGate | Version : 2.27.2 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,54 +2,75 @@
|
||||
|
||||
**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))
|
||||
- **🗺️ Interactive Graph View** — Canvas force-directed with Barnes-Hut O(n log n), filters (tag, type), depth, focus mode, navigation history ←→↑, export PNG, preview on hover (Ctrl+click)
|
||||
- **🗂️ Multi-vault** : View multiple Obsidian vaults simultaneously
|
||||
@@ -61,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
|
||||
@@ -319,7 +342,18 @@ 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`) | — |
|
||||
| `OBSIGATE_WEB_RETRY` | Web tools network retries (house-made backoff) | `1` |
|
||||
| `OBSIGATE_WEB_CACHE_TTL` | SQLite cache TTL for web results (seconds, `0` = off) | `900` |
|
||||
| `OBSIGATE_GITEA_URL` / `OBSIGATE_GITEA_TOKEN` | Gitea connected source (`git_list_repos`…) | — |
|
||||
| `OBSIGATE_GITHUB_TOKEN` | GitHub token (`git_list_repos`…) | — |
|
||||
|
||||
> These keys can also be entered **from the UI** (menu → Configurations →
|
||||
> "Connected sources & search"): the stored value goes to `data/api_keys.json`
|
||||
> and takes precedence over the environment variable.
|
||||
|
||||
>All these variables are documented in `.env.example`.
|
||||
|
||||
@@ -485,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:
|
||||
@@ -509,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.
|
||||
@@ -676,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.
|
||||
@@ -692,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 |
|
||||
@@ -719,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 |
|
||||
|
||||
@@ -752,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 |
|
||||
@@ -904,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)
|
||||
@@ -987,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`)
|
||||
|
||||
@@ -1010,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.
|
||||
@@ -1059,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
|
||||
@@ -1085,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.8.2).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.2).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.8.2 | Last updated: May 2026*
|
||||
*Project: ObsiGate | Version: 2.27.2 | 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,43 +109,61 @@ 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
|
||||
|
||||
|
||||
def _load_revoked():
|
||||
"""Load revoked token JTIs from disk into memory (once)."""
|
||||
global _revoked_loaded, _revoked_jtis
|
||||
global _revoked_loaded, _revoked_map
|
||||
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)
|
||||
# Drop entries whose underlying token has itself expired.
|
||||
now = int(time.time())
|
||||
_revoked_jtis = {
|
||||
jti for jti, exp in data.items()
|
||||
if exp > now
|
||||
_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_jtis = set()
|
||||
_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."""
|
||||
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).
|
||||
"""
|
||||
_load_revoked()
|
||||
_revoked_jtis.add(jti)
|
||||
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]}...")
|
||||
|
||||
@@ -136,4 +171,128 @@ def revoke_token(jti: str):
|
||||
def is_token_revoked(jti: str) -> bool:
|
||||
"""Check if a token JTI has been revoked."""
|
||||
_load_revoked()
|
||||
return jti in _revoked_jtis
|
||||
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}")
|
||||
|
||||
@@ -4,19 +4,23 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
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")
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
#: Hosts considered safe to bind without authentication (loopback only).
|
||||
_LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost", "0:0:0:0:0:0:0:1"}
|
||||
|
||||
|
||||
def is_auth_enabled() -> bool:
|
||||
"""Check if authentication is enabled via environment variable.
|
||||
@@ -26,6 +30,34 @@ def is_auth_enabled() -> bool:
|
||||
return os.environ.get("OBSIGATE_AUTH_ENABLED", "true").lower() != "false"
|
||||
|
||||
|
||||
def is_insecure_mode_allowed() -> bool:
|
||||
"""True when the operator explicitly accepts running without auth (BUG-037)."""
|
||||
return os.environ.get("OBSIGATE_ALLOW_INSECURE", "false").lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def bind_host_from_argv(argv: list[str] | None = None) -> str | None:
|
||||
"""Extract the ``--host`` value from the process arguments (uvicorn), if any.
|
||||
|
||||
Returns ``None`` when no explicit host is passed (uvicorn then defaults to
|
||||
loopback ``127.0.0.1``).
|
||||
"""
|
||||
args = sys.argv if argv is None else argv
|
||||
for i, arg in enumerate(args):
|
||||
if arg == "--host" and i + 1 < len(args):
|
||||
return args[i + 1]
|
||||
if arg.startswith("--host="):
|
||||
return arg.split("=", 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
def is_loopback_host(host: str | None) -> bool:
|
||||
"""True when *host* is a loopback address (or unset → uvicorn default)."""
|
||||
if not host:
|
||||
return True
|
||||
normalized = host.strip().strip("[]").lower()
|
||||
return normalized in _LOOPBACK_HOSTS
|
||||
|
||||
|
||||
def get_current_user(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
@@ -83,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
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
# backend/auth/password.py
|
||||
# Argon2id password hashing — OWASP 2024 recommended algorithm.
|
||||
# Parameters: time_cost=2, memory_cost=64MB, parallelism=2
|
||||
# Parameters (BUG-038): time_cost=2, memory_cost=19 MiB, parallelism=1
|
||||
# (OWASP current recommendation for Argon2id). The previous 64 MiB setting
|
||||
# allowed memory exhaustion under concurrent login attempts.
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerificationError, VerifyMismatchError
|
||||
|
||||
#: Argon2id cost parameters (OWASP 2024: m=19456 KiB, t=2, p=1).
|
||||
ARGON2_TIME_COST = 2
|
||||
ARGON2_MEMORY_COST_KIB = 19456 # 19 MiB
|
||||
ARGON2_PARALLELISM = 1
|
||||
|
||||
ph = PasswordHasher(
|
||||
time_cost=2,
|
||||
memory_cost=65536, # 64 MB
|
||||
parallelism=2,
|
||||
time_cost=ARGON2_TIME_COST,
|
||||
memory_cost=ARGON2_MEMORY_COST_KIB,
|
||||
parallelism=ARGON2_PARALLELISM,
|
||||
hash_len=32,
|
||||
salt_len=16,
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
@@ -124,31 +167,32 @@ async def auth_status():
|
||||
async def login(body: LoginRequest, response: Response, request: Request):
|
||||
"""Authenticate a user. Returns access token and sets refresh cookie.
|
||||
|
||||
Implements timing-safe responses to prevent user enumeration:
|
||||
a failed login with an unknown user takes the same time as one
|
||||
with a known user (dummy hash is computed).
|
||||
Implements timing-safe responses to prevent user enumeration: a failed
|
||||
login with an unknown user takes the same time as one with a known user
|
||||
(dummy hash is computed). BUG-039: unknown, inactive, locked and
|
||||
per-account rate-limited accounts all answer the same ``401`` so the HTTP
|
||||
status can never reveal whether an account exists.
|
||||
"""
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
# IP-based rate limiting (10 failures / 15 min per IP). It is not
|
||||
# account-specific, so a 429 here cannot be used to enumerate accounts.
|
||||
if is_rate_limited(client_ip):
|
||||
raise HTTPException(429, "Trop de tentatives depuis cette adresse IP (15min)")
|
||||
|
||||
user = get_user(body.username)
|
||||
|
||||
if not user:
|
||||
# BUG-039: uniform 401 + equivalent timing for every account-state outcome.
|
||||
if not user or not user.get("active"):
|
||||
# Timing-safe: simulate hash computation to prevent user enumeration
|
||||
hash_password("dummy_timing_protection")
|
||||
raise HTTPException(401, "Identifiants invalides")
|
||||
|
||||
if not user.get("active"):
|
||||
raise HTTPException(403, "Compte désactivé")
|
||||
|
||||
# IP-based rate limiting (10 failures / 15 min per IP)
|
||||
client_ip = get_client_ip(request)
|
||||
if is_rate_limited(client_ip):
|
||||
raise HTTPException(429, "Trop de tentatives depuis cette adresse IP (15min)")
|
||||
|
||||
# BUG-031: per-account budget still applies when the attacker rotates IPs.
|
||||
if is_account_rate_limited(body.username):
|
||||
raise HTTPException(429, "Trop de tentatives sur ce compte (15min)")
|
||||
|
||||
if is_locked(body.username):
|
||||
raise HTTPException(429, "Compte temporairement verrouillé (15min)")
|
||||
# Kept indistinguishable from a wrong password (BUG-039).
|
||||
if is_account_rate_limited(body.username) or is_locked(body.username):
|
||||
hash_password("dummy_timing_protection")
|
||||
raise HTTPException(401, "Identifiants invalides")
|
||||
|
||||
if not verify_password(body.password, user["password_hash"]):
|
||||
attempts = record_login_failure(body.username)
|
||||
@@ -216,6 +260,7 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
|
||||
"display_name": user["display_name"],
|
||||
"role": user["role"],
|
||||
"vaults": user["vaults"],
|
||||
"avatar": user.get("avatar"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -338,6 +383,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"),
|
||||
}
|
||||
|
||||
|
||||
@@ -345,19 +391,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)
|
||||
@@ -368,6 +418,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"),
|
||||
}
|
||||
|
||||
|
||||
@@ -443,7 +494,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
|
||||
@@ -453,10 +506,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,
|
||||
}
|
||||
|
||||
|
||||
@@ -558,18 +622,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.
|
||||
@@ -579,14 +650,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:
|
||||
@@ -665,7 +739,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
|
||||
@@ -677,8 +751,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}
|
||||
@@ -692,7 +767,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)
|
||||
|
||||
@@ -703,13 +778,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))
|
||||
@@ -860,3 +938,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(
|
||||
|
||||
@@ -44,6 +44,9 @@ MAX_UPDATE_BYTES = 8 * 1024 * 1024
|
||||
#: Taille maximale d'un snapshot texte (protection anti-abus).
|
||||
MAX_TEXT_CHARS = 8 * 1024 * 1024
|
||||
|
||||
#: Taille maximale d'un message brut reçu (protection anti-abus, BUG-036).
|
||||
MAX_MESSAGE_CHARS = 16 * 1024 * 1024
|
||||
|
||||
#: Palette de couleurs attribuées aux utilisateurs (curseurs + avatars).
|
||||
PEER_COLORS = [
|
||||
"#e6194b", "#3cb44b", "#4363d8", "#f58231", "#911eb4",
|
||||
@@ -68,9 +71,13 @@ def authenticate_websocket(websocket: WebSocket) -> dict[str, Any] | None:
|
||||
"""Authenticate a WebSocket connection.
|
||||
|
||||
Mirrors :func:`backend.auth.middleware.get_current_user` but works on the
|
||||
WebSocket scope: the JWT is read from the ``access_token`` cookie (sent
|
||||
automatically by same-origin browsers during the handshake) or, as a
|
||||
fallback, from the ``token`` query parameter.
|
||||
WebSocket scope: the JWT is read from the ``access_token`` cookie, which
|
||||
same-origin browsers send automatically during the handshake.
|
||||
|
||||
BUG-036: the token is **never** accepted from the query string anymore —
|
||||
URLs end up in access logs, proxies and browser history. Browsers cannot
|
||||
set custom headers on a WebSocket handshake, so the HttpOnly cookie set at
|
||||
login is the only supported transport.
|
||||
|
||||
Returns the user dict, or ``None`` if authentication fails.
|
||||
"""
|
||||
@@ -88,7 +95,7 @@ def authenticate_websocket(websocket: WebSocket) -> dict[str, Any] | None:
|
||||
"_token_vaults": ["*"],
|
||||
}
|
||||
|
||||
token = websocket.query_params.get("token") or websocket.cookies.get("access_token")
|
||||
token = websocket.cookies.get("access_token")
|
||||
if not token:
|
||||
return None
|
||||
|
||||
@@ -274,6 +281,9 @@ class CollabManager:
|
||||
|
||||
# -- message handling ---------------------------------------------------
|
||||
async def _on_message(self, room: CollabRoom, client: CollabClient, raw: str) -> None:
|
||||
# BUG-036: drop oversized frames before parsing them.
|
||||
if not isinstance(raw, str) or len(raw) > MAX_MESSAGE_CHARS:
|
||||
return
|
||||
try:
|
||||
message = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
"""Génération du Guide d'utilisation téléchargeable en Markdown et PDF (#105).
|
||||
|
||||
Source unique de vérité : la modale ``#help-modal`` de ``frontend/index.html``
|
||||
(comme dans l'application) + les blocs ``data-i18n`` résolus dans les locales
|
||||
``frontend/locales/{fr,en}.json`` — le téléchargement reflète donc exactement
|
||||
ce que voit l'utilisateur, dans sa langue.
|
||||
|
||||
Le Markdown est produit par un convertisseur HTML→MD minimal (stdlib) ; le
|
||||
PDF passe par le moteur d'export existant (WeasyPrint) avec repli reportlab
|
||||
quand les bibliothèques natives GTK manquent (Windows).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("obsigate.guide")
|
||||
|
||||
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()
|
||||
sha = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:16]
|
||||
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"}
|
||||
# En-tête HTML du guide (mode lecture)
|
||||
_HEADER_BLOCK = "ObsiGate User Guide"
|
||||
|
||||
|
||||
class Node:
|
||||
"""Noeud DOM minimal (stdlib only)."""
|
||||
|
||||
__slots__ = ("attrs", "children", "parent", "tag")
|
||||
|
||||
def __init__(self, tag: str, attrs: dict[str, str | None], parent: Node | None = None):
|
||||
self.tag = tag
|
||||
self.attrs = attrs
|
||||
self.children: list[Node | str] = []
|
||||
self.parent = parent
|
||||
|
||||
def cls(self) -> str:
|
||||
return self.attrs.get("class") or ""
|
||||
|
||||
def i18n(self) -> str | None:
|
||||
v = self.attrs.get("data-i18n")
|
||||
return v if isinstance(v, str) else None
|
||||
|
||||
def find_all(self, tag: str) -> list[Node]:
|
||||
out: list[Node] = []
|
||||
for c in self.children:
|
||||
if isinstance(c, Node):
|
||||
if c.tag == tag:
|
||||
out.append(c)
|
||||
out.extend(c.find_all(tag))
|
||||
return out
|
||||
|
||||
|
||||
_VOID_TAGS = {"br", "img", "hr", "input", "meta", "link"}
|
||||
|
||||
|
||||
class _TreeBuilder(HTMLParser):
|
||||
"""Constructeur d'arbre tolérant (ignore les balises orphelines)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.root = Node("#root", {})
|
||||
self.cur = self.root
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
a = {k: v for k, v in attrs}
|
||||
node = Node(tag, a, self.cur)
|
||||
self.cur.children.append(node)
|
||||
if tag not in _VOID_TAGS:
|
||||
self.cur = node
|
||||
|
||||
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
a = {k: v for k, v in attrs}
|
||||
self.cur.children.append(Node(tag, a, self.cur))
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
n: Node | None = self.cur
|
||||
while n is not None and n.tag != tag:
|
||||
n = n.parent
|
||||
if n is not None and n.parent is not None:
|
||||
self.cur = n.parent
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
self.cur.children.append(data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extraction / cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_cache: dict[tuple[str, str], tuple[tuple[float, int, float, int], bytes]] = {}
|
||||
|
||||
|
||||
def _read_index_html() -> str:
|
||||
return INDEX_HTML.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _guide_fragment(index_html: str) -> str:
|
||||
"""Le HTML de #help-modal…help-content jusqu'au footer du guide."""
|
||||
start = index_html.index('id="help-modal"')
|
||||
cstart = index_html.index('<div class="help-content">', start)
|
||||
end = index_html.index('<div class="help-footer">', cstart)
|
||||
return index_html[cstart:end]
|
||||
|
||||
|
||||
def _locale_strings(lang: str) -> dict[str, str]:
|
||||
path = LOCALES_DIR / (lang if lang in ("fr", "en") else "fr")
|
||||
return json.loads(Path(path).with_suffix(".json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _signature() -> tuple[float, int, float, int]:
|
||||
st = INDEX_HTML.stat()
|
||||
lt = (LOCALES_DIR / "fr.json").stat()
|
||||
return (st.st_mtime, st.st_size, lt.st_mtime, lt.st_size)
|
||||
|
||||
|
||||
def _app_version() -> str:
|
||||
try:
|
||||
return VERSION_FILE.read_text(encoding="utf-8").strip() or "dev"
|
||||
except OSError:
|
||||
return "dev"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Résolution i18n : un node portant data-i18n est REMPLACÉ par le contenu
|
||||
# (HTML) de la locale — exactement comme _applyDOM() dans le navigateur.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_i18n(node: Node, loc: dict[str, str]) -> list[Node | str]:
|
||||
"""Retourne les children effectifs d'un node (locale si data-i18n[-html])."""
|
||||
key = node.i18n() or node.attrs.get("data-i18n-html")
|
||||
if not isinstance(key, str):
|
||||
return node.children
|
||||
value = loc.get(key)
|
||||
if value is None:
|
||||
# clé absente de la locale : garder le texte FR inline de index.html
|
||||
return node.children
|
||||
tb = _TreeBuilder()
|
||||
tb.feed(f"<span>{value}</span>")
|
||||
span = tb.root.children[0]
|
||||
assert isinstance(span, Node)
|
||||
return span.children
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_WS_RE = re.compile(r"[ \t]*\n[ \t]*")
|
||||
|
||||
|
||||
def _collapse(text: str) -> str:
|
||||
return _WS_RE.sub(" ", text).strip()
|
||||
|
||||
|
||||
def _md_inline(node: Node | str, loc: dict[str, str]) -> str:
|
||||
if isinstance(node, str):
|
||||
return _collapse(node)
|
||||
tag = node.tag
|
||||
kids = _resolve_i18n(node, loc)
|
||||
inner = "".join(_md_inline(c, loc) for c in kids)
|
||||
if tag == "br":
|
||||
return " "
|
||||
if tag in ("strong", "b"):
|
||||
t = inner.strip()
|
||||
return f"**{t}**" if t else ""
|
||||
if tag in ("em", "i"):
|
||||
if node.cls().startswith("lucide") or tag == "i" and not inner.strip():
|
||||
return ""
|
||||
t = inner.strip()
|
||||
return f"*{t}*" if t else ""
|
||||
if tag == "code":
|
||||
t = inner.replace("`", "'").strip()
|
||||
return f"`{t}`" if t else ""
|
||||
if tag == "kbd":
|
||||
t = inner.strip()
|
||||
return f"`{t}`" if t else ""
|
||||
if tag == "a":
|
||||
href = node.attrs.get("href") or ""
|
||||
t = inner.strip()
|
||||
if href.startswith("http") and t:
|
||||
return f"[{t}]({href})"
|
||||
return t
|
||||
if tag == "img":
|
||||
alt = node.attrs.get("alt") or ""
|
||||
return f"![{alt}]"
|
||||
return inner
|
||||
|
||||
|
||||
def _md_block(node: Node | str, out: list[str], loc: dict[str, str], depth: int = 0) -> None:
|
||||
"""Remplit ``out`` (bloc courant) — ``pending`` gère listes imbriquées."""
|
||||
if isinstance(node, str):
|
||||
t = _collapse(node)
|
||||
if t:
|
||||
out.append(t)
|
||||
return
|
||||
if any(c in node.cls().split() for c in _SKIP_CLASSES):
|
||||
return
|
||||
tag = node.tag
|
||||
|
||||
if tag == "pre":
|
||||
raw = _pre_text(node)
|
||||
lang = "mermaid" if "mermaid" in raw[:40] or "language-mermaid" in _pre_classes(node) else ""
|
||||
out.append(f"```{lang}\n{raw.rstrip()}\n```")
|
||||
return
|
||||
|
||||
kids = _resolve_i18n(node, loc)
|
||||
|
||||
if tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
||||
level = int(tag[1])
|
||||
text = _collapse("".join(_md_inline(c, loc) for c in kids))
|
||||
if text:
|
||||
out.append("#" * level + " " + text)
|
||||
return
|
||||
|
||||
if tag == "p":
|
||||
text = _collapse("".join(_md_inline(c, loc) for c in kids))
|
||||
if text:
|
||||
out.append(text)
|
||||
return
|
||||
|
||||
if tag in ("ul", "ol"):
|
||||
_md_list(kids, out, loc, tag, depth)
|
||||
return
|
||||
|
||||
if tag == "table":
|
||||
_md_table(node, out, loc)
|
||||
return
|
||||
|
||||
# conteneurs neutres (section, div, span de bloc, li imbriqué…)
|
||||
for c in kids:
|
||||
_md_block(c, out, loc, depth)
|
||||
|
||||
|
||||
def _md_list(items: list[Node | str], out: list[str], loc: dict[str, str], kind: str, depth: int) -> None:
|
||||
n = 0
|
||||
for li in items:
|
||||
if isinstance(li, str):
|
||||
continue
|
||||
if li.tag == "li":
|
||||
n += 1
|
||||
marker = "- " if kind == "ul" else f"{n}. "
|
||||
text_parts: list[str] = []
|
||||
nested: list[Node] = []
|
||||
for c in li.children:
|
||||
if isinstance(c, Node) and c.tag in ("ul", "ol"):
|
||||
nested.append(c)
|
||||
else:
|
||||
text_parts.append(_md_inline(c, loc))
|
||||
line = _collapse("".join(text_parts))
|
||||
if line:
|
||||
out.append(" " * depth + marker + line)
|
||||
for sub in nested:
|
||||
_md_list(sub.children, out, loc, sub.tag, depth + 1)
|
||||
elif li.tag in ("ul", "ol"):
|
||||
_md_list(li.children, out, loc, li.tag, depth)
|
||||
|
||||
|
||||
def _md_table(node: Node, out: list[str], loc: dict[str, str]) -> None:
|
||||
rows = node.find_all("tr")
|
||||
if not rows:
|
||||
return
|
||||
grid: list[list[str]] = []
|
||||
for tr in rows:
|
||||
cells = []
|
||||
for td in tr.children:
|
||||
if isinstance(td, Node) and td.tag in ("td", "th"):
|
||||
cells.append(_collapse("".join(_md_inline(c, loc) for c in td.children)).replace("|", "\\|") or " ")
|
||||
if cells:
|
||||
grid.append(cells)
|
||||
if not grid:
|
||||
return
|
||||
width = max(len(r) for r in grid)
|
||||
grid = [r + [" "] * (width - len(r)) for r in grid]
|
||||
out.append("| " + " | ".join(grid[0]) + " |")
|
||||
out.append("|" + "|".join([" --- "] * width) + "|")
|
||||
for r in grid[1:]:
|
||||
out.append("| " + " | ".join(r) + " |")
|
||||
|
||||
|
||||
def _pre_text(node: Node) -> str:
|
||||
"""Texte brut préservé d'un <pre> (les locales n'y touchent pas)."""
|
||||
buf: list[str] = []
|
||||
|
||||
def walk(n: Node | str) -> None:
|
||||
if isinstance(n, str):
|
||||
buf.append(n)
|
||||
return
|
||||
for c in n.children:
|
||||
walk(c)
|
||||
|
||||
walk(node)
|
||||
return "".join(buf).strip("\n")
|
||||
|
||||
|
||||
def _pre_classes(node: Node) -> str:
|
||||
cls = node.cls()
|
||||
for c in node.find_all("code"):
|
||||
cls += " " + c.cls()
|
||||
return cls
|
||||
|
||||
|
||||
def build_guide_markdown(lang: str = "fr") -> bytes:
|
||||
"""Guide complet en Markdown (UTF-8), dans la langue demandée."""
|
||||
index_html = _read_index_html()
|
||||
loc = _locale_strings(lang)
|
||||
tree = _TreeBuilder()
|
||||
tree.feed(_guide_fragment(index_html))
|
||||
root = tree.root.children[0]
|
||||
assert isinstance(root, Node)
|
||||
|
||||
blocks: list[str] = []
|
||||
content = _guide_title_fr if lang == "fr" else _guide_title_en
|
||||
blocks.append("# " + content)
|
||||
for section in root.find_all("section"):
|
||||
_md_block(section, blocks, loc)
|
||||
blocks.append(
|
||||
"---\n\n"
|
||||
+ _export_footer(lang)
|
||||
)
|
||||
md = "\n\n".join(b for b in blocks if b.strip()) + "\n"
|
||||
return md.encode("utf-8")
|
||||
|
||||
|
||||
_guide_title_fr = "Guide d'utilisation ObsiGate"
|
||||
_guide_title_en = "ObsiGate User Guide"
|
||||
|
||||
|
||||
def _export_footer(lang: str) -> str:
|
||||
loc = _locale_strings(lang)
|
||||
template = loc.get("guide105.export_footer", "")
|
||||
if "%s" not in template and "{" not in template:
|
||||
template = "ObsiGate {version}"
|
||||
today = datetime.datetime.now(tz=datetime.timezone.utc).date().isoformat()
|
||||
return _collapse(template).format(version=_app_version(), date=today)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTML (pour le PDF) — mêmes règles, sortie balisée propre
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _html_inline(node: Node | str, loc: dict[str, str]) -> str:
|
||||
if isinstance(node, str):
|
||||
return html.escape(_collapse(node), quote=False)
|
||||
tag = node.tag
|
||||
kids = _resolve_i18n(node, loc)
|
||||
inner = "".join(_html_inline(c, loc) for c in kids)
|
||||
if tag == "br":
|
||||
return " "
|
||||
if tag in ("strong", "b") and inner.strip():
|
||||
return f"<strong>{inner}</strong>"
|
||||
if tag in ("em",) and inner.strip():
|
||||
return f"<em>{inner}</em>"
|
||||
if tag == "code":
|
||||
t = inner.strip()
|
||||
return f"<code>{t}</code>" if t else ""
|
||||
if tag == "kbd":
|
||||
t = inner.strip()
|
||||
return f"<code>{t}</code>" if t else ""
|
||||
if tag == "a":
|
||||
href = node.attrs.get("href") or ""
|
||||
if href.startswith("http"):
|
||||
return f'<a href="{html.escape(href, quote=True)}">{inner}</a>'
|
||||
return inner
|
||||
return inner
|
||||
|
||||
|
||||
def _html_block(node: Node | str, out: list[str], loc: dict[str, str]) -> None:
|
||||
if isinstance(node, str):
|
||||
t = _collapse(node)
|
||||
if t:
|
||||
out.append(f"<p>{html.escape(t, quote=False)}</p>")
|
||||
return
|
||||
if any(c in node.cls().split() for c in _SKIP_CLASSES):
|
||||
return
|
||||
tag = node.tag
|
||||
|
||||
if tag == "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)
|
||||
|
||||
if tag in ("h2", "h3", "h4"):
|
||||
text = _collapse("".join(_html_inline(c, loc) for c in kids))
|
||||
if text:
|
||||
out.append(f"<{tag}>{text}</{tag}>")
|
||||
return
|
||||
|
||||
if tag == "p":
|
||||
text = "".join(_html_inline(c, loc) for c in kids).strip()
|
||||
if text:
|
||||
out.append(f"<p>{text}</p>")
|
||||
return
|
||||
|
||||
if tag in ("ul", "ol"):
|
||||
out.append(_html_list(kids, loc, tag))
|
||||
return
|
||||
|
||||
if tag == "table":
|
||||
out.append(_html_table(node, loc))
|
||||
return
|
||||
|
||||
for c in kids:
|
||||
_html_block(c, out, loc)
|
||||
|
||||
|
||||
def _html_list(items: list[Node | str], loc: dict[str, str], kind: str) -> str:
|
||||
parts: list[str] = []
|
||||
n = 0
|
||||
for li in items:
|
||||
if isinstance(li, str):
|
||||
continue
|
||||
if li.tag == "li":
|
||||
n += 1
|
||||
text_parts: list[str] = []
|
||||
nested: list[Node] = []
|
||||
for c in li.children:
|
||||
if isinstance(c, Node) and c.tag in ("ul", "ol"):
|
||||
nested.append(c)
|
||||
else:
|
||||
text_parts.append(_html_inline(c, loc))
|
||||
line = "".join(text_parts).strip()
|
||||
inner = line + "".join(_html_list(s.children, loc, s.tag) for s in nested)
|
||||
if inner:
|
||||
parts.append(f"<li>{inner}</li>")
|
||||
elif li.tag in ("ul", "ol"):
|
||||
parts.append(_html_list(li.children, loc, li.tag))
|
||||
body = "".join(parts)
|
||||
return f"<{kind}>{body}</{kind}>"
|
||||
|
||||
|
||||
def _html_table(node: Node, loc: dict[str, str]) -> str:
|
||||
rows_html: list[str] = []
|
||||
for tr in node.find_all("tr"):
|
||||
cells: list[str] = []
|
||||
for td in tr.children:
|
||||
if isinstance(td, Node) and td.tag in ("td", "th"):
|
||||
tag = td.tag
|
||||
inner = _collapse("".join(_html_inline(c, loc) for c in td.children))
|
||||
cells.append(f"<{tag}>{inner}</{tag}>")
|
||||
if cells:
|
||||
rows_html.append("<tr>{}</tr>".format("".join(cells)))
|
||||
return "<table>{}</table>".format("".join(rows_html))
|
||||
|
||||
|
||||
def build_guide_html(lang: str = "fr") -> str:
|
||||
"""Corps HTML autonome du guide (pour rendu PDF)."""
|
||||
index_html = _read_index_html()
|
||||
loc = _locale_strings(lang)
|
||||
tree = _TreeBuilder()
|
||||
tree.feed(_guide_fragment(index_html))
|
||||
root = tree.root.children[0]
|
||||
assert isinstance(root, Node)
|
||||
|
||||
blocks: list[str] = []
|
||||
for section in root.find_all("section"):
|
||||
_html_block(section, blocks, loc)
|
||||
return "\n".join(blocks)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PDF (WeasyPrint, repli reportlab)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_guide_pdf(lang: str = "fr") -> bytes:
|
||||
lang_norm = lang if lang in ("fr", "en") else "fr"
|
||||
title = _guide_title_fr if lang_norm == "fr" else _guide_title_en
|
||||
loc = _locale_strings(lang_norm)
|
||||
note = loc.get("guide105.arch_diagram_note", "")
|
||||
footer = _export_footer(lang_norm)
|
||||
try:
|
||||
from backend.pdf_export import build_pdf_html, generate_pdf
|
||||
|
||||
body = build_guide_html(lang_norm)
|
||||
body += (
|
||||
f"<hr><p style='color:#777;font-size:11px'>{html.escape(note, quote=False)} — {html.escape(footer, quote=False)}</p>"
|
||||
)
|
||||
return generate_pdf(build_pdf_html(body, title), title)
|
||||
except Exception as e: # WeasyPrint lève à l'import OU au rendu (GTK absent)
|
||||
logger.warning("WeasyPrint indisponible pour le guide PDF (%s) — repli reportlab", e)
|
||||
md = build_guide_markdown(lang_norm).decode("utf-8")
|
||||
from backend.tools.documents import _render_reportlab_pdf
|
||||
|
||||
return _render_reportlab_pdf(md, title)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Point d'entrée + cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_guide_document(fmt: str, lang: str) -> tuple[bytes, str, str]:
|
||||
"""Retourne (octets, media_type, filename) pour le format demandé.
|
||||
|
||||
``fmt`` : ``md`` | ``pdf``. Résultat mis en cache tant que index.html et
|
||||
fr.json ne changent pas (les locales en ne divergent jamais sur les
|
||||
structures ; la signature couvre l'essentiel).
|
||||
"""
|
||||
fmt = "pdf" if fmt == "pdf" else "md"
|
||||
lang = "en" if lang == "en" else "fr"
|
||||
key = (fmt, lang)
|
||||
sig = _signature()
|
||||
hit = _cache.get(key)
|
||||
if hit and hit[0] == sig:
|
||||
payload = hit[1]
|
||||
else:
|
||||
payload = build_guide_pdf(lang) if fmt == "pdf" else build_guide_markdown(lang)
|
||||
_cache[key] = (sig, payload)
|
||||
fname = f"ObsiGate-Guide-{_app_version()}-{lang}.{fmt}"
|
||||
media = "application/pdf" if fmt == "pdf" else "text/markdown; charset=utf-8"
|
||||
return payload, media, fname
|
||||
@@ -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,19 +503,69 @@ 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, extract_pdf_text
|
||||
raw = extract_pdf_text(fpath, max_chars=100000)
|
||||
from backend.pdf_reader import extract_pdf_metadata
|
||||
# BUG-040: only the (cheap) metadata is read during the
|
||||
# scan. Full-text extraction is deferred to a background
|
||||
# pass (``enrich_pdf_texts``) so a vault with many/large
|
||||
# PDFs no longer blocks startup and index rebuilds.
|
||||
pdf_meta = extract_pdf_metadata(fpath)
|
||||
title = pdf_meta.get("title") or fpath.stem.replace("-", " ").replace("_", " ")
|
||||
content_preview = raw[:200].strip()
|
||||
raw = ""
|
||||
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("_", " ")
|
||||
@@ -510,7 +584,7 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
|
||||
title, post.content
|
||||
)
|
||||
|
||||
files.append({
|
||||
file_info = {
|
||||
"path": str(relative).replace("\\", "/"),
|
||||
"title": title,
|
||||
"tags": tags,
|
||||
@@ -519,7 +593,12 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
|
||||
"size": stat.st_size,
|
||||
"modified": modified,
|
||||
"extension": ext,
|
||||
})
|
||||
}
|
||||
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:
|
||||
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
||||
@@ -531,8 +610,89 @@ 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 deferred during the scan: PDFs (BUG-040) + excalidraw (#86).
|
||||
|
||||
``_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 files (PDF + excalidraw) whose text extraction was
|
||||
attempted.
|
||||
"""
|
||||
from backend.pdf_reader import extract_pdf_text
|
||||
|
||||
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:
|
||||
continue
|
||||
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"], "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, kind in pending:
|
||||
try:
|
||||
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("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 deferred enrichment for %s: %s", file_path, exc
|
||||
)
|
||||
|
||||
logger.info("Deferred text enrichment: extracted text for %d file(s)", enriched)
|
||||
return enriched
|
||||
|
||||
|
||||
async def build_index(progress_callback=None) -> None:
|
||||
@@ -540,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()
|
||||
@@ -568,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
|
||||
@@ -632,6 +805,8 @@ async def reload_index() -> dict[str, Any]:
|
||||
Dict mapping vault names to their file/tag counts.
|
||||
"""
|
||||
await build_index()
|
||||
# BUG-040/#86: complete the deferred PDF + excalidraw extraction.
|
||||
await enrich_pdf_texts()
|
||||
stats = {}
|
||||
for name, data in index.items():
|
||||
stats[name] = {"file_count": len(data["files"]), "tag_count": len(data["tags"])}
|
||||
@@ -659,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
|
||||
@@ -695,7 +878,10 @@ async def reload_single_vault(vault_name: str) -> dict[str, Any]:
|
||||
# Rebuild attachment index for this vault only
|
||||
from backend.attachment_indexer import build_attachment_index
|
||||
await build_attachment_index({vault_name: config})
|
||||
|
||||
|
||||
# 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"])}
|
||||
logger.info(f"Vault '{vault_name}' reindexed: {stats['file_count']} files, {stats['tag_count']} tags")
|
||||
return stats
|
||||
@@ -764,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()
|
||||
|
||||
@@ -2,7 +2,6 @@ import asyncio
|
||||
import html as html_mod
|
||||
import json as _json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
@@ -16,6 +15,7 @@ from datetime import datetime, timezone
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import frontmatter
|
||||
import mistune
|
||||
@@ -52,6 +52,8 @@ from backend.indexer import (
|
||||
remove_vault_from_index,
|
||||
update_single_file,
|
||||
)
|
||||
from backend.media_thumbs import generate_thumbnail, is_decodable
|
||||
from backend.media_types import IMAGE_EXTENSIONS, is_audio, is_image, is_video, media_mime_type
|
||||
from backend.openapi_docs import (
|
||||
API_DESCRIPTION,
|
||||
TAGS_METADATA,
|
||||
@@ -123,6 +125,9 @@ from backend.services.mutations import (
|
||||
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,
|
||||
)
|
||||
@@ -203,7 +208,16 @@ class FileContentResponse(BaseModel):
|
||||
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)")
|
||||
@@ -384,19 +398,6 @@ class ReloadResponse(BaseModel):
|
||||
vaults: dict[str, Any] = Field(description="Per-vault file counts after reload")
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Application health status."""
|
||||
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")
|
||||
|
||||
|
||||
class DirectoryCreateRequest(BaseModel):
|
||||
"""Request to create a new directory."""
|
||||
path: str = Field(description="Relative path of the new directory")
|
||||
@@ -699,20 +700,23 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
|
||||
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
|
||||
"worker-src 'self' blob:; "
|
||||
"frame-src 'self' blob:; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'; "
|
||||
"frame-ancestors 'self';"
|
||||
)
|
||||
# A route may set a stricter per-response policy (e.g. ``sandbox`` for
|
||||
# standalone SVG, #108-B3); keep it instead of overwriting it.
|
||||
if "Content-Security-Policy" not in response.headers:
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
|
||||
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
|
||||
"worker-src 'self' blob:; "
|
||||
"frame-src 'self' blob:; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'; "
|
||||
"frame-ancestors 'self';"
|
||||
)
|
||||
# Static assets are NOT content-hashed, so they must revalidate:
|
||||
# ``immutable``/long max-age made Cloudflare and mobile browsers serve
|
||||
# a stale build for a year (the service worker cache compounded it).
|
||||
@@ -722,12 +726,56 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
return response
|
||||
|
||||
|
||||
def _guard_insecure_auth() -> None:
|
||||
"""Warn or refuse to start when authentication is disabled (BUG-037).
|
||||
|
||||
With ``OBSIGATE_AUTH_ENABLED=false`` every request is served as an
|
||||
anonymous admin. That is convenient for local use but dangerous when the
|
||||
process is reachable from a network. Binding to a non-loopback host
|
||||
without the explicit ``OBSIGATE_ALLOW_INSECURE=true`` opt-in is refused.
|
||||
"""
|
||||
from backend.auth.middleware import (
|
||||
bind_host_from_argv,
|
||||
is_auth_enabled,
|
||||
is_insecure_mode_allowed,
|
||||
is_loopback_host,
|
||||
)
|
||||
|
||||
if is_auth_enabled():
|
||||
return
|
||||
|
||||
if is_insecure_mode_allowed():
|
||||
logger.warning(
|
||||
"Authentication is DISABLED and OBSIGATE_ALLOW_INSECURE=true: every request "
|
||||
"is treated as an anonymous administrator. Do not expose this instance."
|
||||
)
|
||||
return
|
||||
|
||||
host = bind_host_from_argv()
|
||||
if not is_loopback_host(host):
|
||||
raise RuntimeError(
|
||||
"Refusing to start: authentication is disabled (OBSIGATE_AUTH_ENABLED=false) "
|
||||
f"while binding to a non-loopback address ('{host}'). This would expose an "
|
||||
"unauthenticated instance with admin access. Enable authentication, or set "
|
||||
"OBSIGATE_ALLOW_INSECURE=true if you really know what you are doing."
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"Authentication is DISABLED (OBSIGATE_AUTH_ENABLED=false): every request is "
|
||||
"treated as an anonymous administrator. This is only safe on a trusted, "
|
||||
"loopback-only deployment."
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan: build index on startup, cleanup on shutdown."""
|
||||
global _search_executor, _vault_watcher
|
||||
_search_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="search")
|
||||
|
||||
|
||||
# BUG-037: refuse to expose an unauthenticated instance on a public bind.
|
||||
_guard_insecure_auth()
|
||||
|
||||
# Bootstrap admin account if needed
|
||||
bootstrap_admin()
|
||||
|
||||
@@ -748,6 +796,11 @@ async def lifespan(app: FastAPI):
|
||||
# Build the semantic (embedding) index in the same background thread pool.
|
||||
await loop.run_in_executor(_search_executor, init_semantic_index)
|
||||
|
||||
# BUG-040: extract the PDF text deferred during the scan now that the
|
||||
# index and inverted index are queryable (keeps startup non-blocking).
|
||||
from backend.indexer import enrich_pdf_texts
|
||||
await enrich_pdf_texts()
|
||||
|
||||
# Scan for plugins in all vaults
|
||||
logger.info("Scanning for plugins...")
|
||||
from backend.indexer import vault_config
|
||||
@@ -801,7 +854,7 @@ async def lifespan(app: FastAPI):
|
||||
_search_executor = None
|
||||
|
||||
|
||||
from backend.version import get_git_commit, get_git_describe, get_version
|
||||
from backend.version import get_version
|
||||
|
||||
app = FastAPI(
|
||||
title="ObsiGate API",
|
||||
@@ -893,6 +946,7 @@ except Exception: # pragma: no cover - WeasyPrint/GTK missing
|
||||
from backend.ai_routes import router as ai_router
|
||||
from backend.bookslm_routes import router as bookslm_router
|
||||
from backend.export import ExportError, export_epub, export_html, export_md_bundle
|
||||
from backend.routers.health import router as health_router
|
||||
from backend.saved_searches import delete_saved, get_saved, save_search
|
||||
from backend.share import (
|
||||
create_share,
|
||||
@@ -915,6 +969,7 @@ app.include_router(auth_router)
|
||||
app.include_router(ai_router)
|
||||
app.include_router(bookslm_router)
|
||||
app.include_router(skills_router)
|
||||
app.include_router(health_router) # ROADMAP #85 T1 — System / health
|
||||
|
||||
# Admin Dashboard endpoints (system stats, audit logs, backups, stream)
|
||||
try:
|
||||
@@ -983,6 +1038,27 @@ def _content_disposition(disposition: str, filename: str) -> str:
|
||||
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 _resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
|
||||
"""Resolve a relative path safely within the vault root.
|
||||
|
||||
@@ -1219,125 +1295,9 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | Non
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API Endpoints
|
||||
# API Endpoints — System / health : voir backend.routers.health (#85 T1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.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
|
||||
uptime = int(time.time() - _SERVER_START_TIME) if '_SERVER_START_TIME' in globals() else 0
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": app.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(),
|
||||
}
|
||||
|
||||
|
||||
@app.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
|
||||
|
||||
# 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["tags"]),
|
||||
"token_count_approx": len(data.get("files", [])) * 1000,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": app.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,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/vaults", response_model=list[VaultInfo])
|
||||
async def api_vaults(current_user=Depends(require_auth)):
|
||||
@@ -1698,6 +1658,37 @@ def _safe_export_name(name: str) -> str:
|
||||
return cleaned or "document"
|
||||
|
||||
|
||||
@app.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}"'},
|
||||
)
|
||||
|
||||
|
||||
@app.put("/api/file/{vault_name}/save", response_model=FileSaveResponse)
|
||||
async def api_file_save(
|
||||
vault_name: str,
|
||||
@@ -1732,6 +1723,41 @@ async def api_file_save(
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
||||
|
||||
|
||||
@app.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"]}
|
||||
|
||||
|
||||
@app.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.
|
||||
@@ -2328,20 +2354,45 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
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 ===
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"}
|
||||
if ext in IMAGE_EXTENSIONS:
|
||||
if is_image(ext):
|
||||
size = file_path.stat().st_size
|
||||
mime_map = {
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp",
|
||||
".bmp": "image/bmp", ".ico": "image/x-icon",
|
||||
}
|
||||
mime = mime_map.get(ext, "application/octet-stream")
|
||||
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="/api/file/{vault_name}/raw?path={path}" '
|
||||
f'alt="{file_path.name}" style="max-width:100%;max-height:80vh;object-fit:contain" />'
|
||||
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 {
|
||||
@@ -2359,6 +2410,61 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
"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:
|
||||
@@ -2534,32 +2640,21 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
}
|
||||
|
||||
|
||||
@app.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.
|
||||
def _stream_file_with_range(file_path: Path, request: Request, media_type: str):
|
||||
"""Return a file response honouring the HTTP ``Range`` header (roadmap #109).
|
||||
|
||||
Supports HTTP Range requests (206 Partial Content) so browsers can
|
||||
progressively render large PDFs in the native viewer.
|
||||
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.
|
||||
"""
|
||||
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")
|
||||
|
||||
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)
|
||||
@@ -2587,7 +2682,6 @@ async def api_pdf_stream(
|
||||
chunk_size = end - start + 1
|
||||
|
||||
async def _partial():
|
||||
# Open + reads offloaded to threads (avoid blocking the event loop — ASYNC230)
|
||||
f = await asyncio.to_thread(open, str(file_path), "rb")
|
||||
try:
|
||||
await asyncio.to_thread(f.seek, start)
|
||||
@@ -2604,18 +2698,45 @@ async def api_pdf_stream(
|
||||
return StreamingResponse(
|
||||
_partial(),
|
||||
status_code=206,
|
||||
media_type="application/pdf",
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(chunk_size),
|
||||
"Content-Disposition": _content_disposition("inline", file_path.name),
|
||||
"Content-Disposition": disposition,
|
||||
},
|
||||
)
|
||||
|
||||
return FileResponse(str(file_path), media_type="application/pdf", headers={
|
||||
return FileResponse(str(file_path), media_type=media_type, headers={
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Disposition": _content_disposition("inline", file_path.name)})
|
||||
"Content-Disposition": disposition})
|
||||
|
||||
|
||||
@app.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")
|
||||
|
||||
|
||||
@app.get("/api/file/{vault_name}/pdf/info", response_model=PdfInfoResponse)
|
||||
@@ -3103,16 +3224,19 @@ async def api_image(vault_name: str, path: str = Query(..., description="Relativ
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"Image not found: {path}")
|
||||
|
||||
# Determine MIME type
|
||||
mime_type, _ = mimetypes.guess_type(str(file_path))
|
||||
if not mime_type:
|
||||
# Default to octet-stream if unknown
|
||||
mime_type = "application/octet-stream"
|
||||
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)
|
||||
return Response(content=content, media_type=mime_type, headers=headers)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="Permission denied")
|
||||
except Exception as e:
|
||||
@@ -3120,6 +3244,91 @@ async def api_image(vault_name: str, path: str = Query(..., description="Relativ
|
||||
raise HTTPException(status_code=500, detail=f"Error serving image: {e!s}")
|
||||
|
||||
|
||||
@app.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)))
|
||||
|
||||
|
||||
@app.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)
|
||||
|
||||
|
||||
@app.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.
|
||||
@@ -3679,6 +3888,68 @@ async def api_delete_ai_key(provider_env: str, current_user=Depends(require_admi
|
||||
return {"status": "deleted", "key": key_name}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool & connected-source keys (#103) — same store as the AI provider keys
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@app.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
|
||||
|
||||
|
||||
@app.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"}
|
||||
|
||||
|
||||
@app.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}
|
||||
|
||||
|
||||
@app.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.
|
||||
@@ -3901,7 +4172,9 @@ async def api_diagnostics(current_user=Depends(require_admin)):
|
||||
vault_stats = {}
|
||||
total_files = 0
|
||||
total_tags = 0
|
||||
for vname, vdata in index.items():
|
||||
# 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}
|
||||
@@ -3909,7 +4182,8 @@ async def api_diagnostics(current_user=Depends(require_admin)):
|
||||
total_tags += tag_count
|
||||
|
||||
# Memory estimate for inverted index
|
||||
word_index_entries = sum(len(docs) for docs in inv.word_index.values())
|
||||
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
|
||||
@@ -3923,7 +4197,7 @@ async def api_diagnostics(current_user=Depends(require_admin)):
|
||||
"vaults": vault_stats,
|
||||
},
|
||||
"inverted_index": {
|
||||
"unique_tokens": len(inv.word_index),
|
||||
"unique_tokens": len(word_index),
|
||||
"total_postings": word_index_entries,
|
||||
"documents": inv.doc_count,
|
||||
"sorted_tokens": len(inv._sorted_tokens),
|
||||
@@ -3950,6 +4224,7 @@ async def api_dashboard(current_user=Depends(require_auth)):
|
||||
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
|
||||
@@ -3958,13 +4233,26 @@ async def api_dashboard(current_user=Depends(require_auth)):
|
||||
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
|
||||
vault_stats.append({"name": vname, "file_count": fc, "tag_count": len(vtags), "total_size_bytes": vsize})
|
||||
return {"vaults": vault_stats, "total_files": total_files, "total_tags": len(total_tags), "total_size_bytes": total_size}
|
||||
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,74 @@
|
||||
"""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"
|
||||
key = hashlib.sha1(f"{file_path}:{stamp}:{size}".encode()).hexdigest()
|
||||
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"
|
||||
@@ -30,6 +30,7 @@ TAGS_METADATA: list[dict[str, str]] = [
|
||||
{"name": "Bookmarks", "description": "Recently opened files, bookmarks and saved searches."},
|
||||
{"name": "Backups", "description": "Automatic file backups, diffs, restore, compression and purge."},
|
||||
{"name": "Export", "description": "Export notes or whole vaults to HTML, Markdown bundle or ePub."},
|
||||
{"name": "Guide", "description": "Download the in-app user guide as Markdown or PDF (mirrors the help modal, FR/EN)."},
|
||||
{"name": "AI", "description": "AI-powered editor actions, provider status and model discovery."},
|
||||
{"name": "BooksLM", "description": "Directory-scoped AI chat (NotebookLM-style) over a vault folder."},
|
||||
{"name": "MCP", "description": "Model Context Protocol server (Streamable HTTP) exposing the shared AI tool layer to external clients (Claude Desktop, Cursor…)."},
|
||||
@@ -98,6 +99,7 @@ _TAG_RULES: list[tuple[re.Pattern[str], str]] = [
|
||||
(re.compile(r"^/api/backups"), "Backups"),
|
||||
(re.compile(r"^/api/file/[^/]+/(backups|diff|restore)"), "Backups"),
|
||||
(re.compile(r"^/api/export"), "Export"),
|
||||
(re.compile(r"^/api/guide"), "Guide"),
|
||||
(re.compile(r"^/api/file/[^/]+/pdf"), "PDF"),
|
||||
(re.compile(r"^/api/search"), "Search"),
|
||||
(re.compile(r"^/api/tags"), "Search"),
|
||||
@@ -179,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;
|
||||
|
||||
@@ -15,8 +15,13 @@ 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
|
||||
mcp==1.9.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,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,
|
||||
}
|
||||
@@ -395,6 +395,7 @@ class DashboardVaultStat(BaseModel):
|
||||
file_count: int
|
||||
tag_count: int
|
||||
total_size_bytes: int
|
||||
image_count: int = 0
|
||||
|
||||
|
||||
class DashboardResponse(BaseModel):
|
||||
@@ -404,6 +405,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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -34,7 +34,7 @@ _PATTERNS = [
|
||||
(re.compile(r'(?:api[_-]?key|apikey|secret|token|password|passwd|auth[_-]?token)\s*[:=]\s*[\'"]?([^\s\'"]{20,})[\'"]?', re.IGNORECASE),
|
||||
lambda m: f'{m.group(0).split("=")[0].split(":")[0]}=[MASQUÉ]' if "=" in m.group(0) or ":" in m.group(0) else '[MASQUÉ]'),
|
||||
|
||||
# Generic long hex/base64 strings that look like secrets (40+ chars)
|
||||
# Prefixed API keys (sk-..., pk-..., rk-...)
|
||||
(re.compile(r'(?:sk|pk|rk)-[a-zA-Z0-9]{20,}'), '[CLÉ API MASQUÉE]'),
|
||||
|
||||
# AWS access keys
|
||||
@@ -43,10 +43,50 @@ _PATTERNS = [
|
||||
# GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_)
|
||||
(re.compile(r'gh[pousr]_[a-zA-Z0-9]{36,}'), '[GITHUB_TOKEN MASQUÉ]'),
|
||||
|
||||
# Generic long random-looking strings (40+ hex chars)
|
||||
(re.compile(r'\b[a-fA-F0-9]{40,64}\b'), '[HEX_KEY MASQUÉ]'),
|
||||
]
|
||||
|
||||
# BUG-035: bare 40–64 char hex strings used to be redacted unconditionally,
|
||||
# which mangled legitimate git commit SHAs, checksums and hashes in notes.
|
||||
# They are now only redacted when a secret-ish keyword sits in the immediate
|
||||
# context; hash/commit keywords explicitly exempt them.
|
||||
_HEX_RE = re.compile(r'\b[a-fA-F0-9]{40,64}\b')
|
||||
_SECRET_CONTEXT_RE = re.compile(
|
||||
r'(?i)\b(?:secret|token|key|apikey|api[_-]?key|password|passwd|auth|bearer|'
|
||||
r'credential|x-api-key|x-auth-token)\b'
|
||||
)
|
||||
_HASH_CONTEXT_RE = re.compile(
|
||||
r'(?i)\b(?:commit|sha\d*|hash|md5|blob|git|checksum|digest|integrity|'
|
||||
r'revision|rev|etag|fingerprint)\b'
|
||||
)
|
||||
#: How far before the hex string a keyword may appear to count as context.
|
||||
_HEX_CONTEXT_WINDOW = 60
|
||||
|
||||
|
||||
def _redact_bare_hex_secrets(text: str) -> tuple:
|
||||
"""Redact 40–64 char hex strings only when a secret keyword is nearby.
|
||||
|
||||
Git/SHA/checksum contexts are left untouched (BUG-035).
|
||||
|
||||
Args:
|
||||
text: Text to scan.
|
||||
|
||||
Returns:
|
||||
(redacted_text, redaction_count) tuple.
|
||||
"""
|
||||
count = 0
|
||||
|
||||
def _replace(match: re.Match) -> str:
|
||||
nonlocal count
|
||||
window = text[max(0, match.start() - _HEX_CONTEXT_WINDOW):match.start()]
|
||||
if _HASH_CONTEXT_RE.search(window):
|
||||
return match.group(0)
|
||||
if _SECRET_CONTEXT_RE.search(window):
|
||||
count += 1
|
||||
return '[HEX_KEY MASQUÉ]'
|
||||
return match.group(0)
|
||||
|
||||
return _HEX_RE.sub(_replace, text), count
|
||||
|
||||
|
||||
def redact(text: str) -> tuple:
|
||||
"""Redact sensitive patterns from text.
|
||||
@@ -66,6 +106,8 @@ def redact(text: str) -> tuple:
|
||||
new_result, n = pattern.subn(str(replacement), result)
|
||||
count += n
|
||||
result = new_result
|
||||
result, hex_count = _redact_bare_hex_secrets(result)
|
||||
count += hex_count
|
||||
if count > 0:
|
||||
logger.info(f"Redacted {count} secret(s) from content")
|
||||
return result, count
|
||||
|
||||
@@ -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":[],'
|
||||
@@ -44,7 +50,7 @@ def _ensure_writable(root: Path) -> None:
|
||||
raise ServiceError("Vault is read-only", code="read_only", status=403)
|
||||
|
||||
|
||||
def _validate_extension(file_path: Path, *, allow_images: bool = False) -> None:
|
||||
def _validate_extension(file_path: Path, *, allow_images: bool = False, allow_docs: bool = False) -> None:
|
||||
"""Reject unsupported file extensions (400)."""
|
||||
from backend.indexer import SUPPORTED_EXTENSIONS
|
||||
|
||||
@@ -53,6 +59,9 @@ def _validate_extension(file_path: Path, *, allow_images: bool = False) -> None:
|
||||
if allow_images:
|
||||
from backend.attachment_indexer import IMAGE_EXTENSIONS
|
||||
allowed = allowed | IMAGE_EXTENSIONS
|
||||
if allow_docs:
|
||||
# Office documents produced by the AI tool layer (#92).
|
||||
allowed = allowed | {".xlsx", ".docx"}
|
||||
|
||||
if ext not in allowed and file_path.name.lower() not in ("dockerfile", "makefile"):
|
||||
raise ServiceError(
|
||||
@@ -215,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,
|
||||
@@ -506,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:
|
||||
@@ -715,17 +830,20 @@ def save_raw_file(
|
||||
content: bytes,
|
||||
*,
|
||||
overwrite: bool = True,
|
||||
allow_docs: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Save a binary or text file to a vault (e.g. from upload / drag-and-drop).
|
||||
|
||||
Creates parent directories automatically and safely validates the path.
|
||||
Supports supported text extensions, images and Excalidraw files.
|
||||
Supports supported text extensions, images, Excalidraw files and — with
|
||||
``allow_docs`` — Office documents (.xlsx/.docx) produced by the AI tools.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Vault-relative path.
|
||||
content: Raw bytes to write.
|
||||
overwrite: When True, replace existing files (with backup).
|
||||
allow_docs: Also accept .xlsx/.docx extensions (AI document tools).
|
||||
|
||||
Returns:
|
||||
Dict with ``success``, ``vault``, ``path``, and ``size``.
|
||||
@@ -733,7 +851,7 @@ def save_raw_file(
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
_validate_extension(file_path, allow_images=True)
|
||||
_validate_extension(file_path, allow_images=True, allow_docs=allow_docs)
|
||||
|
||||
rel_path = _rel(root, file_path)
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ Note: ObsiGate uses implicit namespace packages (no tracked ``__init__.py``,
|
||||
which ``.gitignore`` excludes via ``_*.py``), hence this explicit facade.
|
||||
"""
|
||||
|
||||
from backend.tools import connected as _connected # noqa: F401 (registers connected-source tools)
|
||||
from backend.tools import crawler as _crawler # noqa: F401 (registers the site crawler)
|
||||
from backend.tools import documents as _documents # noqa: F401 (registers document tools)
|
||||
from backend.tools import service as _service # noqa: F401 (registers tools)
|
||||
from backend.tools import web as _web # noqa: F401 (registers web tools)
|
||||
from backend.tools.context import (
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Connected sources — Gitea & GitHub repositories (phase 2 #92).
|
||||
|
||||
The assistant can query the source-hosting platforms the project actually
|
||||
uses (ObsiGate is hosted on Gitea): repositories, issues/pull requests and
|
||||
repository files. Everything is READ-risk, rate-limited through the shared
|
||||
registry and audited.
|
||||
|
||||
Configuration (environment — injected by Infisical in production, never
|
||||
hard-coded):
|
||||
|
||||
* ``OBSIGATE_GITEA_URL`` — base URL of the self-hosted instance (e.g.
|
||||
``https://git.example.net``); the ``gitea`` provider is only available when
|
||||
this variable is set. Admin-controlled, so the SSRF guard does not apply
|
||||
(unlike user-supplied URLs). Both the URL and the tokens can also be set
|
||||
from the configuration page (stored in ``data/api_keys.json``, #103) —
|
||||
the stored value takes precedence over the environment.
|
||||
* ``OBSIGATE_GITEA_TOKEN`` — optional personal access token (private repos).
|
||||
* ``OBSIGATE_GITHUB_TOKEN`` — optional token (raises the API rate limits and
|
||||
unlocks private repositories).
|
||||
|
||||
Cloud drives (Google Drive / OneDrive) deliberately stay out of the core:
|
||||
per the documented roadmap they are best served by an *external MCP server*
|
||||
(#79) so the OAuth surface remains outside ObsiGate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.tools.context import ToolError, ToolRisk
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import GitGetFileInput, GitProviderInput, GitSearchIssuesInput
|
||||
from backend.tools.secrets import get_tool_key
|
||||
|
||||
logger = logging.getLogger("obsigate.tools.connected")
|
||||
|
||||
TIMEOUT = 10.0
|
||||
USER_AGENT = "ObsiGateAssistant/1.0 (+self-hosted vault AI)"
|
||||
MAX_FILE_BYTES = 300_000
|
||||
GITHUB_API = "https://api.github.com"
|
||||
|
||||
|
||||
def _provider_base(provider: str) -> tuple[str, str]:
|
||||
"""Return (base_url, auth_header_value) for the requested provider."""
|
||||
if provider == "gitea":
|
||||
base = get_tool_key("OBSIGATE_GITEA_URL").rstrip("/")
|
||||
if not base:
|
||||
raise ToolError(
|
||||
"Source Gitea non configurée (OBSIGATE_GITEA_URL absente).",
|
||||
code="provider_not_configured",
|
||||
)
|
||||
token = get_tool_key("OBSIGATE_GITEA_TOKEN")
|
||||
return base, f"token {token}" if token else ""
|
||||
if provider == "github":
|
||||
token = get_tool_key("OBSIGATE_GITHUB_TOKEN")
|
||||
return GITHUB_API, f"Bearer {token}" if token else ""
|
||||
raise ToolError(
|
||||
f"Fournisseur inconnu : {provider} ('gitea' ou 'github')",
|
||||
code="invalid_arguments",
|
||||
)
|
||||
|
||||
|
||||
def _headers(auth: str) -> dict[str, str]:
|
||||
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
||||
if auth:
|
||||
headers["Authorization"] = auth
|
||||
return headers
|
||||
|
||||
|
||||
def _request(method: str, url: str, auth: str, **kwargs: Any) -> httpx.Response:
|
||||
try:
|
||||
resp = httpx.request(
|
||||
method, url, headers=_headers(auth), timeout=TIMEOUT, follow_redirects=False,
|
||||
**kwargs,
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("connected source request failed %s: %s", url, e)
|
||||
raise ToolError(
|
||||
"Source connectée momentanément indisponible.",
|
||||
code="connected_source_unavailable",
|
||||
) from e
|
||||
if resp.status_code in (401, 403):
|
||||
raise ToolError(
|
||||
"Accès refusé par la source connectée (jeton manquant ou expiré).",
|
||||
code="permission_denied",
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
raise ToolError("Ressource introuvable sur la source connectée.", code="not_found")
|
||||
resp.raise_for_status()
|
||||
return resp
|
||||
|
||||
|
||||
def _normalize_repo(item: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"name": item.get("name") or "",
|
||||
"full_name": item.get("full_name") or "",
|
||||
"url": item.get("html_url") or item.get("clone_url") or "",
|
||||
"description": item.get("description") or "",
|
||||
"updated": item.get("updated_at") or "",
|
||||
"private": bool(item.get("private", False)),
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="git_list_repos",
|
||||
description=(
|
||||
"List repositories on the connected Gitea instance or GitHub account "
|
||||
"(name, url, description, last update). Use when the user asks about "
|
||||
"their code projects."
|
||||
),
|
||||
input_model=GitProviderInput,
|
||||
risk=ToolRisk.READ,
|
||||
)
|
||||
def git_list_repos(ctx, params: GitProviderInput) -> dict[str, Any]:
|
||||
"""Query the configured source and return normalized repositories."""
|
||||
base, auth = _provider_base(params.provider)
|
||||
if params.provider == "gitea":
|
||||
url = base + "/api/v1/repos/search"
|
||||
query: dict[str, Any] = {"limit": params.limit}
|
||||
if params.repo:
|
||||
query["q"] = params.repo
|
||||
resp = _request("GET", url, auth, params=query)
|
||||
items = resp.json().get("data") or []
|
||||
else:
|
||||
if params.repo:
|
||||
url = GITHUB_API + f"/repos/{params.repo.strip('/')}"
|
||||
items = [_request("GET", url, auth).json()]
|
||||
else:
|
||||
resp = _request(
|
||||
"GET", GITHUB_API + "/user/repos",
|
||||
auth, params={"per_page": params.limit, "sort": "updated"},
|
||||
)
|
||||
items = resp.json()
|
||||
repos = [_normalize_repo(item) for item in items if isinstance(item, dict)]
|
||||
return {"provider": params.provider, "count": len(repos), "repos": repos}
|
||||
|
||||
|
||||
@tool(
|
||||
name="git_search_issues",
|
||||
description=(
|
||||
"Search issues and pull requests on the connected Gitea instance or "
|
||||
"GitHub (title/body keywords, optional repository scope, open/closed)."
|
||||
),
|
||||
input_model=GitSearchIssuesInput,
|
||||
risk=ToolRisk.READ,
|
||||
)
|
||||
def git_search_issues(ctx, params: GitSearchIssuesInput) -> dict[str, Any]:
|
||||
"""Query issues (and PRs) from the configured source."""
|
||||
base, auth = _provider_base(params.provider)
|
||||
state = params.state if params.state in ("open", "closed") else "open"
|
||||
if params.provider == "gitea":
|
||||
if params.repo:
|
||||
url = base + f"/api/v1/repos/{params.repo.strip('/')}/issues"
|
||||
query: dict[str, Any] = {"state": state, "limit": params.limit, "q": params.query}
|
||||
resp = _request("GET", url, auth, params=query)
|
||||
items = resp.json()
|
||||
else:
|
||||
url = base + "/api/v1/repos/issues/search"
|
||||
resp = _request("GET", url, auth, params={
|
||||
"q": params.query, "state": state, "limit": params.limit,
|
||||
})
|
||||
items = resp.json()
|
||||
else:
|
||||
clause = f"{params.query} is:issue is:{state}"
|
||||
if params.repo:
|
||||
clause += f" repo:{params.repo.strip('/')}"
|
||||
resp = _request(
|
||||
"GET", GITHUB_API + "/search/issues", auth,
|
||||
params={"q": clause, "per_page": params.limit},
|
||||
)
|
||||
items = (resp.json().get("items") or [])
|
||||
issues = [
|
||||
{
|
||||
"id": item.get("number") or item.get("id") or "",
|
||||
"title": (item.get("title") or "")[:300],
|
||||
"url": item.get("html_url") or "",
|
||||
"state": item.get("state") or "",
|
||||
"pull_request": bool(item.get("pull_request")),
|
||||
}
|
||||
for item in (items if isinstance(items, list) else [])
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
return {
|
||||
"provider": params.provider,
|
||||
"query": params.query,
|
||||
"count": len(issues),
|
||||
"issues": issues,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="git_get_file",
|
||||
description=(
|
||||
"Read a file's content from a connected Gitea or GitHub repository "
|
||||
"(source code, docs, config). Text/JSON only, size-capped."
|
||||
),
|
||||
input_model=GitGetFileInput,
|
||||
risk=ToolRisk.READ,
|
||||
)
|
||||
def git_get_file(ctx, params: GitGetFileInput) -> dict[str, Any]:
|
||||
"""Fetch one repository file and return its decoded text content."""
|
||||
base, auth = _provider_base(params.provider)
|
||||
repo = params.repo.strip("/")
|
||||
path = params.path.strip("/")
|
||||
if not repo or not path:
|
||||
raise ToolError(
|
||||
"'repo' (owner/nom) et 'path' sont obligatoires", code="invalid_arguments"
|
||||
)
|
||||
if params.provider == "gitea":
|
||||
url = base + f"/api/v1/repos/{repo}/contents/{path}"
|
||||
else:
|
||||
url = GITHUB_API + f"/repos/{repo}/contents/{path}"
|
||||
if params.ref:
|
||||
url += f"?ref={params.ref}"
|
||||
resp = _request("GET", url, auth)
|
||||
data = resp.json()
|
||||
encoded = data.get("content") or ""
|
||||
if (data.get("encoding") or "") == "base64" and encoded:
|
||||
try:
|
||||
content = base64.b64decode(encoded).decode("utf-8", errors="replace")
|
||||
except (ValueError, binascii.Error) as e:
|
||||
raise ToolError(
|
||||
"Contenu du fichier illisible (encodage inattendu).",
|
||||
code="file_decode_error",
|
||||
) from e
|
||||
else:
|
||||
content = encoded
|
||||
truncated = len(content) > MAX_FILE_BYTES
|
||||
return {
|
||||
"provider": params.provider,
|
||||
"repo": repo,
|
||||
"path": data.get("path") or path,
|
||||
"size": data.get("size") or len(content),
|
||||
"content": content[:MAX_FILE_BYTES],
|
||||
"truncated": truncated,
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Multi-page site crawl — ``crawl_site`` (phase 2 #92, WRITE + confirmation).
|
||||
|
||||
The assistant can digest a small public site (documentation, docs portal) and
|
||||
store a Markdown summary inside a vault: one section per page, title, URL and
|
||||
readable text. The crawl is bounded and same-host only:
|
||||
|
||||
* max 20 pages (``max_pages``), same hostname, breadth-first from the entry URL;
|
||||
* SSRF guard on every URL (scheme + private-address rejection), size caps;
|
||||
* no third-party crawler dependency (scrapy deliberately avoided — a bounded
|
||||
httpx BFS keeps the surface small and the runtime predictable; the task is
|
||||
executed as a single background-style tool run instead of a web request
|
||||
pipeline).
|
||||
|
||||
Risk is WRITE: the digest is written into a vault, so the two-step
|
||||
confirmation applies (Apply card in the UI, propose/apply over MCP).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.tools.context import ToolContext, ToolError, ToolRisk
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import CrawlSiteInput
|
||||
from backend.tools.web import (
|
||||
USER_AGENT,
|
||||
_assert_public_http_url,
|
||||
_html_to_text,
|
||||
_response_text,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("obsigate.tools.crawler")
|
||||
|
||||
MAX_PAGE_BYTES = 800_000
|
||||
MAX_TOTAL_BYTES = 6_000_000
|
||||
MAX_TEXT_PER_PAGE = 12_000
|
||||
PAGE_TIMEOUT = 10.0
|
||||
_LINK_RE = re.compile(r'<a[^>]*href="([^"#]+)"', re.IGNORECASE)
|
||||
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
||||
|
||||
|
||||
def _same_host(url: str, host: str) -> bool:
|
||||
return (urlparse(url).hostname or "") == host
|
||||
|
||||
|
||||
def _extract_links(raw: str, base_url: str) -> list[str]:
|
||||
import html as html_lib
|
||||
|
||||
links: list[str] = []
|
||||
for match in _LINK_RE.finditer(raw):
|
||||
href = html_lib.unescape(match.group(1)).strip()
|
||||
if not href or href.lower().startswith(("javascript:", "mailto:", "tel:")):
|
||||
continue
|
||||
absolute = urljoin(base_url, href)
|
||||
if absolute.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".pdf", ".zip")):
|
||||
continue
|
||||
links.append(absolute.split("#", 1)[0])
|
||||
return links
|
||||
|
||||
|
||||
def _fetch_page(url: str) -> tuple[str, str]:
|
||||
"""Fetch one page (SSRF-guarded, manual redirects) → (title, text)."""
|
||||
current = _assert_public_http_url(url)
|
||||
resp = None
|
||||
for _hop in range(5):
|
||||
resp = httpx.get(
|
||||
current,
|
||||
headers={"User-Agent": USER_AGENT, "Accept": "text/html,*/*"},
|
||||
timeout=PAGE_TIMEOUT,
|
||||
follow_redirects=False,
|
||||
)
|
||||
if resp.status_code in (301, 302, 303, 307, 308):
|
||||
location = resp.headers.get("location") or ""
|
||||
if not location:
|
||||
break
|
||||
current = _assert_public_http_url(str(httpx.URL(current).join(location)))
|
||||
continue
|
||||
break
|
||||
assert resp is not None
|
||||
resp.raise_for_status()
|
||||
ctype = (resp.headers.get("content-type") or "").lower()
|
||||
if "html" not in ctype and "text" not in ctype:
|
||||
raise ToolError(
|
||||
f"Type de contenu non pris en charge: {ctype.split(';')[0] or 'inconnu'}",
|
||||
code="unsupported_content_type",
|
||||
)
|
||||
raw = (resp.content[:MAX_PAGE_BYTES]).decode(resp.encoding or "utf-8", errors="replace")
|
||||
title_match = _TITLE_RE.search(raw)
|
||||
import html as html_lib
|
||||
|
||||
title = html_lib.unescape(title_match.group(1)).strip()[:300] if title_match else ""
|
||||
return title, _html_to_text(raw)[:MAX_TEXT_PER_PAGE]
|
||||
|
||||
|
||||
@tool(
|
||||
name="crawl_site",
|
||||
description=(
|
||||
"Crawl a small public site (same-host only, max 20 pages) starting at "
|
||||
"a URL and save a Markdown digest (title, url, readable text per page) "
|
||||
"into a vault. Use to capture an online documentation for offline use."
|
||||
),
|
||||
input_model=CrawlSiteInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def crawl_site(ctx: ToolContext, params: CrawlSiteInput) -> dict[str, Any]:
|
||||
"""Bounded BFS crawl; writes the digest file and returns a summary."""
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.mutations import save_raw_file
|
||||
|
||||
start = _assert_public_http_url(params.url.strip())
|
||||
host = urlparse(start).hostname or ""
|
||||
if not host:
|
||||
raise ToolError("URL sans hôte", code="invalid_url")
|
||||
|
||||
queue: list[str] = [start]
|
||||
seen: set[str] = {start}
|
||||
pages: list[dict[str, Any]] = []
|
||||
total_bytes = 0
|
||||
failures: list[str] = []
|
||||
|
||||
while queue and len(pages) < params.max_pages and total_bytes < MAX_TOTAL_BYTES:
|
||||
url = queue.pop(0)
|
||||
try:
|
||||
title, text = _fetch_page(url)
|
||||
except ToolError as e:
|
||||
failures.append(url)
|
||||
logger.warning("crawl_site page failed %s: %s", url, e.code)
|
||||
continue
|
||||
except httpx.HTTPError as e:
|
||||
failures.append(url)
|
||||
logger.warning("crawl_site page failed %s: %s", url, e)
|
||||
continue
|
||||
pages.append({"url": url, "title": title, "text": text})
|
||||
total_bytes += len(text)
|
||||
if len(pages) >= params.max_pages:
|
||||
break
|
||||
try:
|
||||
raw_resp = httpx.get(
|
||||
url, headers={"User-Agent": USER_AGENT}, timeout=PAGE_TIMEOUT,
|
||||
follow_redirects=False,
|
||||
)
|
||||
raw = _response_text(raw_resp)
|
||||
except (httpx.HTTPError, ValueError):
|
||||
continue
|
||||
for link in _extract_links(raw, url):
|
||||
if len(pages) + len(queue) >= params.max_pages:
|
||||
break
|
||||
if link in seen or not _same_host(link, host):
|
||||
continue
|
||||
try:
|
||||
_assert_public_http_url(link)
|
||||
except ToolError:
|
||||
continue
|
||||
seen.add(link)
|
||||
queue.append(link)
|
||||
|
||||
if not pages:
|
||||
raise ToolError(
|
||||
"Aucune page n'a pu être récupérée pour ce site.",
|
||||
code="crawl_failed",
|
||||
)
|
||||
|
||||
lines = [
|
||||
f"# Crawl de {host}",
|
||||
"",
|
||||
f"> {len(pages)} page(s) capturée(s) depuis {start} — {time.strftime('%Y-%m-%d %H:%M')}",
|
||||
"",
|
||||
]
|
||||
for page in pages:
|
||||
lines.append(f"## {page['title'] or page['url']}")
|
||||
lines.append("")
|
||||
lines.append(f"Source : {page['url']}")
|
||||
lines.append("")
|
||||
lines.append(page["text"])
|
||||
lines.append("")
|
||||
digest = "\n".join(lines).encode("utf-8")
|
||||
try:
|
||||
saved = save_raw_file(
|
||||
params.vault, params.path, digest, overwrite=True, allow_docs=False
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise ToolError(e.message, code=e.code, details=e.details) from e
|
||||
return {
|
||||
"url": start,
|
||||
"vault": params.vault,
|
||||
"path": saved.get("path", params.path),
|
||||
"pages": len(pages),
|
||||
"failed": failures[:10],
|
||||
"size": saved.get("size", len(digest)),
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Document-production tools (phase 2 #92) — WRITE, confirmation required.
|
||||
|
||||
The assistant can generate real files inside a vault:
|
||||
|
||||
* ``create_xlsx`` — spreadsheet (openpyxl);
|
||||
* ``create_docx`` — Word document (python-docx);
|
||||
* ``create_csv`` — CSV (stdlib);
|
||||
* ``create_pdf`` — PDF (reportlab, from markdown-ish content).
|
||||
|
||||
Every tool is ``WRITE`` (two-step confirm in the UI / propose-apply over MCP),
|
||||
vault-scoped through ``requires_vault`` and saved via the shared mutation
|
||||
service (path safety, read-only check, backup on overwrite).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv as csv_lib
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
from xml.sax import saxutils
|
||||
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.mutations import save_raw_file
|
||||
from backend.tools.context import ToolContext, ToolError, ToolRisk
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import CsvInput, DocxInput, PdfInput, SpreadsheetInput
|
||||
|
||||
logger = logging.getLogger("obsigate.tools.documents")
|
||||
|
||||
MAX_PDF_CHARS = 200_000
|
||||
MAX_ROWS = 5_000
|
||||
|
||||
|
||||
def _save(vault: str, path: str, content: bytes, overwrite: bool) -> dict[str, Any]:
|
||||
"""Shared save helper (maps ServiceError to ToolError)."""
|
||||
try:
|
||||
return save_raw_file(vault, path, content, overwrite=overwrite, allow_docs=True)
|
||||
except ServiceError as e:
|
||||
raise ToolError(e.message, code=e.code, details=e.details) from e
|
||||
|
||||
|
||||
def _check_rows(rows: list[list[Any]]) -> None:
|
||||
if not rows:
|
||||
raise ToolError("Aucune ligne fournie", code="invalid_arguments")
|
||||
if len(rows) > MAX_ROWS:
|
||||
raise ToolError(
|
||||
f"Trop de lignes ({len(rows)} > {MAX_ROWS})", code="invalid_arguments"
|
||||
)
|
||||
|
||||
|
||||
def _check_extension(path: str, expected: str) -> str:
|
||||
"""Enforce the document extension; return the normalized path."""
|
||||
path = (path or "").strip()
|
||||
if not path.lower().endswith(expected):
|
||||
raise ToolError(
|
||||
f"Extension attendue : {expected}", code="invalid_arguments"
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
@tool(
|
||||
name="create_xlsx",
|
||||
description=(
|
||||
"Create an .xlsx spreadsheet in a vault from rows of cell values "
|
||||
"(first row = header). Use for tables, budgets, checklists the user "
|
||||
"asked to turn into an Excel file."
|
||||
),
|
||||
input_model=SpreadsheetInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def create_xlsx(ctx: ToolContext, params: SpreadsheetInput) -> dict[str, Any]:
|
||||
"""Build the workbook with openpyxl and save it into the vault."""
|
||||
from openpyxl import Workbook
|
||||
|
||||
_check_rows(params.rows)
|
||||
path = _check_extension(params.path, ".xlsx")
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = params.sheet_name[:31] or "Feuille1"
|
||||
for row in params.rows:
|
||||
ws.append(list(row))
|
||||
buffer = io.BytesIO()
|
||||
wb.save(buffer)
|
||||
return _save(params.vault, path, buffer.getvalue(), params.overwrite)
|
||||
|
||||
|
||||
@tool(
|
||||
name="create_docx",
|
||||
description=(
|
||||
"Create a .docx Word document in a vault from an optional title and "
|
||||
"ordered paragraphs. Use for letters, reports, structured drafts."
|
||||
),
|
||||
input_model=DocxInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def create_docx(ctx: ToolContext, params: DocxInput) -> dict[str, Any]:
|
||||
"""Build the document with python-docx and save it into the vault."""
|
||||
from docx import Document
|
||||
|
||||
if not params.paragraphs:
|
||||
raise ToolError("Aucun paragraphe fourni", code="invalid_arguments")
|
||||
path = _check_extension(params.path, ".docx")
|
||||
doc = Document()
|
||||
if params.title.strip():
|
||||
doc.add_heading(params.title.strip(), level=1)
|
||||
for paragraph in params.paragraphs:
|
||||
doc.add_paragraph(paragraph)
|
||||
buffer = io.BytesIO()
|
||||
doc.save(buffer)
|
||||
return _save(params.vault, path, buffer.getvalue(), params.overwrite)
|
||||
|
||||
|
||||
@tool(
|
||||
name="create_csv",
|
||||
description=(
|
||||
"Create a .csv file in a vault from rows of cell values (first row = "
|
||||
"header). Use for flat data exports, simple tables."
|
||||
),
|
||||
input_model=CsvInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def create_csv(ctx: ToolContext, params: CsvInput) -> dict[str, Any]:
|
||||
"""Serialize the rows and save the CSV into the vault."""
|
||||
_check_rows(params.rows)
|
||||
path = _check_extension(params.path, ".csv")
|
||||
delimiter = params.delimiter if params.delimiter in (",", ";", "\t") else ","
|
||||
buffer = io.StringIO()
|
||||
writer = csv_lib.writer(buffer, delimiter=delimiter, lineterminator="\n")
|
||||
writer.writerows(params.rows)
|
||||
return _save(params.vault, path, buffer.getvalue().encode("utf-8"), params.overwrite)
|
||||
|
||||
|
||||
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
|
||||
|
||||
|
||||
def _markdown_to_flowables(content: str) -> list[tuple[str, str]]:
|
||||
"""Split markdown-ish content into (style, text) blocks for reportlab."""
|
||||
blocks: list[tuple[str, str]] = []
|
||||
for raw_line in content.splitlines():
|
||||
line = raw_line.rstrip()
|
||||
if not line.strip():
|
||||
continue
|
||||
heading = _HEADING_RE.match(line)
|
||||
if heading:
|
||||
blocks.append((f"H{min(3, len(heading.group(1)))}", heading.group(2).strip()))
|
||||
else:
|
||||
blocks.append(("P", line.strip()))
|
||||
return blocks
|
||||
|
||||
|
||||
def _render_markdown_pdf(content: str, title: str) -> bytes | None:
|
||||
"""Render markdown → HTML → PDF through the document-page pipeline.
|
||||
|
||||
Uses the same stack as the « Download PDF » button of the document viewer
|
||||
(mistune with the table plugin + WeasyPrint print CSS), so tables, code
|
||||
blocks and lists are laid out correctly. Returns ``None`` when WeasyPrint
|
||||
is not importable (missing GTK on some hosts) so the caller can fall back
|
||||
to the simplified reportlab renderer.
|
||||
"""
|
||||
try:
|
||||
import mistune
|
||||
|
||||
from backend.pdf_export import build_pdf_html, generate_pdf
|
||||
|
||||
renderer = mistune.create_markdown(
|
||||
escape=False,
|
||||
plugins=["table", "strikethrough", "footnotes", "task_lists"],
|
||||
)
|
||||
html = renderer(content)
|
||||
return generate_pdf(build_pdf_html(html, title), title)
|
||||
except Exception as e:
|
||||
# WeasyPrint loads GTK lazily: a missing native library can surface at
|
||||
# import OR render time. Fall back to the simple renderer either way.
|
||||
logger.warning("WeasyPrint pipeline unavailable for create_pdf: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _render_reportlab_pdf(content: str, title: str) -> bytes:
|
||||
"""Fallback renderer (no WeasyPrint): headings + paragraphs, no tables."""
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import getSampleStyleSheet
|
||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
style_map = {
|
||||
"P": styles["BodyText"],
|
||||
"H1": styles["Heading1"],
|
||||
"H2": styles["Heading2"],
|
||||
"H3": styles["Heading3"],
|
||||
}
|
||||
buffer = io.BytesIO()
|
||||
doc = SimpleDocTemplate(buffer, pagesize=A4, title=title[:200])
|
||||
story: list[Any] = [Paragraph(saxutils.escape(title[:300]), styles["Title"])]
|
||||
for style, line in _markdown_to_flowables(content):
|
||||
story.append(Spacer(1, 4))
|
||||
story.append(Paragraph(saxutils.escape(line), style_map[style]))
|
||||
doc.build(story)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
@tool(
|
||||
name="create_pdf",
|
||||
description=(
|
||||
"Create a .pdf document in a vault from markdown content (headings, "
|
||||
"paragraphs, tables, code blocks, lists). Use for printable "
|
||||
"deliverables; tables are laid out like the document-page PDF export."
|
||||
),
|
||||
input_model=PdfInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def create_pdf(ctx: ToolContext, params: PdfInput) -> dict[str, Any]:
|
||||
"""Render the content and save the PDF into the vault.
|
||||
|
||||
Primary path: mistune (tables) + WeasyPrint — identical to the viewer's
|
||||
« Download PDF » export. Fallback (WeasyPrint unavailable): simplified
|
||||
reportlab layout without tables.
|
||||
"""
|
||||
path = _check_extension(params.path, ".pdf")
|
||||
content = params.content[:MAX_PDF_CHARS]
|
||||
pdf_bytes = _render_markdown_pdf(content, params.title[:300])
|
||||
if pdf_bytes is None:
|
||||
pdf_bytes = _render_reportlab_pdf(content, params.title[:300])
|
||||
return _save(params.vault, path, pdf_bytes, params.overwrite)
|
||||
@@ -47,6 +47,14 @@ _STEP_LABELS: dict[str, tuple[str, str | None]] = {
|
||||
"restore_backup": ("backup_restore", "path"),
|
||||
"web_search": ("web_search", "query"),
|
||||
"fetch_url": ("fetch_url", "url"),
|
||||
"crawl_site": ("crawl", "url"),
|
||||
"git_list_repos": ("git_repos", "provider"),
|
||||
"git_search_issues": ("git_issues", "query"),
|
||||
"git_get_file": ("git_file", "path"),
|
||||
"create_xlsx": ("xlsx_create", "path"),
|
||||
"create_docx": ("docx_create", "path"),
|
||||
"create_csv": ("csv_create", "path"),
|
||||
"create_pdf": ("pdf_create", "path"),
|
||||
}
|
||||
|
||||
GENERIC_KEY = "generic"
|
||||
|
||||
@@ -251,6 +251,90 @@ class FetchUrlInput(BaseModel):
|
||||
"""Fetch one public web page and return its readable text."""
|
||||
|
||||
url: str = Field(..., description="Absolute http(s) URL of a public page")
|
||||
render: bool = Field(
|
||||
False,
|
||||
description="Render JavaScript with the optional Playwright worker (dynamic SPA pages)",
|
||||
)
|
||||
|
||||
|
||||
class CrawlSiteInput(BaseModel):
|
||||
"""Crawl a small public site (same-host only) and save a digest into a vault."""
|
||||
|
||||
url: str = Field(..., description="Absolute http(s) URL where the crawl starts")
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the digest file to write (.md)")
|
||||
max_pages: int = Field(5, ge=1, le=20, description="Maximum number of pages to crawl")
|
||||
|
||||
|
||||
class GitProviderInput(BaseModel):
|
||||
"""Base fields for connected-source tools (Gitea / GitHub)."""
|
||||
|
||||
provider: str = Field(..., description="'gitea' (OBSIGATE_GITEA_URL) or 'github'")
|
||||
repo: str = Field("", description="Optional 'owner/name' repository filter")
|
||||
limit: int = Field(20, ge=1, le=50, description="Maximum number of entries")
|
||||
|
||||
|
||||
class GitSearchIssuesInput(BaseModel):
|
||||
"""Search issues/pull requests on a connected Gitea or GitHub instance."""
|
||||
|
||||
provider: str = Field(..., description="'gitea' or 'github'")
|
||||
query: str = Field(..., min_length=1, description="Search keywords")
|
||||
repo: str = Field("", description="Optional 'owner/name' scope (empty = instance-wide)")
|
||||
state: str = Field("open", description="'open' or 'closed'")
|
||||
limit: int = Field(10, ge=1, le=20, description="Maximum number of issues")
|
||||
|
||||
|
||||
class GitGetFileInput(BaseModel):
|
||||
"""Read a file from a connected Gitea or GitHub repository."""
|
||||
|
||||
provider: str = Field(..., description="'gitea' or 'github'")
|
||||
repo: str = Field(..., description="'owner/name' repository")
|
||||
path: str = Field(..., description="Repository-relative file path")
|
||||
ref: str = Field("", description="Optional branch/tag/commit (empty = default branch)")
|
||||
|
||||
|
||||
class SpreadsheetInput(BaseModel):
|
||||
"""Create an .xlsx spreadsheet in a vault from rows of cells."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the file to write (.xlsx)")
|
||||
rows: list[list[str | int | float | bool | None]] = Field(
|
||||
..., description="Rows of cell values (first row = header)"
|
||||
)
|
||||
sheet_name: str = Field("Feuille1", description="Worksheet name")
|
||||
overwrite: bool = Field(True, description="Replace an existing file (with backup)")
|
||||
|
||||
|
||||
class DocxInput(BaseModel):
|
||||
"""Create a .docx Word document in a vault from paragraphs."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the file to write (.docx)")
|
||||
title: str = Field("", description="Optional document title (heading 1)")
|
||||
paragraphs: list[str] = Field(..., description="Paragraph texts, in order")
|
||||
overwrite: bool = Field(True, description="Replace an existing file (with backup)")
|
||||
|
||||
|
||||
class CsvInput(BaseModel):
|
||||
"""Create a .csv file in a vault from rows of cells."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the file to write (.csv)")
|
||||
rows: list[list[str | int | float | bool | None]] = Field(
|
||||
..., description="Rows of cell values (first row = header)"
|
||||
)
|
||||
delimiter: str = Field(",", description="Field separator (',' ';' '\\t')")
|
||||
overwrite: bool = Field(True, description="Replace an existing file (with backup)")
|
||||
|
||||
|
||||
class PdfInput(BaseModel):
|
||||
"""Create a .pdf document in a vault from markdown-ish content."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the file to write (.pdf)")
|
||||
title: str = Field("Document", description="Document title")
|
||||
content: str = Field(..., description="Content (headings with #/##, then paragraphs)")
|
||||
overwrite: bool = Field(True, description="Replace an existing file (with backup)")
|
||||
|
||||
|
||||
class ToolResult(BaseModel):
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Tool-layer secrets — user-configured tokens & API keys (#103).
|
||||
|
||||
The connected-source (Gitea / GitHub) and keyed web-search (Tavily, Brave,
|
||||
SerpAPI, Exa) tools read their credentials through this module instead of
|
||||
``os.environ`` directly. The value comes from the store the user edits in the
|
||||
configuration page (``data/api_keys.json`` — the same file the AI provider
|
||||
keys use) first, then falls back to the environment (Infisical-injected in
|
||||
production). Nothing is ever hard-coded and no tool result carries a secret
|
||||
(the registry redacts payloads).
|
||||
|
||||
Allowed names are whitelisted: only the variables below can be stored or
|
||||
deleted from the configuration page.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("obsigate.tools.secrets")
|
||||
|
||||
# Whitelisted configuration names (config page « Sources connectées & recherche »).
|
||||
TOOL_KEY_NAMES: tuple[str, ...] = (
|
||||
"OBSIGATE_TAVILY_API_KEY",
|
||||
"OBSIGATE_BRAVE_API_KEY",
|
||||
"OBSIGATE_SERPAPI_API_KEY",
|
||||
"OBSIGATE_EXA_API_KEY",
|
||||
"OBSIGATE_GITEA_URL",
|
||||
"OBSIGATE_GITEA_TOKEN",
|
||||
"OBSIGATE_GITHUB_TOKEN",
|
||||
)
|
||||
|
||||
_SECRET_MARKERS = ("API_KEY", "TOKEN")
|
||||
|
||||
|
||||
def _keys_file() -> Path:
|
||||
base = os.environ.get("OBSIGATE_DATA_DIR", "data")
|
||||
return Path(base) / "api_keys.json"
|
||||
|
||||
|
||||
def _read_keys() -> dict:
|
||||
path = _keys_file()
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as e:
|
||||
logger.warning("tool key store unreadable (%s): %s", path, e)
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _write_keys(data: dict) -> None:
|
||||
path = _keys_file()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def is_secret_name(name: str) -> bool:
|
||||
"""True for API keys / tokens (masked in API responses); URLs are clear."""
|
||||
return any(marker in name for marker in _SECRET_MARKERS)
|
||||
|
||||
|
||||
def mask_value(name: str, value: str) -> str:
|
||||
"""Mask a secret for display; non-secret values (URLs) are returned as-is."""
|
||||
if not value:
|
||||
return ""
|
||||
if not is_secret_name(name):
|
||||
return value
|
||||
return value[:4] + "..." + value[-4:] if len(value) > 8 else "***"
|
||||
|
||||
|
||||
def get_tool_key(name: str) -> str:
|
||||
"""Stored (configuration page) value first, then environment fallback."""
|
||||
if name not in TOOL_KEY_NAMES:
|
||||
return os.environ.get(name, "").strip()
|
||||
stored = _read_keys().get(name)
|
||||
if isinstance(stored, str) and stored.strip():
|
||||
return stored.strip()
|
||||
return os.environ.get(name, "").strip()
|
||||
|
||||
|
||||
def set_tool_key(name: str, value: str) -> None:
|
||||
"""Persist one whitelisted key into the store (admin configuration page)."""
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
return False
|
||||
@@ -8,6 +8,16 @@ Phase 1 of the documented web-toolset roadmap:
|
||||
answering « je n'ai pas accès à internet ».
|
||||
* ``fetch_url`` — retrieve a public web page and return readable text.
|
||||
|
||||
Phase 2 (#92) additions:
|
||||
|
||||
* keyed providers — Tavily, Brave Search, SerpAPI and Exa are used first when
|
||||
their API key is configured (env, injected by Infisical in production);
|
||||
* SQLite cache — search/fetch results are cached with a TTL
|
||||
(:mod:`backend.tools.webcache`);
|
||||
* retry with backoff — transient network errors get one extra attempt;
|
||||
* dynamic rendering — ``fetch_url(render=True)`` uses an isolated Playwright
|
||||
worker (optional dependency, graceful degradation).
|
||||
|
||||
All are READ-risk tools (no confirmation), rate-limited through the shared
|
||||
registry, SSRF-guarded (scheme + private-address rejection), and size-capped.
|
||||
|
||||
@@ -16,6 +26,14 @@ Configuration (environment):
|
||||
* ``OBSIGATE_WEB_TIMEOUT`` — seconds, default 10
|
||||
* ``OBSIGATE_WEB_FALLBACK`` — ``0``/``false`` disables the keyless HTML
|
||||
fallbacks (SearXNG only), default enabled
|
||||
* ``OBSIGATE_TAVILY_API_KEY`` / ``OBSIGATE_BRAVE_API_KEY`` /
|
||||
``OBSIGATE_SERPAPI_API_KEY`` / ``OBSIGATE_EXA_API_KEY`` — optional keyed
|
||||
providers, tried before SearXNG when set
|
||||
* ``OBSIGATE_WEB_PROVIDERS`` — optional comma-separated provider order
|
||||
(e.g. ``brave,searxng``); keyed providers without a key are skipped
|
||||
* ``OBSIGATE_WEB_RETRY`` — extra attempts for transient network errors
|
||||
(default 1)
|
||||
* ``OBSIGATE_WEB_CACHE_TTL`` — cache TTL seconds, ``0`` disables (default 900)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -28,15 +46,18 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.tools import webcache
|
||||
from backend.tools.context import ToolError, ToolRisk, ToolScope
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import FetchUrlInput, WebSearchInput
|
||||
from backend.tools.secrets import get_tool_key
|
||||
|
||||
logger = logging.getLogger("obsigate.tools.web")
|
||||
|
||||
@@ -48,6 +69,7 @@ WEB_FALLBACK_ENABLED = os.environ.get("OBSIGATE_WEB_FALLBACK", "1").strip().lowe
|
||||
"no",
|
||||
"off",
|
||||
}
|
||||
WEB_RETRY_ATTEMPTS = int(os.environ.get("OBSIGATE_WEB_RETRY", "1"))
|
||||
USER_AGENT = "ObsiGateAssistant/1.0 (+self-hosted vault AI)"
|
||||
# Search engines reject non-browser agents on their public HTML endpoints.
|
||||
BROWSER_UA = (
|
||||
@@ -163,6 +185,115 @@ def _result(
|
||||
}
|
||||
|
||||
|
||||
def _with_retry(call: Callable[[], Any]) -> Any:
|
||||
"""Run *call* with one extra attempt on transient network errors.
|
||||
|
||||
House-made backoff (the roadmap's « tenacity ou boucle maison »): DNS
|
||||
blips and rate-limit hiccups are the common failure mode, and a single
|
||||
retry keeps the fallback chain from being consumed too early.
|
||||
"""
|
||||
for attempt in range(1 + max(0, WEB_RETRY_ATTEMPTS)):
|
||||
try:
|
||||
return call()
|
||||
except httpx.TransportError:
|
||||
if attempt >= max(0, WEB_RETRY_ATTEMPTS):
|
||||
raise
|
||||
time.sleep(0.2 * (attempt + 1))
|
||||
raise RuntimeError("unreachable") # pragma: no cover
|
||||
|
||||
|
||||
def _env_key(name: str) -> str:
|
||||
"""Read an API key: configuration-page store first, then environment."""
|
||||
return get_tool_key(name)
|
||||
|
||||
|
||||
def _search_tavily(query: str, params: WebSearchInput) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
"""Tavily Search API (agent-oriented results, key required)."""
|
||||
resp = httpx.post(
|
||||
"https://api.tavily.com/search",
|
||||
json={
|
||||
"api_key": _env_key("OBSIGATE_TAVILY_API_KEY"),
|
||||
"query": query,
|
||||
"max_results": params.max_results,
|
||||
"search_depth": "basic",
|
||||
"include_answer": False,
|
||||
},
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
timeout=WEB_TIMEOUT,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return [
|
||||
_result(item.get("title") or "", item.get("url") or "", item.get("content") or "")
|
||||
for item in (data.get("results") or [])
|
||||
], []
|
||||
|
||||
|
||||
def _search_brave(query: str, params: WebSearchInput) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
"""Brave Search API (key required)."""
|
||||
resp = httpx.get(
|
||||
"https://api.search.brave.com/res/v1/web/search",
|
||||
params={"q": query, "count": params.max_results, "safesearch": "moderate"},
|
||||
headers={
|
||||
"X-Subscription-Id": _env_key("OBSIGATE_BRAVE_API_KEY"),
|
||||
"Accept": "application/json",
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
timeout=WEB_TIMEOUT,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return [
|
||||
_result(item.get("title") or "", item.get("url") or "", item.get("description") or "")
|
||||
for item in ((data.get("web") or {}).get("results") or [])
|
||||
], []
|
||||
|
||||
|
||||
def _search_serpapi(query: str, params: WebSearchInput) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
"""SerpAPI (Google SERP, key required)."""
|
||||
resp = httpx.get(
|
||||
"https://serpapi.com/search",
|
||||
params={"q": query, "api_key": _env_key("OBSIGATE_SERPAPI_API_KEY"),
|
||||
"num": params.max_results},
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
timeout=WEB_TIMEOUT,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return [
|
||||
_result(item.get("title") or "", item.get("link") or "", item.get("snippet") or "")
|
||||
for item in (data.get("organic_results") or [])
|
||||
], []
|
||||
|
||||
|
||||
def _search_exa(query: str, params: WebSearchInput) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
"""Exa neural search (key required)."""
|
||||
resp = httpx.post(
|
||||
"https://api.exa.ai/search",
|
||||
json={"query": query, "numResults": params.max_results},
|
||||
headers={
|
||||
"x-api-key": _env_key("OBSIGATE_EXA_API_KEY"),
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
timeout=WEB_TIMEOUT,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return [
|
||||
_result(item.get("title") or "", item.get("url") or "", (item.get("text") or "")[:600])
|
||||
for item in (data.get("results") or [])
|
||||
], []
|
||||
|
||||
|
||||
# Keyed providers: name -> (implementation, API key env var)
|
||||
_KEYED_PROVIDERS: dict[str, tuple[_Provider, str]] = {
|
||||
"tavily": (_search_tavily, "OBSIGATE_TAVILY_API_KEY"),
|
||||
"brave": (_search_brave, "OBSIGATE_BRAVE_API_KEY"),
|
||||
"serpapi": (_search_serpapi, "OBSIGATE_SERPAPI_API_KEY"),
|
||||
"exa": (_search_exa, "OBSIGATE_EXA_API_KEY"),
|
||||
}
|
||||
|
||||
|
||||
def _search_searxng(
|
||||
query: str, params: WebSearchInput
|
||||
) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
@@ -288,8 +419,22 @@ _Provider = Callable[[str, WebSearchInput], "tuple[list[dict[str, Any]], list[st
|
||||
|
||||
|
||||
def _provider_chain() -> list[tuple[str, _Provider]]:
|
||||
"""Ordered providers: self-hosted meta-search first, then keyless fallbacks."""
|
||||
chain: list[tuple[str, _Provider]] = [("searxng", _search_searxng)]
|
||||
"""Ordered providers: keyed APIs first, then self-hosted, then keyless.
|
||||
|
||||
``OBSIGATE_WEB_PROVIDERS`` (comma-separated) overrides the default order;
|
||||
unknown names are ignored and keyed providers without their key are skipped.
|
||||
"""
|
||||
chain: list[tuple[str, _Provider]] = []
|
||||
configured = [
|
||||
name.strip().lower()
|
||||
for name in os.environ.get("OBSIGATE_WEB_PROVIDERS", "").split(",")
|
||||
if name.strip()
|
||||
]
|
||||
for name in configured or list(_KEYED_PROVIDERS):
|
||||
entry = _KEYED_PROVIDERS.get(name)
|
||||
if entry and _env_key(entry[1]):
|
||||
chain.append((name, entry[0]))
|
||||
chain.append(("searxng", _search_searxng))
|
||||
if WEB_FALLBACK_ENABLED:
|
||||
chain.append(("duckduckgo", _search_duckduckgo))
|
||||
chain.append(("bing", _search_bing))
|
||||
@@ -313,6 +458,17 @@ def web_search(ctx, params: WebSearchInput) -> dict[str, Any]:
|
||||
if not query:
|
||||
raise ToolError("Requête vide", code="invalid_arguments")
|
||||
|
||||
key = webcache.cache_key("search", {
|
||||
"q": query,
|
||||
"max_results": params.max_results,
|
||||
"category": params.category,
|
||||
"language": params.language,
|
||||
"page": params.page,
|
||||
})
|
||||
cached = webcache.cache_get(key)
|
||||
if cached is not None:
|
||||
return {**cached, "cached": True}
|
||||
|
||||
attempts: list[str] = []
|
||||
unresponsive: list[str] = []
|
||||
reachable = False
|
||||
@@ -320,8 +476,12 @@ def web_search(ctx, params: WebSearchInput) -> dict[str, Any]:
|
||||
|
||||
for name, provider in _provider_chain():
|
||||
attempts.append(name)
|
||||
|
||||
def _attempt(p: _Provider = provider) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
return p(query, params)
|
||||
|
||||
try:
|
||||
results, engines = provider(query, params)
|
||||
results, engines = _with_retry(_attempt)
|
||||
except (httpx.HTTPError, ValueError, AttributeError) as e:
|
||||
logger.warning("web_search provider %s failed: %s", name, e)
|
||||
last_error = e
|
||||
@@ -338,6 +498,7 @@ def web_search(ctx, params: WebSearchInput) -> dict[str, Any]:
|
||||
}
|
||||
if unresponsive:
|
||||
payload["unresponsive_engines"] = unresponsive[:8]
|
||||
webcache.cache_set(key, payload)
|
||||
return payload
|
||||
|
||||
if not reachable:
|
||||
@@ -378,6 +539,20 @@ def web_search(ctx, params: WebSearchInput) -> dict[str, Any]:
|
||||
def fetch_url(ctx, params: FetchUrlInput) -> dict[str, Any]:
|
||||
"""Retrieve one page, guard against SSRF, and extract its text."""
|
||||
url = _assert_public_http_url(params.url.strip())
|
||||
key = webcache.cache_key("fetch", {"url": url, "render": params.render})
|
||||
cached = webcache.cache_get(key)
|
||||
if cached is not None:
|
||||
return {**cached, "cached": True}
|
||||
|
||||
if params.render:
|
||||
# Dynamic pages (SPA/React): delegated to the isolated Playwright
|
||||
# worker; the browser dependency stays optional (graceful error).
|
||||
from backend.tools.webrender import render_page
|
||||
|
||||
payload = render_page(url)
|
||||
webcache.cache_set(key, payload)
|
||||
return payload
|
||||
|
||||
try:
|
||||
# Follow redirects manually so every hop is re-checked against the
|
||||
# private-address SSRF guard (a public page can redirect to 127.0.0.1).
|
||||
@@ -414,10 +589,12 @@ def fetch_url(ctx, params: FetchUrlInput) -> dict[str, Any]:
|
||||
title_match = re.search(r"<title[^>]*>(.*?)</title>", raw, re.IGNORECASE | re.DOTALL)
|
||||
title = html_lib.unescape(title_match.group(1)).strip()[:300] if title_match else ""
|
||||
text = _html_to_text(raw)[:MAX_TEXT_CHARS]
|
||||
return {
|
||||
payload = {
|
||||
"url": str(resp.url),
|
||||
"status": resp.status_code,
|
||||
"title": title,
|
||||
"text": text,
|
||||
"truncated": len(raw) > MAX_TEXT_CHARS,
|
||||
}
|
||||
webcache.cache_set(key, payload)
|
||||
return payload
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""SQLite cache for web tool results (search results, fetched pages).
|
||||
|
||||
Phase 2 of the web-toolset roadmap (« Transverse »): repeated web searches and
|
||||
page fetches (common in agent loops, where the model re-reads a source) must
|
||||
not hammer the providers. Results are cached in a dedicated SQLite table with
|
||||
a TTL; the cache is best-effort — any error silently disables it so a broken
|
||||
database file never takes the assistant down.
|
||||
|
||||
Configuration (environment):
|
||||
* ``OBSIGATE_DATA_DIR`` — base data directory (default ``data``)
|
||||
* ``OBSIGATE_WEB_CACHE_PATH`` — explicit cache file override
|
||||
* ``OBSIGATE_WEB_CACHE_TTL`` — seconds, ``0`` disables the cache (default 900)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("obsigate.tools.webcache")
|
||||
|
||||
DEFAULT_TTL_SECONDS = 900
|
||||
_schema_ready = False
|
||||
_write_lock = threading.Lock()
|
||||
|
||||
|
||||
def ttl_seconds() -> float:
|
||||
"""Configured TTL in seconds (``0`` = cache disabled)."""
|
||||
return float(os.environ.get("OBSIGATE_WEB_CACHE_TTL", str(DEFAULT_TTL_SECONDS)))
|
||||
|
||||
|
||||
def _cache_path() -> Path:
|
||||
override = os.environ.get("OBSIGATE_WEB_CACHE_PATH", "").strip()
|
||||
if override:
|
||||
return Path(override)
|
||||
return Path(os.environ.get("OBSIGATE_DATA_DIR", "data")) / "web_cache.sqlite3"
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
"""Open (and lazily create) the cache database."""
|
||||
global _schema_ready
|
||||
path = _cache_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path, timeout=5, check_same_thread=False)
|
||||
if not _schema_ready:
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS web_cache ("
|
||||
"key TEXT PRIMARY KEY, value TEXT NOT NULL, created REAL NOT NULL)"
|
||||
)
|
||||
conn.commit()
|
||||
_schema_ready = True
|
||||
return conn
|
||||
|
||||
|
||||
def cache_key(prefix: str, payload: dict[str, Any]) -> str:
|
||||
"""Deterministic cache key from a prefix and the normalized arguments."""
|
||||
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)
|
||||
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
|
||||
return f"{prefix}:{digest}"
|
||||
|
||||
|
||||
def cache_get(key: str) -> Any | None:
|
||||
"""Return the cached payload for *key*, or ``None`` (miss/expiry/disabled)."""
|
||||
if ttl_seconds() <= 0:
|
||||
return None
|
||||
try:
|
||||
conn = _connect()
|
||||
row = conn.execute(
|
||||
"SELECT value, created FROM web_cache WHERE key = ?", (key,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
except sqlite3.Error as e:
|
||||
logger.warning("web cache read failed (%s): %s", key, e)
|
||||
return None
|
||||
if row is None:
|
||||
return None
|
||||
value, created = row
|
||||
if time.time() - float(created) > ttl_seconds():
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def cache_set(key: str, value: Any) -> None:
|
||||
"""Store *value* under *key* (best effort, never raises)."""
|
||||
if ttl_seconds() <= 0:
|
||||
return
|
||||
try:
|
||||
with _write_lock:
|
||||
conn = _connect()
|
||||
conn.execute(
|
||||
"INSERT INTO web_cache (key, value, created) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value, created = excluded.created",
|
||||
(key, json.dumps(value, ensure_ascii=False, default=str), time.time()),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except sqlite3.Error as e:
|
||||
logger.warning("web cache write failed (%s): %s", key, e)
|
||||
|
||||
|
||||
def purge_expired() -> int:
|
||||
"""Delete expired rows; return the number of removed entries (maintenance)."""
|
||||
try:
|
||||
conn = _connect()
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM web_cache WHERE created < ?", (time.time() - ttl_seconds(),)
|
||||
)
|
||||
conn.commit()
|
||||
deleted = cursor.rowcount
|
||||
conn.close()
|
||||
return int(deleted)
|
||||
except sqlite3.Error as e:
|
||||
logger.warning("web cache purge failed: %s", e)
|
||||
return 0
|
||||
|
||||
|
||||
def clear_cache() -> int:
|
||||
"""Drop every cached entry (tests / admin); returns the number of rows."""
|
||||
try:
|
||||
conn = _connect()
|
||||
cursor = conn.execute("DELETE FROM web_cache")
|
||||
conn.commit()
|
||||
deleted = cursor.rowcount
|
||||
conn.close()
|
||||
return int(deleted)
|
||||
except sqlite3.Error as e:
|
||||
logger.warning("web cache clear failed: %s", e)
|
||||
return 0
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Dynamic page rendering (Playwright) — ``fetch_url(render=True)``.
|
||||
|
||||
Static pages are fetched with httpx inside :mod:`backend.tools.web`. Dynamic
|
||||
pages (SPA/React, JS-loaded content) need a real browser engine; this module
|
||||
runs one Playwright call inside a dedicated worker thread so browser
|
||||
crashes/timeouts never take over the tool layer, and the heavyweight
|
||||
dependency stays optional:
|
||||
|
||||
* not installed → ``ToolError(code="playwright_unavailable")`` with a clear
|
||||
message (the assistant explains the limitation instead of hanging);
|
||||
* installed → ``pip install playwright && playwright install chromium``.
|
||||
|
||||
The SSRF guard (scheme + private-address rejection) is applied before the
|
||||
browser navigates. Note: unlike the httpx path, internal redirects performed
|
||||
by the browser engine are not re-checked hop by hop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html as html_lib
|
||||
import logging
|
||||
import re
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
|
||||
from backend.tools.context import ToolError
|
||||
from backend.tools.web import (
|
||||
MAX_TEXT_CHARS,
|
||||
USER_AGENT,
|
||||
_assert_public_http_url,
|
||||
_html_to_text,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("obsigate.tools.webrender")
|
||||
|
||||
# One worker: browser automation is serialized on purpose (one Chromium at a
|
||||
# time keeps memory predictable on small hosts).
|
||||
_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="obsigate-playwright")
|
||||
GOTO_TIMEOUT_MS = 20_000
|
||||
|
||||
|
||||
def _playwright_available() -> bool:
|
||||
try:
|
||||
import playwright # noqa: F401
|
||||
except ImportError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _render_in_worker(url: str) -> dict[str, Any]:
|
||||
"""Synchronous Playwright render — runs in the dedicated worker thread."""
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
status = 0
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
try:
|
||||
page = browser.new_page(user_agent=USER_AGENT)
|
||||
response = page.goto(url, wait_until="networkidle", timeout=GOTO_TIMEOUT_MS)
|
||||
if response is not None:
|
||||
status = response.status
|
||||
raw = page.content()
|
||||
title = html_lib.unescape(page.title() or "").strip()
|
||||
text = _html_to_text(raw)[:MAX_TEXT_CHARS]
|
||||
finally:
|
||||
browser.close()
|
||||
title = re.sub(r"\s+", " ", title)[:300]
|
||||
return {
|
||||
"url": url,
|
||||
"status": status,
|
||||
"title": title,
|
||||
"text": text,
|
||||
"rendered": True,
|
||||
"truncated": len(raw) > MAX_TEXT_CHARS,
|
||||
}
|
||||
|
||||
|
||||
def render_page(url: str) -> dict[str, Any]:
|
||||
"""Render *url* (JavaScript included) and return readable text.
|
||||
|
||||
Raises:
|
||||
ToolError: ``playwright_unavailable`` when the optional dependency is
|
||||
missing, ``render_unavailable`` when the render itself failed.
|
||||
"""
|
||||
_assert_public_http_url(url)
|
||||
if not _playwright_available():
|
||||
raise ToolError(
|
||||
"Rendu dynamique indisponible : Playwright n'est pas installé "
|
||||
"(pip install playwright && playwright install chromium).",
|
||||
code="playwright_unavailable",
|
||||
)
|
||||
try:
|
||||
return _executor.submit(_render_in_worker, url).result(timeout=GOTO_TIMEOUT_MS / 1000 + 40)
|
||||
except ToolError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("render_page failed for %s: %s", url, e)
|
||||
raise ToolError(
|
||||
"Le rendu dynamique de la page a échoué.", code="render_unavailable"
|
||||
) from e
|
||||
@@ -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.8.2"
|
||||
version = "2.27.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.8.2"
|
||||
version = "2.27.2"
|
||||
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.8.2",
|
||||
"version": "2.27.2",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -308,7 +308,7 @@ Pour répondre au besoin de cibler un fournisseur/modèle sans dépendre uniquem
|
||||
- Lecture : `backend/ai.py` (`_read_app_config`, `get_default_provider`, `_load_provider_keys`).
|
||||
- Écriture : `POST /api/config` (admin) — clés ajoutées à `_DEFAULT_CONFIG` (`backend/main.py:4270`).
|
||||
- Rechargement à chaud : `reload_ai_config()` met à jour `PROVIDERS` **en place** (les imports existants restent valides).
|
||||
- UI : section « Clés API Intelligence Artificielle » (`frontend/index.html` `#cfg-ai`), sélecteurs « Fournisseur par défaut » + « Modèle par défaut », sauvegardés par `saveAIKeys()` (`frontend/js/config.js`).
|
||||
- UI : section « Clés API Intelligence Artificielle » (`frontend/index.html` `#cfg-ai`, cartes dépliables par fournisseur — #104), sélecteurs « Fournisseur par défaut » + « Modèle par défaut », sauvegardés par `saveAIKeys()` (`frontend/js/config.js`).
|
||||
|
||||
**Précédence de résolution du modèle** : override par requête > `ai_default_models[provider]` > variable d'environnement `*_MODEL` > défaut codé en dur.
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
@@ -144,12 +144,12 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *BUG-032* | [🟡 IMPORTANT] Indexation : symlinks suivis (contenu hors vault indexé) + scan initial coûteux | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/indexer.py` | Placer un symlink dans le vault vers un dossier externe puis relancer l'index | `_scan_vault` réécrit avec `os.walk(followlinks=False)` + refus des symlinks sortant de la racine ; test `TestSymlinkIndexing` | Scan incrémental/index persistant : voir #86 (phase 3) |
|
||||
| *BUG-033* | [🟡 IMPORTANT] Recherche classique et tool IA `search_fulltext` en O(N) sans inverted index | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/search.py`, `backend/tools/service.py` | `GET /api/search` sur un vault de 50 000 fichiers | `search()` récupère les candidats via l'inverted index (intersection des termes + expansion de préfixes), repli sur le scan pendant la construction | `search_fulltext` en bénéficie automatiquement |
|
||||
| *BUG-034* | [🟡 IMPORTANT] CSP affaiblie (`'unsafe-inline'` + CDN distants) et token d'accès en sessionStorage | 🟢 corrigé | P1 | 🔐 sécurité | IA | `backend/main.py`, `frontend/js/auth.js`, `frontend/js/admin.js`, `frontend/js/sync.js` | Inspecter les en-têtes CSP ; lire sessionStorage en console | Token en mémoire + cookie HttpOnly (plus de `sessionStorage`) ; CSP durcie (`object-src 'none'`, `base-uri`, `form-action`, `frame-ancestors`). *Reste : migration nonce* | `'unsafe-inline'` conservé tant que les gestionnaires inline n'ont pas été convertis (résidu documenté) |
|
||||
| *BUG-035* | [🔵 MINEUR] `secret_redactor` : faux positifs sur les hashs hex (git, SHA) | 🔴 ouvert | P2 | ⚙️ backend | IA | `backend/secret_redactor.py` | Lire une note contenant un commit git (40 caractères hexadécimaux) | Restreindre le périmètre de détection (contexte clé/token) + whitelist | Contenus mutilés dans les lectures et réponses IA |
|
||||
| *BUG-036* | [🔵 MINEUR] Collab WebSocket : token en query string | 🔴 ouvert | P2 | ⚙️ backend | IA | `backend/collab.py` | Observer l'URL du websocket dans le trafic réseau | Passer le token en header / étape d'authentification initiale ; borner la taille des messages | Jeton visible dans les logs/proxys |
|
||||
| *BUG-037* | [🔵 MINEUR] Compte « anonymous » administrateur si auth désactivée | 🔴 ouvert | P2 | 🔐 sécurité | IA | `backend/auth/middleware.py` | Démarrer avec l'authentification désactivée | Avertissement explicite au démarrage + refus de déploiement public sans auth | Comportement par conception mais risqué si mal configuré |
|
||||
| *BUG-038* | [🔵 MINEUR] Argon2 à 64 MB par vérification : risque d'épuisement mémoire | 🔴 ouvert | P2 | 🔐 sécurité | IA | `backend/auth/password.py:8` | Lancer de nombreux `POST /api/auth/login` simultanés | Recalibrer (~19 MB, t=2, p=1, norme OWASP actuelle) + maintien du rate-limit | DoS mémoire possible sur les petites instances |
|
||||
| *BUG-039* | [🔵 MINEUR] Enumération de comptes : 429 (verrouillé) vs 401 (inconnu) | 🔴 ouvert | P3 | 🔐 sécurité | IA | `backend/auth/router.py:120` | Tenter un login sur un compte verrouillé puis un nom inconnu | Répondre 401 uniforme avec un timing équivalent | Le statut HTTP distingue l'existence d'un compte |
|
||||
| *BUG-040* | [🔵 MINEUR] Extraction PDF intégrale (100 ko) au scan de démarrage | 🔴 ouvert | P2 | ⚙️ backend | IA | `backend/indexer.py:465` | Démarrer sur un vault contenant de nombreux PDF | Analyser les PDF en tâche de fond / à la demande (lazy) | Ralentit fortement le démarrage et le rebuild d'index |
|
||||
| *BUG-035* | [🔵 MINEUR] `secret_redactor` : faux positifs sur les hashs hex (git, SHA) | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/secret_redactor.py` | Lire une note contenant un commit git (40 caractères hexadécimaux) | Masquage hex conditionné au contexte (`_redact_bare_hex_secrets`) : secret exigé dans les 60 caractères précédents, exemption explicite pour `commit`/`sha*`/`hash`/`checksum`/`git`/`etag`. Tests : `tests/test_api_main.py::TestSecretRedactor` (+4) | Contenus mutilés dans les lectures et réponses IA |
|
||||
| *BUG-036* | [🔵 MINEUR] Collab WebSocket : token en query string | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/collab.py` | Observer l'URL du websocket dans le trafic réseau | `authenticate_websocket` ne lit plus `?token=` : cookie HttpOnly `access_token` uniquement ; rejet des trames > `MAX_MESSAGE_CHARS` (16 Mio) avant analyse. Tests : `tests/test_collab.py` (+3) | Jeton visible dans les logs/proxys |
|
||||
| *BUG-037* | [🔵 MINEUR] Compte « anonymous » administrateur si auth désactivée | 🟢 corrigé | P2 | 🔐 sécurité | IA | `backend/auth/middleware.py`, `backend/main.py` | Démarrer avec l'authentification désactivée | `_guard_insecure_auth()` : avertissement explicite + refus de démarrage sur bind non-loopback sans `OBSIGATE_ALLOW_INSECURE=true`. Tests : `tests/test_auth.py::TestInsecureAuthGuard` (+6) | Comportement par conception mais risqué si mal configuré |
|
||||
| *BUG-038* | [🔵 MINEUR] Argon2 à 64 MB par vérification : risque d'épuisement mémoire | 🟢 corrigé | P2 | 🔐 sécurité | IA | `backend/auth/password.py` | Lancer de nombreux `POST /api/auth/login` simultanés | Recalibré à `m=19456 Kio (19 Mio), t=2, p=1` (OWASP) ; anciens hachages valides + rehash auto. Test : `tests/test_auth.py::TestPasswordHashing::test_argon2_memory_recalibrated` | DoS mémoire possible sur les petites instances |
|
||||
| *BUG-039* | [🔵 MINEUR] Enumération de comptes : 429 (verrouillé) vs 401 (inconnu) | 🟢 corrigé | P3 | 🔐 sécurité | IA | `backend/auth/router.py` | Tenter un login sur un compte verrouillé puis un nom inconnu | Login uniforme : inconnu / désactivé / verrouillé / rate-limit par compte → `401 Identifiants invalides` + hachage factice (timing équivalent) ; seul le rate-limit IP reste `429`. Tests : `tests/test_auth_api.py` (+3) | Le statut HTTP distinguait l'existence d'un compte |
|
||||
| *BUG-040* | [🔵 MINEUR] Extraction PDF intégrale (100 ko) au scan de démarrage | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/indexer.py`, `backend/main.py` | Démarrer sur un vault contenant de nombreux PDF | `_scan_vault` ne lit que les métadonnées ; `enrich_pdf_texts()` extrait le texte après l'index (démarrage) et après chaque réindexation. Tests : `tests/test_pdf.py` (+3) | Ralentit fortement le démarrage et le rebuild d'index |
|
||||
| *BUG-041* | [🟡 IMPORTANT] Assistant IA : échec sur un répertoire vide (« Aucun fichier markdown trouvé dans ce dossier ») au lieu de répondre | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `backend/bookslm_routes.py`, `backend/bookslm.py`, `frontend/js/bookslm.js` | Ouvrir l'assistant sur un dossier vide puis envoyer une question | `_resolve_system_prompt` dégrade vers le prompt Général + bloc « Dossier vide » (plus de 404) ; contexte applicatif `app_context` enrichi (documents ouverts, répertoire, recherche, fichiers récents) | Le 404 bloquait toute la requête. Feature #88, fiche `docs/features/ai-app-context.md`. Tests : `tests/test_bookslm.py` (+3), `tests/frontend/ai.test.mjs` |
|
||||
| *BUG-042* | [🟡 IMPORTANT] Assistant IA : liens de fichiers non fiables (« File not found: ») — pas de règle déterministe nom / dossier / chemin | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Cliquer les liens de fichiers/dossiers dans une réponse de l'assistant (noms avec espaces et/ou accents, chemin préfixé par le nom du vault) | `_classifyPath` distingue `name` (copie presse-papiers) / `dir` (révélation arborescence) / `file` (ouverture) ; `_activatePath()` résout le chemin contre l'index du vault (exact → suffixe → basename unique) avant d'agir ; espaces + accents pris en charge (classes Unicode `\p{L}\p{N}\p{M}`, comparaison normalisée NFC, markdown `<…>`/`%20`, code inline, mentions brutes confirmées par l'index) ; `_splitVaultPrefix` retire un préfixe `Vault/…` et ouvre dans ce vault (`_fetchPathsForVault`) | Les liens morts ouvraient un fichier inexistant. Feature #88. Tests : `tests/frontend/ai.test.mjs` (+11) |
|
||||
| *BUG-043* | [🟡 IMPORTANT] Assistant IA : la liste des fournisseurs de la barre latérale ne suit pas les ajouts/retraits de clés API dans la configuration du projet | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/ai.js`, `frontend/js/bookslm.js`, `frontend/js/config.js` | Ajouter (ou supprimer) une clé de fournisseur AI dans la configuration puis observer le menu Fournisseur de l'assistant sans recharger la page | Le picker lit `/api/ai/status` **une seule fois**, à sa construction, et le panneau de l'assistant est un singleton monté pour toute la session → liste figée. Nouveau `refreshAIPickers()` (exporté par `ai.js`) qui reconstruit chaque picker monté dans son emplacement `.ai-picker-slot` (conservé même sans fournisseur configuré, donc un premier fournisseur s'y monte aussi) ; appelé après `saveAIKeys()` et `deleteAIKey()` (`config.js`) ; une sélection dont le fournisseur n'est plus configuré est purgée de `obsigate_ai_picker` (retour au défaut + modèle effacé au lieu d'un nom fantôme) | Il fallait recharger la page pour voir un nouveau fournisseur (ou en voir disparaître un). Feature #82. Tests : `tests/frontend/ai.test.mjs` (+4) |
|
||||
@@ -165,7 +165,30 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *BUG-053* | [🟡 IMPORTANT] Assistant IA (mode agent) : le fichier demandé n'est pas créé — le modèle émet un bloc texte `obsigate-action` au lieu d'appeler l'outil `create_file` | 🟢 corrigé | P1 | ⚙️ backend + 🤖 ia | IA | `backend/bookslm.py`, `backend/bookslm_routes.py`, `tests/test_bookslm.py` | Mode agent, contexte Général (ou dossier vide) : « créer le fichier TestVault/sport/… avec le tableau des 84 matchs » → réponse avec un bloc ```obsigate-action``` tronqué, aucun fichier | `backend/bookslm.py` : protocole d'action scindé — `GENERAL_ACTION_TOOL_PROTOCOL` (outils natifs, interdiction des blocs `obsigate-action`) utilisé quand `agent=True`, protocole texte conservé pour le chat classique ; `backend/bookslm_routes.py` : `_resolve_system_prompt(..., agent=True)` depuis l'endpoint agent + règle « Mode agent » pour les prompts dossier/documents, `max_tokens` agent 4096 → 8192 (contenu de fichier complet) | Cause : le prompt Général enseignait encore le protocole texte alors que l'agent dispose du function calling. Tests : `tests/test_bookslm.py` (+3) |
|
||||
| *BUG-054* | [🟡 IMPORTANT] Éditeur « Editer » : le bouton Sauvegarder reste bloqué sur le spinner de chargement (retour au crochet uniquement après un refresh complet) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/utils.js` | Ouvrir un fichier → Editer → cliquer Sauvegarder (ou Ctrl+S) ; rouvrir l'éditeur : le bouton reste un spinner désactivé | Nouveau helper `resetSaveButton()` (crochet `✓` + `disabled=false` + styles en ligne nettoyés) appelé à l'ouverture (`openEditor`), à la fermeture (`closeEditor`) et en cas d'échec (`saveFile`). Tests : `tests/frontend/editor-inline.test.mjs` (+4) | Le nœud `#editor-save` est partagé entre sessions : l'état « spinner + désactivé » posé par une sauvegarde manuelle n'était jamais remis à zéro (succès → fermeture puis réouverture, Forge, ou échec réseau dans le `catch`). Seul un rechargement de `index.html` restaurait le crochet |
|
||||
| *BUG-055* | [🟡 IMPORTANT] Éditeur Forge : l'autocomplétion (Tab) ajoute des espaces parasites, l'effacement détruit le mot complété et la complétion fantôme est illisible | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/editor-poc.html`, `frontend/js/autocomplete.js`, `backend/ai.py`, `.gitea/workflows/ci.yml`, `tests/frontend/forge-completion.test.mjs` (nouveau) | Forge : taper un mot, puis Tab pour compléter ; un espace (voire deux) s'insère avant le mot complété, et le retour arrière efface l'ajout. La prédiction IA s'affichait décalée (texte miroir du document entier) | **Cause** : trois gestionnaires `keydown` Tab indépendants s'exécutaient tous — l'indentation (`insertAtCursor(' ')`) s'ajoutait à la complétion de mot et à l'acceptation du ghost. **Correctif** : gestion **unifiée** de Tab (`liste ouverte > ghost > mot du document > indentation`, une seule action), helpers purs partagés (`getWordFragment`, `findWordCompletions`, `normalizeGhost`, `chooseTabAction`) dans `autocomplete.js`, liste déroulante si plusieurs candidats, dropdown positionné au curseur, ghost **positionné au curseur** (fini le miroir du document, nettoyé au déplacement/scroll), complétion de mot sans espace garanti (`normalizeGhost` tronque au premier espace) et prompt `/api/ai/inline-complete` simplifié. Tests : `tests/frontend/forge-completion.test.mjs` (28). | Cause du bug : l'indentation Tab n'était pas conditionnée à l'absence de suggestion. Le ghost re-rendait tout le texte transparent + prédiction, d'où l'impression d'espaces et les erreurs d'effacement |
|
||||
| *BUG-056* | [🟡 IMPORTANT] Éditeur Forge en plein écran : l'Assistant IA s'ouvre en arrière-plan et reste invisible | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/editor-poc.html`, `frontend/js/sync.js`, `tests/frontend/forge-completion.test.mjs`, `tests/frontend/editor-inline.test.mjs` | Forge : passer en plein écran puis cliquer le bouton « Assistant IA » (ou `Ctrl+J`) — le panneau s'ouvre dans le document parent, masqué par l'iframe plein écran | Sortie du plein écran **avant** d'ouvrir le panneau, des deux côtés : côté iframe (`openAssistant` → `document.exitFullscreen()` puis `postMessage` à la résolution) **et** côté parent (`sync.js` sur `forge-open-ai` → `document.exitFullscreen()` puis `openForCurrentContext()`), car le plein écran peut être détenu par le document parent et non par l'iframe (dans ce cas `document.fullscreenElement` est nul dans l'iframe et sa sortie échoue). Tests : `forge-completion.test.mjs` (+1), `editor-inline.test.mjs` (+1) | Le panneau assistant est monté dans `document.body` du parent : l'API Fullscreen ne rend que l'élément plein écran et ses descendants, donc il ne peut pas s'afficher au-dessus de l'iframe Forge en plein écran. La sortie côté iframe seule ne suffisait pas quand le parent détient le plein écran |
|
||||
| *BUG-057* | [🟡 IMPORTANT] Assistant IA : le bouton « Ajouter » est inopérant dans l'éditeur Forge (fonctionne seulement dans « Editer ») | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js`, `frontend/editor-poc.html` | Ouvrir un document dans Forge, demander une réponse à l'assistant puis cliquer « Ajouter » | `_insertIntoEditor()` cible Forge (`#forge-iframe`) : `postMessage({ type: 'parent-insert', text })` ; `editor-poc.html` insère au curseur (`insertAtCursor`) et marque le tampon modifié. Repli textarea inclus. Tests : `tests/frontend/ai.test.mjs` (+3), `tests/frontend/editor-inline.test.mjs` (+1) | `state.editorView` (CodeMirror) est nul en Forge : le clic affichait « Aucun document ouvert dans l'éditeur » |
|
||||
| *BUG-058* | [🔵 MINEUR] Éditeur « Editer » : la barre de numérotation de ligne ne suit pas la couleur du thème (gutter clair `#f5f5f5` en thème sombre) | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/style.css` | Ouvrir un document → Editer en thème sombre : la colonne des numéros de ligne reste gris clair alors que le fond de l'éditeur est sombre | Thème du gutter CodeMirror via les variables CSS (`color-mix(var(--text-primary) …)` pour le fond, `--text-secondary` pour les numéros, `--border` pour la séparation, `--text-primary` pour la ligne active) au lieu des valeurs codées en dur de CodeMirror ; test de non-régression dans `tests/frontend/editor-inline.test.mjs`. Vérifié Playwright (instance de test) : sombre `color(srgb 0.90 0.93 0.95 / 0.05)` + bordure `#21262d`, clair `color(srgb 0.12 0.14 0.16 / 0.05)` + bordure `#d0d7de` | CodeMirror applique `background:#f5f5f5` par défaut, indépendamment du thème ObsiGate ; en mode sombre le fond de l'éditeur suit `--bg-secondary` mais pas le gutter |
|
||||
| *BUG-060* | [🟡 IMPORTANT] Viewer PDF : l'affichage des pages ne fonctionne pas — seule la barre d'outils « PDF — N pages » s'affiche, le contenu reste vide | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/viewer.js`, `tests/frontend/pdf-viewer.test.mjs` (nouveau), `tests/e2e/pdf-viewer.spec.js` (nouveau) | Cliquer un fichier `.pdf` dans l'arborescence | `frontend/js/viewer.js` : le rendu PDF passe de `<embed type="application/pdf">` à `<iframe>` (autorisée par `frame-src 'self'`, le stream étant same-origin). Tests : `tests/frontend/pdf-viewer.test.mjs` (+6) et `tests/e2e/pdf-viewer.spec.js` (fixture `test_vault/sample-pdf.pdf`) | Cause : la CSP durcie en BUG-034 pose `object-src 'none'`, directive qui gouverne `<embed>`/`<object>` → le lecteur PDF natif était bloqué (barre d'outils rendue, corps vide). Le test E2E échoue bien avec l'ancien `<embed>`. `object-src 'none'` conservé (le correctif ne désarme pas la CSP) |
|
||||
| *BUG-061* | [🟡 IMPORTANT] Assistant IA : le bouton « Plein écran » n'agrandit plus le panneau | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/style.css`, `tests/frontend/ai.test.mjs` | Ouvrir l'assistant, redimensionner le panneau, puis cliquer « Plein écran » | La largeur du panneau est écrite en ligne par la poignée de redimensionnement / la largeur persistée (`localStorage`) ; l'inline l'emportait sur `.bookslm-panel.fullscreen { width: 100vw }`. Ajout de `!important` sur la règle plein écran. Tests : `ai.test.mjs` (+1 : classe basculée + règle CSS). Vérifié Playwright : 640 px → 1400 px (viewport) |
|
||||
| *BUG-062* | [🟡 IMPORTANT] Viewer PDF : le document ne prend pas toute la largeur quand la navigation est masquée | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/style.css`, `tests/frontend/pdf-viewer.test.mjs`, `tests/e2e/pdf-viewer.spec.js` | Ouvrir un PDF puis masquer la barre de navigation gauche | La règle `.sidebar.hidden ~ .content-wrapper .content-area { max-width: 1200px }` (colonne de lecture centrée) s'appliquait aussi aux viewers plein cadre. Ajout de `.content-area:has(.pdf-viewer-container)` (et `.image-viewer-container`) avec `max-width: none; margin: 0`. Test E2E : `max-width` calculé = `none`, conteneur = largeur du contenu |
|
||||
| *BUG-063* | [🟡 IMPORTANT] Viewer PDF : la table des matières s'affiche mais ne navigue pas | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/viewer.js`, `tests/frontend/pdf-viewer.test.mjs`, `tests/e2e/pdf-viewer.spec.js` (fixture `test_vault/sample-pdf-toc.pdf`) | Ouvrir un PDF avec signets, puis cliquer une entrée de la TOC | Deux causes : (1) `contentWindow.location.hash='page=N'` n'atteint pas le document (lecteur PDF natif dans une fenêtre `about:blank`) ; (2) un simple changement de fragment sur `iframe.src` est une navigation same-document **ignorée** par le lecteur natif. `navigatePdfToPage()` (liens `data-page` + listeners, plus d'`onclick` inline) recharge réellement l'iframe via un paramètre de query qui change (`&_pdfpage=<ts>#page=N`). Test E2E : `src` finit par `&_pdfpage=<n>#page=3`. Vérifié en Chrome *headful* : page 1 → page 8 → page 1 (captures identiques au retour) | Le fragment seul ne suffisait pas : Chrome applique `#page=N` au **chargement**, pas lors d'un changement de fragment |
|
||||
| *BUG-064* | [🟡 IMPORTANT] Éditeur Excalidraw : le diagramme ne s'affiche jamais (canvas vide), pour tout fichier `.excalidraw` / `.excalidraw.md` | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/excalidraw-editor.html`, `backend/main.py`, `tests/frontend/excalidraw-viewer.test.mjs`, `tests/test_security_hardening.py`, `tests/e2e/excalidraw.spec.js`, `test_vault/diagram-app-export.excalidraw` | Ouvrir un `.excalidraw` (ou `.excalidraw.md`) dans ObsiGate | Deux causes : (1) la feuille de style d'Excalidraw n'était jamais chargée → éditeur non stylisé + `.excalidraw` sans hauteur fixe → boucle de resize jusqu'au plafond `2^25` (33 554 432 px) → scène blanche. Correctif : `<link>` CSS depuis esm.sh + `style-src` CSP autorisant `https://esm.sh`. (2) `appState.collaborators` objet JSON → `collaborators.forEach is not a function` ; `sanitizeAppState()` reconvertit en `Map` et écarte `width/height/offsetLeft/offsetTop`. | Vérifié navigateur : hauteur canvas 525 px (avant 33 554 432), dessin affiché, UI stylisée, 0 erreur. E2E + tests statiques CSP/CSS ajoutés. |
|
||||
| *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)
|
||||
|
||||
@@ -223,6 +246,33 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-09-17 | #101 | Feature | `frontend/editor-poc.html`, `frontend/js/sync.js`, `frontend/js/viewer.js`, `frontend/js/utils.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/editor-inline.test.mjs`, `docs/features/forge-assistant.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md` | **#101** : le bouton « AI Panel » de Forge ouvre désormais l'**Assistant IA** partagé (`postMessage forge-open-ai` → `bookslm.openForCurrentContext()`) au lieu du mini-chat isolé (supprimé) ; Forge lit `localStorage['obsigate_ai_picker']` (`aiPickerSelection()`) pour ses appels `/api/ai/*` et sa complétion fantôme (repli `ollama`), endpoints corrigés (`make-longer`/`make-shorter`, `target_lang`) ; bouton **plein écran** natif ajouté à Forge (iframe `allow="fullscreen"`) et à Editer (`#editor-fullscreen`, conteneur `#editor-container`, sortie à la fermeture, Échap laissé au navigateur) ; i18n `editor.fullscreen`/`editor.exit_fullscreen`. Vérifié : `tests/frontend/editor-inline.test.mjs` 40/40 (+10), unit 9/9, validate-imports 38 modules, 13 suites JSDOM vertes. | 🟢 livré (en attente vérif utilisateur) |
|
||||
| 2026-09-17 | BUG-055 | Correction | `frontend/editor-poc.html`, `frontend/js/autocomplete.js`, `backend/ai.py`, `.gitea/workflows/ci.yml`, `tests/frontend/forge-completion.test.mjs` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-055** : trois gestionnaires `keydown` Tab indépendants s'exécutaient à chaque appui — l'indentation (`insertAtCursor(' ')`) s'ajoutait à la complétion de mot (`insertAtCursor(suffixe)`) et à l'acceptation du ghost text, d'où l'espace parasite avant le mot complété puis un effacement destructeur. Gestion **unifiée** de Tab (`liste ouverte > ghost > mot du document > indentation`), helpers purs partagés (`getWordFragment`/`findWordCompletions`/`normalizeGhost`/`chooseTabAction`) extraits dans `autocomplete.js`, liste déroulante au curseur quand plusieurs mots correspondent, ghost **positionné au curseur** (plus de miroir du document entier, nettoyé au déplacement/scroll), complétion de mot sans espace garantie et prompt `/api/ai/inline-complete` simplifié (128 tokens). Vérifié : `tests/frontend/forge-completion.test.mjs` 28/28 (nouveau), `unit.test.mjs` 9/9, `editor-inline.test.mjs` 40/40, `ai.test.mjs` 88/88, validate-imports 38 modules, pytest 1101 passed / 6 skipped, ruff 0, mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-17 | BUG-055 (complément) | Correction | `frontend/editor-poc.html`, `frontend/js/utils.js`, `tests/frontend/forge-completion.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-055 (complément)** : une complétion acceptée au `Tab` disparaissait 1–2 s plus tard. Cause : l'auto-sauvegarde (2 s) déclenche un `index_updated` SSE sur le fichier affiché, et `reloadExternalWrite` rechargeait le tampon Forge **depuis le disque**, écrasant toute frappe postérieure à la sauvegarde. Le rechargement SSE est désormais ignoré si le tampon est modifié (`parent-reload` sans `force` + `isDirty` ; garde équivalente sur le point d'auto-sauvegarde CodeMirror) ; seul `obsigate:file-written` (assistant IA) passe `force=true`. L'auto-sauvegarde ne remet plus l'état « enregistré » si des modifications sont arrivées pendant la requête (Forge + CodeMirror), et `acceptGhost()` annule la requête de prédiction en attente. Vérifié : `forge-completion.test.mjs` 31/31 (+3), `editor-inline.test.mjs` 41/41 (+1), 14 suites frontend vertes, validate-imports 38 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-17 | BUG-056 | Correction | `frontend/editor-poc.html`, `frontend/js/sync.js`, `tests/frontend/forge-completion.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-056** : en plein écran Forge, l'Assistant IA s'ouvrait en arrière-plan. La sortie du plein écran est désormais faite **côté iframe** (`openAssistant` → `document.exitFullscreen()` puis `postMessage` à la résolution) **et côté parent** (`sync.js` sur `forge-open-ai` → `document.exitFullscreen()` puis `openForCurrentContext()`), car le plein écran peut appartenir au document parent (l'iframe voit alors `fullscreenElement` nul et sa sortie échoue — c'était le cas non couvert par le premier correctif). Vérifié : `forge-completion.test.mjs` 32/32 (+1), `editor-inline.test.mjs` 42/42 (+1), 14 suites frontend vertes, validate-imports 38 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-17 | BUG-057, #102 | Correction + feature | `frontend/js/bookslm.js`, `frontend/editor-poc.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `docs/archive/COMPLETED_v1-v2.md`, `docs/ROADMAP.md`, `CHANGELOG.md` | **BUG-057** : le bouton « Ajouter » de l'assistant ne ciblait que `state.editorView` (CodeMirror) ; en Forge il affichait « Aucun document ouvert dans l'éditeur ». `_insertIntoEditor()` gère désormais les trois surfaces : CodeMirror, l'iframe Forge (`postMessage({ type: 'parent-insert', text })` → `insertAtCursor` dans `editor-poc.html`) et le textarea de repli. **#102** : chaque bloc de code d'une réponse reçoit un bouton « Ajouter la section » (`.bookslm-code-insert`, révélé au survol) qui insère le contenu du bloc sans les délimiteurs ` ``` `. Vérifié : `ai.test.mjs` 91/91 (+3), `editor-inline.test.mjs` 43/43 (+1), `forge-completion.test.mjs` 32/32, unit 9/9, validate-imports 38 modules, pytest 1101 passed / 6 skipped, ruff 0, mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-17 | BUG-058 | Correction | `frontend/style.css`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-058** : la barre de numérotation de ligne de l'éditeur « Editer » ne suivait pas le thème — CodeMirror peint `.cm-gutters` avec des valeurs claires codées en dur (`#f5f5f5`, bordure `#ddd`), visibles en thème sombre. Correctif : le gutter dérive des variables CSS ObsiGate (`background: color-mix(in srgb, var(--text-primary) 5%, transparent)`, `color: var(--text-secondary)`, `border-right: 1px solid var(--border)`, ligne active `color-mix(… 10% …)` / `--text-primary`), donc il suit les 15 thèmes et les 4 modes. Vérifié : `editor-inline.test.mjs` 44/44 (+1), unit 9/9, validate-imports 38 modules, pytest 1101 passed / 6 skipped, ruff 0, mypy 0, et Playwright sur l'instance de test (route `style.css` remplacée par le fichier local) — sombre `color(srgb 0.90 0.93 0.95 / 0.05)` + bordure `#21262d`, clair `color(srgb 0.12 0.14 0.16 / 0.05)` + bordure `#d0d7de`, plus de `rgb(245,245,245)`. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-17 | BUG-059 | Correction | `frontend/js/bookslm.js`, `tests/frontend/ai.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-059** : dans une conversation ouverte (post ancré en haut), **tout clic** dans la fenêtre de messages — lien de fichier, étapes, sélection de texte — faisait sauter toute la conversation au bas de la fenêtre. Cause : le gestionnaire `mousedown` de dépintage (prévu pour la molette/tactile/poignée de scroll) se déclenchait aussi sur un simple clic, et le retrait du padding d'ancre (`paddingBottom`) bornait le `scrollTop` à la nouvelle hauteur max → saut au bas. Correctif : helper pur `isScrollbarPress(target, clientX, container)` — un appui ne dépine que s'il vise la **poignée de scroll** (cible = conteneur + zone de gouttière droite) ; molette et tactile conservent leur comportement. Vérifié : `ai.test.mjs` 92/92 (+1), unit 9/9, validate-imports 38 modules, pytest / ruff / mypy inchangés côté backend. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-17 | BUG-060 | Correction | `frontend/js/viewer.js`, `.gitea/workflows/ci.yml`, `tests/frontend/pdf-viewer.test.mjs` (nouveau), `tests/e2e/pdf-viewer.spec.js` (nouveau), `test_vault/sample-pdf.pdf` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-060** : l'ouverture d'un PDF n'affichait aucune page (barre d'outils « PDF — N pages » présente, corps vide). Cause : la CSP durcie en BUG-034 pose `object-src 'none'` — directive qui gouverne `<embed>`/`<object>` — alors que le viewer rendait le PDF via `<embed type="application/pdf">` : le lecteur natif était bloqué. Correctif : rendu dans une `<iframe>` (autorisée par `frame-src 'self'`, le stream `/api/file/{vault}/pdf/stream` étant same-origin) ; `object-src 'none'` conservé. Tests : `pdf-viewer.test.mjs` 6/6 (statique : pas d'`<embed>`, CSP `frame-src 'self'`, iframe pleine hauteur), `pdf-viewer.spec.js` (E2E : iframe + stream `application/pdf` 200/206 + zéro violation CSP ; échoue bien avec l'ancien `<embed>`). Vérifié : pytest 1184 passed / 6 skipped, frontend 14 suites JSDOM vertes, validate-imports 38 modules, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-17 | BUG-035, BUG-036, BUG-037, BUG-038, BUG-039, BUG-040 | Correction | `backend/secret_redactor.py`, `backend/collab.py`, `backend/auth/{middleware,password,router}.py`, `backend/indexer.py`, `backend/main.py`, `tests/test_api_main.py`, `tests/test_auth.py`, `tests/test_auth_api.py`, `tests/test_collab.py`, `tests/test_pdf.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **Lot de 6 bugs mineurs (P2/P3)** : BUG-035 masquage hex conditionné au contexte (git/SHA épargnés) ; BUG-036 jeton WebSocket cookie-only (plus de `?token=`) + plafond de trame 16 Mio ; BUG-037 garde-fou au démarrage (refus d'un bind public sans auth sauf `OBSIGATE_ALLOW_INSECURE=true`) ; BUG-038 Argon2 recalibré 19 Mio/t=2/p=1 ; BUG-039 login uniforme 401 (fini 429/403 distinctifs) ; BUG-040 extraction PDF différée via `enrich_pdf_texts()`. Vérifié : pytest 1204 passed / 6 skipped, ruff 0, mypy 0 (77 fichiers), frontend validate-imports 38 modules + unit 9/9. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-17 | BUG-061, BUG-062, BUG-063 | Correction | `frontend/style.css`, `frontend/js/viewer.js`, `tests/frontend/ai.test.mjs`, `tests/frontend/pdf-viewer.test.mjs`, `tests/e2e/pdf-viewer.spec.js`, `test_vault/sample-pdf-toc.pdf` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **Viewer PDF & assistant IA** : BUG-061 le bouton plein écran du panneau assistant l'emportait mal sur la largeur inline (redimensionnement/persistée) → `width: 100vw !important` ; BUG-062 le plafond de lecture 1200 px s'appliquait au PDF quand la navigation était masquée → `:has(.pdf-viewer-container)` en `max-width:none` ; BUG-063 la TOC PDF ne naviguait pas (`contentWindow` = `about:blank`) → `navigatePdfToPage()` recharge l'iframe avec `#page=N`, liens `data-page` sans `onclick` inline. Vérifié : `ai.test.mjs` 93/93, `pdf-viewer.test.mjs` 8/8, validate-imports 38 modules (311 exports), unit 9/9, E2E `pdf-viewer.spec.js` 3/3, et Playwright sur l'instance de test (plein écran 640→1400 px, `src` → `#page=3`, `max-width:none`). | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-17 | BUG-063 (complément) | Correction | `frontend/js/viewer.js`, `tests/frontend/pdf-viewer.test.mjs`, `tests/e2e/pdf-viewer.spec.js`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-063 non résolu au premier correctif** : définir `iframe.src = base + '#page=N'` ne change que le fragment → navigation same-document que le lecteur PDF natif ignore. Diagnostic en Chrome *headful* (comparaison de captures) : fragment présent au chargement = OK ; changement de fragment après chargement = aucun effet ; changement de query + fragment = OK. `navigatePdfToPage()` ajoute donc un paramètre de query horodaté (`&_pdfpage=<ts>#page=N`) pour forcer un vrai rechargement. Vérifié via l'UI de l'app (Chrome headful) : page 1 → page 8 → retour page 1 (hash de capture identique au retour). Tests : `pdf-viewer.test.mjs` 8/8, E2E `pdf-viewer.spec.js` 3/3. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-17 | BUG-064 | Correction | `frontend/excalidraw-editor.html`, `backend/main.py`, `tests/frontend/excalidraw-viewer.test.mjs`, `tests/test_security_hardening.py`, `tests/e2e/excalidraw.spec.js`, `test_vault/diagram-app-export.excalidraw` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-064** : aucun diagramme Excalidraw ne s'affichait (canvas vide). Diagnostic navigateur : `.excalidraw` sans hauteur fixe → boucle de redimensionnement 525 → 56 181 → **33 554 432 px** (`2^25`, plafond Excalidraw) ; canvas de 33 Mpx impossible à dessiner → scène blanche. **Cause 1** : la feuille de style `@excalidraw/excalidraw` n'était jamais chargée (seuls 18 règles CSS présentes, toutes ObsiGate) — l'éditeur était non stylisé. Correctif : `<link rel="stylesheet" href="https://esm.sh/@excalidraw/[email protected]/dist/prod/index.css">` + `https://esm.sh` ajouté à `style-src` de la CSP. **Cause 2** : `appState.collaborators` (Map sérialisée en objet JSON par l'app/plugin) faisait planter Excalidraw 0.18 (`collaborators.forEach is not a function`) ; `sanitizeAppState()` reconvertit en `Map` et écarte la géométrie de viewport importée (`width/height/offsetLeft/offsetTop`). Vérifié Playwright sur l'instance de test (port 2020) : hauteur canvas 525 px, rectangle + losange affichés, UI stylisée, 0 `pageerror`. Tests : `excalidraw-viewer.test.mjs` 8/8 (dont 3 nouveaux), `TestCspExcalidrawStylesheet` (pytest), E2E (hauteur de canvas bornée). | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-17 | BUG-064 (complément) | Correction | `frontend/style.css`, `tests/frontend/excalidraw-viewer.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-064 (complément)** : quand la barre de navigation gauche est masquée, le viewer Excalidraw restait borné à la colonne de lecture centrée de 1200 px. La règle `.sidebar.hidden ~ .content-wrapper .content-area { max-width: 1200px }` s'appliquait au viewer comme aux notes. Ajout de `.content-area:has(iframe[src*="excalidraw-editor.html"])` en `max-width: none; margin: 0` (même traitement que les viewers PDF/image, BUG-062). Vérifié Playwright (viewport 1400 px) : contenu 1115 → 1400 px, iframe 1035 → 1320 px, `max-width` calculé `none`. Test statique ajouté (`excalidraw-viewer.test.mjs` 9/9). | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-17 | BUG-065, #78 (complément) | Correction + feature | `frontend/js/excalidraw-viewer.js`, `frontend/js/utils.js`, `frontend/excalidraw-editor.html`, `tests/frontend/excalidraw-viewer.test.mjs`, `docs/features/excalidraw.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-065** : l'auto-save Excalidraw (débounce 2 s) déclenchait `PUT save` → SSE `index_updated` → `reloadExternalWrite` → `openFile` → recréation de l'iframe = refresh visible pendant le dessin. Auto-save retirée (`excalidraw-viewer.js` : plus de `requestSave`/`saveTimer`), sauvegarde explicite (bouton 💾 / Ctrl+S) ; `reloadExternalWrite` (utils.js) court-circuite le re-rendu si un iframe Excalidraw est ouvert sur ce fichier (attributs `data-excalidraw-vault`/`data-excalidraw-path`) ; le badge « Modified » suit désormais une signature des éléments (`id:versionNonce`) au lieu de tout `onChange` — resize/zoom/plein écran ne marquent plus le fichier modifié. **#78 (complément)** : bouton **plein écran** `#btn-fullscreen` dans la barre d'outils de l'éditeur (`requestFullscreen` sur le document de l'iframe) + iframe créée avec `allow="fullscreen" allowfullscreen`. Vérifié Playwright : bascule plein écran OK (`document.fullscreenElement` true→false), badge non modifié après bascule ; tests statiques `excalidraw-viewer.test.mjs` 12/12, validate-imports 38 modules, unit 9/9. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 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.8.2 | **Dernière mise à jour :** 2026-09-17
|
||||
> **Version :** 2.27.2 | **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,86 +61,30 @@
|
||||
|
||||
---
|
||||
|
||||
## ⚪ 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
|
||||
|
||||
### 92. Assistant IA — Écosystème d'outils (phase 2 : web étendu, sources connectées, documents)
|
||||
|
||||
- **Effort :** 3-5 jours | **Impact :** 🟠 | **Zone :** backend (`backend/tools/`)
|
||||
- **Dépend de :** #91 (registre + section « steps » + `web_search`/`fetch_url` livrés)
|
||||
- **Description :** étendre le catalogue d'outils de l'assistant au-delà du vault, en
|
||||
suivant la feuille de route technique détaillée :
|
||||
[features/ai-tools-roadmap.md](./features/ai-tools-roadmap.md) (frameworks évalués,
|
||||
bibliothèques par catégorie, transverse retry/cache/secrets/async).
|
||||
- **Sous-tâches :**
|
||||
- [x] `web_search` : chaîne de repli sans clé (SearXNG → DuckDuckGo → Bing, `OBSIGATE_WEB_FALLBACK`) — BUG-051
|
||||
- [ ] `web_search` : fournisseurs optionnels à clé (Tavily, Brave, SerpAPI, Exa)
|
||||
- [ ] `fetch_url` : pages dynamiques via Playwright (worker isolé) ; crawl multi-pages Scrapy en tâche de fond
|
||||
- [ ] Sources connectées : Gitea/GitHub (priorité haute) puis Google Drive / OneDrive (OAuth2 `authlib`)
|
||||
- [ ] Production de documents : conversion, tableurs, PDF/Word (outils WRITE + confirmation)
|
||||
- [ ] Transverse : `tenacity` (backoff), cache SQLite des résultats web avec TTL, secrets via Infisical
|
||||
- [ ] Chaque outil : libellé `labels.py` + clés i18n `ai.step.*` FR/EN + tests (httpx mocké)
|
||||
|
||||
---
|
||||
|
||||
## ⚪ 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.
|
||||
- **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).**
|
||||
- **Statut :** 🔵 en cours depuis 2026-09-26 — découpe par tranches à impact minimal (comportement inchangé, un domaine par commit). **T1 livrée (v2.27.2) :** `health` (`/api/health`, `/api/health/detailed` → `backend/routers/health.py`, `HealthResponse` → `schemas.py`).
|
||||
- **Description :** extraire le monolithe `backend/main.py` (~4 827 lignes au 2026-09-26, ~17 % du backend) 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. L'état mémoire actuel (index, inverted index, vecteurs sémantiques, `SSEManager`, collab) rend le multi-workers unsafe.
|
||||
- **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
|
||||
- [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai — `main.py` conservé comme assemblage (< 500 lignes) ; dédupliquer les modèles Pydantic vers `schemas.py`. **Avancement :** `health` ✅ (T1, `backend/routers/health.py`) ; `tools/registry.py` existe déjà (permissions/quotas/redaction — à compléter, pas à créer)
|
||||
- [ ] Compléter `tools/registry.py` (existant : permissions/quotas/redaction) comme contrat central des outils IA si des manques sont constatés
|
||||
- [ ] Persister index, JTI révoqués et compteurs de rate-limit (SQLite par défaut, Redis en option multi-nœuds ; le rate-limit actuel est in-memory mono-process)
|
||||
- [ ] Verrous asyncio autour de l'index global et des stores JSON ; auditer les `except Exception` larges (> 100 occurrences) : best-effort (backup/audit) vs masquage d'erreur (erreurs typées 4xx/5xx + test)
|
||||
- [ ] Extraire le service de partage public (expiration, révocation, quotas)
|
||||
|
||||
### 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é).**
|
||||
- **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
|
||||
|
||||
---
|
||||
@@ -152,6 +97,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) |
|
||||
@@ -199,6 +145,25 @@
|
||||
| 99 | Sidebar — Filtrage des vues « Récents » et « Sauvegardes » | 2.6.0 | [features/sidebar-filters.md](./features/sidebar-filters.md) |
|
||||
| 100 | Assistant IA — Deep Research en pastille (au lieu du texte injecté) | 2.6.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) |
|
||||
| 101 | Forge — Assistant IA partagé (bouton AI Panel = assistant, fournisseur/modèle configuré, autocomplétion) + plein écran Forge/Editer | 2.8.0 | [features/forge-assistant.md](./features/forge-assistant.md) |
|
||||
| BUG-057 | Assistant IA — bouton « Ajouter » fonctionnel dans l'éditeur Forge (en plus d'« Editer ») | 2.9.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
| 102 | Assistant IA — bouton « Ajouter la section » par bloc de code (insertion du bloc seul) | 2.9.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
| 92 | Assistant IA — Écosystème d'outils phase 2 (recherche à clé, cache/retry, Playwright, crawl, Gitea/GitHub, documents XLSX/DOCX/CSV/PDF) | 2.10.0 | [features/ai-tools-roadmap.md](./features/ai-tools-roadmap.md) |
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -206,17 +171,17 @@
|
||||
|
||||
| Priorité | Items | Effort total estimé |
|
||||
|---|---|---|
|
||||
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93, #94–100 | ~114 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 |
|
||||
| ⚪ P2 restant | #92 Assistant IA — écosystème d'outils phase 2 (web étendu, sources connectées, documents) | 3-5 jours |
|
||||
| ⚪ P0/P1 restant | #85-87 Refonte architecturale, performance, CI/CD (issues BUG-035 → BUG-040) | ~15-23 jours |
|
||||
| **Total restant** | **10 items + finitions** | **~30-47 jours** |
|
||||
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–115, #117, #92 | ~133 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 | #85, #87 Refonte architecturale, CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~11-17 jours |
|
||||
| **Total chemin critique** | **#77 fin + #85 + #87** | **~12-18 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.
|
||||
- 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.
|
||||
|
||||
@@ -385,6 +385,40 @@ Fichiers texte non markdown :
|
||||
|
||||
---
|
||||
|
||||
## #102 — Assistant IA : « Ajouter » dans Forge + ajout d'un bloc de code ✅ TERMINÉ
|
||||
|
||||
Deux compléments au bouton « Ajouter » de l'assistant IA.
|
||||
|
||||
- **BUG-057 — Forge** : `bookslm.js::_insertIntoEditor()` ne ciblait que
|
||||
`state.editorView` (CodeMirror de « Editer ») et affichait « Aucun document ouvert
|
||||
dans l'éditeur » en Forge. Il prend désormais en charge les trois surfaces :
|
||||
CodeMirror, l'iframe Forge (délégation par `postMessage({ type: 'parent-insert' })`,
|
||||
insert au curseur via `insertAtCursor` côté `editor-poc.html`) et le textarea de
|
||||
repli.
|
||||
- **#102 — Ajout d'un bloc** : chaque bloc de code d'une réponse reçoit un bouton
|
||||
« Ajouter la section » (révélé au survol, `.bookslm-code-insert`) qui insère
|
||||
uniquement le contenu du bloc (sans les délimiteurs ` ``` `), au lieu de la réponse
|
||||
complète.
|
||||
- **Tests** : `tests/frontend/ai.test.mjs` (+3 : Forge, textarea, bloc de code) ;
|
||||
`tests/frontend/editor-inline.test.mjs` (+1 : handler `parent-insert`).
|
||||
|
||||
---
|
||||
|
||||
## #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,88 @@
|
||||
# #104 — Configuration : Redesign UI de la section « Clés API Intelligence Artificielle »
|
||||
|
||||
> **Statut :** ✅ livré (v2.12.0) · **Zone :** `frontend/index.html`, `frontend/js/config.js`,
|
||||
> `frontend/js/ai.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`
|
||||
|
||||
## Problème
|
||||
|
||||
La section « 🤖 Clés API IA » du panneau de configuration était une longue liste plate de
|
||||
champs de saisie (DeepSeek, OpenRouter, Gemini, NVIDIA, QwenCloud, Xiaomi, Mistral) sans
|
||||
structure ni hiérarchie visuelle : les sélecteurs défaut, les clés et les modèles se
|
||||
cotoyaient au même niveau, les badges « Configuré » étaient collés à chaque champ, et les
|
||||
boutons d'action se perdaient en milieu de section.
|
||||
|
||||
## Objectifs
|
||||
|
||||
Interface plus professionnelle, mieux structurée, moins fatigante visuellement (style
|
||||
SaaS moderne, dark mode, cartes + accordéons), et qui reste utilisable quand la liste de
|
||||
fournisseurs s'allonge.
|
||||
|
||||
## Structure livrée
|
||||
|
||||
### 1. En-tête de section
|
||||
|
||||
- Titre + sous-titre explicatif (i18n `config.ai_header_desc`).
|
||||
- **Barre de recherche** (`#cfg-ai-search`) filtrant les cartes fournisseurs via
|
||||
`filterAIProviders()` — normalisation insensible à la casse **et aux accents**
|
||||
(`_sidebarNorm`), correspondance sur le nom affiché ou l'identifiant. État vide
|
||||
« Aucun fournisseur ne correspond » (`#cfg-ai-providers-empty`).
|
||||
|
||||
### 2. Carte « Configuration par défaut »
|
||||
|
||||
- Carte visuellement distincte (`.ai-default-card`) : titre en petites capitales.
|
||||
- Grille **2 colonnes** : « Fournisseur par défaut » / « Modèle par défaut »
|
||||
(`#cfg-ai-default-provider`, `#cfg-ai-default-model`).
|
||||
- **Capacités du modèle en badges colorés** : nouveau
|
||||
`renderCapabilityBadges(caps)` (`frontend/js/ai.js`) qui n'affiche que les capacités
|
||||
actives sous forme de tags (`.ai-cap-badge`), au lieu de la checklist ☑/□
|
||||
(`renderCapabilityList`, conservée pour les pickers de l'assistant).
|
||||
|
||||
### 3. Fournisseurs d'API — cartes dépliables
|
||||
|
||||
Rendu dynamique par `_renderAIProviderCards()` depuis `AI_PROVIDER_NAMES` + nouveau
|
||||
`AI_PROVIDER_META` (nom affiché, placeholder spécifique au fournisseur). Par carte :
|
||||
|
||||
- **Replié** : logo (initiale dans une pastille), nom, **badge de statut**
|
||||
(« Configuré » vert / « Non configuré » gris, `.ai-provider-badge.configured`) et
|
||||
**corbeille discrète** (`cfg-<provider>-delete`, visible uniquement si une clé existe ;
|
||||
`stopPropagation` pour ne pas déplier la carte ; confirmation conservée).
|
||||
- **Déplié** : libellés **au-dessus** des champs (`.ai-field-label`), **API key à 60 % /
|
||||
modèle à 40 %** (grille `3fr 2fr`), placeholder par fournisseur.
|
||||
- Accessibilité : en-tête en `role="button"` + `tabindex="0"` (clavier Entrée/Espace),
|
||||
`aria-expanded` synchronisé, chevron animé, focus visible.
|
||||
- Les boutons « Configuré / × Supprimer » redondants dans les champs sont supprimés —
|
||||
le statut vit dans l'en-tête de la carte.
|
||||
|
||||
### 4. Barre d'actions
|
||||
|
||||
- Footer **sticky** en bas de section (`.ai-keys-footer`) : « Sauvegarder » (primaire
|
||||
`.config-btn-save`) et « Tester » (outline `.config-btn-secondary`), plus le span de
|
||||
statut du test. Toujours accessible pendant le défilement du panneau.
|
||||
|
||||
## Compatibilité
|
||||
|
||||
- Les ID `cfg-<provider>-key`, `cfg-<provider>-model`, `cfg-<provider>-badge`,
|
||||
`cfg-<provider>-delete`, `cfg-ai-default-*`, `cfg-ai-status` sont conservés :
|
||||
`saveAIKeys()`, `testAIKeys()`, `deleteAIKey()` et le câblage des événements de
|
||||
`initConfigModal()` ne changent pas, ni la resynchronisation des pickers IA
|
||||
(`refreshAIPickers()` après save/suppression, BUG-043).
|
||||
- i18n : nouvelles clés `config.ai_header_desc`, `config.ai_search_placeholder`,
|
||||
`config.ai_default_section`, `config.ai_providers_title`, `config.ai_providers_empty`,
|
||||
`config.ai_status_configured`, `config.ai_status_not_configured`,
|
||||
`config.ai_delete_key_title` (FR + EN).
|
||||
|
||||
## Styles
|
||||
|
||||
`.ai-keys-*`, `.ai-provider-*`, `.ai-cap-badge`, `.ai-field*` — uniquement des variables
|
||||
CSS existantes (`--surface`, `--bg-secondary`, `--border`, `--accent`, `--success`,
|
||||
`--danger`, `--accent-bg`…), focus visibles, responsive 1 colonne < 600 px.
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/frontend/config-ai-keys.test.mjs` (JSDOM, 7 tests) : rendu des 7 cartes,
|
||||
badges de statut selon les clés masquées renvoyées par `GET /api/config/ai-keys`,
|
||||
bascule replié/déplié (classe `open` + `aria-expanded`), filtre de recherche
|
||||
(casse/accents + état vide), collecte et POST des clés saisies par `saveAIKeys()`,
|
||||
suppression avec confirmation (DELETE sur l'env name), badges de capacités.
|
||||
- Suites existantes (38 modules, validate-imports, ai.test.mjs, sidebar-filters…) : vertes.
|
||||
- E2E Playwright (chromium-desktop) : 91 passed / 3 skipped, 100 % sans retries.
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# #92 — Assistant IA — Écosystème d'outils : feuille de route technique
|
||||
|
||||
> **Statut :** ⚪ Backlog (phase 1 livrée dans #91)
|
||||
> **Statut :** ✅ livré (phase 2, version 2.10.0) — phase 1 livrée dans #91
|
||||
> **Effort estimé :** 3-5 jours pour la phase 2 | **Impact :** 🟠
|
||||
> **Références :** [Roadmap](../ROADMAP.md) · [Outils & MCP #79](./ai-tools-mcp.md) ·
|
||||
> [Fenêtre de discussion #91](./ai-assistant-conversation-ux.md) · [Changelog](../../CHANGELOG.md)
|
||||
@@ -36,6 +36,17 @@ réécriture de la boucle n'est nécessaire.
|
||||
|
||||
## 3. Phase 2 — catégories à implémenter
|
||||
|
||||
> **Livré (2.10.0, #92).** Récapitulatif des décisions finales :
|
||||
|
||||
| Catégorie | Décision livrée |
|
||||
|---|---|
|
||||
| Recherche web étendue | Tavily, Brave, SerpAPI, Exa à clé (`OBSIGATE_*_API_KEY`), essayés avant SearXNG ; ordre via `OBSIGATE_WEB_PROVIDERS` |
|
||||
| Lecture de pages | `fetch_url(render=True)` → worker Playwright isolé (`backend/tools/webrender.py`), dépendance optionnelle + erreur explicite |
|
||||
| Crawl multi-pages | `crawl_site` (WRITE + confirmation) : BFS httpx borné (≤ 20 pages, même hôte, SSRF sur chaque URL) → condensé Markdown dans le vault. Scrapy écarté (dépendance lourde inutile à cette échelle) |
|
||||
| Sources connectées | Gitea + GitHub (`git_list_repos`, `git_search_issues`, `git_get_file`) via env/Infisical ; drives cloud (Drive/OneDrive) orientés serveur MCP externe (#79). **#103 (2.11.0)** : les clés (URL Gitea, tokens Gitea/GitHub, clés Tavily/Brave/SerpAPI/Exa) se saisissent aussi dans la page Configurations — `backend/tools/secrets.py`, valeur stockée prioritaire sur l'env |
|
||||
| Production de documents | `create_xlsx`, `create_docx`, `create_csv`, `create_pdf` — WRITE + confirmation, écrit via `save_raw_file(allow_docs=True)` (path safety + backup) |
|
||||
| Transverse | Cache SQLite (`webcache.py`, TTL `OBSIGATE_WEB_CACHE_TTL`), retry backoff maison (`OBSIGATE_WEB_RETRY`), secrets par env (Infisical-compatible) |
|
||||
|
||||
### 3.1 Recherche web étendue (`web_search`)
|
||||
- **Fallback sans clé — ✅ livré (BUG-051)** : chaîne de fournisseurs dans
|
||||
`backend/tools/web.py` — SearXNG auto-hébergé (`OBSIGATE_SEARXNG_URL`) puis, si
|
||||
|
||||
@@ -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": "***"}
|
||||
}}}
|
||||
```
|
||||
@@ -109,11 +109,14 @@ Serveur → client :
|
||||
|
||||
## Sécurité
|
||||
|
||||
- Authentification obligatoire si `OBSIGATE_AUTH_ENABLED=true` (cookie ou `?token=`).
|
||||
- Authentification obligatoire si `OBSIGATE_AUTH_ENABLED=true` : le jeton est lu depuis le cookie
|
||||
HttpOnly `access_token` (envoyé lors du handshake same-origin). Le jeton en query string
|
||||
(`?token=`) n'est **plus accepté** (BUG-036 : URLs journalisées par les proxies).
|
||||
- Vérification `check_vault_access()` par connexion (un utilisateur ne peut pas rejoindre une room
|
||||
d'une vault non autorisée).
|
||||
- `resolve_safe_path()` empêche toute traversée de chemin (`../../`).
|
||||
- Bornes anti-abus : `MAX_UPDATE_BYTES` (8 Mo) par mise à jour, `MAX_TEXT_CHARS` (8 Mio) par snapshot.
|
||||
- Bornes anti-abus : `MAX_UPDATE_BYTES` (8 Mo) par mise à jour, `MAX_TEXT_CHARS` (8 Mio) par snapshot,
|
||||
`MAX_MESSAGE_CHARS` (16 Mio) par trame brute.
|
||||
- Le serveur ne décode pas le binaire Yjs : il le stocke et le relaie tel quel (pas de surface
|
||||
d'attaque supplémentaire côté parsing).
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# #78 — Éditeur Excalidraw — Ouverture et édition de fichiers .excalidraw
|
||||
|
||||
> **Statut :** ✅ Terminé (2026-09-10 — éditeur iframe complet, détection, création, autosave, support `.excalidraw.md`, B5 extraction texte pour la recherche, C8 création via menu contextuel, F3 E2E `tests/e2e/excalidraw.spec.js`, doc H1-H3. F2 non retenu. BUG-002 corrigé)
|
||||
> **Statut :** ✅ Terminé (2026-09-10 — éditeur iframe complet, détection, création, autosave, support `.excalidraw.md`, B5 extraction texte pour la recherche, C8 création via menu contextuel, F3 E2E `tests/e2e/excalidraw.spec.js`, doc H1-H3. F2 non retenu. BUG-002 et BUG-064 corrigés. 2026-09 : A9 bouton **plein écran** ajouté, auto-save retirée au profit d'une sauvegarde explicite (BUG-065))
|
||||
> **Effort :** 3-4 jours | **Impact :** 🟡
|
||||
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog — 2.2.0](../../CHANGELOG.md)
|
||||
|
||||
@@ -56,17 +56,18 @@
|
||||
</script>
|
||||
```
|
||||
- [x] **A5. Rendu du composant** : Monter `<ExcalidrawLib.Excalidraw>` dans le conteneur avec les `initialData` reçues. Configurer les callbacks `onChange` pour détecter les modifications.
|
||||
- [x] **A6. Barre d'outils minimaliste** (dans l'iframe, superposée en haut à droite) :
|
||||
- Bouton « 💾 Sauvegarder » → envoie les données au parent
|
||||
- Badge « Modifié » (disparaît après sauvegarde)
|
||||
- Indicateur de thème 🌙/☀️
|
||||
- Optionnel : bouton « Export PNG » et « Export SVG » (natif Excalidraw)
|
||||
- [x] **A6. Barre d'outils minimaliste** (dans l'iframe ; depuis 2026-09 : **colonne d'icônes** collée au bord droit (`right: 0`), début à `45%` de la hauteur, empilement vertical) :
|
||||
- Bouton « Sauvegarder » (icône disquette → coche après sauvegarde) → envoie les données au parent
|
||||
- Boutons « Export PNG » (icône image) et « Export SVG » (icône vectorielle) — infobulles au survol
|
||||
- Bouton plein écran (A9)
|
||||
- Badge « Modifié » réduit à une pastille au-dessus des boutons
|
||||
- [x] **A7. Communication postMessage** :
|
||||
- Réception : écouter `message` → si `type === "init"`, charger `data.elements` + `data.appState` + `data.files` dans l'état Excalidraw. Si `type === "theme"`, basculer `theme` (dark/light).
|
||||
- Émission : `postMessage({type: "save", data: {elements, appState, files}}, "*")` quand l'utilisateur sauvegarde.
|
||||
- Émission : `postMessage({type: "ready"}, "*")` au chargement pour signaler que l'iframe est prête.
|
||||
- Émission : `postMessage({type: "modified", dirty: true/false}, "*")` pour l'indicateur de modification.
|
||||
- [x] **A8. Gestion des erreurs** : Si les données sont invalides (JSON corrompu, pas un fichier Excalidraw), afficher un message d'erreur stylisé dans l'iframe.
|
||||
- [x] **A9. Bouton plein écran** (ajouté 2026-09) : bouton `#btn-fullscreen` dans la barre d'outils de l'iframe → `document.documentElement.requestFullscreen()` (l'iframe parent est créée avec `allow="fullscreen" allowfullscreen`) ; l'icône bascule entrer/sortir via `fullscreenchange`. La feuille de style Excalidraw étant chargée, le canvas suit le redimensionnement. Test statique : `tests/frontend/excalidraw-viewer.test.mjs`.
|
||||
|
||||
## B. Backend — Détection et API (0.5 jour)
|
||||
- [x] **B1. Ajout à `SUPPORTED_EXTENSIONS`** : Ajouter `.excalidraw` dans `backend/indexer.py:56` pour que les fichiers apparaissent dans l'arborescence et soient indexés.
|
||||
@@ -100,7 +101,7 @@
|
||||
- Pour les fichiers `.excalidraw` : remplacer « Éditer (Forge) » par « Ouvrir dans Excalidraw.com » (lien externe, nouvel onglet)
|
||||
- Garder « Télécharger » (.excalidraw) et « pop-out »
|
||||
- Badge « Excalidraw » avec icône `pen-tool`
|
||||
- [x] **C4. Auto-save** : Débounce 2 secondes après la dernière modification dans l'iframe → sauvegarde automatique silencieuse (comme l'éditeur markdown #29). L'iframe émet `modified` → le parent démarre un timer → au bout de 2s sans nouvelle modification → `postMessage({type: "requestSave"})` → l'iframe répond avec `save` → le parent écrit via l'API.
|
||||
- [x] **C4. Sauvegarde explicite uniquement** (modifié 2026-09 : l'auto-save a été **retirée**, BUG-065) : l'iframe émet `modified` → le badge « Modified » s'affiche, mais **aucune sauvegarde automatique** n'est déclenchée. La sauvegarde se fait par le bouton « 💾 Save » de l'iframe ou `Ctrl+S`. Raison : chaque écriture déclenche l'événement SSE `index_updated`, qui re-rendait la vue et **rechargeait l'iframe** (refresh visible en pleine édition).
|
||||
- [x] **C5. Raccourci Ctrl+S** : L'iframe intercepte Ctrl+S → envoie `save` au parent → le parent sauvegarde → confirmation visuelle (toast « Excalidraw sauvegardé »).
|
||||
- [x] **C6. Compatibilité Split View (#75)** : L'iframe s'affiche dans le content-area du panneau actif. Le `PaneTabManager` gère le cache : quand on switch d'onglet, l'état de l'iframe est préservé (elle reste dans le DOM, juste masquée). Plusieurs iframes Excalidraw peuvent coexister dans différents panneaux.
|
||||
- [x] **C7. Création via la modale « Nouveau fichier »** : Dans `frontend/js/ui.js`, fonction `showCreateFileModal()` :
|
||||
@@ -141,6 +142,8 @@
|
||||
- **Taille du bundle** : React + ReactDOM + Excalidraw ≈ 2.5 Mo minifié. Chargé depuis `esm.sh` (CDN global, cache HTTP). L'impact n'est perceptible qu'à la première ouverture d'un `.excalidraw`. Solution : précharger l'iframe en arrière-plan (`<link rel="prefetch">`) après le chargement de l'app.
|
||||
- **Performance React dans iframe** : React dans une iframe fonctionne parfaitement — c'est un contexte JavaScript indépendant. Testé sur Chrome, Firefox, Safari, Edge.
|
||||
- **CORS et esm.sh** : Les modules ESM depuis `esm.sh` sont servis avec les headers CORS appropriés. L'iframe est same-origin (`/frontend/excalidraw-editor.html`) donc pas de problème.
|
||||
- **Compatibilité des exports de l'app Excalidraw** (BUG-064) : `appState.collaborators` est une `Map` qu'Excalidraw sérialise en objet JSON (`{}`) ; elle doit être reconvertie en `Map` (`sanitizeAppState()` dans `frontend/excalidraw-editor.html`) avant `initialData`, sinon Excalidraw 0.18 plante (`collaborators.forEach is not a function`). La géométrie de viewport (`width`, `height`, `offsetLeft`, `offsetTop`) est également écartée : ce sont des valeurs mesurées côté fenêtre source, qu'Excalidraw recalcule. Couvert par un test E2E (`diagram-app-export.excalidraw`).
|
||||
- **Feuille de style Excalidraw obligatoire** (BUG-064) : `@excalidraw/excalidraw` n'injecte pas son CSS automatiquement — il faut le charger explicitement (`<link>` vers `…/@excalidraw/[email protected]/dist/prod/index.css`). Sans lui, l'éditeur est non stylisé **et** `.excalidraw` n'a pas de hauteur fixe, ce qui déclenche une boucle de redimensionnement jusqu'au plafond `2^25` (33 554 432 px) : le canvas devient indessinable et la scène reste blanche. Le CDN `esm.sh` doit donc figurer dans `style-src` de la CSP (`backend/main.py`). Garde-fous : `tests/frontend/excalidraw-viewer.test.mjs` et `TestCspExcalidrawStylesheet`.
|
||||
- **Mises à jour d'Excalidraw** : La version est épinglée (`@0.18.0`). Pour mettre à jour, changer le numéro dans le HTML + tester. Le format de données `.excalidraw` est stable (v2 depuis 2021).
|
||||
- **Sécurité postMessage** : Vérifier `event.origin` dans les deux sens. L'iframe n'accepte que les messages de `window.parent`. Le parent n'accepte que les messages de l'iframe connue. Pas de `"*"` en production.
|
||||
- **Tauri Desktop (#77)** : L'iframe se charge depuis le filesystem local (`tauri://localhost/frontend/excalidraw-editor.html`). Les imports ESM depuis `esm.sh` fonctionnent si le réseau est disponible. Pour le mode offline, bundler Excalidraw dans l'app desktop (à traiter dans #77, pas ici).
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# #105 — Guide d'utilisation : couverture, téléchargement MD/PDF, Architecture
|
||||
|
||||
> **Statut :** livré | **Version :** 2.13.0 | **Bugs liés :** BUG-067
|
||||
> **Roadmap :** [docs/ROADMAP.md](../ROADMAP.md) · **Changelog :** [../../CHANGELOG.md](../../CHANGELOG.md)
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Après ~35 features livrées (#70→#104), le guide intégré (modale « Guide
|
||||
d'utilisation », `#help-modal` de `frontend/index.html`) ne représentait plus
|
||||
l'application : Mermaid, hors-ligne/PWA, collaboration Yjs, desktop Tauri,
|
||||
exports HTML/ePub/ZIP, recherche sémantique, MFA/WebAuthn, push, split view,
|
||||
API OpenAPI/MCP étaient absents. L'item couvre quatre livrables :
|
||||
|
||||
1. **Audit de couverture** — toutes les fonctionnalités visibles ET non visibles
|
||||
(API, MCP, webhooks, endpoints de partage) sont documentées.
|
||||
2. **Section Architecture** — diagramme Mermaid des grandes composantes.
|
||||
3. **Téléchargement Markdown + PDF** du guide, dans la langue courante.
|
||||
4. **Lecture desktop élargie** (mode Tauri + grands viewports web).
|
||||
|
||||
## 2. Source de vérité du contenu
|
||||
|
||||
Le contenu des nouvelles sections vit dans **`scripts/guide_content.py`** :
|
||||
dictionnaire `CONTENT` (clé → FR, EN) + constructeurs HTML qui **ne peuvent
|
||||
pas diverger** des locales (le FR inline est généré depuis `CONTENT`).
|
||||
|
||||
- `scripts/merge_guide_locales.py` → injecte les clés `guide105.*` dans
|
||||
`frontend/locales/{fr,en}.json` (insertion textuelle, parité assertée).
|
||||
- `scripts/insert_guide_sections.py` → insère sections/TOC/compléments dans
|
||||
`index.html` (idempotent, préserve les fins de ligne, vérifie l'équilibre
|
||||
`<section>` et l'absence d'ancre morte).
|
||||
- **Règle :** ne jamais éditer les blocs `guide105.*` des JSON ni les sections
|
||||
#105 de `index.html` à la main — modifier `guide_content.py` et relancer les
|
||||
deux scripts (séquence figée, sinon HTML et locales divergent).
|
||||
|
||||
## 3. Rendu du guide dans l'app
|
||||
|
||||
Les textes portent `data-i18n="guide105.*"` ; `_applyDOM()` (i18n.js) les
|
||||
remplit avec la locale courante. Les valeurs FR/EN contiennent du **HTML
|
||||
minimal** (`<code>`, `<strong>`, `<a href="https://…">`) — sûre ici car ces
|
||||
chaînes sont statiques dans le dépôt (jamais de contenu utilisateur ; même
|
||||
convention que `help.desc_*` existant).
|
||||
|
||||
Le diagramme d'architecture est un bloc `<pre class="mermaid-code"><code
|
||||
class="language-mermaid">` : à l'ouverture de la modale, `renderGuideMermaid()`
|
||||
(config.js) appelle `renderMermaidBlocks()` (mermaid-viewer.js) sur la modale —
|
||||
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 affiche le PNG pré-rendu
|
||||
(section 4 — WeasyPrint n'exécute pas Mermaid).
|
||||
|
||||
## 4. Téléchargement (MD + PDF)
|
||||
|
||||
`backend/guide_export.py` : parseur stdlib (html.parser → arbre minimal) ;
|
||||
extraction de `#help-modal`→`.help-content`, résolution i18n **identique à
|
||||
_applyDOM** (un élément `data-i18n` est remplacé par la valeur locale, sinon le
|
||||
FR inline sert de repli), conversion Markdown (titres, listes imbriquées,
|
||||
tables, fenced code, gras/italique/code/kbd/links http) et HTML propre pour
|
||||
WeasyPrint. PDF : pipeline d'export existant (`build_pdf_html` + `generate_pdf`),
|
||||
repli `_render_reportlab_pdf` (markdown simplifié) quand GTK manque (Windows
|
||||
nu) — même stratégie que le bouton « PDF » des documents (#92).
|
||||
`get_guide_document(fmt, lang)` met en cache (octets+signature mtime/size de
|
||||
`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 **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`
|
||||
(une fois, après le garde Tauri). CSS (section « Help Modal: desktop » de
|
||||
`style.css`) : conteneur 1760 px / 96 vw, contenu 1440 px, modale 94 vh ; le
|
||||
même layout est accordé aux viewports ≥1400 px web via media-query (contenu
|
||||
1280 px). Le mobile reste inchangé (≤768 px plein écran).
|
||||
|
||||
## 6. Couverture fonctionnelle du guide (après #105)
|
||||
|
||||
| Domaine app | Section du guide |
|
||||
|---|---|
|
||||
| Vaults, arborescence, filtres, breadcrumb | Interface, Navigation |
|
||||
| Onglets, popout, split view | Onglets, Personnalisation |
|
||||
| Recherche TF-IDF, opérateurs, facettes, sémantique, recherches sauvegardées, signets | Recherche, Bibliothèque |
|
||||
| Tags, frontmatter | Tags |
|
||||
| Fichiers, types supportés, CodeMirror, PDF, export HTML/ePub/ZIP, anti-doublons upload | Fichiers |
|
||||
| Mermaid | Diagrammes |
|
||||
| Excalidraw | Excalidraw |
|
||||
| Éditeur, AI toolbar, BooksLM, commandes @//, skills, images, outils, historique | Édition, IA |
|
||||
| Édition mobile | Mobile (section dédiée, BUG-067) |
|
||||
| Graphe, backlinks | Bibliothèque, Graphe |
|
||||
| Palette de commandes, raccourcis | Palette, Raccourcis |
|
||||
| Partage public, PDF du partage, webhooks HMAC | Partage, Webhooks |
|
||||
| Backups, diff, purge, audit log, gestionnaire | Sauvegardes et Audits |
|
||||
| JWT/Argon2id, rate limit, MFA TOTP/WebAuthn, admin dashboard, secrets, CSP | Sécurité |
|
||||
| PWA hors-ligne, IndexedDB queue, watcher, conflits Syncthing | Hors-ligne, Bibliothèque |
|
||||
| Collaboration Yjs/CRDT, awareness, push VAPID | Collaboration, Interface |
|
||||
| Tauri desktop (updater signé, wizard, fenêtrage) | Desktop |
|
||||
| API REST OpenAPI (/docs, /redoc, /api, /openapi.json), MCP /mcp, automatisation | API & Intégrations |
|
||||
| i18n FR/EN, export du guide multilingue | Multilingue |
|
||||
| Architecture technique (couches, flux, données, déploiement) | **Architecture** (nouveau) |
|
||||
| Plugins sandboxés | Plugins |
|
||||
|
||||
## 7. Tests & vérifications
|
||||
|
||||
- `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), **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→v23 (guides + boutons icônes).
|
||||
|
||||
## 8. Limites assumées
|
||||
|
||||
- Le PDF est généré côté serveur avec les polices système ; en l'absence de
|
||||
GTK (Windows de dev) c'est le repli reportlab (sans tableaux) — la voie
|
||||
WeasyPrint est celle des conteneurs Docker/prod.
|
||||
- Le sommaire et les sections sont en dur dans `index.html` : tout ajout futur
|
||||
de section passe par `guide_content.py` + les deux scripts (jamais à la main).
|
||||
@@ -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`).
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
- **Implémentation réelle (vérifiée 2026-09-07) :**
|
||||
- **Bugs corrigés (2026-09) :** `api_pdf_stream` crashait en 500 (`NameError: current_user` jamais injecté) ; l'indexation incrémentale du watcher faisait `read_text()` sur les PDFs (garbage) ; Range/206 et `pdf/info` absents malgré le texte ci-dessous.
|
||||
- **BUG-060 (2026-09-17) :** l'affichage inline ne fonctionnait plus — la CSP durcie en BUG-034 (`object-src 'none'`) bloquait l'`<embed>` du viewer (barre d'outils rendue, corps vide). Le rendu passe par une `<iframe>` (autorisée par `frame-src 'self'`), conforme à E1. Tests : `tests/frontend/pdf-viewer.test.mjs` + `tests/e2e/pdf-viewer.spec.js`.
|
||||
- `GET /api/file/{vault}/pdf/info` — métadonnées seules sans transférer le document (C3)
|
||||
- Stream avec `Accept-Ranges` + 206 Partial Content (single range, suffix-range, 416) (C2)
|
||||
- `OBSIGATE_PDF_MAX_SIZE_MB` (50) + `OBSIGATE_PDF_EXTRACT_TIMEOUT` (30s via thread-pool) (B4/G3)
|
||||
|
||||
@@ -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 |
@@ -1200,9 +1200,23 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
// iframe, so the AI button simply asks the parent to open it — no duplicated
|
||||
// panel, and the configured provider/model is shared automatically.
|
||||
function openAssistant() {
|
||||
try {
|
||||
window.parent.postMessage({ type: 'forge-open-ai' }, '*');
|
||||
} catch (e) { /* not embedded */ }
|
||||
var notify = function() {
|
||||
try {
|
||||
window.parent.postMessage({ type: 'forge-open-ai' }, '*');
|
||||
} catch (e) { /* not embedded */ }
|
||||
};
|
||||
// The shared assistant lives in the parent document, which cannot render
|
||||
// above a fullscreen Forge iframe: leave fullscreen first so the panel is
|
||||
// actually visible when it opens.
|
||||
if (document.fullscreenElement && document.exitFullscreen) {
|
||||
try {
|
||||
var p = document.exitFullscreen();
|
||||
if (p && p.then) p.then(notify, notify);
|
||||
else notify();
|
||||
} catch (e) { notify(); }
|
||||
} else {
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Fullscreen (#101) ----
|
||||
@@ -1460,6 +1474,13 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
window.addEventListener('message', function(e) {
|
||||
if (!e.data || !e.data.type) return;
|
||||
if (e.data.type === 'parent-save') { forceSave(); }
|
||||
// #102/BUG-057 — the parent AI assistant's « Ajouter » button inserts its
|
||||
// answer (or a single code block) at the current cursor position.
|
||||
if (e.data.type === 'parent-insert' && typeof e.data.text === 'string') {
|
||||
var p = getPos();
|
||||
insertAtCursor((p.s > 0 ? '\n' : '') + e.data.text);
|
||||
showToast('Texte ajoute au document', 'ok');
|
||||
}
|
||||
// #93 — the parent reloads the document after an external write (AI
|
||||
// assistant edit_file / append_to_file / create_file): re-read from disk
|
||||
// and drop the stale local buffer that would otherwise be autosaved back
|
||||
|
||||
@@ -7,34 +7,48 @@
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Excalidraw Editor</title>
|
||||
<!-- Excalidraw's stylesheet MUST be loaded: without it the editor is
|
||||
unstyled AND `.excalidraw` has no fixed height, so Excalidraw's
|
||||
ResizeObserver feedback loop grows the canvas to the 2^25 hard cap and
|
||||
the scene renders blank. Loaded from esm.sh (same origin as the JS
|
||||
modules, already allowed by `font-src` for the relative font URLs). -->
|
||||
<link rel="stylesheet" href="https://esm.sh/@excalidraw/[email protected]/dist/prod/index.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body, #root { width: 100%; height: 100%; overflow: hidden; }
|
||||
body { background: #ffffff; }
|
||||
|
||||
/* Toolbar overlay in top-right corner */
|
||||
/* Icon-only toolbar, vertical, flush against the right edge:
|
||||
right edge at 100% of the viewport width, group starts at 45% of the
|
||||
viewport height from the top. */
|
||||
#excalidraw-toolbar {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
right: 12px;
|
||||
right: 0;
|
||||
top: 45%;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
#excalidraw-toolbar button {
|
||||
padding: 5px 10px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
#excalidraw-toolbar button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: block;
|
||||
}
|
||||
#excalidraw-toolbar button:hover { background: #f0f0f0; }
|
||||
#excalidraw-toolbar button.primary {
|
||||
background: #6965db;
|
||||
@@ -43,11 +57,12 @@
|
||||
}
|
||||
#excalidraw-toolbar button.primary:hover { background: #5b57c4; }
|
||||
|
||||
/* Dirty indicator */
|
||||
/* Dirty indicator (small dot above the buttons) */
|
||||
#dirty-badge {
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: #e07b39;
|
||||
font-weight: 500;
|
||||
font-weight: 700;
|
||||
display: none;
|
||||
}
|
||||
#dirty-badge.visible { display: inline; }
|
||||
@@ -86,10 +101,11 @@
|
||||
<div id="loading">Loading Excalidraw…</div>
|
||||
<div id="root"></div>
|
||||
<div id="excalidraw-toolbar">
|
||||
<span id="dirty-badge">● Modified</span>
|
||||
<button id="btn-save" class="primary" title="Save (Ctrl+S)">💾 Save</button>
|
||||
<button id="btn-export-png" title="Export PNG">🖼 PNG</button>
|
||||
<button id="btn-export-svg" title="Export SVG">📐 SVG</button>
|
||||
<span id="dirty-badge" title="Modified">●</span>
|
||||
<button id="btn-save" class="primary" title="Save (Ctrl+S)" aria-label="Save"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg></button>
|
||||
<button id="btn-export-png" title="Export PNG" aria-label="Export PNG"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg></button>
|
||||
<button id="btn-export-svg" title="Export SVG" aria-label="Export SVG"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 7a24 24 0 0 1 0 10M4.5 7a24 24 0 0 0 0 10M7 19.5a24 24 0 0 1 10 0M7 4.5a24 24 0 0 0 10 0"/><rect x="2" y="2" width="5" height="5" rx="1"/><rect x="17" y="2" width="5" height="5" rx="1"/><rect x="17" y="17" width="5" height="5" rx="1"/><rect x="2" y="17" width="5" height="5" rx="1"/></svg></button>
|
||||
<button id="btn-fullscreen" title="Fullscreen" aria-label="Fullscreen"></button>
|
||||
</div>
|
||||
|
||||
<!-- Excalidraw is loaded from esm.sh WITHOUT the `?alias=react:…` query.
|
||||
@@ -129,6 +145,11 @@
|
||||
const btnSave = document.getElementById("btn-save");
|
||||
const btnExportPng = document.getElementById("btn-export-png");
|
||||
const btnExportSvg = document.getElementById("btn-export-svg");
|
||||
const btnFullscreen = document.getElementById("btn-fullscreen");
|
||||
|
||||
// Icon swapped in on a successful save (floppy → checkmark → floppy).
|
||||
const SAVE_ICON = btnSave.innerHTML;
|
||||
const CHECK_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
|
||||
// --- postMessage helpers ---
|
||||
function sendToParent(msg) {
|
||||
@@ -177,8 +198,8 @@
|
||||
}
|
||||
sendToParent({ type: "save", data });
|
||||
markClean();
|
||||
btnSave.textContent = "💾 Saved!";
|
||||
setTimeout(() => { btnSave.textContent = "💾 Save"; }, 1200);
|
||||
btnSave.innerHTML = CHECK_ICON;
|
||||
setTimeout(() => { btnSave.innerHTML = SAVE_ICON; }, 1200);
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
@@ -189,9 +210,45 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Excalidraw serializes its Map-typed appState fields (notably
|
||||
// `collaborators`) to plain JSON objects on save — that is what files
|
||||
// exported by the Excalidraw app / Obsidian plugin contain. Feeding such a
|
||||
// plain object back through `initialData.appState` makes Excalidraw 0.18
|
||||
// call `.forEach()` on it and crash with
|
||||
// "e.appState.collaborators.forEach is not a function", leaving the canvas
|
||||
// blank. Restore the expected Map shape (and drop anything unusable).
|
||||
function sanitizeAppState(appState) {
|
||||
if (!appState || typeof appState !== "object") return {};
|
||||
// Viewport geometry is computed by Excalidraw from the container size.
|
||||
// Files exported by the Excalidraw app carry whatever the *source* window
|
||||
// measured (Obsidian pane, browser tab, …) and can contain absurd values
|
||||
// (e.g. `height: 22369622`). Importing them makes Excalidraw size its
|
||||
// canvas beyond the browser limit, so the scene renders off-screen /
|
||||
// blank. Drop them and let Excalidraw recompute.
|
||||
for (const key of ["width", "height", "offsetLeft", "offsetTop"]) {
|
||||
delete appState[key];
|
||||
}
|
||||
if (appState.collaborators && !(appState.collaborators instanceof Map)) {
|
||||
try {
|
||||
appState.collaborators = new Map(Object.entries(appState.collaborators));
|
||||
} catch (e) {
|
||||
appState.collaborators = new Map();
|
||||
}
|
||||
}
|
||||
return appState;
|
||||
}
|
||||
|
||||
// Signature of the drawn content only. Excalidraw's onChange also fires for
|
||||
// appState-only changes (resize, fullscreen, zoom, scroll); those must not
|
||||
// flag the diagram as modified.
|
||||
function sceneSignature(elements) {
|
||||
return (elements || []).map((el) => `${el.id}:${el.versionNonce}`).join("|");
|
||||
}
|
||||
|
||||
// --- Excalidraw component ---
|
||||
function App({ initialData, theme }) {
|
||||
const [appState, setAppState] = React.useState(null);
|
||||
const lastSigRef = React.useRef(null);
|
||||
|
||||
// Excalidraw 0.18 exposes its imperative API through the `excalidrawAPI`
|
||||
// prop, called with the API object once mounted (NOT the legacy
|
||||
@@ -204,13 +261,20 @@
|
||||
if (theme === "dark") {
|
||||
api.updateScene({ appState: { theme: "dark" } });
|
||||
}
|
||||
// Stop ignoring changes once the initial mount settles.
|
||||
setTimeout(() => { ignoreChanges = false; }, 800);
|
||||
// Stop ignoring changes once the initial mount settles, and snapshot
|
||||
// the loaded scene so a later appState-only change is not "dirty".
|
||||
setTimeout(() => {
|
||||
ignoreChanges = false;
|
||||
lastSigRef.current = sceneSignature(api.getSceneElements());
|
||||
}, 800);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const onChange = React.useCallback((elements, state, files) => {
|
||||
if (ignoreChanges) return;
|
||||
const sig = sceneSignature(elements);
|
||||
if (sig === lastSigRef.current) return;
|
||||
lastSigRef.current = sig;
|
||||
markDirty();
|
||||
}, []);
|
||||
|
||||
@@ -257,6 +321,7 @@
|
||||
appState = msg.data.appState || {};
|
||||
files = msg.data.files || {};
|
||||
}
|
||||
appState = sanitizeAppState(appState);
|
||||
const initialData = { elements, appState, files };
|
||||
currentTheme = msg.theme || "light";
|
||||
setTheme(currentTheme);
|
||||
@@ -340,6 +405,35 @@
|
||||
console.error("SVG export failed:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Fullscreen ---
|
||||
// The parent iframe is created with `allow="fullscreen" allowfullscreen`, so
|
||||
// requesting fullscreen on this document makes the whole editor fill the
|
||||
// screen. Excalidraw's ResizeObserver then grows the canvas to match.
|
||||
const FS_ENTER = '<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="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>';
|
||||
const FS_EXIT = '<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="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/></svg>';
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
const req = document.documentElement.requestFullscreen
|
||||
&& document.documentElement.requestFullscreen();
|
||||
if (req && typeof req.catch === "function") {
|
||||
req.catch((err) => console.warn("Fullscreen request failed:", err));
|
||||
}
|
||||
} else if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("fullscreenchange", () => {
|
||||
const on = !!document.fullscreenElement;
|
||||
btnFullscreen.innerHTML = on ? FS_EXIT : FS_ENTER;
|
||||
btnFullscreen.title = on ? "Exit fullscreen" : "Fullscreen";
|
||||
btnFullscreen.classList.toggle("active", on);
|
||||
});
|
||||
|
||||
btnFullscreen.innerHTML = FS_ENTER;
|
||||
btnFullscreen.addEventListener("click", toggleFullscreen);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
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 |
|
After Width: | Height: | Size: 194 KiB |
@@ -0,0 +1,159 @@
|
||||
// ObsiGate — #106 : Actions instantanées de l'assistant IA.
|
||||
//
|
||||
// Two responsibilities, deliberately framework-free and DOM-free so the
|
||||
// context rules stay unit-testable (tests/frontend/ai-quick-actions.test.mjs):
|
||||
//
|
||||
// 1. ACTION_CATALOG — every prompt action grouped by category. Each action
|
||||
// carries an id, a lucide icon name, an i18n label key and an i18n
|
||||
// prompt key; both strings are resolved through t() at render time so
|
||||
// the FR/EN switch is live.
|
||||
// 2. detectContext / suggestionsFor — a pure function of the assistant's
|
||||
// live state (mode, open documents, current file, editor selection)
|
||||
// returning the context key, then the 3 top action ids for that context
|
||||
// (contextual triage table, see docs/features/ai-quick-actions.md).
|
||||
//
|
||||
// Context precedence (first match wins):
|
||||
// selection — the user has a live text selection in the open editor
|
||||
// code — the focused document is a source file (.py, .js, .sh…)
|
||||
// multi_doc — two or more documents are open in tabs/panes
|
||||
// single_doc — exactly one text document (.md, .txt, …) is open
|
||||
// directory — a vault folder was opened from the tree context menu
|
||||
// general — nothing open: app-help assistant
|
||||
import { t } from './i18n.js';
|
||||
|
||||
/** Categories of the action catalogue (order = drawer display order). */
|
||||
export const CATEGORIES = Object.freeze([
|
||||
{ id: 'synthesis', icon: 'sparkles', labelKey: 'qa.cat_synthesis' },
|
||||
{ id: 'structure', icon: 'list-checks', labelKey: 'qa.cat_structure' },
|
||||
{ id: 'code', icon: 'code', labelKey: 'qa.cat_code' },
|
||||
{ id: 'cross', icon: 'git-compare', labelKey: 'qa.cat_cross' },
|
||||
{ id: 'edition', icon: 'pen-tool', labelKey: 'qa.cat_edition' },
|
||||
{ id: 'general', icon: 'help-circle', labelKey: 'qa.cat_general' },
|
||||
]);
|
||||
|
||||
/**
|
||||
* The full catalogue. `labelKey` is the button text, `promptKey` the message
|
||||
* actually sent to the assistant (kept richer than the label on purpose:
|
||||
* labels stay scannable, prompts stay precise).
|
||||
*/
|
||||
export const ACTION_CATALOG = Object.freeze([
|
||||
// ── Synthèse & Analyse ──────────────────────────────────────────────
|
||||
{ id: 'summarize_3', cat: 'synthesis', icon: 'align-left', labelKey: 'qa.summarize_3', promptKey: 'qa.summarize_3.prompt' },
|
||||
{ id: 'frictions', cat: 'synthesis', icon: 'alert-triangle', labelKey: 'qa.frictions', promptKey: 'qa.frictions.prompt' },
|
||||
{ id: 'vulgarize', cat: 'synthesis', icon: 'lightbulb', labelKey: 'qa.vulgarize', promptKey: 'qa.vulgarize.prompt' },
|
||||
{ id: 'faq', cat: 'synthesis', icon: 'help-circle', labelKey: 'qa.faq', promptKey: 'qa.faq.prompt' },
|
||||
// ── Productivité & Structuration ────────────────────────────────────
|
||||
{ id: 'checklist', cat: 'structure', icon: 'list-checks', labelKey: 'qa.checklist', promptKey: 'qa.checklist.prompt' },
|
||||
{ id: 'plan', cat: 'structure', icon: 'list-ordered', labelKey: 'qa.plan', promptKey: 'qa.plan.prompt' },
|
||||
{ id: 'memo', cat: 'structure', icon: 'scroll-text', labelKey: 'qa.memo', promptKey: 'qa.memo.prompt' },
|
||||
{ id: 'frontmatter', cat: 'structure', icon: 'braces', labelKey: 'qa.frontmatter', promptKey: 'qa.frontmatter.prompt', agent: true },
|
||||
{ id: 'frontmatter_update', cat: 'structure', icon: 'refresh-cw', labelKey: 'qa.frontmatter_update', promptKey: 'qa.frontmatter_update.prompt', agent: true },
|
||||
{ id: 'backlinks', cat: 'structure', icon: 'link-2', labelKey: 'qa.backlinks', promptKey: 'qa.backlinks.prompt' },
|
||||
{ id: 'sections', cat: 'structure', icon: 'heading', labelKey: 'qa.sections', promptKey: 'qa.sections.prompt' },
|
||||
// ── Code & Scripts ───────────────────────────────────────────────────
|
||||
{ id: 'explain_code', cat: 'code', icon: 'file-code', labelKey: 'qa.explain_code', promptKey: 'qa.explain_code.prompt' },
|
||||
{ id: 'audit_code', cat: 'code', icon: 'bug', labelKey: 'qa.audit_code', promptKey: 'qa.audit_code.prompt' },
|
||||
{ id: 'doc_code', cat: 'code', icon: 'braces', labelKey: 'qa.doc_code', promptKey: 'qa.doc_code.prompt' },
|
||||
{ id: 'test_code', cat: 'code', icon: 'flask-conical', labelKey: 'qa.test_code', promptKey: 'qa.test_code.prompt' },
|
||||
// ── Cross-documents ──────────────────────────────────────────────────
|
||||
{ id: 'compare', cat: 'cross', icon: 'git-compare', labelKey: 'qa.compare', promptKey: 'qa.compare.prompt' },
|
||||
{ id: 'merge', cat: 'cross', icon: 'layers', labelKey: 'qa.merge', promptKey: 'qa.merge.prompt' },
|
||||
{ id: 'timeline', cat: 'cross', icon: 'history', labelKey: 'qa.timeline', promptKey: 'qa.timeline.prompt' },
|
||||
// ── Édition & Reformulation (sélection active) ───────────────────────
|
||||
{ id: 'concise', cat: 'edition', icon: 'scissors', labelKey: 'qa.concise', promptKey: 'qa.concise.prompt' },
|
||||
{ id: 'fix_style', cat: 'edition', icon: 'spell-check', labelKey: 'qa.fix_style', promptKey: 'qa.fix_style.prompt' },
|
||||
{ id: 'rephrase', cat: 'edition', icon: 'pen-tool', labelKey: 'qa.rephrase', promptKey: 'qa.rephrase.prompt' },
|
||||
{ id: 'translate', cat: 'edition', icon: 'languages', labelKey: 'qa.translate', promptKey: 'qa.translate.prompt' },
|
||||
// ── Général (aucun document ouvert) — reprises des anciennes suggestions
|
||||
{ id: 'capabilities', cat: 'general', icon: 'sparkles', labelKey: 'qa.capabilities', promptKey: 'qa.capabilities.prompt' },
|
||||
{ id: 'search_help', cat: 'general', icon: 'search', labelKey: 'qa.search_help', promptKey: 'qa.search_help.prompt' },
|
||||
{ id: 'create_note', cat: 'general', icon: 'notebook-pen', labelKey: 'qa.create_note', promptKey: 'qa.create_note.prompt' },
|
||||
]);
|
||||
|
||||
/** id → action record, for O(1) lookup by the preset tables. */
|
||||
export const ACTIONS_BY_ID = Object.freeze(
|
||||
ACTION_CATALOG.reduce((acc, a) => { acc[a.id] = a; return acc; }, {}),
|
||||
);
|
||||
|
||||
/** File extensions treated as source code (drive the `code` context). */
|
||||
export const CODE_EXT_RE = /\.(?:py|js|mjs|cjs|ts|tsx|jsx|vue|svelte|go|rs|java|kt|c|h|cpp|hpp|cc|cs|php|rb|swift|sh|bash|zsh|ps1|bat|sql|lua|pl|r|dart|scala)$/i;
|
||||
|
||||
/** Text-ish document extensions (everything else stays "single_doc"). */
|
||||
export const TEXT_DOC_EXT_RE = /\.(?:md|markdown|mdx|txt|rst|org|adoc)$/i;
|
||||
|
||||
/**
|
||||
* Map a set of live facts to one context key.
|
||||
*
|
||||
* @param {object} facts
|
||||
* @param {string} facts.mode assistant mode ('directory'|'documents'|'general')
|
||||
* @param {number} facts.docCount number of open documents (tabs/panes)
|
||||
* @param {string|null} facts.currentPath path of the focused document, if any
|
||||
* @param {boolean} facts.hasSelection true when the open editor holds a text selection
|
||||
* @param {number} [facts.fileCount] indexed files of the current directory context
|
||||
* @returns {'selection'|'code'|'multi_doc'|'single_doc'|'directory'|'general'}
|
||||
*/
|
||||
export function detectContext(facts) {
|
||||
const { mode, docCount = 0, currentPath = null, hasSelection = false } = facts || {};
|
||||
// A live editor selection is the strongest intent: act on the selection.
|
||||
if (hasSelection) return 'selection';
|
||||
if (mode === 'documents' || docCount > 0) {
|
||||
if (currentPath && CODE_EXT_RE.test(currentPath)) return 'code';
|
||||
if (docCount >= 2) return 'multi_doc';
|
||||
return 'single_doc';
|
||||
}
|
||||
if (mode === 'directory') return 'directory';
|
||||
return 'general';
|
||||
}
|
||||
|
||||
/**
|
||||
* Contextual triage: the 3 top action ids per context (the "boutons 1-3" of
|
||||
* the design table). Everything not shown stays reachable via the drawer.
|
||||
*/
|
||||
export const CONTEXT_PRESETS = Object.freeze({
|
||||
single_doc: ['summarize_3', 'checklist', 'frontmatter', 'frontmatter_update'],
|
||||
multi_doc: ['merge', 'compare', 'frictions'],
|
||||
code: ['explain_code', 'audit_code', 'test_code'],
|
||||
selection: ['concise', 'fix_style', 'explain_selection'],
|
||||
directory: ['summarize_dir', 'themes', 'checklist'],
|
||||
general: ['capabilities', 'search_help', 'create_note'],
|
||||
});
|
||||
|
||||
// Preset ids that are context phrasings rather than catalogue entries
|
||||
// (their prompt needs the directory/list framing, so they are synthesized).
|
||||
const EXTRA_ACTIONS = Object.freeze({
|
||||
explain_selection: { id: 'explain_selection', cat: 'edition', icon: 'lightbulb', labelKey: 'qa.explain_selection', promptKey: 'qa.explain_selection.prompt' },
|
||||
summarize_dir: { id: 'summarize_dir', cat: 'synthesis', icon: 'align-left', labelKey: 'bookslm.suggestion_summary', promptKey: 'bookslm.suggestion_summary' },
|
||||
themes: { id: 'themes', cat: 'synthesis', icon: 'library', labelKey: 'bookslm.suggestion_themes', promptKey: 'bookslm.suggestion_themes' },
|
||||
});
|
||||
|
||||
/** Resolve an action id (catalogue or preset extra) to its record. */
|
||||
export function getAction(id) {
|
||||
return ACTIONS_BY_ID[id] || EXTRA_ACTIONS[id] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ordered action records suggested for one context key.
|
||||
* Unknown keys fall back to the general preset (never throws).
|
||||
*/
|
||||
export function suggestionsFor(contextKey) {
|
||||
const ids = CONTEXT_PRESETS[contextKey] || CONTEXT_PRESETS.general;
|
||||
return ids.map((id) => getAction(id)).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Translated label + prompt for an action record (empty string when absent). */
|
||||
export function actionTexts(action) {
|
||||
if (!action) return { label: '', prompt: '' };
|
||||
return { label: t(action.labelKey), prompt: t(action.promptKey) };
|
||||
}
|
||||
|
||||
/** Badge text for the contextual header chip. */
|
||||
export function contextBadgeKey(contextKey, docCount) {
|
||||
switch (contextKey) {
|
||||
case 'selection': return 'qa.badge_selection';
|
||||
case 'code': return 'qa.badge_code';
|
||||
case 'multi_doc': return 'qa.badge_multi';
|
||||
case 'single_doc': return 'qa.badge_single';
|
||||
case 'directory': return 'qa.badge_directory';
|
||||
default: return docCount > 0 ? 'qa.badge_single' : 'qa.badge_general';
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,24 @@ function renderCapabilityList(caps) {
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render only the *enabled* capabilities as colored tags (#104). Used by the
|
||||
* config panel where a compact read-only summary reads better than checkboxes.
|
||||
*/
|
||||
function renderCapabilityBadges(caps) {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'ai-caps-badges';
|
||||
if (!caps) return box;
|
||||
AI_CAPABILITY_KEYS.forEach((key) => {
|
||||
if (!caps[key]) return;
|
||||
const item = document.createElement('span');
|
||||
item.className = 'ai-cap-badge';
|
||||
item.textContent = t(`ai.cap_${key}`);
|
||||
box.appendChild(item);
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
/** Accent-insensitive, case-insensitive normalization for model search. */
|
||||
function _normalizeText(value) {
|
||||
return String(value || '')
|
||||
@@ -1019,6 +1037,7 @@ export {
|
||||
AI_CAPABILITY_KEYS,
|
||||
getModelCapabilities,
|
||||
renderCapabilityList,
|
||||
renderCapabilityBadges,
|
||||
_buildPickerUI as buildAIPickerUI,
|
||||
refreshAIPickers,
|
||||
PICKER_SLOT_CLASS,
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as UI from './ui.js';
|
||||
import * as Utils from './utils.js';
|
||||
import { initI18n, t } from './i18n.js';
|
||||
import { initAIFab } from './ai-fab.js';
|
||||
import { initNowPlaying } from './now-playing.js';
|
||||
|
||||
// Wire up AI toolbar toast (avoids circular import in utils.js)
|
||||
window._obsigateShowToast = UI.showToast;
|
||||
@@ -113,6 +114,7 @@ async function init() {
|
||||
setupFocusMode();
|
||||
Utils.safeCreateIcons();
|
||||
initAIFab();
|
||||
initNowPlaying();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* ObsiGate — Authentication: API helper, AuthManager, login form, AdminPanel */
|
||||
import { state } from './state.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
import { showToast, closeHeaderMenu } from './ui.js';
|
||||
import { safeCreateIcons, escapeHtml } from './utils.js';
|
||||
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
|
||||
import { t, getLocale, setLocale } from './i18n.js';
|
||||
import { showWelcome } from './viewer.js';
|
||||
|
||||
@@ -13,6 +13,15 @@ window.handleLogout = () => {
|
||||
AuthManager.logout();
|
||||
};
|
||||
|
||||
/** Two-letter initials for the sidebar account avatar (#112). */
|
||||
function userInitials(name) {
|
||||
const parts = String(name || "").trim().split(/\s+/).filter(Boolean);
|
||||
if (!parts.length) return "?";
|
||||
const first = parts[0][0] || "";
|
||||
const last = parts.length > 1 ? parts[parts.length - 1][0] : "";
|
||||
return (first + last).toUpperCase() || "?";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -116,6 +125,18 @@ const AuthManager = {
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
},
|
||||
|
||||
/** Merge fields into the cached user object (#113 — profile avatar). */
|
||||
updateCachedUser(fields) {
|
||||
const next = { ...(this.getUser() || {}), ...fields };
|
||||
sessionStorage.setItem(this.USER_KEY, JSON.stringify(next));
|
||||
return next;
|
||||
},
|
||||
|
||||
/** Whether authentication is enabled on this instance (#113). */
|
||||
isAuthEnabled() {
|
||||
return !!this._authEnabled;
|
||||
},
|
||||
|
||||
isTokenExpired() {
|
||||
const expiry = sessionStorage.getItem(this.TOKEN_EXPIRY_KEY);
|
||||
if (!expiry) return true;
|
||||
@@ -266,6 +287,13 @@ const AuthManager = {
|
||||
});
|
||||
},
|
||||
|
||||
async changePassword(currentPassword, newPassword) {
|
||||
return await api("/api/auth/change-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
||||
});
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
const token = this.getToken();
|
||||
@@ -316,22 +344,58 @@ const AuthManager = {
|
||||
const app = document.getElementById("app");
|
||||
if (login) login.classList.add("hidden");
|
||||
if (app) app.classList.remove("hidden");
|
||||
this.renderUserMenu();
|
||||
this.renderUserSection();
|
||||
},
|
||||
|
||||
renderUserMenu() {
|
||||
/**
|
||||
* Render the account block pinned at the bottom of the sidebar (#112) —
|
||||
* the user identity/logout no longer live in the header.
|
||||
*/
|
||||
renderUserSection() {
|
||||
const user = this.getUser();
|
||||
const userMenu = document.getElementById("user-menu");
|
||||
if (!userMenu) return;
|
||||
const section = document.getElementById("sidebar-user");
|
||||
if (!user || !this._authEnabled) {
|
||||
userMenu.innerHTML = "";
|
||||
if (section) section.hidden = true;
|
||||
return;
|
||||
}
|
||||
userMenu.innerHTML = '<span class="user-display-name">' + (user.display_name || user.username) + "</span>" + '<button class="btn-logout" id="logout-btn" title="' + t('auth.logout_title') + '" onclick="window.handleLogout()"><i data-lucide="log-out" style="width:14px;height:14px"></i></button>';
|
||||
if (!section) return;
|
||||
|
||||
const name = user.display_name || user.username || "";
|
||||
const nameEl = document.getElementById("sidebar-user-name");
|
||||
if (nameEl) nameEl.textContent = name;
|
||||
const roleEl = document.getElementById("sidebar-user-role");
|
||||
if (roleEl) roleEl.textContent = user.role === "admin" ? t("sidebar.user_role_admin") : t("sidebar.user_role_user");
|
||||
const avatarEl = document.getElementById("sidebar-user-avatar");
|
||||
if (avatarEl) {
|
||||
// #113: custom avatar image when set, initials as the fallback.
|
||||
if (user.avatar) {
|
||||
avatarEl.textContent = "";
|
||||
const img = document.createElement("img");
|
||||
img.src = user.avatar;
|
||||
img.alt = "";
|
||||
img.className = "sidebar-user-avatar-img";
|
||||
avatarEl.appendChild(img);
|
||||
} else {
|
||||
avatarEl.textContent = userInitials(name);
|
||||
}
|
||||
}
|
||||
section.hidden = false;
|
||||
safeCreateIcons();
|
||||
|
||||
const logoutBtn = document.getElementById("logout-btn");
|
||||
if (logoutBtn) logoutBtn.addEventListener("click", () => AuthManager.logout());
|
||||
const logoutBtn = document.getElementById("sidebar-user-logout");
|
||||
if (logoutBtn && !logoutBtn._obsigateBound) {
|
||||
logoutBtn._obsigateBound = true;
|
||||
logoutBtn.addEventListener("click", () => this.logout());
|
||||
}
|
||||
const profileBtn = document.getElementById("sidebar-user-profile");
|
||||
if (profileBtn && !profileBtn._obsigateBound) {
|
||||
profileBtn._obsigateBound = true;
|
||||
profileBtn.addEventListener("click", () => {
|
||||
closeMobileSidebar();
|
||||
const trigger = document.getElementById("profile-open-btn");
|
||||
if (trigger) trigger.click();
|
||||
});
|
||||
}
|
||||
|
||||
const adminRow = document.getElementById("admin-menu-row");
|
||||
if (adminRow) {
|
||||
@@ -348,6 +412,9 @@ const AuthManager = {
|
||||
}
|
||||
},
|
||||
|
||||
// Backwards-compatible alias (older callers/tests may use this name).
|
||||
renderUserMenu() { this.renderUserSection(); },
|
||||
|
||||
// ── Initialization ──────────────────────────────────────────────
|
||||
|
||||
async checkAuthStatus() {
|
||||
@@ -545,8 +612,22 @@ function _startWebauthnLogin(mfaSection, username, rememberMe) {
|
||||
|
||||
|
||||
function showMfaChallenge(username, rememberMe, loginBtn, loginErrorEl, mfaMethod) {
|
||||
const loginBox = document.querySelector(".login-box");
|
||||
if (!loginBox) return;
|
||||
// BUG-069: the challenge used to mount into `.login-box`, which does not
|
||||
// exist in index.html (the login markup is `#login-screen > .login-card >
|
||||
// #login-form`) — querySelector returned null and the function silently
|
||||
// returned, leaving the user stuck on the login page with no error after
|
||||
// entering correct credentials. Mount into the real card, and never fail
|
||||
// silently: surface the problem in the login error box instead.
|
||||
const loginBox = document.querySelector(".login-card")
|
||||
|| document.getElementById("login-screen");
|
||||
if (!loginBox) {
|
||||
const fallback = loginErrorEl || document.getElementById("login-error");
|
||||
if (fallback) {
|
||||
fallback.textContent = t("mfa.challenge_unavailable");
|
||||
fallback.classList.remove("hidden");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide the normal login form
|
||||
const loginForm = document.getElementById("login-form");
|
||||
@@ -1058,11 +1139,79 @@ async function initMfaSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
// Password change (BUG-068: the "Sécurité du compte" section had no way to
|
||||
// change the password although POST /api/auth/change-password exists).
|
||||
_renderPasswordSection(area);
|
||||
|
||||
// WebAuthn security keys section (ROADMAP #64)
|
||||
_renderWebauthnSection(area);
|
||||
}
|
||||
|
||||
|
||||
function _renderPasswordSection(container) {
|
||||
if (!container || document.getElementById("password-settings")) return;
|
||||
const section = document.createElement("div");
|
||||
section.id = "password-settings";
|
||||
section.className = "password-settings";
|
||||
section.innerHTML = `
|
||||
<h4 class="webauthn-title">${t("mfa.password_change_title")}</h4>
|
||||
<p class="mfa-info-text">${t("mfa.password_change_desc")}</p>
|
||||
<div class="form-group">
|
||||
<label>${t("mfa.current_password_label")}</label>
|
||||
<input type="password" id="pwd-current" class="config-input"
|
||||
placeholder="${t('mfa.current_password_placeholder')}" autocomplete="current-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${t("mfa.new_password_label")}</label>
|
||||
<input type="password" id="pwd-new" class="config-input"
|
||||
placeholder="${t('mfa.new_password_placeholder')}" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${t("mfa.new_password_confirm_label")}</label>
|
||||
<input type="password" id="pwd-confirm" class="config-input"
|
||||
placeholder="${t('mfa.new_password_confirm_placeholder')}" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="mfa-recovery-actions">
|
||||
<button class="config-btn-primary" id="pwd-change-btn">${t("mfa.password_change_btn")}</button>
|
||||
</div>
|
||||
<p class="mfa-error hidden" id="pwd-change-error"></p>
|
||||
`;
|
||||
container.appendChild(section);
|
||||
|
||||
section.querySelector("#pwd-change-btn").addEventListener("click", async () => {
|
||||
const errEl = section.querySelector("#pwd-change-error");
|
||||
const current = section.querySelector("#pwd-current").value;
|
||||
const next = section.querySelector("#pwd-new").value;
|
||||
const confirm = section.querySelector("#pwd-confirm").value;
|
||||
const btn = section.querySelector("#pwd-change-btn");
|
||||
errEl.classList.add("hidden");
|
||||
if (!current || !next || !confirm) {
|
||||
errEl.textContent = t("mfa.fill_all_fields");
|
||||
errEl.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
if (next !== confirm) {
|
||||
errEl.textContent = t("mfa.password_mismatch");
|
||||
errEl.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await AuthManager.changePassword(current, next);
|
||||
showToast(t("mfa.password_changed"), "success");
|
||||
section.querySelector("#pwd-current").value = "";
|
||||
section.querySelector("#pwd-new").value = "";
|
||||
section.querySelector("#pwd-confirm").value = "";
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message || String(err);
|
||||
errEl.classList.remove("hidden");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function _renderWebauthnSection(container) {
|
||||
if (!container || !window.PublicKeyCredential) return;
|
||||
|
||||
@@ -1085,10 +1234,10 @@ async function _renderWebauthnSection(container) {
|
||||
const listHtml = keys.length
|
||||
? `<ul class="webauthn-key-list">${keys.map((k) => `
|
||||
<li class="webauthn-key-item">
|
||||
<span class="webauthn-key-label">🔑 ${k.label || "Security key"}</span>
|
||||
<span class="webauthn-key-meta">${(k.transports || []).join(", ") || "—"}</span>
|
||||
<span class="webauthn-key-label">🔑 ${escapeHtml(k.label || "Security key")}</span>
|
||||
<span class="webauthn-key-meta">${escapeHtml((k.transports || []).join(", ") || "—")}</span>
|
||||
<button class="config-btn-secondary config-btn-sm webauthn-key-remove"
|
||||
data-id="${k.credential_id}">${t("mfa.webauthn_remove")}</button>
|
||||
data-id="${escapeHtml(k.credential_id)}">${t("mfa.webauthn_remove")}</button>
|
||||
</li>`).join("")}</ul>`
|
||||
: `<p class="mfa-info-text">${t("mfa.webauthn_none")}</p>`;
|
||||
|
||||
@@ -1111,7 +1260,10 @@ async function _renderWebauthnSection(container) {
|
||||
const label = prompt(t("mfa.webauthn_label_prompt"), "Ma clé");
|
||||
const result = await AuthManager.webauthnRegister(credential, label || "Security key");
|
||||
if (result.recovery_codes && result.recovery_codes.length) {
|
||||
_showRecoveryCodes(result.recovery_codes);
|
||||
// BUG-068: first-time WebAuthn enable issues recovery codes. There is
|
||||
// no #mfa-setup-flow-area in the "already enabled" view, so render
|
||||
// them into the WebAuthn flow area instead of losing them.
|
||||
_showRecoveryCodes(result.recovery_codes, "webauthn-flow-area");
|
||||
} else {
|
||||
showToast(t("mfa.webauthn_added"), "success");
|
||||
}
|
||||
@@ -1145,16 +1297,28 @@ async function _startMfaSetup() {
|
||||
|
||||
try {
|
||||
const data = await AuthManager.mfaSetup();
|
||||
// BUG-068: the QR code comes from the backend as a local SVG data: URI
|
||||
// (see POST /api/auth/mfa/totp/setup → qr_data_url). The previous
|
||||
// third-party QR image was blocked by the CSP
|
||||
// (img-src 'self' data: blob:) so it never displayed — and it leaked the
|
||||
// otpauth URI (TOTP secret) to a third party. Fall back to the manual
|
||||
// secret when the backend has no QR generator available.
|
||||
const qrImg = data.qr_data_url
|
||||
? `<img id="mfa-qr-img" alt="QR Code" class="mfa-qr-code-img"
|
||||
src="${data.qr_data_url}"
|
||||
onerror="this.style.display='none';document.getElementById('mfa-qr-fallback').style.display='block';">`
|
||||
: "";
|
||||
const fallbackStyle = data.qr_data_url ? "display:none" : "";
|
||||
flowArea.innerHTML = `
|
||||
<div class="mfa-setup-card">
|
||||
<h4>${t("mfa.scan_qr")}</h4>
|
||||
<div class="mfa-qr-container">
|
||||
<img id="mfa-qr-img" alt="QR Code" class="mfa-qr-code"
|
||||
src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(data.otpauth_uri)}">
|
||||
${qrImg}
|
||||
<p class="mfa-info-text" id="mfa-qr-fallback" style="${fallbackStyle}">${t("mfa.qr_unavailable")}</p>
|
||||
</div>
|
||||
<details class="mfa-secret-details">
|
||||
<details class="mfa-secret-details" ${data.qr_data_url ? "" : "open"}>
|
||||
<summary>${t("mfa.manual_entry")}</summary>
|
||||
<code class="mfa-secret-code">${data.secret}</code>
|
||||
<code class="mfa-secret-code">${escapeHtml(data.secret)}</code>
|
||||
</details>
|
||||
<div class="mfa-verify-section">
|
||||
<label>${t("mfa.enter_code")}</label>
|
||||
@@ -1201,12 +1365,16 @@ async function _startMfaSetup() {
|
||||
}
|
||||
|
||||
|
||||
function _showRecoveryCodes(codes) {
|
||||
const flowArea = document.getElementById("mfa-setup-flow-area");
|
||||
const area = document.getElementById("mfa-setup-area");
|
||||
function _showRecoveryCodes(codes, targetId) {
|
||||
// BUG-068: the recovery codes must be visible wherever the enable flow ran.
|
||||
// The TOTP flow owns #mfa-setup-flow-area, but the WebAuthn first-enable
|
||||
// path (#webauthn-flow-area) has none — previously those codes were lost.
|
||||
const flowArea = document.getElementById(targetId || "mfa-setup-flow-area")
|
||||
|| document.getElementById("webauthn-flow-area")
|
||||
|| document.getElementById("mfa-setup-area");
|
||||
if (!flowArea) return;
|
||||
|
||||
const codesHtml = codes.map(c => `<code class="mfa-recovery-code">${c}</code>`).join("\n");
|
||||
const codesHtml = codes.map(c => `<code class="mfa-recovery-code">${escapeHtml(c)}</code>`).join("\n");
|
||||
flowArea.innerHTML = `
|
||||
<div class="mfa-recovery-card">
|
||||
<h4>🔑 ${t("mfa.recovery_codes_title")}</h4>
|
||||
|
||||