Compare commits

...
5 Commits
Author SHA1 Message Date
bruno e2417cb5ab feat: support audio & vidéo — lecteurs HTML5 intégrés #109
CI / lint (push) Successful in 1m58s
CI / security (push) Successful in 1m22s
CI / test (push) Successful in 4m32s
CI / build (push) Successful in 2m6s
CI / e2e (push) Successful in 12m57s
2026-09-23 09:14:34 -04:00
bruno eccbf7474e feat: support complet des images — arborescence, visionneuse, indexation #108
CI / lint (push) Successful in 1m57s
CI / security (push) Successful in 1m22s
CI / test (push) Successful in 4m32s
CI / build (push) Failing after 1m16s
CI / e2e (push) Skipped
2026-09-23 07:47:04 -04:00
bruno 69cee4d93a docs(roadmap): add #108 image support and #109 audio/video players
CI / lint (push) Successful in 1m55s
CI / security (push) Successful in 1m25s
CI / test (push) Successful in 4m33s
CI / build (push) Successful in 1m15s
CI / e2e (push) Successful in 11m59s
- #108: images parity — tree + indexing (never read bytes into TF-IDF),
  fix broken standalone viewer (img src points to JSON /raw instead of
  /api/image), zoom/pan viewer, thumbnails, SVG sandbox hardening
- #109: audio/video — HTML5 players, shared media streaming endpoint
  with Range/206 (extract helper from existing pdf/stream), codec
  fallback UI, PWA/mobile notes
- Update effort summary (8 items, ~28-43 days)
2026-09-22 23:28:55 -04:00
bruno 705f755b6b docs: guides d'utilisation, capture reelle et README ameliores
CI / lint (push) Successful in 2m2s
CI / security (push) Successful in 1m25s
CI / test (push) Successful in 4m13s
CI / build (push) Successful in 1m16s
CI / e2e (push) Successful in 11m56s
2026-09-22 22:40:51 -04:00
bruno 8ad8eaac71 test: spec E2E mobile pour la page Configurations BUG-071
CI / lint (push) Successful in 1m54s
CI / security (push) Successful in 1m21s
CI / test (push) Successful in 3m58s
CI / build (push) Failing after 1m22s
CI / e2e (push) Skipped
2026-09-22 22:03:42 -04:00
58 changed files with 4706 additions and 382 deletions
+1
View File
@@ -91,6 +91,7 @@ bash scripts/run-e2e-local.sh -g "nom du test" # filtre / --headed
| Travail à venir + index | `docs/ROADMAP.md` |
| Historique des versions | `CHANGELOG.md` |
| Conception par feature | `docs/features/<slug>.md` |
| Guides d'utilisation | `docs/GUIDES/` |
| Archive du complété | `docs/archive/COMPLETED_v1-v2.md` |
| Bugs / TODO | `docs/ISSUES_TODOLIST.md` |
| Build & releases | `docs/DEVELOPMENT_AND_RELEASES.md` |
+90 -1
View File
@@ -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.16.4**.
> [Unreleased](#unreleased). La dernière version livrée est **2.18.0**.
---
@@ -14,6 +14,95 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [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é
+78 -39
View File
@@ -1,61 +1,75 @@
# ObsiGate
> **Version française** — ce document est le miroir synchronisé de [README.md](README.md) (référence complète). Dernière synchronisation : juin 2026.
> **Version française** — ce document est le miroir synchronisé de [README.md](README.md) (référence complète). Dernière synchronisation : septembre 2026.
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.16.4-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.18.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
[![CI/CD](https://img.shields.io/badge/CI%2FCD-Gitea_Actions-green.svg)](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 ObsiGate — tableau de bord Statistiques avec vaults, tags et raccourcis clavier](docs/images/obsigate-home.png)
> Interface web d'ObsiGate : sidebar multi-vault, recherche globale, statistiques et raccourcis.
---
## 📚 Guides
Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/GUIDES/) :
| Guide | Contenu |
|---|---|
| 🚀 [Prise en main](docs/GUIDES/PRISE_EN_MAIN.md) | Premier lancement, interface, navigation, vaults, raccourcis |
| 🔍 [Recherche, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Syntaxe de requête, recherche sémantique, lecteur PDF, diagrammes |
| 🤖 [Assistant IA & Forge](docs/GUIDES/ASSISTANT_IA_FORGE.md) | Fournisseurs, éditeur IA, BooksLM, Forge, commandes `@` / `/` |
| 📝 [Édition & collaboration](docs/GUIDES/COLLABORATION.md) | Édition simultanée, curseurs distants, persistance |
| 📱 [PWA & hors-ligne](docs/GUIDES/PWA_HORS_LIGNE.md) | Installation, cache hors-ligne, file de synchro, notifications |
| 🔌 [API REST](docs/GUIDES/API_REST.md) | Authentification, clés API, endpoints, exemples `curl`, SSE |
| 🧩 [Serveur MCP](docs/GUIDES/MCP.md) | Brancher Claude Desktop, Cursor, Cline… sur vos vaults |
| 🔒 [Authentification & sécurité](docs/GUIDES/AUTHENTIFICATION_SECURITE.md) | Utilisateurs, MFA, permissions par vault, durcissement |
| 🐳 [Déploiement Docker](docs/GUIDES/DEPLOIEMENT_DOCKER.md) | `docker-compose`, volumes, reverse proxy, mises à jour |
| 🖥️ [Desktop (Tauri)](docs/GUIDES/DESKTOP.md) | Installation, premier lancement, build depuis les sources, dépannage |
> Index complet : [`docs/GUIDES/README.md`](docs/GUIDES/README.md).
---
## 📋 Table des matières
- [Fonctionnalités](#fonctionnalites)
- [Prérequis](#prerequis)
- [Installation rapide](#installation-rapide)
- [Configuration détaillée](#configuration-detaillee)
- [Variables d'environnement](#variables-denvironnement)
- [🔒 Authentification](#authentification)
- [Ajouter une nouvelle vault](#ajouter-une-nouvelle-vault)
- [Build & déploiement avec build.sh](#build-deploiement-avec-buildsh)
- [Rendu d'images Obsidian](#rendu-dimages-obsidian)
- [Desktop (Tauri) — Application native](#desktop-tauri-application-native)
- [Utilisation](#utilisation)
- [API](#api)
- [Recherche avancée](#recherche-avancee)
- [Dépannage](#depannage)
- [Performance](#performance)
- [Sécurité](#securite)
- [Stack technique](#stack-technique)
- [Architecture](#architecture)
- [Développement](#developpement)
- [Licence](#licence)
- [Changelog](#changelog)
- ✨ [Fonctionnalités](#fonctionnalites)
- 📚 [Guides](#guides)
- 🚀 [Prérequis](#prerequis)
- ⚡ [Installation rapide](#installation-rapide)
- ⚙️ [Configuration détaillée](#configuration-detaillee)
- 🌍 [Variables d'environnement](#variables-denvironnement)
- 🔒 [Authentification](#authentification)
- ➕ [Ajouter une nouvelle vault](#ajouter-une-nouvelle-vault)
- 🔨 [Build & déploiement avec build.sh](#build-deploiement-avec-buildsh)
- 🖼️ [Rendu d'images Obsidian](#rendu-dimages-obsidian)
- 🖥️ [Desktop (Tauri) — Application native](#desktop-tauri-application-native)
- 📖 [Utilisation](#utilisation)
- 👥 [Collaboration temps réel](#collaboration-temps-reel)
- 🔌 [API](#api)
- 🔍 [Recherche avancée](#recherche-avancee)
- 🔧 [Dépannage](#depannage)
- ⚡ [Performance](#performance)
- 🛡️ [Sécurité](#securite)
- 🏗️ [Stack technique](#stack-technique)
- 🏠 [Architecture](#architecture)
- 📝 [Développement](#developpement)
- 📄 [Licence](#licence)
- 🤝 [Support](#support)
- 📝 [Changelog](#changelog)
---
## ✨ Fonctionnalités
- **🤖 AI Editor intégré** — Éditeur CodeMirror 6 avec toolbar IA : amélioration, correction, traduction, génération, réécriture personnalisée, toolbox (liste, tableau, frontmatter, canvas) — multi-provider DeepSeek/OpenRouter/Gemini
- **🧩 Serveur MCP & agent IA** — Serveur Model Context Protocol intégré (`/mcp`) et assistant avec function calling : lisez, cherchez et modifiez vos vaults depuis Claude Desktop, Cursor… avec confirmations two-step, permissions par vault, rate limiting et redaction des secrets ([guide](docs/MCP_GUIDE.md))
- **🧩 Serveur MCP & agent IA** — Serveur Model Context Protocol intégré (`/mcp`) et assistant avec function calling : lisez, cherchez et modifiez vos vaults depuis Claude Desktop, Cursor… avec confirmations two-step, permissions par vault, rate limiting et redaction des secrets ([guide](docs/GUIDES/MCP.md))
- **👥 Collaboration temps réel** — Édition simultanée d'un même document (Yjs/CRDT) : curseurs distants colorés, indicateur de présence, fusion sans conflit, reconnexion automatique et persistance serveur ([détail](docs/features/collaboration.md))
- **📖 Guide d'utilisation intégré** — Aide complète en FR/EN accessible depuis le menu Options : interface, navigation, recherche, fichiers, IA, sécurité, API & intégrations (OpenAPI, MCP), hors-ligne, collaboration, desktop, plus une section **Architecture** avec diagramme Mermaid ; téléchargeable en **Markdown** et **PDF** dans la langue courante ([détail](docs/features/guide-coverage-105.md))
- **📱 Éditeur mobile natif** — Édition optimisée pour le tactile : barre d'outils Markdown flottante (gras/italique/code/liste/lien), bouton « Coller » persistant (contournement iOS), zoom par pincement et hauteur ajustable, raccourcis swipe (liens entrants / table des matières) et mode lecture plein écran avec navigation entre fichiers ([détail](docs/features/mobile-editor.md))
@@ -69,6 +83,7 @@
- **🏷️ 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), 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)
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
@@ -282,6 +297,7 @@ Un compte **admin** connecté voit une icône 🛡️ dans le header : liste, cr
| `OBSIGATE_WEBHOOK_ALLOW_HTTP` | Autoriser les webhooks non HTTPS | `false` |
| `OBSIGATE_WEBHOOK_ALLOW_PRIVATE` | Autoriser les webhooks vers des adresses privées/boucle | `false` |
| `OBSIGATE_PDF_MAX_SIZE_MB` | Taille max des PDF extraits (text indexation) | `50` |
| `OBSIGATE_MEDIA_MAX_INLINE_MB` | Taille max pour la lecture audio/vidéo intégrée (au-delà : téléchargement) | `500` |
| `OBSIGATE_PDF_EXTRACT_TIMEOUT` | Timeout extraction PDF (secondes) | `30` |
| `OBSIGATE_TAVILY_API_KEY` / `OBSIGATE_BRAVE_API_KEY` / `OBSIGATE_SERPAPI_API_KEY` / `OBSIGATE_EXA_API_KEY` | Fournisseurs de recherche web à clé (essayés avant SearXNG) | — |
| `OBSIGATE_WEB_PROVIDERS` | Ordre des fournisseurs de recherche (ex. `brave,searxng`) | — |
@@ -392,6 +408,18 @@ ObsiGate supporte **toutes les syntaxes d'images Obsidian** avec résolution int
6. Index de démarrage (match le plus proche)
7. Fallback : placeholder stylisé `[image not found: filename.ext]`
### Visionneuse & arborescence
Les images sont de plein droit des fichiers du vault : elles apparaissent dans
l'arborescence, sont indexées (nom + métadonnées, **jamais les octets**) et
s'ouvrent dans une **visionneuse dédiée** — zoom molette 0,1×–8×, pan au
glisser, double-clic pour réinitialiser, navigation ←/→ entre les images du
dossier (avec pellicule de miniatures WebP), panneau de métadonnées, lightbox
plein écran, ouverture de l'original et téléchargement. Le filtre de recherche
`ext:png`/`ext:jpg` est disponible. Formats décodables : PNG, JPEG, GIF, WebP,
BMP, ICO, SVG (SVG servi avec une politique CSP `sandbox`). **HEIC/HEIF**
(iPhone) n'est pas décodable par les navigateurs et n'est pas pris en charge.
### Configuration
```yaml
@@ -412,6 +440,8 @@ curl -X POST http://localhost:2020/api/attachments/rescan/MonVault
## 🖥️ Desktop (Tauri) — Application native
> 📖 Guide complet : [Desktop (Tauri)](docs/GUIDES/DESKTOP.md)
ObsiGate Desktop est une application native construite avec [Tauri](https://tauri.app/) (Rust + webview système). Elle embarque le backend Python et le frontend dans un exécutable standalone — zéro Docker, zéro ligne de commande.
> 🚧 **Version 2.0.0 — binaires en cours de stabilisation.** Pour l'instant, le build depuis les sources est recommandé.
@@ -571,6 +601,8 @@ Cycle de vie : Tauri spawn le backend Python → health check → splash de dém
## 👥 Collaboration temps réel
> 📖 Guide complet : [Édition & collaboration](docs/GUIDES/COLLABORATION.md)
Plusieurs utilisateurs peuvent éditer le même document markdown simultanément (façon Google Docs) :
- **Fusion sans conflit** grâce à Yjs (CRDT) : deux personnes peuvent taper au même endroit, aucune
@@ -590,6 +622,8 @@ fenêtres) pour voir la collaboration en action.
## 🔌 API
> 📖 Guide complet : [API REST](docs/GUIDES/API_REST.md) · [Serveur MCP](docs/GUIDES/MCP.md)
ObsiGate expose une API REST complète :
| Endpoint | Description | Méthode | Auth |
@@ -617,6 +651,7 @@ ObsiGate expose une API REST complète :
| `/api/events` | Flux SSE temps réel | GET | Oui |
| `/api/vaults/add` / `/api/vaults/{name}` | Gestion dynamique des vaults | POST/DELETE | Admin |
| `/api/image/{vault}?path=` | Servir une image | GET | Oui |
| `/api/media/{vault}/thumb?path=&size=` | Miniature WebP (cache disque) | GET | Oui |
| `/api/config` | Lire / écrire la configuration | GET/POST | Oui/Admin |
| `/api/diagnostics` | Statistiques index et mémoire | GET | Admin |
@@ -637,6 +672,8 @@ curl "http://localhost:2020/api/file/Recettes?path=pizza.md"
## 🔍 Recherche avancée
> 📖 Guide complet : [Recherche, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
### Syntaxe de requête
| Opérateur | Description | Exemple |
@@ -758,6 +795,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)
@@ -927,8 +966,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.16.4).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.18.0).
---
*Projet : ObsiGate | Version : 2.16.4 | Dernière mise à jour : Juin 2026*
*Projet : ObsiGate | Version : 2.18.0 | Dernière mise à jour : Septembre 2026*
+80 -34
View File
@@ -2,53 +2,73 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.16.4-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.18.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
[![CI/CD](https://img.shields.io/badge/CI%2FCD-Gitea_Actions-green.svg)](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 interface — statistics dashboard with vaults, tags and keyboard shortcuts](docs/images/obsigate-home.png)
> ObsiGate web interface: multi-vault sidebar, global search, dashboard stats and shortcuts.
---
## 📚 Guides
Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
| Guide | What it covers |
|---|---|
| 🚀 [Getting Started](docs/GUIDES/PRISE_EN_MAIN.md) | First run, interface, navigation, vaults, shortcuts |
| 🔍 [Search, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Query syntax, semantic search, PDF viewer, diagrams |
| 🤖 [AI Assistant & Forge](docs/GUIDES/ASSISTANT_IA_FORGE.md) | Providers, AI editor, BooksLM, Forge, `@` / `/` commands |
| 📝 [Editing & Collaboration](docs/GUIDES/COLLABORATION.md) | Simultaneous editing, remote cursors, persistence |
| 📱 [PWA & Offline](docs/GUIDES/PWA_HORS_LIGNE.md) | Install as an app, offline cache, sync queue, push |
| 🔌 [REST API](docs/GUIDES/API_REST.md) | Authentication, API keys, endpoints, `curl` examples, SSE |
| 🧩 [MCP Server](docs/GUIDES/MCP.md) | Connect Claude Desktop, Cursor, Cline… to your vaults |
| 🔒 [Auth & Security](docs/GUIDES/AUTHENTIFICATION_SECURITE.md) | Users, MFA, per-vault permissions, hardening |
| 🐳 [Docker Deployment](docs/GUIDES/DEPLOIEMENT_DOCKER.md) | `docker-compose`, volumes, reverse proxy, updates |
| 🖥️ [Desktop (Tauri)](docs/GUIDES/DESKTOP.md) | Install, first run, build from source, troubleshooting |
> All guides are currently written in **French**. See the full index:
> [`docs/GUIDES/README.md`](docs/GUIDES/README.md).
---
## 📋 Table of Contents
- [Features](#features)
- [Architecture](#architecture)
- [Prerequisites](#prerequisites)
- [Quick Installation](#quick-installation)
- [Detailed Configuration](#detailed-configuration)
- [Environment Variables](#environment-variables)
- [🔒 Authentication](#authentication)
- [Adding a New Vault](#adding-a-new-vault)
- [Build & Deployment with build.sh](#build--deployment-with-buildsh)
- [Desktop (Tauri) — Native Application](#desktop-tauri--native-application)
- [Usage](#usage)
- [API](#api)
- [Performance](#performance)
- [Troubleshooting](#troubleshooting)
- [Tech Stack](#tech-stack)
- [Changelog](#changelog)
- ✨ [Features](#features)
- 📚 [Guides](#guides)
- 🚀 [Prerequisites](#prerequisites)
- ⚡ [Quick Installation](#quick-installation)
- ⚙️ [Detailed Configuration](#detailed-configuration)
- 🌍 [Environment Variables](#environment-variables)
- 🔒 [Authentication](#authentication)
- ➕ [Adding a New Vault](#adding-a-new-vault)
- 🔨 [Build & Deployment with build.sh](#build--deployment-with-buildsh)
- 🖼️ [Obsidian Image Rendering](#obsidian-image-rendering)
- 🖥️ [Desktop (Tauri) — Native Application](#desktop-tauri--native-application)
- 📖 [Usage](#usage)
- 👥 [Real-time Collaboration](#real-time-collaboration)
- 🔌 [API](#api)
- 🔍 [Advanced Search](#advanced-search)
- 🛡️ [Security](#security)
- ⚡ [Performance](#performance)
- 🔧 [Troubleshooting](#troubleshooting)
- 🏗️ [Tech Stack](#tech-stack)
- 🏠 [Architecture](#architecture)
- 📝 [Development](#development)
- 📄 [License](#license)
- 🤝 [Support](#support)
- 📝 [Changelog](#changelog)
---
## ✨ Features
- **🤖 Integrated AI Editor** — CodeMirror 6 editor with AI toolbar: improve, correct, translate, generate, custom rewrite, toolbox (list, table, frontmatter, canvas) — multi-provider DeepSeek/OpenRouter/Gemini
- **🧩 MCP Server & AI Agent** — Built-in Model Context Protocol server (`/mcp`) and tool-calling assistant: read, search and edit your vaults from Claude Desktop, Cursor… with two-step confirmations, per-vault permissions, rate limiting and secret redaction ([guide](docs/MCP_GUIDE.md))
- **🧩 MCP Server & AI Agent** — Built-in Model Context Protocol server (`/mcp`) and tool-calling assistant: read, search and edit your vaults from Claude Desktop, Cursor… with two-step confirmations, per-vault permissions, rate limiting and secret redaction ([guide](docs/GUIDES/MCP.md))
- **👥 Real-time Collaboration** — Simultaneous editing of the same document (Yjs/CRDT): colored remote cursors, presence indicator, conflict-free merge, automatic reconnection and server-side persistence ([details](docs/features/collaboration.md))
- **📖 Built-in User Guide** — Complete FR/EN help from the Options menu: interface, navigation, search, files, AI, security, API & integrations (OpenAPI, MCP), offline, collaboration, desktop, plus an **Architecture** section with a Mermaid diagram; downloadable as **Markdown** and **PDF** in the current language ([details](docs/features/guide-coverage-105.md))
- **📱 Native Mobile Editor** — Touch-optimised editing: floating Markdown toolbar (bold/italic/code/list/link), persistent Paste button (iOS workaround), pinch-zoom font & adjustable height, swipe shortcuts (backlinks / table of contents) and a full-screen reading mode with page navigation ([details](docs/features/mobile-editor.md))
@@ -62,6 +82,7 @@
- **🏷️ 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), 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)
- **🎨 Syntax Highlight** : Syntax highlighting for code blocks
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
@@ -320,6 +341,7 @@ When an **admin** account is logged in, a 🛡️ icon appears in the header. Cl
| `OBSIGATE_WEBHOOK_ALLOW_HTTP` | Allow non-HTTPS webhook targets | `false` |
| `OBSIGATE_WEBHOOK_ALLOW_PRIVATE` | Allow webhooks to private/loopback addresses | `false` |
| `OBSIGATE_PDF_MAX_SIZE_MB` | Max PDF size for text extraction | `50` |
| `OBSIGATE_MEDIA_MAX_INLINE_MB` | Max size for inline audio/video playback (above: download) | `500` |
| `OBSIGATE_PDF_EXTRACT_TIMEOUT` | PDF extraction timeout (seconds) | `30` |
| `OBSIGATE_TAVILY_API_KEY` / `OBSIGATE_BRAVE_API_KEY` / `OBSIGATE_SERPAPI_API_KEY` / `OBSIGATE_EXA_API_KEY` | Keyed web-search providers (tried before SearXNG) | — |
| `OBSIGATE_WEB_PROVIDERS` | Search provider order (e.g. `brave,searxng`) | — |
@@ -496,6 +518,17 @@ ObsiGate uses 7 resolution strategies in order of priority:
6. **Startup index (closest match)** : If multiple files have the same name
7. **Fallback** : Display a styled placeholder `[image not found: filename.ext]`
### Viewer & file tree
Images are first-class vault files: they appear in the tree, are indexed (name +
metadata, **never the bytes**) and open in a **dedicated viewer** — wheel zoom
0.1×–8×, drag pan, double-click to reset, ←/→ navigation between images in the
same folder (WebP thumbnail filmstrip), metadata panel, full-screen lightbox,
open original and download. The `ext:png`/`ext:jpg` search filter is available.
Decodable formats: PNG, JPEG, GIF, WebP, BMP, ICO, SVG (SVG served with a
`sandbox` CSP). **HEIC/HEIF** (iPhone) is not decodable by browsers and is not
supported.
### Configuration
To optimize resolution, configure the attachments folder for each vault:
@@ -520,6 +553,8 @@ curl -X POST http://localhost:2020/api/attachments/rescan/MyVault
## 🖥️ Desktop (Tauri) — Native Application
> 📖 Full guide: [Desktop (Tauri)](docs/GUIDES/DESKTOP.md)
ObsiGate Desktop is a native application built with [Tauri](https://tauri.app/) (Rust + system webview). It embeds the Python backend and frontend in a standalone executable — zero Docker, zero command line.
> 🚧 **Version 2.0.0 — binaries are being stabilized.** For now, building from source is recommended.
@@ -687,6 +722,8 @@ Lifecycle: Tauri spawns the Python backend → health check → opens the webvie
## 👥 Real-time Collaboration
> 📖 Full guide: [Editing & Collaboration](docs/GUIDES/COLLABORATION.md)
Multiple users can edit the same markdown document simultaneously (Google Docs style):
- **Conflict-free merge** via Yjs (CRDT): two people can type in the same place, no change is lost.
@@ -703,6 +740,8 @@ No configuration is required: open the same file in two browsers (or two windows
## 🔌 API
> 📖 Full guide: [REST API](docs/GUIDES/API_REST.md) · [MCP Server](docs/GUIDES/MCP.md)
ObsiGate exposes a complete REST API :
| Endpoint | Description | Method | Auth |
@@ -730,6 +769,7 @@ ObsiGate exposes a complete REST API :
| `/api/events` | Real-time SSE stream | GET | Yes |
| `/api/vaults/add` / `/api/vaults/{name}` | Dynamic vault management | POST/DELETE | Admin |
| `/api/image/{vault}?path=` | Serve an image | GET | Yes |
| `/api/media/{vault}/thumb?path=&size=` | WebP thumbnail (disk cache) | GET | Yes |
| `/api/config` | Read / write configuration | GET/POST | Yes/Admin |
| `/api/diagnostics` | Index and memory statistics | GET | Admin |
@@ -763,6 +803,8 @@ curl "http://localhost:2020/api/file/Recipes?path=pizza.md"
## 🔍 Advanced Search
> 📖 Full guide: [Search, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
### Query Syntax
| Operator | Description | Example |
@@ -915,6 +957,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)
@@ -1070,7 +1114,9 @@ ObsiGate/
├── Dockerfile # Multi-stage, healthcheck, non-root
├── docker-compose.yml # Deployment with healthcheck and auth env vars
├── build.sh # Automated build & deployment (docker compose build + up)
└── docs/CONTRIBUTING.md # Contribution guide
└── docs/
├── GUIDES/ # User guides (getting started, API, MCP, desktop…)
└── CONTRIBUTING.md # Contribution guide
```
### Contributing
@@ -1096,8 +1142,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.16.4).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.18.0).
---
*Project: ObsiGate | Version: 2.16.4 | Last updated: May 2026*
*Project: ObsiGate | Version: 2.18.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.16.4
2.18.0
+2 -3
View File
@@ -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]]] = {}
+5
View File
@@ -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
+14 -1
View File
@@ -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
@@ -69,7 +71,7 @@ SUPPORTED_EXTENSIONS = {
".dockerfile", ".makefile", ".cmake",
".excalidraw",
".excalidraw.md",
}
} | set(IMAGE_EXTENSIONS) | set(AUDIO_EXTENSIONS) | set(VIDEO_EXTENSIONS)
# Ignored directories (configurable via OBSIGATE_IGNORED_DIRS env var)
@@ -550,6 +552,13 @@ def _scan_vault(
title = fpath.stem.replace(".excalidraw", "").replace("-", " ").replace("_", " ")
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 = ""
else:
raw = fpath.read_text(encoding="utf-8", errors="replace")
title = fpath.stem.replace("-", " ").replace("_", " ")
@@ -934,6 +943,10 @@ 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 = ""
else:
raw = fpath.read_text(encoding="utf-8", errors="replace")
content_preview = raw[:200].strip()
+262 -60
View File
@@ -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,
@@ -203,6 +205,11 @@ 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_json: bool | None = Field(default=None, description="True for JSON files")
is_excalidraw: bool | None = Field(default=None, description="True for Excalidraw diagram files")
@@ -699,20 +706,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 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';"
)
# 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).
@@ -1032,6 +1042,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.
@@ -2409,19 +2440,18 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
raise HTTPException(status_code=500, detail=f"Error reading PDF: {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 {
@@ -2439,6 +2469,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:
@@ -2614,32 +2699,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)
@@ -2667,7 +2741,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)
@@ -2684,18 +2757,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)
@@ -3183,16 +3283,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:
@@ -3200,6 +3303,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.
@@ -4092,6 +4280,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
@@ -4100,13 +4289,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,
}
# ---------------------------------------------------------------------------
+74
View File
@@ -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
+76
View File
@@ -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"
+1
View File
@@ -24,3 +24,4 @@ sse-starlette==2.1.3
openpyxl>=3.1
python-docx>=1.1
reportlab>=4.0
pillow>=10.0
+2
View File
@@ -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,7 @@ class DashboardResponse(BaseModel):
total_files: int
total_tags: int
total_size_bytes: int
total_images: int = 0
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.16.4"
version = "2.18.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.16.4"
version = "2.18.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.16.4",
"version": "2.18.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+2 -2
View File
@@ -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
+326
View File
@@ -0,0 +1,326 @@
# 🔌 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 |
### 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) |
+217
View File
@@ -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 |
+229
View File
@@ -0,0 +1,229 @@
# 🔒 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*).
---
## 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 |
+86
View File
@@ -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 |
+221
View File
@@ -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` |
+201
View File
@@ -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).
+191
View File
@@ -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` |
+236
View File
@@ -0,0 +1,236 @@
# 🚀 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.
### 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) |
+136
View File
@@ -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.
+54
View File
@@ -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.
+193
View File
@@ -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` |
+2 -1
View File
@@ -179,7 +179,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *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 | 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-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 |
| | | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -257,6 +257,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 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) |
---
+7 -179
View File
@@ -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).
+5 -3
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.16.4 | **Dernière mise à jour :** 2026-09-22
> **Version :** 2.18.0 | **Dernière mise à jour :** 2026-09-23
> **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)**
@@ -182,6 +182,8 @@
| 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) |
---
@@ -189,11 +191,11 @@
| Priorité | Items | Effort total estimé |
|---|---|---|
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–107, #92 | ~120 jours réalisés |
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–109, #92 | ~125 jours réalisés |
| 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour |
| ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
| ⚪ P0/P1 restant | #85, #87 Refonte architecturale, CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~11-17 jours |
| **Total restant** | **6 items + finitions** | **~23-36 jours** |
| **Total restant** | **6 items + finitions** | **~23-38 jours** |
---
+1 -1
View File
@@ -67,7 +67,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
+96
View File
@@ -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.
+99
View File
@@ -0,0 +1,99 @@
# #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`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

+31 -29
View File
@@ -201,37 +201,39 @@ const EXT_ICONS = {
".tex": "file-text",
".latex": "file-text",
// Image files
".png": "file-image",
".jpg": "file-image",
".jpeg": "file-image",
".gif": "file-image",
".svg": "file-image",
".webp": "file-image",
".bmp": "file-image",
".ico": "file-image",
".tiff": "file-image",
".tif": "file-image",
// Image files (roadmap #108-D1)
".png": "image",
".jpg": "image",
".jpeg": "image",
".gif": "image",
".svg": "image",
".webp": "image",
".bmp": "image",
".ico": "image",
".tiff": "image",
".tif": "image",
// Audio files
".mp3": "file-music",
".wav": "file-music",
".flac": "file-music",
".aac": "file-music",
".ogg": "file-music",
".m4a": "file-music",
".wma": "file-music",
// Audio files (roadmap #109-B2)
".mp3": "audio-lines",
".wav": "audio-lines",
".flac": "audio-lines",
".aac": "audio-lines",
".ogg": "audio-lines",
".oga": "audio-lines",
".opus": "audio-lines",
".m4a": "audio-lines",
".wma": "audio-lines",
// Video files
".mp4": "play",
".avi": "play",
".mov": "play",
".wmv": "play",
".flv": "play",
".webm": "play",
".mkv": "play",
".m4v": "play",
".3gp": "play",
// Video files (roadmap #109-B2)
".mp4": "video",
".avi": "video",
".mov": "video",
".wmv": "video",
".flv": "video",
".webm": "video",
".mkv": "video",
".m4v": "video",
".3gp": "video",
// Archive files
".zip": "file-archive",
+394 -19
View File
@@ -540,12 +540,393 @@ export function navigatePdfToPage(area, page) {
iframe.src = `${base}${sep}_pdfpage=${Date.now()}#page=${page}`;
}
// ---------------------------------------------------------------------------
// Image viewer (roadmap #108-D)
// ---------------------------------------------------------------------------
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"]);
const IMAGE_ZOOM_MIN = 0.1;
const IMAGE_ZOOM_MAX = 8;
let _imageViewerCleanup = null;
/** Clamp a zoom factor into the supported [0.1, 8] range. */
export function clampImageZoom(value) {
if (!Number.isFinite(value)) return 1;
return Math.min(IMAGE_ZOOM_MAX, Math.max(IMAGE_ZOOM_MIN, value));
}
/** True when *p* has a viewable image extension. */
export function isImagePath(p) {
const lower = (p || "").toLowerCase();
const dot = lower.lastIndexOf(".");
return dot !== -1 && IMAGE_EXTS.has(lower.slice(dot));
}
/** Build the byte-serving URL used by <img> / thumbnails. */
export function buildImageUrl(vault, path) {
return `/api/image/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`;
}
function buildThumbUrl(vault, path, size) {
return `/api/media/${encodeURIComponent(vault)}/thumb?path=${encodeURIComponent(path)}&size=${size}`;
}
function formatBytes(n) {
if (!n && n !== 0) return "";
if (n < 1024) return `${n} o`;
if (n < 1048576) return `${(n / 1024).toFixed(1)} Ko`;
return `${(n / 1048576).toFixed(1)} Mo`;
}
/**
* Render a full image viewer: centered image, wheel zoom (0.1×–8×), drag pan,
* double-click reset, ←/→ navigation between siblings, thumbnail filmstrip,
* collapsible metadata panel and a full-viewport lightbox.
*/
export function renderImageViewer(area, data) {
const vault = data.vault;
const path = data.path;
const fileName = (path || "").split("/").pop();
const imgUrl = buildImageUrl(vault, path);
area.innerHTML = "";
const container = el("div", { class: "image-viewer-container", tabindex: "0" });
// ── Toolbar ────────────────────────────────────────────────────────────
const toolbar = el("div", { class: "image-toolbar" });
toolbar.appendChild(el("span", { class: "image-title", title: path }, [
document.createTextNode(data.title || fileName),
]));
toolbar.appendChild(el("div", { class: "image-toolbar-spacer" }));
const zoomBadge = el("span", { class: "image-zoom-badge" }, [document.createTextNode("100%")]);
toolbar.appendChild(zoomBadge);
const mkBtn = (iconName, label, cls) => {
const b = el("button", { class: `btn-action${cls ? " " + cls : ""}`, type: "button", title: label, "aria-label": label }, [icon(iconName, 14)]);
return b;
};
const zoomOutBtn = mkBtn("zoom-out", t("viewer.image_zoom_out"));
const zoomInBtn = mkBtn("zoom-in", t("viewer.image_zoom_in"));
const zoomResetBtn = mkBtn("rotate-ccw", t("viewer.image_zoom_reset"));
const prevBtn = mkBtn("chevron-left", t("viewer.image_prev"));
const nextBtn = mkBtn("chevron-right", t("viewer.image_next"));
const originalBtn = mkBtn("external-link", t("viewer.image_open_original"));
const downloadBtn = mkBtn("download", t("viewer.download"));
const metaBtn = mkBtn("info", t("viewer.image_metadata"));
const lightboxBtn = mkBtn("maximize", t("viewer.image_fullscreen"));
[prevBtn, nextBtn, zoomOutBtn, zoomInBtn, zoomResetBtn, metaBtn, originalBtn, downloadBtn, lightboxBtn]
.forEach((b) => toolbar.appendChild(b));
container.appendChild(toolbar);
// ── Stage (image + pan/zoom) ───────────────────────────────────────────
const stage = el("div", { class: "image-stage" });
const img = el("img", { class: "image-main", src: imgUrl, alt: data.title || fileName, draggable: "false" });
stage.appendChild(img);
container.appendChild(stage);
// ── Thumbnail filmstrip (navigation) ───────────────────────────────────
const strip = el("div", { class: "image-nav-strip", hidden: true });
container.appendChild(strip);
// ── Metadata panel ─────────────────────────────────────────────────────
const metaPanel = el("div", { class: "image-meta-panel" });
metaPanel.hidden = true;
container.appendChild(metaPanel);
let scale = 1;
let tx = 0;
let ty = 0;
const applyTransform = () => {
img.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`;
zoomBadge.textContent = `${Math.round(scale * 100)}%`;
};
const resetView = () => { scale = 1; tx = 0; ty = 0; applyTransform(); };
const setZoom = (next) => {
scale = clampImageZoom(next);
if (scale === 1) { tx = 0; ty = 0; }
applyTransform();
};
const renderMeta = () => {
const rows = [
[t("viewer.image_type"), data.image_mime || ""],
[t("viewer.image_dimensions"), img.naturalWidth ? `${img.naturalWidth} × ${img.naturalHeight}` : ""],
[t("viewer.metadata_size"), formatBytes(data.size_bytes)],
[t("viewer.metadata_path"), path],
];
if (data.modified) rows.push([t("viewer.metadata_modified"), data.modified]);
metaPanel.innerHTML = "";
const dl = el("dl", {});
rows.forEach(([k, v]) => {
dl.appendChild(el("dt", {}, [document.createTextNode(k)]));
dl.appendChild(el("dd", {}, [document.createTextNode(v || "—")]));
});
metaPanel.appendChild(dl);
};
// ── Sibling navigation ─────────────────────────────────────────────────
let siblings = [];
let currentIndex = -1;
const go = (delta) => {
if (siblings.length < 2 || currentIndex < 0) return;
const next = (currentIndex + delta + siblings.length) % siblings.length;
openFile(vault, siblings[next].path);
};
const renderStrip = () => {
if (siblings.length < 2) { strip.hidden = true; return; }
strip.hidden = false;
strip.innerHTML = "";
siblings.forEach((s, i) => {
const thumb = el("img", {
class: `image-thumb${i === currentIndex ? " active" : ""}`,
src: buildThumbUrl(vault, s.path, 96),
alt: s.name,
title: s.name,
loading: "lazy",
});
thumb.addEventListener("click", () => { if (s.path !== path) openFile(vault, s.path); });
strip.appendChild(thumb);
});
};
(async () => {
try {
const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
const res = await api(`/api/browse/${encodeURIComponent(vault)}?path=${encodeURIComponent(dir)}`);
siblings = (res.items || []).filter((it) => it.type === "file" && isImagePath(it.path));
currentIndex = siblings.findIndex((it) => it.path === path);
prevBtn.disabled = nextBtn.disabled = siblings.length < 2;
renderStrip();
} catch (_) { /* navigation is best-effort */ }
})();
// ── Interactions ───────────────────────────────────────────────────────
stage.addEventListener("wheel", (e) => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
setZoom(scale * factor);
}, { passive: false });
let dragging = false;
let startX = 0;
let startY = 0;
let startTx = 0;
let startTy = 0;
stage.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
dragging = true;
startX = e.clientX; startY = e.clientY; startTx = tx; startTy = ty;
stage.classList.add("panning");
if (stage.setPointerCapture) stage.setPointerCapture(e.pointerId);
});
stage.addEventListener("pointermove", (e) => {
if (!dragging) return;
tx = startTx + (e.clientX - startX);
ty = startTy + (e.clientY - startY);
applyTransform();
});
const endDrag = (e) => {
dragging = false;
stage.classList.remove("panning");
if (stage.releasePointerCapture && e.pointerId != null) {
try { stage.releasePointerCapture(e.pointerId); } catch (_) { /* already released */ }
}
};
stage.addEventListener("pointerup", endDrag);
stage.addEventListener("pointercancel", endDrag);
stage.addEventListener("dblclick", resetView);
zoomInBtn.addEventListener("click", () => setZoom(scale * 1.25));
zoomOutBtn.addEventListener("click", () => setZoom(scale / 1.25));
zoomResetBtn.addEventListener("click", resetView);
prevBtn.addEventListener("click", () => go(-1));
nextBtn.addEventListener("click", () => go(1));
originalBtn.addEventListener("click", () => window.open(imgUrl, "_blank"));
downloadBtn.addEventListener("click", () => {
const dlUrl = `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(path)}`;
window.open(dlUrl, "_blank");
});
metaBtn.addEventListener("click", () => {
metaPanel.hidden = !metaPanel.hidden;
if (!metaPanel.hidden) renderMeta();
});
lightboxBtn.addEventListener("click", () => {
container.classList.toggle("lightbox");
lightboxBtn.setAttribute("aria-pressed", container.classList.contains("lightbox") ? "true" : "false");
});
img.addEventListener("load", () => { if (!metaPanel.hidden) renderMeta(); });
img.addEventListener("error", () => {
stage.innerHTML = "";
stage.appendChild(el("div", { class: "image-error" }, [document.createTextNode(t("viewer.image_error"))]));
});
const onKey = (e) => {
if (e.key === "ArrowLeft") { e.preventDefault(); go(-1); }
else if (e.key === "ArrowRight") { e.preventDefault(); go(1); }
else if (e.key === "+" || e.key === "=") { e.preventDefault(); setZoom(scale * 1.25); }
else if (e.key === "-") { e.preventDefault(); setZoom(scale / 1.25); }
else if (e.key === "0") { e.preventDefault(); resetView(); }
else if (e.key === "Escape") { container.classList.remove("lightbox"); }
};
document.addEventListener("keydown", onKey);
_imageViewerCleanup = () => document.removeEventListener("keydown", onKey);
area.appendChild(container);
safeCreateIcons();
applyTransform();
try { container.focus({ preventScroll: true }); } catch (_) { /* non-fatal */ }
}
let _mediaViewerCleanup = null;
/** Format a duration in seconds as ``m:ss`` (or ``h:mm:ss``). */
export function formatMediaDuration(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return "";
const total = Math.floor(seconds);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const pad = (n) => String(n).padStart(2, "0");
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
/** Build the byte-range streaming URL used by the HTML5 media players (#109). */
export function buildMediaUrl(vault, path) {
return `/api/media/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`;
}
function buildDownloadUrl(vault, path) {
return `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(path)}`;
}
/**
* Fallback shown when a media codec is not playable in the browser (roadmap
* #109-C3) or when the file is too large to stream inline.
*/
export function renderMediaFallback(area, data, message) {
const fileName = (data.path || "").split("/").pop();
const sizeStr = formatBytes(data.size_bytes);
area.innerHTML = "";
const box = el("div", { class: "unsupported-file" }, [
icon("file", 48),
el("div", { class: "filename" }, [document.createTextNode(fileName)]),
el("div", {}, [document.createTextNode(message)]),
sizeStr ? el("div", { style: "font-size:0.85rem;margin-top:4px" }, [document.createTextNode(t("viewer.metadata_size") + " : " + sizeStr)]) : null,
]);
const dlBtn = el("button", { class: "btn-action", type: "button" }, [
icon("download", 14), document.createTextNode(" " + t("viewer.download")),
]);
dlBtn.addEventListener("click", () => window.open(buildDownloadUrl(data.vault, data.path), "_blank"));
const openBtn = el("button", { class: "btn-action", type: "button" }, [
icon("external-link", 14), document.createTextNode(" " + t("viewer.image_open_original")),
]);
openBtn.addEventListener("click", () => window.open(data.stream_url || buildMediaUrl(data.vault, data.path), "_blank"));
box.appendChild(el("div", { class: "media-fallback-actions" }, [dlBtn, openBtn]));
area.appendChild(box);
safeCreateIcons();
}
function mediaToolbar(data, extraButtons) {
const toolbar = el("div", { class: "media-toolbar" });
toolbar.appendChild(el("span", { class: "media-title", title: data.path }, [
document.createTextNode(data.title || (data.path || "").split("/").pop()),
]));
toolbar.appendChild(el("div", { class: "media-toolbar-spacer" }));
(extraButtons || []).forEach((b) => toolbar.appendChild(b));
const openBtn = el("button", { class: "btn-action", type: "button", title: t("viewer.image_open_original"), "aria-label": t("viewer.image_open_original") }, [icon("external-link", 14)]);
openBtn.addEventListener("click", () => window.open(data.stream_url || buildMediaUrl(data.vault, data.path), "_blank"));
const downloadBtn = el("button", { class: "btn-action", type: "button", title: t("viewer.download"), "aria-label": t("viewer.download") }, [icon("download", 14)]);
downloadBtn.addEventListener("click", () => window.open(buildDownloadUrl(data.vault, data.path), "_blank"));
toolbar.appendChild(openBtn);
toolbar.appendChild(downloadBtn);
return toolbar;
}
/**
* Render an audio player (roadmap #109-B): full-width native ``<audio>``,
* artwork placeholder, duration read from ``loadedmetadata`` and a toolbar
* with the vault, size, open-original and download actions.
*/
export function renderAudioViewer(area, data) {
const streamUrl = data.stream_url || buildMediaUrl(data.vault, data.path);
area.innerHTML = "";
const container = el("div", { class: "audio-viewer-container" });
const badge = el("span", { class: "media-duration-badge" }, [document.createTextNode("--:--")]);
const vaultLabel = el("span", { class: "media-meta" }, [document.createTextNode(`${data.vault} · ${formatBytes(data.size_bytes)}`)]);
container.appendChild(mediaToolbar(data, [vaultLabel, badge]));
const stage = el("div", { class: "audio-stage" });
const artwork = el("div", { class: "audio-artwork" }, [icon("audio-lines", 64)]);
const audio = el("audio", { class: "audio-player", controls: "", preload: "metadata", src: streamUrl });
stage.appendChild(artwork);
stage.appendChild(audio);
container.appendChild(stage);
audio.addEventListener("loadedmetadata", () => {
badge.textContent = formatMediaDuration(audio.duration) || "--:--";
});
audio.addEventListener("error", () => {
renderMediaFallback(area, data, t("viewer.media_unsupported"));
});
_mediaViewerCleanup = () => { try { audio.pause(); audio.removeAttribute("src"); } catch (_) { /* detached */ } };
area.appendChild(container);
safeCreateIcons();
}
/**
* Render a video player (roadmap #109-C): centered native ``<video>`` with a
* black letterboxed stage and a toolbar. Playing is stopped when the content
* area is re-rendered (tab switch / navigation).
*/
export function renderVideoViewer(area, data) {
const streamUrl = data.stream_url || buildMediaUrl(data.vault, data.path);
area.innerHTML = "";
const container = el("div", { class: "video-viewer-container" });
const badge = el("span", { class: "media-duration-badge" }, [document.createTextNode("--:--")]);
const vaultLabel = el("span", { class: "media-meta" }, [document.createTextNode(`${data.vault} · ${formatBytes(data.size_bytes)}`)]);
container.appendChild(mediaToolbar(data, [vaultLabel, badge]));
const stage = el("div", { class: "video-stage" });
const video = el("video", { class: "video-player", controls: "", playsinline: "", preload: "metadata", src: streamUrl });
stage.appendChild(video);
container.appendChild(stage);
video.addEventListener("loadedmetadata", () => {
const parts = [formatMediaDuration(video.duration) || "--:--"];
if (video.videoWidth && video.videoHeight) parts.push(`${video.videoWidth} × ${video.videoHeight}`);
badge.textContent = parts.join(" · ");
});
video.addEventListener("error", () => {
renderMediaFallback(area, data, t("viewer.media_unsupported"));
});
_mediaViewerCleanup = () => { try { video.pause(); video.removeAttribute("src"); } catch (_) { /* detached */ } };
area.appendChild(container);
safeCreateIcons();
}
export function renderFile(data) {
// #93 — An inline edition session (#editor-container mounted in the content
// area) is destroyed by this very re-render: release it first so the editor
// state (CodeMirror view, Forge iframe, Yjs session) is torn down cleanly
// instead of being wiped mid-session by a tab switch / sidebar click.
if (isInlineEditorActive()) detachInlineEditor();
// #108 — release the image viewer's document-level shortcuts before swapping
// the content area (otherwise they leak on every re-render).
if (_imageViewerCleanup) { _imageViewerCleanup(); _imageViewerCleanup = null; }
// #109 — stop any playing audio/video when the file view is replaced
// (tab switch, navigation): no persistent playback in v1.
if (_mediaViewerCleanup) { _mediaViewerCleanup(); _mediaViewerCleanup = null; }
const area = getContentArea();
// Handle PDF files — render in iframe with TOC sidebar
@@ -596,25 +977,19 @@ export function renderFile(data) {
return;
}
// Handle images
// Handle images — dedicated zoom/pan viewer (roadmap #108-D)
if (data.is_image) {
const imgUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
area.innerHTML = `
<div class="image-viewer-container">
<div class="file-toolbar">
<span class="file-info">${escapeHtml(data.title)}</span>
<button class="btn-action" onclick="window.open('${imgUrl}', '_blank')">
<i data-lucide="maximize" style="width:14px;height:14px"></i> Plein écran
</button>
<button class="btn-action" onclick="window.open('/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}', '_blank')">
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
</button>
</div>
<div class="image-viewer-body">
${data.html}
</div>
</div>`;
lucide.createIcons();
renderImageViewer(area, data);
return;
}
// Handle audio / video — native HTML5 players (roadmap #109)
if (data.is_audio) {
renderAudioViewer(area, data);
return;
}
if (data.is_video) {
renderVideoViewer(area, data);
return;
}
@@ -629,7 +1004,7 @@ export function renderFile(data) {
<div class="unsupported-file">
<i data-lucide="file" style="width:48px;height:48px"></i>
<div class="filename">${escapeHtml(data.path.split("/").pop())}</div>
<div>Ce fichier est binaire et ne peut pas être affiché.</div>
<div>${data.media_too_large ? escapeHtml(t("viewer.media_too_large")) : "Ce fichier est binaire et ne peut pas être affiché."}</div>
${sizeStr ? `<div style="font-size:0.85rem;margin-top:4px">Taille : ${sizeStr}</div>` : ""}
<button class="btn-action" id="unsupported-download-btn">
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
+14 -1
View File
@@ -1823,11 +1823,24 @@
"viewer.export_title": "Export document",
"viewer.forge_brand": "Forge",
"viewer.forge_title": "Forge (new editor)",
"viewer.image_dimensions": "Dimensions",
"viewer.image_error": "Unable to load the image",
"viewer.image_fullscreen": "Full screen (lightbox)",
"viewer.image_metadata": "Metadata",
"viewer.image_next": "Next image",
"viewer.image_open_original": "Open original",
"viewer.image_prev": "Previous image",
"viewer.image_type": "Type",
"viewer.image_zoom_in": "Zoom in",
"viewer.image_zoom_out": "Zoom out",
"viewer.image_zoom_reset": "Reset zoom",
"viewer.index_start": "Starting index...",
"viewer.index_updated": "Updated",
"viewer.loaded_from_cache": "File loaded from offline cache",
"viewer.loading": "Loading file...",
"viewer.markdown": "Markdown",
"viewer.media_too_large": "File too large for inline playback. Download it to watch.",
"viewer.media_unsupported": "This format cannot be played in your browser.",
"viewer.metadata_created": "Created",
"viewer.metadata_modified": "Modified",
"viewer.metadata_path": "Path",
@@ -2116,7 +2129,7 @@
"guide105.lib_h3_conflicts": "Sync conflicts",
"guide105.lib_conflicts": "If you sync the vault with Syncthing, ObsiGate detects conflict files (\"sync-conflict\" copies) and offers to compare then resolve them from a dedicated page in the Options menu.",
"guide105.lib_h3_attach": "Attachments & media",
"guide105.lib_attach": "Inline <code>![[image.png]]</code> images, attachments and media (audio, video, embedded PDFs) are rendered in the viewer and indexed for search; the \"Rescan attachments\" button in Configuration rebuilds the attachment index.",
"guide105.lib_attach": "Inline <code>![[image.png]]</code> images, attachments and media (audio, video, embedded PDFs) are rendered in the viewer and indexed for search. Images also appear in the file tree and open in a dedicated viewer (wheel zoom, pan, navigation between images in the folder, thumbnails, metadata, lightbox); audio (.mp3, .wav, .flac…) and video (.mp4, .webm…) files open in a built-in HTML5 player (play, seek, speed, fullscreen), falling back to download when the format is not playable in the browser. The \"Rescan attachments\" button in Configuration rebuilds the attachment index.",
"guide105.off_pwa": "ObsiGate is a PWA: install it (install icon in the address bar) to open it like an app. The service worker caches the UI and your recently viewed documents.",
"guide105.off_edit": "Offline you can read cached documents and even edit them: changes are queued in IndexedDB.",
"guide105.off_sync": "When back online the queue replays automatically (sync badge in the header). If the server version diverged meanwhile, the file is flagged as conflict and the server copy is kept as a backup.",
+14 -1
View File
@@ -1823,11 +1823,24 @@
"viewer.export_title": "Exporter le document",
"viewer.forge_brand": "Forge",
"viewer.forge_title": "Forge (nouvel éditeur)",
"viewer.image_dimensions": "Dimensions",
"viewer.image_error": "Impossible de charger l'image",
"viewer.image_fullscreen": "Plein écran (lightbox)",
"viewer.image_metadata": "Métadonnées",
"viewer.image_next": "Image suivante",
"viewer.image_open_original": "Ouvrir l'original",
"viewer.image_prev": "Image précédente",
"viewer.image_type": "Type",
"viewer.image_zoom_in": "Zoom avant",
"viewer.image_zoom_out": "Zoom arrière",
"viewer.image_zoom_reset": "Réinitialiser le zoom",
"viewer.index_start": "Démarrage index.",
"viewer.index_updated": "Mise à jour",
"viewer.loaded_from_cache": "Fichier chargé depuis le cache hors-ligne",
"viewer.loading": "Chargement du fichier...",
"viewer.markdown": "Markdown",
"viewer.media_too_large": "Fichier trop volumineux pour la lecture intégrée. Téléchargez-le pour le lire.",
"viewer.media_unsupported": "Ce format ne peut pas être lu dans votre navigateur.",
"viewer.metadata_created": "Créé",
"viewer.metadata_modified": "Modifié",
"viewer.metadata_path": "Chemin",
@@ -2116,7 +2129,7 @@
"guide105.lib_h3_conflicts": "Conflits de synchronisation",
"guide105.lib_conflicts": "Si vous synchronisez le vault avec Syncthing, ObsiGate détecte les fichiers de conflit (copies « sync-conflict ») et propose de les comparer puis résoudre depuis la page dédiée du menu Options.",
"guide105.lib_h3_attach": "Fichiers joints & médias",
"guide105.lib_attach": "Les images <code>![[image.png]]</code>, pièces jointes et médias (audio, vidéo, PDF intégrés) dans les notes sont rendus dans le viewer et indexés pour la recherche ; le bouton « Rescan attachments » de la configuration recrée l'index des pièces jointes.",
"guide105.lib_attach": "Les images <code>![[image.png]]</code>, pièces jointes et médias (audio, vidéo, PDF intégrés) dans les notes sont rendus dans le viewer et indexés pour la recherche. Les images apparaissent aussi dans l'arborescence et s'ouvrent dans une visionneuse dédiée (zoom molette, pan, navigation entre images du dossier, miniatures, métadonnées, lightbox) ; les fichiers audio (.mp3, .wav, .flac…) et vidéo (.mp4, .webm…) s'ouvrent dans un lecteur HTML5 intégré (lecture, déplacement, vitesse, plein écran), avec repli sur le téléchargement si le format n'est pas lisible par le navigateur. Le bouton « Rescan attachments » de la configuration recrée l'index des pièces jointes.",
"guide105.off_pwa": "ObsiGate est une PWA : installez-la (icône d'installation de la barre d'adresse) pour l'ouvrir comme une application. Le service worker met en cache l'interface et vos derniers documents consultés.",
"guide105.off_edit": "Hors-ligne, vous pouvez lire les documents en cache et même les éditer : les modifications sont mises en file d'attente dans IndexedDB.",
"guide105.off_sync": "Au retour en ligne, la file se rejoue automatiquement (badge de synchronisation dans l'en-tête). Si la version serveur a divergé entre-temps, le fichier est marqué en conflit et la version serveur est préservée en backup.",
+210
View File
@@ -9639,6 +9639,216 @@ body.desktop-mode .editor-container {
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
}
/* #108-D — dedicated image viewer: zoom, pan, filmstrip, metadata, lightbox */
.image-viewer-container {
display: flex;
flex-direction: column;
height: 100%;
outline: none;
}
.image-toolbar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--surface1);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.image-toolbar .image-title {
color: var(--text);
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 40%;
}
.image-toolbar-spacer {
flex: 1;
}
.image-zoom-badge {
font-variant-numeric: tabular-nums;
font-size: 0.75rem;
color: var(--text-dim);
min-width: 46px;
text-align: center;
}
.image-stage {
flex: 1;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
background: var(--surface2);
cursor: grab;
touch-action: none;
}
.image-stage.panning {
cursor: grabbing;
}
.image-main {
max-width: 100%;
max-height: 100%;
object-fit: contain;
transform-origin: center center;
user-select: none;
-webkit-user-drag: none;
will-change: transform;
}
.image-error {
color: var(--text-dim);
font-size: 0.9rem;
}
.image-nav-strip {
display: flex;
gap: 6px;
padding: 8px;
overflow-x: auto;
background: var(--surface1);
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.image-nav-strip[hidden] {
display: none;
}
.image-thumb {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 4px;
border: 2px solid transparent;
cursor: pointer;
opacity: 0.65;
flex-shrink: 0;
}
.image-thumb:hover {
opacity: 1;
}
.image-thumb.active {
border-color: var(--accent);
opacity: 1;
}
.image-meta-panel {
padding: 10px 16px;
background: var(--surface1);
border-top: 1px solid var(--border);
font-size: 0.8rem;
color: var(--text-dim);
flex-shrink: 0;
}
.image-meta-panel[hidden] {
display: none;
}
.image-meta-panel dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
margin: 0;
}
.image-meta-panel dt {
color: var(--text-dim);
}
.image-meta-panel dd {
margin: 0;
color: var(--text);
overflow-wrap: anywhere;
}
.image-viewer-container.lightbox {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(0, 0, 0, 0.9);
}
.image-viewer-container.lightbox .image-nav-strip,
.image-viewer-container.lightbox .image-meta-panel {
display: none;
}
/* ── Audio / Video viewers (roadmap #109) ── */
.audio-viewer-container,
.video-viewer-container {
display: flex;
flex-direction: column;
height: 100%;
}
.media-toolbar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--surface1);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.media-toolbar .media-title {
color: var(--text);
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 40%;
}
.media-toolbar-spacer {
flex: 1;
}
.media-duration-badge {
font-variant-numeric: tabular-nums;
font-size: 0.75rem;
color: var(--text-dim);
min-width: 46px;
text-align: center;
}
.media-meta {
font-size: 0.75rem;
color: var(--text-dim);
}
.audio-stage {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
padding: 32px 24px;
min-height: 50vh;
background: var(--surface2);
}
.audio-artwork {
display: flex;
align-items: center;
justify-content: center;
width: 140px;
height: 140px;
border-radius: 12px;
background: var(--surface1);
border: 1px solid var(--border);
color: var(--accent);
}
.audio-player {
width: min(560px, 100%);
}
.video-stage {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #000;
overflow: hidden;
min-height: 60vh;
}
.video-player {
max-width: 100%;
max-height: calc(100vh - 180px);
object-fit: contain;
}
.media-fallback-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
/* ── Text / CSV Viewer ── */
.text-viewer-container {
display: flex;
+7
View File
@@ -109,6 +109,13 @@ self.addEventListener('fetch', (event) => {
// Let the browser handle range requests (PDF/streamed media) directly.
if (request.headers.has('range')) return;
// Streamed audio/video is large and range-driven — never cache it
// (roadmap #109-E2). Image thumbnails (/api/media/{vault}/thumb) stay cached.
if (url.pathname.startsWith('/api/media/') && !url.pathname.endsWith('/thumb')) {
event.respondWith(fetch(request));
return;
}
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request, API_CACHE, () =>
new Response(JSON.stringify({ error: 'Offline' }), {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.16.4",
"version": "2.18.0",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="64" viewBox="0 0 96 64">
<rect width="96" height="64" fill="#2a7de1" />
<circle cx="48" cy="32" r="20" fill="#ffc828" />
</svg>

After

Width:  |  Height:  |  Size: 191 B

Binary file not shown.
+22
View File
@@ -94,6 +94,28 @@ def test_vault_dir(tmp_path: Path) -> str:
# Non-markdown file
(vault / "config.json").write_text('{"key": "value"}', encoding="utf-8")
# Image attachments (roadmap #108) — indexed as metadata-only binaries and
# listed in the tree / browse endpoint.
import base64
(vault / "chatScreenshot.png").write_bytes(base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAen63NgAAAAASUVORK5CYII="
))
(vault / "vector-icon.svg").write_text(
'<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2"></svg>',
encoding="utf-8",
)
# Audio/video media (roadmap #109) — indexed as metadata-only binaries,
# streamed via /api/media. Prefer the committed E2E fixture when present.
repo_media = Path(__file__).resolve().parent.parent / "test_vault" / "sample-audio.mp3"
if repo_media.exists():
(vault / "sample-audio.mp3").write_bytes(repo_media.read_bytes())
else:
(vault / "sample-audio.mp3").write_bytes(
b"ID3\x03\x00\x00\x00\x00\x00\x00" + bytes(range(256)) * 16
)
# File with accents in title
(vault / "café_crème.md").write_text(
"---\ntitle: Café Crème\n---\n# Café Crème\nUn bon café.\n",
+94
View File
@@ -0,0 +1,94 @@
/**
* E2E tests for the Configurations modal on mobile (BUG-071).
*
* Runs only under the `chromium-mobile` Playwright project (viewport ≤ 768px);
* skipped on the desktop project that the CI job executes — same convention
* as mobile-editor.spec.js.
*
* Covered:
* - the TOC hamburger (#config-hamburger) is visible and reveals #config-nav,
* which is hidden by default on mobile;
* - picking a TOC entry scrolls to the section, marks the link active and
* collapses the nav;
* - the modal content does not overflow horizontally at 393px.
*
* Run:
* npx playwright test tests/e2e/config-mobile.spec.js --project=chromium-mobile
* (ObsiGate listening on http://localhost:2029, auth disabled)
*/
import { test, expect } from '@playwright/test';
const MOBILE_MAX_WIDTH = 768;
async function boot(page) {
await page.goto('/');
await page.waitForSelector('#app:not(.hidden)', { timeout: 15000 });
await expect(page.locator('#header-menu-btn')).toBeVisible({ timeout: 15000 });
}
async function openConfigModal(page) {
await page.locator('#header-menu-btn').click();
await page.locator('#config-open-btn').click();
await expect(page.locator('#config-modal.active')).toBeVisible();
}
test.describe('Configurations modal on mobile (BUG-071)', () => {
test('hamburger reveals the table of contents', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openConfigModal(page);
// TOC hidden by default on mobile, hamburger visible.
await expect(page.locator('#config-hamburger')).toBeVisible();
await expect(page.locator('#config-nav')).toBeHidden();
await page.locator('#config-hamburger').click();
await expect(page.locator('#config-nav')).toBeVisible();
});
test('picking a section scrolls to it and collapses the nav', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openConfigModal(page);
await page.locator('#config-hamburger').click();
const link = page.locator('#config-nav a[href="#cfg-tokens"]');
await expect(link).toBeVisible();
await link.click();
// Nav collapses on mobile after selection…
await expect(page.locator('#config-nav')).toBeHidden();
// …the link is marked active…
await expect(link).toHaveClass(/active/);
// …and the section scrolls into view inside the modal (smooth scroll:
// poll for the settled position instead of racing the animation).
await expect
.poll(
async () => {
const box = await page.locator('#cfg-tokens').boundingBox();
const modalBox = await page.locator('#config-modal').boundingBox();
if (!box || !modalBox) return Number.POSITIVE_INFINITY;
return box.y - (modalBox.y + modalBox.height);
},
{ timeout: 8000 },
)
.toBeLessThanOrEqual(0);
});
test('no horizontal overflow at 393px', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openConfigModal(page);
for (const section of ['#cfg-ai', '#cfg-tokens', '#cfg-webhooks', '#cfg-partages-publics']) {
await page.locator('#config-hamburger').click();
await page.locator(`#config-nav a[href="${section}"]`).click();
}
const overflow = await page.evaluate(() => {
const scroller = document.getElementById('config-scroll');
return scroller.scrollWidth - scroller.clientWidth;
});
expect(overflow).toBeLessThanOrEqual(1);
});
});
+98
View File
@@ -0,0 +1,98 @@
/**
* E2E tests for the ObsiGate image viewer (roadmap #108).
*
* Fixtures : `test_vault/sample-image.png` (96x64) + `test_vault/sample-vector.svg`.
*
* Run (local):
* BASE_URL=http://localhost:2029 npx playwright test tests/e2e/image-viewer.spec.js
* BASE_URL=http://localhost:2029 npx playwright test tests/e2e/image-viewer.spec.js --headed
*/
import { test, expect } from '@playwright/test';
const BASE = process.env.BASE_URL || 'http://localhost:2029';
const CREDS = {
username: process.env.OBSIGATE_USER || 'admin',
password: process.env.OBSIGATE_PASS || 'test123',
};
async function login(page) {
await page.goto(BASE);
const loginForm = page.locator('#login-screen');
await expect(loginForm).toBeVisible({ timeout: 5000 }).catch(() => {});
if (await loginForm.isVisible()) {
await page.fill('#login-username', CREDS.username);
await page.fill('#login-password', CREDS.password);
await page.click('#login-btn');
}
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
}
async function openFile(page, vault, filePath) {
const treeItem = page.locator(`.tree-item[data-vault="${vault}"][data-path="${filePath}"]`);
if (!(await treeItem.count())) {
await page.locator(`.tree-item.vault-item[data-vault="${vault}"]`).first().click();
await treeItem.waitFor({ state: 'attached', timeout: 8000 });
}
await treeItem.dblclick({ timeout: 5000 });
}
test.describe('Image viewer — zoom / pan / navigation (#108)', () => {
test('affiche l\'image dans la visionneuse dédiée (URL /api/image)', async ({ page }) => {
// #108-B1 — l'image isolée doit pointer vers /api/image (octets), pas /raw (JSON).
const imageResponsePromise = page.waitForResponse(
(r) => r.url().includes('/api/image/') && r.status() === 200,
{ timeout: 15000 },
);
await login(page);
await openFile(page, 'TestVault', 'sample-image.png');
const main = page.locator('#content-area .image-viewer-container img.image-main');
await expect(main).toBeVisible({ timeout: 10000 });
await expect(main).toHaveAttribute('src', /\/api\/image\/TestVault\?path=/);
const resp = await imageResponsePromise;
expect(resp.headers()['content-type']).toContain('image/png');
// Le badge de zoom démarre à 100 %.
await expect(page.locator('#content-area .image-zoom-badge')).toHaveText('100%');
});
test('le zoom molette et le reset modifient la transform', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-image.png');
await expect(page.locator('#content-area .image-stage')).toBeVisible({ timeout: 10000 });
const badge = page.locator('#content-area .image-zoom-badge');
await expect(badge).toHaveText('100%');
await page.locator('#content-area .image-stage').hover();
await page.mouse.wheel(0, -240);
await expect(badge).not.toHaveText('100%', { timeout: 5000 });
const transform = await page.locator('#content-area img.image-main').evaluate(
(el) => getComputedStyle(el).transform,
);
expect(transform).not.toBe('none');
// Double-clic = réinitialisation.
await page.locator('#content-area .image-stage').dblclick();
await expect(badge).toHaveText('100%');
});
test('navigue entre les images du dossier via la pellicule', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-image.png');
const strip = page.locator('#content-area .image-nav-strip');
await expect(strip).toBeVisible({ timeout: 10000 });
// sample-image.png et sample-vector.svg partagent le dossier racine.
await expect(strip.locator('img.image-thumb')).toHaveCount(2);
await page.locator('#content-area .image-nav-strip img.image-thumb').first().click();
await expect(page.locator('#content-area .image-title')).toBeVisible();
});
});
+75
View File
@@ -0,0 +1,75 @@
/**
* E2E tests for the ObsiGate audio/video viewers (roadmap #109).
*
* Fixtures : `test_vault/sample-audio.mp3` (1 s sine, ~5 ko) +
* `test_vault/sample-video.webm` (64x64 VP8, ~1 ko).
*
* Run (local):
* BASE_URL=http://localhost:2029 npx playwright test tests/e2e/media-viewer.spec.js
*/
import { test, expect } from '@playwright/test';
const BASE = process.env.BASE_URL || 'http://localhost:2029';
const CREDS = {
username: process.env.OBSIGATE_USER || 'admin',
password: process.env.OBSIGATE_PASS || 'test123',
};
async function login(page) {
await page.goto(BASE);
const loginForm = page.locator('#login-screen');
await expect(loginForm).toBeVisible({ timeout: 5000 }).catch(() => {});
if (await loginForm.isVisible()) {
await page.fill('#login-username', CREDS.username);
await page.fill('#login-password', CREDS.password);
await page.click('#login-btn');
}
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
}
async function openFile(page, vault, filePath) {
const treeItem = page.locator(`.tree-item[data-vault="${vault}"][data-path="${filePath}"]`);
if (!(await treeItem.count())) {
await page.locator(`.tree-item.vault-item[data-vault="${vault}"]`).first().click();
await treeItem.waitFor({ state: 'attached', timeout: 8000 });
}
await treeItem.dblclick({ timeout: 5000 });
}
test.describe('Media viewers — HTML5 audio/video (#109)', () => {
test('rend un lecteur audio natif branché sur /api/media', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-audio.mp3');
const audio = page.locator('#content-area .audio-viewer-container audio.audio-player');
await expect(audio).toBeVisible({ timeout: 10000 });
await expect(audio).toHaveAttribute('src', /\/api\/media\/TestVault\?path=/);
await expect(audio).toHaveAttribute('controls', '');
// La durée est lue côté client via loadedmetadata.
await expect(page.locator('#content-area .media-duration-badge')).not.toHaveText('--:--', { timeout: 10000 });
});
test('rend un lecteur vidéo natif branché sur /api/media', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-video.webm');
const video = page.locator('#content-area .video-viewer-container video.video-player');
await expect(video).toBeVisible({ timeout: 10000 });
await expect(video).toHaveAttribute('src', /\/api\/media\/TestVault\?path=/);
await expect(video).toHaveAttribute('playsinline', '');
});
test('le streaming média honore les requêtes Range (206)', async ({ page }) => {
await login(page);
const resp = await page.request.get(
`${BASE}/api/media/TestVault?path=${encodeURIComponent('sample-video.webm')}`,
{ headers: { Range: 'bytes=0-99' } },
);
expect(resp.status()).toBe(206);
expect(resp.headers()['content-range']).toMatch(/^bytes 0-99\/\d+$/);
expect(resp.headers()['accept-ranges']).toBe('bytes');
});
});
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env node
/**
* ObsiGate — Image viewer tests (roadmap #108-D).
*
* Pure helpers (clampImageZoom / isImagePath / buildImageUrl) are evaluated in
* a VM sandbox to avoid importing the full browser-only viewer chain. The rest
* are static checks on the source and CSS.
*
* Usage: node tests/frontend/image-viewer.test.mjs
*/
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import vm from "node:vm";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, "..", "..");
const viewer = readFileSync(path.join(ROOT, "frontend", "js", "viewer.js"), "utf8");
const utils = readFileSync(path.join(ROOT, "frontend", "js", "utils.js"), "utf8");
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
const main = readFileSync(path.join(ROOT, "backend", "main.py"), "utf8");
function test(label, fn) {
try {
fn();
console.log(" \u2713 " + label);
} catch (err) {
console.error(" \u2717 " + label + "\n " + String(err.message).slice(0, 300));
process.exitCode = 1;
}
}
// ── Pure helpers (VM sandbox) ──────────────────────────────────────────────
const begin = viewer.indexOf("const IMAGE_EXTS");
const end = viewer.indexOf("function formatBytes");
assert.ok(begin !== -1 && end !== -1, "image viewer helper block not found");
const sandbox = {};
vm.createContext(sandbox);
vm.runInContext(
`${viewer.slice(begin, end).replace(/\bexport /g, "")}\nglobalThis.clampImageZoom = clampImageZoom; globalThis.isImagePath = isImagePath; globalThis.buildImageUrl = buildImageUrl;`,
sandbox,
);
const { clampImageZoom, isImagePath, buildImageUrl } = sandbox;
test("clampImageZoom bounds to [0.1, 8]", () => {
assert.equal(clampImageZoom(0.01), 0.1);
assert.equal(clampImageZoom(100), 8);
assert.equal(clampImageZoom(2), 2);
assert.equal(clampImageZoom(NaN), 1);
});
test("isImagePath recognises image extensions (case-insensitive)", () => {
for (const p of ["a.png", "a.JPG", "dir/b.jpeg", "x/y/z.svg", "p.webp", "q.bmp", "r.ico", "g.gif"]) {
assert.equal(isImagePath(p), true, p);
}
for (const p of ["a.md", "a.txt", "noext", "a.PDF", ""]) {
assert.equal(isImagePath(p), false, p);
}
});
test("buildImageUrl encodes vault and path", () => {
const url = buildImageUrl("My Vault", "café image.png");
assert.equal(url, "/api/image/My%20Vault?path=caf%C3%A9%20image.png");
});
// ── Static checks: viewer wiring ───────────────────────────────────────────
test("viewer.js dispatches is_image to renderImageViewer", () => {
assert.match(viewer, /if \(data\.is_image\) \{\s*renderImageViewer\(area, data\);/);
});
test("renderImageViewer never uses the JSON raw endpoint as an image src", () => {
const start = viewer.indexOf("export function renderImageViewer");
const stop = viewer.indexOf("export function renderFile");
assert.ok(start !== -1 && stop > start, "renderImageViewer block not found");
const block = viewer.slice(start, stop);
assert.doesNotMatch(block, /\/raw\?path=/, "images must be served by /api/image, not /raw");
assert.match(block, /buildImageUrl\(vault, path\)/);
});
test("image viewer supports wheel zoom, drag pan and double-click reset", () => {
assert.match(viewer, /stage\.addEventListener\("wheel"/);
assert.match(viewer, /stage\.addEventListener\("pointerdown"/);
assert.match(viewer, /stage\.addEventListener\("dblclick", resetView\)/);
assert.match(viewer, /renderImageViewer/);
});
test("image viewer exposes thumbnail navigation via /api/media thumb", () => {
assert.match(viewer, /\/api\/media\/\$\{encodeURIComponent\(vault\)\}\/thumb\?path=/);
assert.match(viewer, /let siblings = \[\]/);
assert.match(viewer, /siblings = \(res\.items \|\| \[\]\)/);
});
// ── Static checks: CSS ─────────────────────────────────────────────────────
test("style.css defines the image viewer layout + lightbox", () => {
assert.match(css, /\.image-stage\s*\{/);
assert.match(css, /\.image-nav-strip\s*\{/);
assert.match(css, /\.image-meta-panel\s*\{/);
assert.match(css, /\.image-viewer-container\.lightbox\s*\{/);
});
// ── Static checks: icon mapping + backend ──────────────────────────────────
test("utils.js maps image extensions to the Lucide 'image' icon", () => {
assert.match(utils, /"\.png": "image"/);
assert.match(utils, /"\.svg": "image"/);
});
test("backend api_file_view points <img> at /api/image", () => {
assert.match(main, /img_url = f"\/api\/image\//);
assert.match(main, /f'<img src="\{img_url\}"/);
});
if (process.exitCode) {
console.error("\nImage viewer tests FAILED");
} else {
console.log("\nAll image viewer tests passed.");
}
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
/**
* ObsiGate — Audio/video viewer tests (roadmap #109).
*
* Pure helpers (formatMediaDuration / buildMediaUrl) are evaluated in a VM
* sandbox to avoid importing the full browser-only viewer chain. The rest are
* static checks on the source, CSS and backend wiring.
*
* Usage: node tests/frontend/media-viewer.test.mjs
*/
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import vm from "node:vm";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, "..", "..");
const viewer = readFileSync(path.join(ROOT, "frontend", "js", "viewer.js"), "utf8");
const utils = readFileSync(path.join(ROOT, "frontend", "js", "utils.js"), "utf8");
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
const sw = readFileSync(path.join(ROOT, "frontend", "sw.js"), "utf8");
const main = readFileSync(path.join(ROOT, "backend", "main.py"), "utf8");
const fr = readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8");
const en = readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8");
function test(label, fn) {
try {
fn();
console.log(" \u2713 " + label);
} catch (err) {
console.error(" \u2717 " + label + "\n " + String(err.message).slice(0, 300));
process.exitCode = 1;
}
}
// ── Pure helpers (VM sandbox) ──────────────────────────────────────────────
const begin = viewer.indexOf("export function formatMediaDuration");
const end = viewer.indexOf("function buildDownloadUrl");
assert.ok(begin !== -1 && end > begin, "media viewer helper block not found");
const sandbox = {};
vm.createContext(sandbox);
vm.runInContext(
`${viewer.slice(begin, end).replace(/\bexport /g, "")}\nglobalThis.formatMediaDuration = formatMediaDuration; globalThis.buildMediaUrl = buildMediaUrl;`,
sandbox,
);
const { formatMediaDuration, buildMediaUrl } = sandbox;
test("formatMediaDuration renders m:ss and h:mm:ss", () => {
assert.equal(formatMediaDuration(0), "0:00");
assert.equal(formatMediaDuration(65), "1:05");
assert.equal(formatMediaDuration(3725), "1:02:05");
assert.equal(formatMediaDuration(NaN), "");
assert.equal(formatMediaDuration(-1), "");
});
test("buildMediaUrl encodes vault and path", () => {
const url = buildMediaUrl("My Vault", "café clip.mp4");
assert.equal(url, "/api/media/My%20Vault?path=caf%C3%A9%20clip.mp4");
});
// ── Static checks: viewer wiring ───────────────────────────────────────────
test("viewer.js dispatches is_audio / is_video to the media viewers", () => {
assert.match(viewer, /if \(data\.is_audio\) \{\s*renderAudioViewer\(area, data\);/);
assert.match(viewer, /if \(data\.is_video\) \{\s*renderVideoViewer\(area, data\);/);
});
test("audio viewer uses a native <audio> element and stream URL", () => {
const start = viewer.indexOf("export function renderAudioViewer");
const stop = viewer.indexOf("export function renderVideoViewer");
assert.ok(start !== -1 && stop > start, "renderAudioViewer block not found");
const block = viewer.slice(start, stop);
assert.match(block, /el\("audio"/);
assert.match(block, /controls: ""/);
assert.match(block, /data\.stream_url \|\| buildMediaUrl/);
});
test("video viewer uses a native <video> element with playsinline", () => {
const start = viewer.indexOf("export function renderVideoViewer");
const stop = viewer.indexOf("export function renderFile");
assert.ok(start !== -1 && stop > start, "renderVideoViewer block not found");
const block = viewer.slice(start, stop);
assert.match(block, /el\("video"/);
assert.match(block, /playsinline: ""/);
});
test("unsupported codec triggers the binary fallback UI", () => {
assert.match(viewer, /audio\.addEventListener\("error",\s*\(\)\s*=>\s*\{\s*renderMediaFallback/);
assert.match(viewer, /video\.addEventListener\("error",\s*\(\)\s*=>\s*\{\s*renderMediaFallback/);
assert.match(viewer, /export function renderMediaFallback/);
});
test("playback is stopped when the content area is re-rendered", () => {
assert.match(viewer, /_mediaViewerCleanup/);
assert.match(viewer, /_mediaViewerCleanup\(\); _mediaViewerCleanup = null;/);
});
// ── Static checks: CSS ─────────────────────────────────────────────────────
test("style.css defines the audio/video viewer layout", () => {
assert.match(css, /\.audio-viewer-container/);
assert.match(css, /\.audio-stage\s*\{/);
assert.match(css, /\.audio-artwork\s*\{/);
assert.match(css, /\.video-stage\s*\{/);
assert.match(css, /\.video-player\s*\{/);
});
// ── Static checks: icons, i18n, service worker ─────────────────────────────
test("utils.js maps media extensions to audio-lines / video icons", () => {
assert.match(utils, /"\.mp3": "audio-lines"/);
assert.match(utils, /"\.flac": "audio-lines"/);
assert.match(utils, /"\.mp4": "video"/);
assert.match(utils, /"\.webm": "video"/);
});
test("media viewer strings exist in FR and EN", () => {
for (const key of ["viewer.media_unsupported", "viewer.media_too_large"]) {
assert.match(fr, new RegExp(`"${key}"`));
assert.match(en, new RegExp(`"${key}"`));
}
});
test("service worker never caches streamed media", () => {
assert.match(sw, /startsWith\('\/api\/media\/'\)/);
assert.match(sw, /endsWith\('\/thumb'\)/);
});
// ── Static checks: backend ─────────────────────────────────────────────────
test("backend exposes /api/media and the shared Range helper", () => {
assert.match(main, /@app\.get\("\/api\/media\/\{vault_name\}"/);
assert.match(main, /def _stream_file_with_range\(/);
assert.match(main, /AUDIO_EXTENSIONS|is_audio/);
assert.match(main, /is_audio\(ext\) or is_video\(ext\)/);
});
if (process.exitCode) {
console.error("\nMedia viewer tests FAILED");
} else {
console.log("\nAll media viewer tests passed.");
}
+109
View File
@@ -0,0 +1,109 @@
# tests/test_image_api.py — Image serving & viewer API (roadmap #108-B/C)
import base64
from pathlib import Path
import pytest
PNG_1x1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAen63NgAAAAASUVORK5CYII="
)
SVG_DOC = b'<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2"><script>alert(1)</script></svg>'
def _write_image(vault_dir: str, name: str, content: bytes) -> None:
(Path(vault_dir) / name).write_bytes(content)
# ═══════════════════════════════════════════════════════════════════
# /api/image — byte serving (BUG fixed in #108-B1)
# ═══════════════════════════════════════════════════════════════════
class TestImageEndpoint:
def test_serves_bytes_and_mime(self, client, test_vault_dir):
_write_image(test_vault_dir, "pic.png", PNG_1x1)
resp = client.get("/api/image/TestVault", params={"path": "pic.png"})
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("image/png")
assert resp.content == PNG_1x1
def test_missing_image_404(self, client):
resp = client.get("/api/image/TestVault", params={"path": "nope.png"})
assert resp.status_code == 404
def test_svg_gets_sandbox_header(self, client, test_vault_dir):
_write_image(test_vault_dir, "vector.svg", SVG_DOC)
resp = client.get("/api/image/TestVault", params={"path": "vector.svg"})
assert resp.status_code == 200
assert resp.headers.get("content-security-policy") == "sandbox"
assert resp.headers.get("x-content-type-options") == "nosniff"
assert resp.content == SVG_DOC
def test_png_keeps_the_global_csp(self, client, test_vault_dir):
_write_image(test_vault_dir, "pic2.png", PNG_1x1)
resp = client.get("/api/image/TestVault", params={"path": "pic2.png"})
csp = resp.headers.get("content-security-policy", "")
assert csp != "sandbox"
assert "default-src" in csp
# ═══════════════════════════════════════════════════════════════════
# /api/file — standalone image view points at /api/image, not /raw
# ═══════════════════════════════════════════════════════════════════
class TestImageViewMetadata:
def test_file_view_uses_image_endpoint(self, client):
resp = client.get("/api/file/TestVault", params={"path": "chatScreenshot.png"})
assert resp.status_code == 200
data = resp.json()
assert data["is_image"] is True
assert data["image_mime"] == "image/png"
assert data["size_bytes"] > 0
assert "/api/image/TestVault?path=chatScreenshot.png" in data["html"]
# The JSON raw endpoint must NOT be used as an <img> source.
assert "/raw?path=" not in data["html"]
def test_file_view_url_encoded(self, client, test_vault_dir):
_write_image(test_vault_dir, "café image.png", PNG_1x1)
resp = client.get("/api/file/TestVault", params={"path": "café image.png"})
assert resp.status_code == 200
html = resp.json()["html"]
assert "/api/image/TestVault?path=caf%C3%A9%20image.png" in html
# ═══════════════════════════════════════════════════════════════════
# /api/media/{vault}/thumb — thumbnails (roadmap #108-C)
# ═══════════════════════════════════════════════════════════════════
class TestThumbnailEndpoint:
def test_png_thumbnail_is_webp(self, client, tmp_path, monkeypatch):
pytest.importorskip("PIL")
monkeypatch.setenv("OBSIGATE_DATA_DIR", str(tmp_path / "data"))
resp = client.get(
"/api/media/TestVault/thumb",
params={"path": "chatScreenshot.png", "size": 64},
)
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("image/webp")
def test_svg_thumbnail_falls_back_to_original(self, client, tmp_path, monkeypatch):
monkeypatch.setenv("OBSIGATE_DATA_DIR", str(tmp_path / "data"))
resp = client.get(
"/api/media/TestVault/thumb",
params={"path": "vector-icon.svg"},
)
assert resp.status_code == 200
assert "svg" in resp.headers["content-type"]
def test_thumb_rejects_non_image(self, client):
resp = client.get(
"/api/media/TestVault/thumb",
params={"path": "config.json"},
)
assert resp.status_code == 400
def test_missing_thumb_404(self, client):
resp = client.get(
"/api/media/TestVault/thumb",
params={"path": "nope.png"},
)
assert resp.status_code == 404
+105
View File
@@ -0,0 +1,105 @@
# tests/test_image_indexing.py — Images in the tree & index (roadmap #108-A)
import asyncio
import base64
from pathlib import Path
from backend.indexer import _index_single_file_sync, _scan_vault
from backend.services.vaults import browse_directory
from backend.watcher import VaultEventHandler
PNG_1x1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAen63NgAAAAASUVORK5CYII="
)
def _make_vault(tmp_path: Path) -> Path:
vault = tmp_path / "Vault"
vault.mkdir()
(vault / "note.md").write_text("# Note\n", encoding="utf-8")
(vault / "chatScreenshot.png").write_bytes(PNG_1x1)
assets = vault / "assets"
assets.mkdir()
(assets / "photo.jpg").write_bytes(PNG_1x1)
return vault
class TestScanVaultImages:
def test_image_indexed_without_content(self, tmp_path):
vault = _make_vault(tmp_path)
result = _scan_vault("V", str(vault))
by_path = {f["path"]: f for f in result["files"]}
assert "chatScreenshot.png" in by_path
image = by_path["chatScreenshot.png"]
assert image["content"] == "" # never read the bytes (#108-A2)
assert image["content_preview"] == ""
assert image["extension"] == ".png"
assert image["size"] > 0
assert "assets/photo.jpg" in by_path
def test_images_present_in_paths(self, tmp_path):
vault = _make_vault(tmp_path)
result = _scan_vault("V", str(vault))
files = {p["path"] for p in result["paths"] if p["type"] == "file"}
assert "chatScreenshot.png" in files
assert "assets/photo.jpg" in files
def test_no_binary_content_leaks(self, tmp_path):
vault = _make_vault(tmp_path)
result = _scan_vault("V", str(vault))
for f in result["files"]:
if f["extension"] in (".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"):
assert f["content"] == ""
class TestSingleFileImage:
def test_metadata_only(self, tmp_path):
vault = _make_vault(tmp_path)
info = _index_single_file_sync(
"V", str(vault), str(vault / "chatScreenshot.png")
)
assert info is not None
assert info["content"] == ""
assert info["extension"] == ".png"
assert info["path"] == "chatScreenshot.png"
class TestWatcherRelevance:
def test_image_extensions_relevant(self, tmp_path):
handler = VaultEventHandler("V", asyncio.Queue(), asyncio.new_event_loop())
assert handler._is_relevant(str(tmp_path / "a.png")) is True
assert handler._is_relevant(str(tmp_path / "a.jpg")) is True
assert handler._is_relevant(str(tmp_path / "a.svg")) is True
assert handler._is_relevant(str(tmp_path / "a.exe")) is False
class TestBrowseAndDashboard:
def test_browse_lists_image(self, client):
resp = client.get("/api/browse/TestVault")
assert resp.status_code == 200
items = resp.json()["items"]
names = {i["name"]: i for i in items}
assert "chatScreenshot.png" in names
assert names["chatScreenshot.png"]["type"] == "file"
assert names["chatScreenshot.png"]["extension"] == ".png"
def test_dashboard_counts_images(self, client):
resp = client.get("/api/dashboard")
assert resp.status_code == 200
data = resp.json()
assert data["total_images"] >= 2
vault = next(v for v in data["vaults"] if v["name"] == "TestVault")
assert vault["image_count"] >= 2
def test_ext_filter_returns_images(self, client):
resp = client.get("/api/search/advanced", params={"q": "ext:png"})
assert resp.status_code == 200
paths = [r["path"] for r in resp.json()["results"]]
assert any(p.endswith(".png") for p in paths)
class TestBrowseService:
def test_browse_directory_service(self, app_with_vault, test_vault_dir):
# The shared index is populated by the app_with_vault fixture.
data = browse_directory("TestVault", "")
names = [i["name"] for i in data["items"]]
assert "chatScreenshot.png" in names
+7 -1
View File
@@ -160,9 +160,15 @@ class TestSupportedExtensions:
for ext in [".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf"]:
assert ext in SUPPORTED_EXTENSIONS, f"{ext} should be supported"
def test_image_extensions_supported(self):
# #108 — images are indexed (name/size/mtime, empty content) so they
# show up in the tree and in file listings.
for ext in [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"]:
assert ext in SUPPORTED_EXTENSIONS, f"{ext} should be supported"
def test_binary_not_supported(self):
assert ".png" not in SUPPORTED_EXTENSIONS
assert ".exe" not in SUPPORTED_EXTENSIONS
assert ".zip" not in SUPPORTED_EXTENSIONS
# ═══════════════════════════════════════════════════════════════════
+11 -2
View File
@@ -70,11 +70,20 @@ class TestFileCRUD:
assert info is None
def test_index_single_file_sync_unsupported(self, test_vault_dir):
bin_file = Path(test_vault_dir) / "test_image.png"
bin_file.write_bytes(b"fake png data")
bin_file = Path(test_vault_dir) / "test_binary.exe"
bin_file.write_bytes(b"fake exe data")
info = _index_single_file_sync("TestVault", test_vault_dir, str(bin_file))
assert info is None
def test_index_single_file_sync_image_metadata_only(self, test_vault_dir):
# #108-A2 — images are indexed (name/size/mtime) but never read.
img_file = Path(test_vault_dir) / "test_image.png"
img_file.write_bytes(b"fake png data")
info = _index_single_file_sync("TestVault", test_vault_dir, str(img_file))
assert info is not None
assert info["extension"] == ".png"
assert info["content"] == ""
def test_add_and_remove_file(self, client):
path = "crud_test_unique_12345.md"
file_info = {
+91
View File
@@ -0,0 +1,91 @@
# tests/test_media_indexing.py — Audio/video in the tree & index (roadmap #109-A/F2)
import asyncio
from pathlib import Path
from backend.indexer import _index_single_file_sync, _scan_vault
from backend.services.vaults import browse_directory
from backend.watcher import VaultEventHandler
MEDIA_BYTES = b"ID3\x03\x00\x00\x00\x00\x00\x00" + bytes(range(256)) * 16
def _make_vault(tmp_path: Path) -> Path:
vault = tmp_path / "Vault"
vault.mkdir()
(vault / "note.md").write_text("# Note\n", encoding="utf-8")
(vault / "note-vocale.mp3").write_bytes(MEDIA_BYTES)
(vault / "clip.mp4").write_bytes(MEDIA_BYTES)
assets = vault / "assets"
assets.mkdir()
(assets / "ambiance.flac").write_bytes(MEDIA_BYTES)
return vault
class TestScanVaultMedia:
def test_audio_video_indexed_without_content(self, tmp_path):
vault = _make_vault(tmp_path)
result = _scan_vault("V", str(vault))
by_path = {f["path"]: f for f in result["files"]}
for path in ("note-vocale.mp3", "clip.mp4", "assets/ambiance.flac"):
assert path in by_path, path
entry = by_path[path]
assert entry["content"] == "" # never read the bytes (#109-A1)
assert entry["content_preview"] == ""
assert entry["size"] > 0
def test_media_present_in_paths(self, tmp_path):
vault = _make_vault(tmp_path)
result = _scan_vault("V", str(vault))
files = {p["path"] for p in result["paths"] if p["type"] == "file"}
assert "note-vocale.mp3" in files
assert "clip.mp4" in files
assert "assets/ambiance.flac" in files
def test_no_binary_content_leaks(self, tmp_path):
vault = _make_vault(tmp_path)
result = _scan_vault("V", str(vault))
for f in result["files"]:
if f["extension"] in (".mp3", ".mp4", ".flac"):
assert f["content"] == ""
class TestSingleFileMedia:
def test_metadata_only(self, tmp_path):
vault = _make_vault(tmp_path)
info = _index_single_file_sync("V", str(vault), str(vault / "note-vocale.mp3"))
assert info is not None
assert info["content"] == ""
assert info["extension"] == ".mp3"
assert info["path"] == "note-vocale.mp3"
class TestWatcherRelevance:
def test_media_extensions_relevant(self, tmp_path):
handler = VaultEventHandler("V", asyncio.Queue(), asyncio.new_event_loop())
for name in ("a.mp3", "a.flac", "a.opus", "a.mp4", "a.webm", "a.mov"):
assert handler._is_relevant(str(tmp_path / name)) is True, name
assert handler._is_relevant(str(tmp_path / "a.mkv")) is False
class TestBrowseAndSearch:
def test_browse_lists_media(self, client):
resp = client.get("/api/browse/TestVault")
assert resp.status_code == 200
items = resp.json()["items"]
names = {i["name"]: i for i in items}
assert "sample-audio.mp3" in names
assert names["sample-audio.mp3"]["type"] == "file"
assert names["sample-audio.mp3"]["extension"] == ".mp3"
def test_ext_filter_returns_audio(self, client):
resp = client.get("/api/search/advanced", params={"q": "ext:mp3"})
assert resp.status_code == 200
paths = [r["path"] for r in resp.json()["results"]]
assert any(p.endswith(".mp3") for p in paths)
class TestBrowseService:
def test_browse_directory_service(self, app_with_vault, test_vault_dir):
data = browse_directory("TestVault", "")
names = [i["name"] for i in data["items"]]
assert "sample-audio.mp3" in names
+168
View File
@@ -0,0 +1,168 @@
# tests/test_media_stream.py — Audio/video streaming & HTTP Range (roadmap #109-A/F1)
import base64
from pathlib import Path
import pytest
# 4 KiB of pseudo-ID3 bytes — the endpoint never decodes the payload, it only
# has to serve bytes with the right MIME and honour Range.
MEDIA_BYTES = b"ID3\x03\x00\x00\x00\x00\x00\x00" + bytes(range(256)) * 16
PDF_BYTES = b"%PDF-1.4\n" + b"x" * 2048 + b"\n%%EOF"
def _write(vault_dir: str, name: str, content: bytes) -> None:
(Path(vault_dir) / name).write_bytes(content)
# ═══════════════════════════════════════════════════════════════════
# /api/file — audio/video metadata returned before read_text (#109-A4)
# ═══════════════════════════════════════════════════════════════════
class TestMediaFileView:
def test_audio_metadata(self, client, test_vault_dir):
_write(test_vault_dir, "note-vocale.mp3", MEDIA_BYTES)
resp = client.get("/api/file/TestVault", params={"path": "note-vocale.mp3"})
assert resp.status_code == 200
data = resp.json()
assert data["is_audio"] is True
assert data["is_video"] is not True
assert data["media_mime"] == "audio/mpeg"
assert data["stream_url"] == "/api/media/TestVault?path=note-vocale.mp3"
assert data["size_bytes"] == len(MEDIA_BYTES)
assert data["unsupported"] is not True
def test_video_metadata(self, client, test_vault_dir):
_write(test_vault_dir, "clip.mp4", MEDIA_BYTES)
resp = client.get("/api/file/TestVault", params={"path": "clip.mp4"})
data = resp.json()
assert data["is_video"] is True
assert data["is_audio"] is not True
assert data["media_mime"] == "video/mp4"
assert data["stream_url"] == "/api/media/TestVault?path=clip.mp4"
def test_large_media_falls_back_to_download(self, client, test_vault_dir, monkeypatch):
_write(test_vault_dir, "huge.mp3", MEDIA_BYTES)
monkeypatch.setenv("OBSIGATE_MEDIA_MAX_INLINE_MB", "0.000001")
resp = client.get("/api/file/TestVault", params={"path": "huge.mp3"})
data = resp.json()
assert data["unsupported"] is True
assert data["media_too_large"] is True
assert not data.get("is_audio")
# ═══════════════════════════════════════════════════════════════════
# /api/media — byte-range streaming (#109-A2/F1)
# ═══════════════════════════════════════════════════════════════════
class TestMediaStreamRange:
def test_no_range_returns_200_and_accept_ranges(self, client, test_vault_dir):
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
resp = client.get("/api/media/TestVault", params={"path": "song.mp3"})
assert resp.status_code == 200
assert resp.headers["accept-ranges"] == "bytes"
assert resp.headers["content-type"].startswith("audio/mpeg")
assert resp.content == MEDIA_BYTES
def test_first_kibibyte_returns_206(self, client, test_vault_dir):
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
resp = client.get(
"/api/media/TestVault",
params={"path": "song.mp3"},
headers={"Range": "bytes=0-1023"},
)
assert resp.status_code == 206
assert resp.headers["accept-ranges"] == "bytes"
assert resp.headers["content-range"] == f"bytes 0-1023/{len(MEDIA_BYTES)}"
assert len(resp.content) == 1024
assert resp.content == MEDIA_BYTES[:1024]
def test_suffix_range(self, client, test_vault_dir):
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
resp = client.get(
"/api/media/TestVault",
params={"path": "song.mp3"},
headers={"Range": "bytes=-100"},
)
assert resp.status_code == 206
assert resp.content == MEDIA_BYTES[-100:]
def test_unsatisfiable_range_returns_416(self, client, test_vault_dir):
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
resp = client.get(
"/api/media/TestVault",
params={"path": "song.mp3"},
headers={"Range": f"bytes={len(MEDIA_BYTES) + 10}-"},
)
assert resp.status_code == 416
assert resp.headers["content-range"] == f"bytes */{len(MEDIA_BYTES)}"
def test_mov_mime_override(self, client, test_vault_dir):
_write(test_vault_dir, "clip.mov", MEDIA_BYTES)
resp = client.get("/api/media/TestVault", params={"path": "clip.mov"})
assert resp.headers["content-type"].startswith("video/quicktime")
def test_rejects_non_media(self, client, test_vault_dir):
resp = client.get("/api/media/TestVault", params={"path": "note1.md"})
assert resp.status_code == 400
def test_missing_media_404(self, client):
resp = client.get("/api/media/TestVault", params={"path": "nope.mp3"})
assert resp.status_code == 404
def test_unknown_vault_404(self, client):
resp = client.get("/api/media/NoSuchVault", params={"path": "song.mp3"})
assert resp.status_code == 404
def test_path_traversal_rejected(self, client, test_vault_dir):
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
resp = client.get("/api/media/TestVault", params={"path": "../../../etc/passwd.mp3"})
assert resp.status_code == 403
def test_media_too_large_returns_413(self, client, test_vault_dir, monkeypatch):
_write(test_vault_dir, "huge.mp3", MEDIA_BYTES)
monkeypatch.setenv("OBSIGATE_MEDIA_MAX_INLINE_MB", "0.000001")
resp = client.get("/api/media/TestVault", params={"path": "huge.mp3"})
assert resp.status_code == 413
# ═══════════════════════════════════════════════════════════════════
# Vault access control (#109-A3)
# ═══════════════════════════════════════════════════════════════════
class TestMediaAccess:
def test_normaluser_cannot_stream_other_vault(self, admin_client):
resp = admin_client.post(
"/api/auth/login", json={"username": "normaluser", "password": "normal123"}
)
assert resp.status_code == 200, resp.text
token = resp.json()["access_token"]
resp = admin_client.get(
"/api/media/OtherVault",
params={"path": "song.mp3"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 403
# ═══════════════════════════════════════════════════════════════════
# Regression: pdf/stream unchanged after extracting the shared helper (#109-A2)
# ═══════════════════════════════════════════════════════════════════
class TestPdfStreamRegression:
def test_pdf_range_still_works(self, client, test_vault_dir):
_write(test_vault_dir, "doc.pdf", PDF_BYTES)
resp = client.get(
"/api/file/TestVault/pdf/stream",
params={"path": "doc.pdf"},
headers={"Range": "bytes=0-9"},
)
assert resp.status_code == 206
assert resp.headers["content-type"].startswith("application/pdf")
assert resp.headers["content-range"] == f"bytes 0-9/{len(PDF_BYTES)}"
assert resp.content == PDF_BYTES[:10]
def test_pdf_without_range_returns_200(self, client, test_vault_dir):
_write(test_vault_dir, "doc.pdf", PDF_BYTES)
resp = client.get("/api/file/TestVault/pdf/stream", params={"path": "doc.pdf"})
assert resp.status_code == 200
assert resp.headers["accept-ranges"] == "bytes"