Compare commits

...
8 Commits
Author SHA1 Message Date
bruno 7e1f5d6852 fix: fixture E2E manquante diagram-app-export.excalidraw (test de regression BUG-064)
CI / lint (push) Successful in 1m38s
CI / security (push) Successful in 1m4s
CI / test (push) Successful in 3m26s
CI / build (push) Successful in 1m5s
CI / e2e (push) Successful in 11m24s
2026-09-18 09:23:22 -04:00
bruno ab766862a3 feat: redesign UI section Cles API IA - recherche, carte defaut, cartes depliables, footer sticky (#104)
CI / lint (push) Successful in 1m47s
CI / security (push) Successful in 1m5s
CI / test (push) Successful in 3m14s
CI / build (push) Successful in 1m1s
CI / e2e (push) Failing after 12m11s
2026-09-18 09:01:20 -04:00
bruno 2bd9dd7535 fix: Editeur Excalidraw - diagramme vide (CSS + appState) et auto-save pendant l'edition (BUG-064, BUG-065) 2026-09-18 08:59:54 -04:00
bruno 7f0f64a42e fix: TOC PDF - forcer le rechargement de l'iframe (BUG-063 complement)
CI / lint (push) Successful in 1m37s
CI / security (push) Successful in 1m15s
CI / test (push) Successful in 3m36s
CI / build (push) Successful in 59s
CI / e2e (push) Successful in 11m13s
2026-09-17 21:16:46 -04:00
bruno f7e068baed fix: viewer PDF (TOC, largeur) et plein ecran assistant (BUG-061 a BUG-063)
CI / lint (push) Successful in 1m36s
CI / security (push) Successful in 1m11s
CI / test (push) Successful in 3m33s
CI / build (push) Successful in 1m2s
CI / e2e (push) Successful in 11m23s
2026-09-17 20:45:38 -04:00
bruno 2e2a33cef3 fix: corrige 6 bugs mineurs (BUG-035 a BUG-040)
CI / lint (push) Successful in 1m36s
CI / security (push) Successful in 1m4s
CI / test (push) Successful in 3m41s
CI / build (push) Successful in 59s
CI / e2e (push) Successful in 11m8s
2026-09-17 20:05:08 -04:00
bruno 2c460022f8 test(pdf): helper d'ouverture robuste au vault replie (BUG-060)
CI / lint (push) Successful in 1m37s
CI / security (push) Successful in 1m3s
CI / test (push) Successful in 3m33s
CI / build (push) Successful in 59s
CI / e2e (push) Successful in 10m57s
2026-09-17 19:42:43 -04:00
bruno 133644a0ba fix(pdf): affichage des pages du viewer PDF via iframe (BUG-060)
CI / lint (push) Successful in 1m36s
CI / security (push) Successful in 1m3s
CI / test (push) Failing after 3m41s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-09-17 19:39:48 -04:00
51 changed files with 2606 additions and 365 deletions
+5
View File
@@ -7,6 +7,11 @@ OBSIGATE_AUTH_ENABLED=true
OBSIGATE_ADMIN_USER=admin OBSIGATE_ADMIN_USER=admin
OBSIGATE_ADMIN_PASSWORD=chab30 OBSIGATE_ADMIN_PASSWORD=chab30
# DANGER : si OBSIGATE_AUTH_ENABLED=false, toute requête devient un admin
# anonyme. Le serveur REFUSE de démarrer sur une adresse non-loopback
# (ex. 0.0.0.0) sauf si l'on force l'opt-in ci-dessous. À réserver au local.
# OBSIGATE_ALLOW_INSECURE=false
# Sécurité des cookies (activer si derrière HTTPS) # Sécurité des cookies (activer si derrière HTTPS)
# OBSIGATE_SECURE_COOKIES=false # OBSIGATE_SECURE_COOKIES=false
+2
View File
@@ -38,6 +38,7 @@ jobs:
- name: Frontend unit tests - name: Frontend unit tests
run: | run: |
node tests/frontend/unit.test.mjs node tests/frontend/unit.test.mjs
node tests/frontend/pdf-viewer.test.mjs
node tests/frontend/forge-completion.test.mjs node tests/frontend/forge-completion.test.mjs
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition) - name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition)
@@ -196,6 +197,7 @@ jobs:
-e DIR_1_NAME=TestDir \ -e DIR_1_NAME=TestDir \
-e DIR_1_PATH=/vaults/TestDir \ -e DIR_1_PATH=/vaults/TestDir \
-e OBSIGATE_AUTH_ENABLED=false \ -e OBSIGATE_AUTH_ENABLED=false \
-e OBSIGATE_ALLOW_INSECURE=true \
obsigate:ci obsigate:ci
# Docker-in-docker : le bind mount $(pwd)/... pointe sur un chemin # Docker-in-docker : le bind mount $(pwd)/... pointe sur un chemin
# du job container, inexistant sur l'hôte → montage vide. Les -v # du job container, inexistant sur l'hôte → montage vide. Les -v
+73 -21
View File
@@ -1,44 +1,93 @@
# AGENTS.md — Instructions obligatoires du dépôt ObsiGate # AGENTS.md — Instructions obligatoires du dépôt ObsiGate
> Ces instructions s'appliquent à **toute** intervention (humaine ou IA) sur ce dépôt. > Ces instructions s'appliquent à **toute** intervention (humaine ou IA) sur ce dépôt.
> Documentation et réponses en **français**.
## Règle n°1 — Méthode de livraison unique ## Règle n°1 — Méthode de livraison unique
Avant toute tâche (fonctionnalité, bug, refactor), **lire et appliquer** Avant toute tâche (fonctionnalité, bug, refactor), **lire et appliquer**
[`docs/DELIVERY_WORKFLOW.md`](./docs/DELIVERY_WORKFLOW.md) (Definition of Done). [`docs/DELIVERY_WORKFLOW.md`](./docs/DELIVERY_WORKFLOW.md) (Definition of Done).
Aucune tâche n'est terminée avant que sa checklist soit complète **et le CI vert**. Aucune tâche n'est terminée avant que sa checklist soit complète **et le CI vert**
(jobs `lint`, `test`, `security`, `build`, `e2e` de `.gitea/workflows/ci.yml`).
## Avant de commencer ## Avant de commencer
1. Lire [`docs/ROADMAP.md`](./docs/ROADMAP.md) (travail à venir + index) et 1. Lire [`docs/ROADMAP.md`](./docs/ROADMAP.md) (travail à venir + index) et
[`docs/ISSUES_TODOLIST.md`](./docs/ISSUES_TODOLIST.md) (bugs). [`docs/ISSUES_TODOLIST.md`](./docs/ISSUES_TODOLIST.md) (bugs).
2. Identifier ou créer l'**ID stable** (`#NN` pour une feature, `BUG-NNN` pour un bug) 2. Identifier ou créer l'**ID stable** (`#NN` pour une feature, `BUG-NNN` pour un bug —
et passer son statut à « en cours » **avant** de coder. jamais réutilisé) et passer son statut à « en cours » **avant** de coder.
## Architecture (ce qui n'est pas obvious)
- **Backend** : FastAPI/Python 3.11, point d'entrée `backend/main.py` (endpoints + rendu
markdown), index en mémoire (`indexer.py`, `search.py`), watcher (`watcher.py`),
auth dans `backend/auth/`. Pas de base de données : JSON dans `data/`.
- **Frontend** : vanilla JS **zéro framework, zéro build npm** (`frontend/app.js`,
`index.html`, `style.css`). Ne pas ajouter de dépendances npm ni d'étape de build.
- **Desktop** : Tauri (Rust) dans `desktop/` ; `tauri.conf.json` embarque `backend/**` et
`frontend/**` depuis `desktop/` — les scripts de build font le **staging** (copie) avant
`cargo tauri build`, sinon le build échoue.
- **i18n** : tout texte d'interface doit exister en FR **et** EN
(`frontend/locales/fr.json` + `en.json`).
## Vérifications locales (pwsh, à faire passer avant tout commit/push)
```powershell
# Backend (venv à la racine)
.\.venv\Scripts\python.exe -m pytest tests/
.\.venv\Scripts\python.exe -m ruff check backend/
.\.venv\Scripts\python.exe -m mypy backend/ --ignore-missing-imports
# Frontend : scripts Node à exécuter directement (pas de runner)
node tests/frontend/validate-imports.mjs
node tests/frontend/unit.test.mjs
# Tests JSDOM : node_modules dans tests/frontend/ (npm install là-bas si absent), ex :
node tests/frontend/pane-manager.test.mjs
# E2E (si UI touchée, ~5 min) : reproduit le job CI e2e (port 2029, auth désactivée)
npm run test:e2e # prérequis : uv, Node >= 20, npx playwright install chromium
bash scripts/run-e2e-local.sh -g "nom du test" # filtre / --headed
```
- Un seul test backend : `.\.venv\Scripts\python.exe -m pytest tests/test_search.py -q`.
- **Sélection E2E** : vérifier chaque sélecteur dans le DOM réel avant de l'utiliser dans un
test ; tout test nouveau/modifié doit passer en local avant push ; pas de contournement
qui masque la flakiness (`waitForTimeout` arbitraires, fallbacks silencieux).
- La suite E2E doit finir à **100 %** sans s'appuyer sur les retries. Jamais de `git push`
avant que les 5 étapes locales soient vertes.
## Version & hooks (pièges)
- `VERSION` (racine) = **source unique de vérité** (SemVer), incrémenté **automatiquement à
chaque commit** par le hook `.githooks/prepare-commit-msg` — `feat` → mineur,
`!:` / `BREAKING CHANGE` → majeur, sinon correctif. Le même commit resynchronise
`package.json`, le desktop Tauri, `README.md`/`README.fr.md`, `docs/ROADMAP.md` et publie
la section `[Unreleased]` du `CHANGELOG.md` en `[X.Y.Z] — date` ; tag `vX.Y.Z` créé au
commit, publié au push (`push.followTags`).
- Hooks **obligatoires**, à installer une fois par clone : `scripts/install-hooks.sh`
(sinon la version ne suit plus et le CI échoue via le garde-fou `tests/test_version.py`).
- Le rattachement des fichiers de bump se fait par un `--amend` immédiat : **le SHA affiché
par `git commit` change** — ne pas s'y fier.
- Commit sans incrément (exceptionnel) : `SKIP_VERSION_BUMP=1 git commit …`.
- Ne jamais réécrire une version déjà publiée dans le CHANGELOG ; jamais de détail dupliqué
entre Roadmap et CHANGELOG.
## À la fin de chaque tâche (obligatoire) ## À la fin de chaque tâche (obligatoire)
- Ajouter/mettre à jour les **tests unitaires**. - Tests unitaires ajoutés/mis à jour (correctif sans test de non-régression = pas terminé).
- Vérifications locales vertes : `pytest`, `ruff`, `mypy`, tests frontend (`E2E` si UI). - Toutes les vérifications locales ci-dessus vertes (`E2E` si UI).
- Mettre à jour la documentation requise : `CHANGELOG.md` (`[Unreleased]`), `docs/ROADMAP.md` - Documentation mise à jour : `CHANGELOG.md` (`[Unreleased]`), `docs/ROADMAP.md` (statut +
(statut + index), fiche `docs/features/` **ou** `docs/archive/`, `docs/ISSUES_TODOLIST.md`, index), fiche `docs/features/` **ou** `docs/archive/`, `docs/ISSUES_TODOLIST.md` (si bug),
guide utilisateur i18n FR/EN + README si impact utilisateur. guide utilisateur i18n FR/EN + README si impact utilisateur, docstrings +
- **Commit** conventionnel référençant l'ID, puis **push**. `response_model` si API.
- Version : le fichier VERSION (racine du dépôt) est la **source unique de - **Commit** conventionnel référençant l'ID (`feat: … #12`), puis **push** et **CI vert**.
vérité (MAJEUR.MINEUR.CORRECTIF), incrémenté automatiquement à chaque commit** par le hook
.githooks/prepare-commit-msg — feat → mineur, !: / BREAKING CHANGE → majeur, sinon
correctif. Le même commit resynchronise package.json, le desktop Tauri, README.md/
README.fr.md, docs/ROADMAP.md et publie la section [Unreleased] du CHANGELOG.md en
[X.Y.Z] — date ; le tag vX.Y.Z est créé au commit et publié au push (push.followTags).
Hooks à installer une fois par clone : scripts/install-hooks.sh. Garde-fou :
tests/test_version.py (détail : docs/DELIVERY_WORKFLOW.md §7).
- Vérifier le **CI Gitea vert** (jobs `lint`, `test`, `security`, `build`, `e2e`).
## Cartographie documentaire ## Cartographie documentaire
| Sujet | Fichier | | Sujet | Fichier |
|---|---| |---|---|
| Méthode de livraison / DoD | `docs/DELIVERY_WORKFLOW.md` | | Méthode de livraison / DoD | `docs/DELIVERY_WORKFLOW.md` |
| Version livrée (source unique) | VERSION + scripts/bump_version.py | | Version livrée (source unique) | `VERSION` + `scripts/bump_version.py` |
| Travail à venir + index | `docs/ROADMAP.md` | | Travail à venir + index | `docs/ROADMAP.md` |
| Historique des versions | `CHANGELOG.md` | | Historique des versions | `CHANGELOG.md` |
| Conception par feature | `docs/features/<slug>.md` | | Conception par feature | `docs/features/<slug>.md` |
@@ -50,5 +99,8 @@ Aucune tâche n'est terminée avant que sa checklist soit complète **et le CI v
## Conventions ## Conventions
- Commits : `type: description` — `feat`, `fix`, `perf`, `refactor`, `docs`, `style`, `chore`, `test`. - Commits : `type: description` — `feat`, `fix`, `perf`, `refactor`, `docs`, `style`, `chore`, `test`.
- **Ne jamais** committer de secrets, clés ou tokens. - Sécurité : tout chemin fichier fourni par l'utilisateur passe par `_resolve_safe_path()`.
- Réponses et documentation en **français** ; respecter le style du code existant. - **Ne jamais** committer de secrets, clés ou tokens (`.env` jamais committé ; secrets dans
`data/api_keys.json` ou variables `OBSIGATE_*`).
- Respecter le style du code existant (ruff/mypy 0 erreur ; CSS variables, pas de couleurs
hardcodées ; `safeCreateIcons()` plutôt que `lucide.createIcons()` direct).
+190 -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). 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 > **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.11.0**. > [Unreleased](#unreleased). La dernière version livrée est **2.12.1**.
--- ---
@@ -14,6 +14,195 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
--- ---
## [2.12.1] — 2026-09-18
---
## [2.12.0] — 2026-09-18
### Ajouté
- **#104 - Configuration : redesign UI de la section « 🤖 Clés API Intelligence Artificielle »** :
la longue liste plate de champs devient une interface structurée et dépliable —
1. **En-tête de section** : titre + sous-titre explicatifs et une **barre de recherche**
(`#cfg-ai-search`) pour filtrer les fournisseurs (normalisée, insensible à la casse et aux
accents), avec état vide « Aucun fournisseur ne correspond ».
2. **Carte « Configuration par défaut »** visuellement distincte : grille **2 colonnes**
fournisseur / modèle par défaut, et **capacités du modèle rendues en badges colorés**
(nouveau `renderCapabilityBadges()` dans `frontend/js/ai.js`, uniquement les capacités
actives) au lieu des cases à cocher.
3. **Fournisseurs d'API en cartes dépliables** (rendu dynamique depuis `AI_PROVIDER_NAMES` par
`_renderAIProviderCards()`, `frontend/js/config.js`) : état replié = logo (initiale), nom,
**badge de statut** (« Configuré » vert / « Non configuré » gris) et **corbeille discrète**
pour supprimer la clé (visible uniquement si configurée, confirmation conservée) ; état
déplié = libellés au-dessus des champs, **API key à 60 % / modèle à 40 %** (grille `3fr 2fr`).
Les boutons « Configuré/Supprimer » redondants à l'intérieur des champs disparaissent — le
statut vit désormais uniquement dans l'en-tête de la carte.
4. **Barre d'actions** : « Sauvegarder » (primaire) et « Tester » (outline) dans un footer
**sticky** en bas de section, toujours accessible pendant le défilement, avec le statut de
test à côté.
Styles `.ai-keys-*` / `.ai-provider-*` en variables CSS (profondeur fond/carte, espacement
généreux, chevron animé, focus visible, responsive 1 colonne < 600 px) ; textes i18n FR/EN
(`config.ai_*`). Les ID d'éléments (`cfg-<provider>-key/model/badge/delete`) et le
comportement de sauvegarde/test/suppression restent inchangés (rétrocompatible pickers IA).
Tests : `tests/frontend/config-ai-keys.test.mjs` (7 — rendu, badges, bascule, filtre, save,
suppression, badges de capacités).
---
## [2.11.6] — 2026-09-18
### Corrigé
- **BUG-064 - Éditeur Excalidraw : le diagramme ne s'affiche jamais (canvas vide)** — deux
causes cumulées :
1. **La feuille de style d'Excalidraw n'était jamais chargée** (`@excalidraw/excalidraw` exige
un import CSS explicite). Sans elle, l'éditeur est non stylisé et `.excalidraw` n'a aucune
hauteur fixe : la boucle de redimensionnement d'Excalidraw fait grossir le canvas jusqu'au
plafond codé en dur de `2^25` (33 554 432 px), que le navigateur ne peut pas dessiner → scène
blanche. Correctifs : `<link>` vers `…/@excalidraw/[email protected]/dist/prod/index.css` dans
`frontend/excalidraw-editor.html` et ajout de `https://esm.sh` à `style-src` de la CSP
(`backend/main.py`).
2. `appState.collaborators` est une `Map` sérialisée en objet JSON (`{}`) par l'app Excalidraw /
le plugin Obsidian ; réinjectée via `initialData`, Excalidraw 0.18 appelait `.forEach()` dessus
et plantait (`e.appState.collaborators.forEach is not a function`). `sanitizeAppState()` la
reconvertit en `Map` et écarte la géométrie de viewport importée (`width`, `height`,
`offsetLeft`, `offsetTop`) pour les deux formats (`.excalidraw` et `.excalidraw.md`).
Tests de non-régression : `tests/frontend/excalidraw-viewer.test.mjs` (CSS lié + CSP),
`tests/test_security_hardening.py::TestCspExcalidrawStylesheet`, `tests/e2e/excalidraw.spec.js`
(hauteur de canvas bornée) + fixtures `test_vault/diagram-app-export.excalidraw`.
- **BUG-064 (complément) - Éditeur Excalidraw : pleine largeur quand la navigation est masquée** :
la règle `.sidebar.hidden ~ .content-wrapper .content-area { max-width: 1200px }` (colonne de
lecture centrée) s'appliquait aussi au viewer Excalidraw. Ajout de
`.content-area:has(iframe[src*="excalidraw-editor.html"])` en `max-width: none; margin: 0`,
comme pour les viewers PDF/image (BUG-062). Fichier : `frontend/style.css`.
- **BUG-065 - Excalidraw : l'auto-save rechargeait la page en pleine édition** : chaque
modification déclenchait, 2 s plus tard, un `PUT /api/file/…/save` ; l'écriture émettait
`index_updated` (SSE) qui re-rendait la vue et **recréait l'iframe** — un refresh visible qui
interrompait le dessin. L'auto-save est supprimée (`frontend/js/excalidraw-viewer.js`) :
sauvegarde explicite par le bouton « 💾 Save » ou `Ctrl+S`. En complément, `reloadExternalWrite`
ne re-rend plus la vue quand un iframe Excalidraw est déjà ouvert sur le fichier
(`iframe[data-excalidraw-*]`), et le badge « Modified » ne se déclenche plus sur les
changements d'`appState` (resize, zoom) mais uniquement sur le contenu (signature des éléments).
- **#78 (complément) - Bouton plein écran pour les diagrammes Excalidraw** : nouveau bouton
`#btn-fullscreen` dans la barre d'outils de l'éditeur (`frontend/excalidraw-editor.html`) qui
bascule le mode plein écran natif ; l'iframe est créée avec `allow="fullscreen" allowfullscreen`
(`frontend/js/excalidraw-viewer.js`). Fichiers : `frontend/excalidraw-editor.html`,
`frontend/js/excalidraw-viewer.js`, `tests/frontend/excalidraw-viewer.test.mjs`.
- **#78 (complément) - Barre d'outils Excalidraw en icônes, verticale à droite** : les boutons
Save / PNG / SVG / plein écran passent en **icônes seules** (34×34 px) dans une colonne
**collée au bord droit** (`right: 0`), débutant à `45%` de la hauteur, empilée verticalement,
avec infobulles et `aria-label`. L'icône du bouton Save devient une coche après une sauvegarde
réussie. Fichier : `frontend/excalidraw-editor.html`.
---
## [2.11.5] — 2026-09-17
### Corrigé
- **BUG-063 (complément) — Viewer PDF : la table des matières ne déplaçait toujours pas la
page** : le premier correctif réassignait `iframe.src` avec le seul fragment `#page=N`, ce qui
ne change que le fragment → navigation *same-document* que le lecteur PDF natif ignore (il
n'applique `#page=N` qu'au chargement). Diagnostic en Chrome *headful* par comparaison de
captures. `navigatePdfToPage()` ajoute désormais un paramètre de query horodaté
(`&_pdfpage=<ts>#page=N`) pour forcer un vrai rechargement de l'iframe. Fichiers :
`frontend/js/viewer.js`, `tests/frontend/pdf-viewer.test.mjs`, `tests/e2e/pdf-viewer.spec.js`.
---
## [2.11.4] — 2026-09-17
### Corrigé
- **BUG-061 — Assistant IA : le bouton « Plein écran » n'agrandissait plus le panneau** : la
largeur du panneau est écrite en style inline par la poignée de redimensionnement (et par la
largeur persistée en `localStorage`) ; cet inline l'emportait sur la règle
`.bookslm-panel.fullscreen { width: 100vw }`, donc le panneau restait à sa largeur courante.
La règle plein écran est désormais prioritaire (`!important`). Fichier : `frontend/style.css`.
- **BUG-062 — Viewer PDF : largeur incomplète quand la navigation est masquée** : la règle de
colonne de lecture centrée (`.sidebar.hidden … { max-width: 1200px }`) s'appliquait aussi aux
viewers plein cadre. Les conteneurs PDF et image sont maintenant exemptés
(`:has(.pdf-viewer-container)` / `:has(.image-viewer-container)` → `max-width: none`). Fichier :
`frontend/style.css`.
- **BUG-063 — Viewer PDF : la table des matières ne naviguait pas** : les liens faisaient
`contentWindow.location.hash = 'page=N'`, mais le lecteur PDF natif vit dans une fenêtre
`about:blank` et l'affectation n'atteignait jamais le document. Nouveau helper
`navigatePdfToPage()` qui recharge l'iframe avec le fragment `#page=N` ; les entrées portent un
`data-page` et sont câblées par des écouteurs (plus d'`onclick` inline). Fichiers :
`frontend/js/viewer.js`, `frontend/style.css`.
- **Tests** : `tests/frontend/ai.test.mjs` (+1), `tests/frontend/pdf-viewer.test.mjs` (TOC, plein
largeur), `tests/e2e/pdf-viewer.spec.js` (TOC `#page=N`, largeur, fixture
`test_vault/sample-pdf-toc.pdf`).
---
## [2.11.3] — 2026-09-17
### Corrigé
- **BUG-035 — `secret_redactor` : faux positifs sur les hashs hex** : la règle qui masquait
tout jeton hexadécimal de 40 à 64 caractères mutilait les hashs git/SHA légitimes des notes.
Le masquage des chaînes hexadécimales n'a désormais lieu que si un mot-clé de secret
(`secret`, `token`, `key`, `password`, `bearer`…) figure dans les 60 caractères précédents ;
un contexte de hash (`commit`, `sha256`, `hash`, `checksum`, `git`, `etag`…) exempte
explicitement la chaîne. Fichier : `backend/secret_redactor.py`.
- **BUG-036 — Collaboration WebSocket : jeton accepté en query string** : le JWT n'est plus lu
depuis `?token=` (URLs journalisées par les proxies et l'historique navigateur). Le cookie
HttpOnly `access_token`, envoyé automatiquement par le navigateur lors du handshake
same-origin, est le seul transport supporté ; les trames brutes dépassant
`MAX_MESSAGE_CHARS` (16 Mio) sont rejetées avant analyse. Fichier : `backend/collab.py`.
- **BUG-037 — Mode sans authentification** : au démarrage, un avertissement explicite est
journalisé quand `OBSIGATE_AUTH_ENABLED=false`. Le serveur **refuse désormais de démarrer**
s'il est lié à une adresse non-loopback sans l'opt-in explicite `OBSIGATE_ALLOW_INSECURE=true`,
pour empêcher l'exposition publique d'une instance sans authentification (admin anonyme).
Fichiers : `backend/auth/middleware.py`, `backend/main.py`.
- **BUG-038 — Argon2 : coût mémoire recalibré** : `memory_cost` passe de 64 Mio à 19 Mio
(`m=19456 Kio, t=2, p=1`, recommandation OWASP actuelle) pour supprimer le risque
d'épuisement mémoire sous connexions simultanées ; les anciens hachages restent valides et
sont migrés automatiquement (`needs_rehash`). Fichier : `backend/auth/password.py`.
- **BUG-039 — Énumération de comptes au login** : les comptes inconnus, désactivés, verrouillés
et limités par le budget par compte répondent tous un `401 Identifiants invalides` avec un
temps équivalent (hachage factice), au lieu d'un `429`/`403` distinctif ; seul le rate-limit
par IP (non lié à un compte) conserve le `429`. Fichier : `backend/auth/router.py`.
- **BUG-040 — Extraction PDF différée au scan** : `_scan_vault` ne lit plus que les métadonnées
des PDF ; l'extraction de texte intégrale (100 kio) est déléguée à `enrich_pdf_texts()`,
exécutée après la construction de l'index/inverted index (démarrage) et après chaque
réindexation. Un vault contenant de nombreux/gros PDF démarre sans être bloqué ; le texte
reste recherchable une fois l'enrichissement terminé. Fichiers : `backend/indexer.py`,
`backend/main.py`.
- **Tests** : `tests/test_api_main.py` (redactor hex), `tests/test_auth.py` (coût Argon2,
garde-fou d'instance non authentifiée), `tests/test_auth_api.py` (login uniforme),
`tests/test_collab.py` (jeton query rejeté, trame surdimensionnée), `tests/test_pdf.py`
(scan différé + enrichissement).
---
## [2.11.2] — 2026-09-17
### Modifié
- **Tests E2E PDF (BUG-060)** : le helper d'ouverture de fichier de
`tests/e2e/pdf-viewer.spec.js` étend désormais le vault dans l'arborescence s'il est replié
(cas d'une instance avec authentification activée) au lieu de supposer le vault déjà déplié.
---
## [2.11.1] — 2026-09-17
### Corrigé
- **BUG-060 — Viewer PDF : l'affichage des pages ne fonctionnait pas** : au clic sur un fichier
`.pdf`, seule la barre d'outils « PDF — N pages » s'affichait, le corps restant vide. La CSP
durcie en BUG-034 pose `object-src 'none'`, directive qui gouverne `<embed>`/`<object>`, alors
que le viewer rendait le PDF via `<embed type="application/pdf">` : le lecteur natif était
bloqué. Le rendu passe désormais par une `<iframe>` (autorisée par `frame-src 'self'`, le stream
`/api/file/{vault}/pdf/stream` étant same-origin) ; `object-src 'none'` est conservé. Fichiers :
`frontend/js/viewer.js`, `tests/frontend/pdf-viewer.test.mjs` (nouveau),
`tests/e2e/pdf-viewer.spec.js` (nouveau, fixture `test_vault/sample-pdf.pdf`).
---
## [2.11.0] — 2026-09-17 ## [2.11.0] — 2026-09-17
### Ajouté ### Ajouté
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. **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.11.0-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.12.1-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![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/) [![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/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -926,8 +926,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog ## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.11.0). Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.12.1).
--- ---
*Projet : ObsiGate | Version : 2.11.0 | Dernière mise à jour : Juin 2026* *Projet : ObsiGate | Version : 2.12.1 | Dernière mise à jour : Juin 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. **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.11.0-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.12.1-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![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/) [![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/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1095,8 +1095,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog ## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.11.0). See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.12.1).
--- ---
*Project: ObsiGate | Version: 2.11.0 | Last updated: May 2026* *Project: ObsiGate | Version: 2.12.1 | Last updated: May 2026*
+1 -1
View File
@@ -1 +1 @@
2.11.0 2.12.1
+32
View File
@@ -4,6 +4,7 @@
import logging import logging
import os import os
import sys
from fastapi import Depends, HTTPException, Request from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -17,6 +18,9 @@ logger = logging.getLogger("obsigate.auth.middleware")
security = HTTPBearer(auto_error=False) security = HTTPBearer(auto_error=False)
#: Hosts considered safe to bind without authentication (loopback only).
_LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost", "0:0:0:0:0:0:0:1"}
def is_auth_enabled() -> bool: def is_auth_enabled() -> bool:
"""Check if authentication is enabled via environment variable. """Check if authentication is enabled via environment variable.
@@ -26,6 +30,34 @@ def is_auth_enabled() -> bool:
return os.environ.get("OBSIGATE_AUTH_ENABLED", "true").lower() != "false" return os.environ.get("OBSIGATE_AUTH_ENABLED", "true").lower() != "false"
def is_insecure_mode_allowed() -> bool:
"""True when the operator explicitly accepts running without auth (BUG-037)."""
return os.environ.get("OBSIGATE_ALLOW_INSECURE", "false").lower() in ("1", "true", "yes", "on")
def bind_host_from_argv(argv: list[str] | None = None) -> str | None:
"""Extract the ``--host`` value from the process arguments (uvicorn), if any.
Returns ``None`` when no explicit host is passed (uvicorn then defaults to
loopback ``127.0.0.1``).
"""
args = sys.argv if argv is None else argv
for i, arg in enumerate(args):
if arg == "--host" and i + 1 < len(args):
return args[i + 1]
if arg.startswith("--host="):
return arg.split("=", 1)[1]
return None
def is_loopback_host(host: str | None) -> bool:
"""True when *host* is a loopback address (or unset → uvicorn default)."""
if not host:
return True
normalized = host.strip().strip("[]").lower()
return normalized in _LOOPBACK_HOSTS
def get_current_user( def get_current_user(
request: Request, request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security), credentials: HTTPAuthorizationCredentials | None = Depends(security),
+11 -4
View File
@@ -1,14 +1,21 @@
# backend/auth/password.py # backend/auth/password.py
# Argon2id password hashing — OWASP 2024 recommended algorithm. # Argon2id password hashing — OWASP 2024 recommended algorithm.
# Parameters: time_cost=2, memory_cost=64MB, parallelism=2 # Parameters (BUG-038): time_cost=2, memory_cost=19 MiB, parallelism=1
# (OWASP current recommendation for Argon2id). The previous 64 MiB setting
# allowed memory exhaustion under concurrent login attempts.
from argon2 import PasswordHasher from argon2 import PasswordHasher
from argon2.exceptions import VerificationError, VerifyMismatchError from argon2.exceptions import VerificationError, VerifyMismatchError
#: Argon2id cost parameters (OWASP 2024: m=19456 KiB, t=2, p=1).
ARGON2_TIME_COST = 2
ARGON2_MEMORY_COST_KIB = 19456 # 19 MiB
ARGON2_PARALLELISM = 1
ph = PasswordHasher( ph = PasswordHasher(
time_cost=2, time_cost=ARGON2_TIME_COST,
memory_cost=65536, # 64 MB memory_cost=ARGON2_MEMORY_COST_KIB,
parallelism=2, parallelism=ARGON2_PARALLELISM,
hash_len=32, hash_len=32,
salt_len=16, salt_len=16,
) )
+18 -17
View File
@@ -124,31 +124,32 @@ async def auth_status():
async def login(body: LoginRequest, response: Response, request: Request): async def login(body: LoginRequest, response: Response, request: Request):
"""Authenticate a user. Returns access token and sets refresh cookie. """Authenticate a user. Returns access token and sets refresh cookie.
Implements timing-safe responses to prevent user enumeration: Implements timing-safe responses to prevent user enumeration: a failed
a failed login with an unknown user takes the same time as one login with an unknown user takes the same time as one with a known user
with a known user (dummy hash is computed). (dummy hash is computed). BUG-039: unknown, inactive, locked and
per-account rate-limited accounts all answer the same ``401`` so the HTTP
status can never reveal whether an account exists.
""" """
client_ip = get_client_ip(request)
# IP-based rate limiting (10 failures / 15 min per IP). It is not
# account-specific, so a 429 here cannot be used to enumerate accounts.
if is_rate_limited(client_ip):
raise HTTPException(429, "Trop de tentatives depuis cette adresse IP (15min)")
user = get_user(body.username) user = get_user(body.username)
if not user: # BUG-039: uniform 401 + equivalent timing for every account-state outcome.
if not user or not user.get("active"):
# Timing-safe: simulate hash computation to prevent user enumeration # Timing-safe: simulate hash computation to prevent user enumeration
hash_password("dummy_timing_protection") hash_password("dummy_timing_protection")
raise HTTPException(401, "Identifiants invalides") raise HTTPException(401, "Identifiants invalides")
if not user.get("active"):
raise HTTPException(403, "Compte désactivé")
# IP-based rate limiting (10 failures / 15 min per IP)
client_ip = get_client_ip(request)
if is_rate_limited(client_ip):
raise HTTPException(429, "Trop de tentatives depuis cette adresse IP (15min)")
# BUG-031: per-account budget still applies when the attacker rotates IPs. # BUG-031: per-account budget still applies when the attacker rotates IPs.
if is_account_rate_limited(body.username): # Kept indistinguishable from a wrong password (BUG-039).
raise HTTPException(429, "Trop de tentatives sur ce compte (15min)") if is_account_rate_limited(body.username) or is_locked(body.username):
hash_password("dummy_timing_protection")
if is_locked(body.username): raise HTTPException(401, "Identifiants invalides")
raise HTTPException(429, "Compte temporairement verrouillé (15min)")
if not verify_password(body.password, user["password_hash"]): if not verify_password(body.password, user["password_hash"]):
attempts = record_login_failure(body.username) attempts = record_login_failure(body.username)
+14 -4
View File
@@ -44,6 +44,9 @@ MAX_UPDATE_BYTES = 8 * 1024 * 1024
#: Taille maximale d'un snapshot texte (protection anti-abus). #: Taille maximale d'un snapshot texte (protection anti-abus).
MAX_TEXT_CHARS = 8 * 1024 * 1024 MAX_TEXT_CHARS = 8 * 1024 * 1024
#: Taille maximale d'un message brut reçu (protection anti-abus, BUG-036).
MAX_MESSAGE_CHARS = 16 * 1024 * 1024
#: Palette de couleurs attribuées aux utilisateurs (curseurs + avatars). #: Palette de couleurs attribuées aux utilisateurs (curseurs + avatars).
PEER_COLORS = [ PEER_COLORS = [
"#e6194b", "#3cb44b", "#4363d8", "#f58231", "#911eb4", "#e6194b", "#3cb44b", "#4363d8", "#f58231", "#911eb4",
@@ -68,9 +71,13 @@ def authenticate_websocket(websocket: WebSocket) -> dict[str, Any] | None:
"""Authenticate a WebSocket connection. """Authenticate a WebSocket connection.
Mirrors :func:`backend.auth.middleware.get_current_user` but works on the Mirrors :func:`backend.auth.middleware.get_current_user` but works on the
WebSocket scope: the JWT is read from the ``access_token`` cookie (sent WebSocket scope: the JWT is read from the ``access_token`` cookie, which
automatically by same-origin browsers during the handshake) or, as a same-origin browsers send automatically during the handshake.
fallback, from the ``token`` query parameter.
BUG-036: the token is **never** accepted from the query string anymore —
URLs end up in access logs, proxies and browser history. Browsers cannot
set custom headers on a WebSocket handshake, so the HttpOnly cookie set at
login is the only supported transport.
Returns the user dict, or ``None`` if authentication fails. Returns the user dict, or ``None`` if authentication fails.
""" """
@@ -88,7 +95,7 @@ def authenticate_websocket(websocket: WebSocket) -> dict[str, Any] | None:
"_token_vaults": ["*"], "_token_vaults": ["*"],
} }
token = websocket.query_params.get("token") or websocket.cookies.get("access_token") token = websocket.cookies.get("access_token")
if not token: if not token:
return None return None
@@ -274,6 +281,9 @@ class CollabManager:
# -- message handling --------------------------------------------------- # -- message handling ---------------------------------------------------
async def _on_message(self, room: CollabRoom, client: CollabClient, raw: str) -> None: async def _on_message(self, room: CollabRoom, client: CollabClient, raw: str) -> None:
# BUG-036: drop oversized frames before parsing them.
if not isinstance(raw, str) or len(raw) > MAX_MESSAGE_CHARS:
return
try: try:
message = json.loads(raw) message = json.loads(raw)
except (ValueError, TypeError): except (ValueError, TypeError):
+74 -6
View File
@@ -481,12 +481,18 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
# PDF handling — special path (binary, uses pdf_reader) # PDF handling — special path (binary, uses pdf_reader)
tags: list[str] = [] tags: list[str] = []
pdf_text_pending = False
if ext == ".pdf": if ext == ".pdf":
from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text from backend.pdf_reader import extract_pdf_metadata
raw = extract_pdf_text(fpath, max_chars=100000) # BUG-040: only the (cheap) metadata is read during the
# scan. Full-text extraction is deferred to a background
# pass (``enrich_pdf_texts``) so a vault with many/large
# PDFs no longer blocks startup and index rebuilds.
pdf_meta = extract_pdf_metadata(fpath) pdf_meta = extract_pdf_metadata(fpath)
title = pdf_meta.get("title") or fpath.stem.replace("-", " ").replace("_", " ") title = pdf_meta.get("title") or fpath.stem.replace("-", " ").replace("_", " ")
content_preview = raw[:200].strip() raw = ""
content_preview = ""
pdf_text_pending = True
elif ext == ".excalidraw" or fpath.name.lower().endswith(".excalidraw.md"): elif ext == ".excalidraw" or fpath.name.lower().endswith(".excalidraw.md"):
raw = fpath.read_text(encoding="utf-8", errors="replace") raw = fpath.read_text(encoding="utf-8", errors="replace")
raw = extract_excalidraw_indexable(raw) raw = extract_excalidraw_indexable(raw)
@@ -510,7 +516,7 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
title, post.content title, post.content
) )
files.append({ file_info = {
"path": str(relative).replace("\\", "/"), "path": str(relative).replace("\\", "/"),
"title": title, "title": title,
"tags": tags, "tags": tags,
@@ -519,7 +525,10 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
"size": stat.st_size, "size": stat.st_size,
"modified": modified, "modified": modified,
"extension": ext, "extension": ext,
}) }
if pdf_text_pending:
file_info["pdf_text_pending"] = True
files.append(file_info)
for tag in tags: for tag in tags:
tag_counts[tag] = tag_counts.get(tag, 0) + 1 tag_counts[tag] = tag_counts.get(tag, 0) + 1
@@ -535,6 +544,60 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
return {"files": files, "tags": tag_counts, "path": vault_path, "paths": paths, "config": {}} return {"files": files, "tags": tag_counts, "path": vault_path, "paths": paths, "config": {}}
async def enrich_pdf_texts(vault_name: str | None = None) -> int:
"""Extract text from PDFs whose extraction was deferred during the scan (BUG-040).
``_scan_vault`` only reads PDF metadata so a vault with many or large PDFs
starts serving immediately. This coroutine runs *after* the index (and the
inverted index) is ready, extracts the missing text off the event loop and
updates the in-memory entry plus the incremental index hooks.
Args:
vault_name: Restrict the pass to a single vault; ``None`` covers every
indexed vault.
Returns:
Number of deferred PDFs whose text extraction was attempted.
"""
from backend.pdf_reader import extract_pdf_text
pending: list[tuple[str, dict[str, Any], Path]] = []
with _index_lock:
for name, vault_data in index.items():
if vault_name is not None and name != vault_name:
continue
vault_root = Path(vault_data.get("path", ""))
for file_info in vault_data.get("files", []):
if file_info.get("pdf_text_pending"):
pending.append((name, file_info, vault_root / file_info["path"]))
if not pending:
return 0
loop = asyncio.get_running_loop()
enriched = 0
for name, file_info, file_path in pending:
try:
raw = await loop.run_in_executor(None, extract_pdf_text, file_path, 100000)
except Exception as exc: # pragma: no cover - defensive
logger.warning("PDF enrichment failed for %s: %s", file_path, exc)
raw = ""
file_info["content"] = raw[:SEARCH_CONTENT_LIMIT]
file_info["content_preview"] = raw[:200].strip()
file_info.pop("pdf_text_pending", None)
enriched += 1
if _on_index_change:
try:
_on_index_change("add", name, file_info["path"], file_info)
except Exception as exc: # pragma: no cover - defensive
logger.warning(
"Index hook failed after PDF enrichment for %s: %s", file_path, exc
)
logger.info("PDF enrichment: extracted text for %d deferred PDF(s)", enriched)
return enriched
async def build_index(progress_callback=None) -> None: async def build_index(progress_callback=None) -> None:
"""Build the full in-memory index for all configured vaults. """Build the full in-memory index for all configured vaults.
@@ -632,6 +695,8 @@ async def reload_index() -> dict[str, Any]:
Dict mapping vault names to their file/tag counts. Dict mapping vault names to their file/tag counts.
""" """
await build_index() await build_index()
# BUG-040: complete the deferred PDF extraction for the rebuilt index.
await enrich_pdf_texts()
stats = {} stats = {}
for name, data in index.items(): for name, data in index.items():
stats[name] = {"file_count": len(data["files"]), "tag_count": len(data["tags"])} stats[name] = {"file_count": len(data["files"]), "tag_count": len(data["tags"])}
@@ -695,7 +760,10 @@ async def reload_single_vault(vault_name: str) -> dict[str, Any]:
# Rebuild attachment index for this vault only # Rebuild attachment index for this vault only
from backend.attachment_indexer import build_attachment_index from backend.attachment_indexer import build_attachment_index
await build_attachment_index({vault_name: config}) await build_attachment_index({vault_name: config})
# BUG-040: complete the deferred PDF extraction for this vault.
await enrich_pdf_texts(vault_name)
stats = {"file_count": len(vault_data["files"]), "tag_count": len(vault_data["tags"])} stats = {"file_count": len(vault_data["files"]), "tag_count": len(vault_data["tags"])}
logger.info(f"Vault '{vault_name}' reindexed: {stats['file_count']} files, {stats['tag_count']} tags") logger.info(f"Vault '{vault_name}' reindexed: {stats['file_count']} files, {stats['tag_count']} tags")
return stats return stats
+51 -2
View File
@@ -702,7 +702,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
response.headers["Content-Security-Policy"] = ( response.headers["Content-Security-Policy"] = (
"default-src 'self'; " "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; " "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; " "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:; " "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; " "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; " "font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
@@ -722,12 +722,56 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
return response return response
def _guard_insecure_auth() -> None:
"""Warn or refuse to start when authentication is disabled (BUG-037).
With ``OBSIGATE_AUTH_ENABLED=false`` every request is served as an
anonymous admin. That is convenient for local use but dangerous when the
process is reachable from a network. Binding to a non-loopback host
without the explicit ``OBSIGATE_ALLOW_INSECURE=true`` opt-in is refused.
"""
from backend.auth.middleware import (
bind_host_from_argv,
is_auth_enabled,
is_insecure_mode_allowed,
is_loopback_host,
)
if is_auth_enabled():
return
if is_insecure_mode_allowed():
logger.warning(
"Authentication is DISABLED and OBSIGATE_ALLOW_INSECURE=true: every request "
"is treated as an anonymous administrator. Do not expose this instance."
)
return
host = bind_host_from_argv()
if not is_loopback_host(host):
raise RuntimeError(
"Refusing to start: authentication is disabled (OBSIGATE_AUTH_ENABLED=false) "
f"while binding to a non-loopback address ('{host}'). This would expose an "
"unauthenticated instance with admin access. Enable authentication, or set "
"OBSIGATE_ALLOW_INSECURE=true if you really know what you are doing."
)
logger.warning(
"Authentication is DISABLED (OBSIGATE_AUTH_ENABLED=false): every request is "
"treated as an anonymous administrator. This is only safe on a trusted, "
"loopback-only deployment."
)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Application lifespan: build index on startup, cleanup on shutdown.""" """Application lifespan: build index on startup, cleanup on shutdown."""
global _search_executor, _vault_watcher global _search_executor, _vault_watcher
_search_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="search") _search_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="search")
# BUG-037: refuse to expose an unauthenticated instance on a public bind.
_guard_insecure_auth()
# Bootstrap admin account if needed # Bootstrap admin account if needed
bootstrap_admin() bootstrap_admin()
@@ -748,6 +792,11 @@ async def lifespan(app: FastAPI):
# Build the semantic (embedding) index in the same background thread pool. # Build the semantic (embedding) index in the same background thread pool.
await loop.run_in_executor(_search_executor, init_semantic_index) await loop.run_in_executor(_search_executor, init_semantic_index)
# BUG-040: extract the PDF text deferred during the scan now that the
# index and inverted index are queryable (keeps startup non-blocking).
from backend.indexer import enrich_pdf_texts
await enrich_pdf_texts()
# Scan for plugins in all vaults # Scan for plugins in all vaults
logger.info("Scanning for plugins...") logger.info("Scanning for plugins...")
from backend.indexer import vault_config from backend.indexer import vault_config
+45 -3
View File
@@ -34,7 +34,7 @@ _PATTERNS = [
(re.compile(r'(?:api[_-]?key|apikey|secret|token|password|passwd|auth[_-]?token)\s*[:=]\s*[\'"]?([^\s\'"]{20,})[\'"]?', re.IGNORECASE), (re.compile(r'(?:api[_-]?key|apikey|secret|token|password|passwd|auth[_-]?token)\s*[:=]\s*[\'"]?([^\s\'"]{20,})[\'"]?', re.IGNORECASE),
lambda m: f'{m.group(0).split("=")[0].split(":")[0]}=[MASQUÉ]' if "=" in m.group(0) or ":" in m.group(0) else '[MASQUÉ]'), lambda m: f'{m.group(0).split("=")[0].split(":")[0]}=[MASQUÉ]' if "=" in m.group(0) or ":" in m.group(0) else '[MASQUÉ]'),
# Generic long hex/base64 strings that look like secrets (40+ chars) # Prefixed API keys (sk-..., pk-..., rk-...)
(re.compile(r'(?:sk|pk|rk)-[a-zA-Z0-9]{20,}'), '[CLÉ API MASQUÉE]'), (re.compile(r'(?:sk|pk|rk)-[a-zA-Z0-9]{20,}'), '[CLÉ API MASQUÉE]'),
# AWS access keys # AWS access keys
@@ -43,10 +43,50 @@ _PATTERNS = [
# GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_) # GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_)
(re.compile(r'gh[pousr]_[a-zA-Z0-9]{36,}'), '[GITHUB_TOKEN MASQUÉ]'), (re.compile(r'gh[pousr]_[a-zA-Z0-9]{36,}'), '[GITHUB_TOKEN MASQUÉ]'),
# Generic long random-looking strings (40+ hex chars)
(re.compile(r'\b[a-fA-F0-9]{40,64}\b'), '[HEX_KEY MASQUÉ]'),
] ]
# BUG-035: bare 40–64 char hex strings used to be redacted unconditionally,
# which mangled legitimate git commit SHAs, checksums and hashes in notes.
# They are now only redacted when a secret-ish keyword sits in the immediate
# context; hash/commit keywords explicitly exempt them.
_HEX_RE = re.compile(r'\b[a-fA-F0-9]{40,64}\b')
_SECRET_CONTEXT_RE = re.compile(
r'(?i)\b(?:secret|token|key|apikey|api[_-]?key|password|passwd|auth|bearer|'
r'credential|x-api-key|x-auth-token)\b'
)
_HASH_CONTEXT_RE = re.compile(
r'(?i)\b(?:commit|sha\d*|hash|md5|blob|git|checksum|digest|integrity|'
r'revision|rev|etag|fingerprint)\b'
)
#: How far before the hex string a keyword may appear to count as context.
_HEX_CONTEXT_WINDOW = 60
def _redact_bare_hex_secrets(text: str) -> tuple:
"""Redact 40–64 char hex strings only when a secret keyword is nearby.
Git/SHA/checksum contexts are left untouched (BUG-035).
Args:
text: Text to scan.
Returns:
(redacted_text, redaction_count) tuple.
"""
count = 0
def _replace(match: re.Match) -> str:
nonlocal count
window = text[max(0, match.start() - _HEX_CONTEXT_WINDOW):match.start()]
if _HASH_CONTEXT_RE.search(window):
return match.group(0)
if _SECRET_CONTEXT_RE.search(window):
count += 1
return '[HEX_KEY MASQUÉ]'
return match.group(0)
return _HEX_RE.sub(_replace, text), count
def redact(text: str) -> tuple: def redact(text: str) -> tuple:
"""Redact sensitive patterns from text. """Redact sensitive patterns from text.
@@ -66,6 +106,8 @@ def redact(text: str) -> tuple:
new_result, n = pattern.subn(str(replacement), result) new_result, n = pattern.subn(str(replacement), result)
count += n count += n
result = new_result result = new_result
result, hex_count = _redact_bare_hex_secrets(result)
count += hex_count
if count > 0: if count > 0:
logger.info(f"Redacted {count} secret(s) from content") logger.info(f"Redacted {count} secret(s) from content")
return result, count return result, count
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]] [[package]]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.11.0" version = "2.12.1"
dependencies = [ dependencies = [
"chrono", "chrono",
"env_logger", "env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.11.0" version = "2.12.1"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"] authors = ["Bruno Charest"]
edition = "2021" edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate", "productName": "ObsiGate",
"version": "2.11.0", "version": "2.12.1",
"identifier": "com.obsigate.desktop", "identifier": "com.obsigate.desktop",
"build": { "build": {
"frontendDist": "../frontend", "frontendDist": "../frontend",
+1 -1
View File
@@ -308,7 +308,7 @@ Pour répondre au besoin de cibler un fournisseur/modèle sans dépendre uniquem
- Lecture : `backend/ai.py` (`_read_app_config`, `get_default_provider`, `_load_provider_keys`). - Lecture : `backend/ai.py` (`_read_app_config`, `get_default_provider`, `_load_provider_keys`).
- Écriture : `POST /api/config` (admin) — clés ajoutées à `_DEFAULT_CONFIG` (`backend/main.py:4270`). - Écriture : `POST /api/config` (admin) — clés ajoutées à `_DEFAULT_CONFIG` (`backend/main.py:4270`).
- Rechargement à chaud : `reload_ai_config()` met à jour `PROVIDERS` **en place** (les imports existants restent valides). - Rechargement à chaud : `reload_ai_config()` met à jour `PROVIDERS` **en place** (les imports existants restent valides).
- UI : section « Clés API Intelligence Artificielle » (`frontend/index.html` `#cfg-ai`), sélecteurs « Fournisseur par défaut » + « Modèle par défaut », sauvegardés par `saveAIKeys()` (`frontend/js/config.js`). - UI : section « Clés API Intelligence Artificielle » (`frontend/index.html` `#cfg-ai`, cartes dépliables par fournisseur — #104), sélecteurs « Fournisseur par défaut » + « Modèle par défaut », sauvegardés par `saveAIKeys()` (`frontend/js/config.js`).
**Précédence de résolution du modèle** : override par requête > `ai_default_models[provider]` > variable d'environnement `*_MODEL` > défaut codé en dur. **Précédence de résolution du modèle** : override par requête > `ai_default_models[provider]` > variable d'environnement `*_MODEL` > défaut codé en dur.
+20 -6
View File
@@ -144,12 +144,12 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-032* | [🟡 IMPORTANT] Indexation : symlinks suivis (contenu hors vault indexé) + scan initial coûteux | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/indexer.py` | Placer un symlink dans le vault vers un dossier externe puis relancer l'index | `_scan_vault` réécrit avec `os.walk(followlinks=False)` + refus des symlinks sortant de la racine ; test `TestSymlinkIndexing` | Scan incrémental/index persistant : voir #86 (phase 3) | | *BUG-032* | [🟡 IMPORTANT] Indexation : symlinks suivis (contenu hors vault indexé) + scan initial coûteux | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/indexer.py` | Placer un symlink dans le vault vers un dossier externe puis relancer l'index | `_scan_vault` réécrit avec `os.walk(followlinks=False)` + refus des symlinks sortant de la racine ; test `TestSymlinkIndexing` | Scan incrémental/index persistant : voir #86 (phase 3) |
| *BUG-033* | [🟡 IMPORTANT] Recherche classique et tool IA `search_fulltext` en O(N) sans inverted index | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/search.py`, `backend/tools/service.py` | `GET /api/search` sur un vault de 50 000 fichiers | `search()` récupère les candidats via l'inverted index (intersection des termes + expansion de préfixes), repli sur le scan pendant la construction | `search_fulltext` en bénéficie automatiquement | | *BUG-033* | [🟡 IMPORTANT] Recherche classique et tool IA `search_fulltext` en O(N) sans inverted index | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/search.py`, `backend/tools/service.py` | `GET /api/search` sur un vault de 50 000 fichiers | `search()` récupère les candidats via l'inverted index (intersection des termes + expansion de préfixes), repli sur le scan pendant la construction | `search_fulltext` en bénéficie automatiquement |
| *BUG-034* | [🟡 IMPORTANT] CSP affaiblie (`'unsafe-inline'` + CDN distants) et token d'accès en sessionStorage | 🟢 corrigé | P1 | 🔐 sécurité | IA | `backend/main.py`, `frontend/js/auth.js`, `frontend/js/admin.js`, `frontend/js/sync.js` | Inspecter les en-têtes CSP ; lire sessionStorage en console | Token en mémoire + cookie HttpOnly (plus de `sessionStorage`) ; CSP durcie (`object-src 'none'`, `base-uri`, `form-action`, `frame-ancestors`). *Reste : migration nonce* | `'unsafe-inline'` conservé tant que les gestionnaires inline n'ont pas été convertis (résidu documenté) | | *BUG-034* | [🟡 IMPORTANT] CSP affaiblie (`'unsafe-inline'` + CDN distants) et token d'accès en sessionStorage | 🟢 corrigé | P1 | 🔐 sécurité | IA | `backend/main.py`, `frontend/js/auth.js`, `frontend/js/admin.js`, `frontend/js/sync.js` | Inspecter les en-têtes CSP ; lire sessionStorage en console | Token en mémoire + cookie HttpOnly (plus de `sessionStorage`) ; CSP durcie (`object-src 'none'`, `base-uri`, `form-action`, `frame-ancestors`). *Reste : migration nonce* | `'unsafe-inline'` conservé tant que les gestionnaires inline n'ont pas été convertis (résidu documenté) |
| *BUG-035* | [🔵 MINEUR] `secret_redactor` : faux positifs sur les hashs hex (git, SHA) | 🔴 ouvert | P2 | ⚙️ backend | IA | `backend/secret_redactor.py` | Lire une note contenant un commit git (40 caractères hexadécimaux) | Restreindre le périmètre de détection (contexte clé/token) + whitelist | Contenus mutilés dans les lectures et réponses IA | | *BUG-035* | [🔵 MINEUR] `secret_redactor` : faux positifs sur les hashs hex (git, SHA) | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/secret_redactor.py` | Lire une note contenant un commit git (40 caractères hexadécimaux) | Masquage hex conditionné au contexte (`_redact_bare_hex_secrets`) : secret exigé dans les 60 caractères précédents, exemption explicite pour `commit`/`sha*`/`hash`/`checksum`/`git`/`etag`. Tests : `tests/test_api_main.py::TestSecretRedactor` (+4) | Contenus mutilés dans les lectures et réponses IA |
| *BUG-036* | [🔵 MINEUR] Collab WebSocket : token en query string | 🔴 ouvert | P2 | ⚙️ backend | IA | `backend/collab.py` | Observer l'URL du websocket dans le trafic réseau | Passer le token en header / étape d'authentification initiale ; borner la taille des messages | Jeton visible dans les logs/proxys | | *BUG-036* | [🔵 MINEUR] Collab WebSocket : token en query string | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/collab.py` | Observer l'URL du websocket dans le trafic réseau | `authenticate_websocket` ne lit plus `?token=` : cookie HttpOnly `access_token` uniquement ; rejet des trames > `MAX_MESSAGE_CHARS` (16 Mio) avant analyse. Tests : `tests/test_collab.py` (+3) | Jeton visible dans les logs/proxys |
| *BUG-037* | [🔵 MINEUR] Compte « anonymous » administrateur si auth désactivée | 🔴 ouvert | P2 | 🔐 sécurité | IA | `backend/auth/middleware.py` | Démarrer avec l'authentification désactivée | Avertissement explicite au démarrage + refus de déploiement public sans auth | Comportement par conception mais risqué si mal configuré | | *BUG-037* | [🔵 MINEUR] Compte « anonymous » administrateur si auth désactivée | 🟢 corrigé | P2 | 🔐 sécurité | IA | `backend/auth/middleware.py`, `backend/main.py` | Démarrer avec l'authentification désactivée | `_guard_insecure_auth()` : avertissement explicite + refus de démarrage sur bind non-loopback sans `OBSIGATE_ALLOW_INSECURE=true`. Tests : `tests/test_auth.py::TestInsecureAuthGuard` (+6) | Comportement par conception mais risqué si mal configuré |
| *BUG-038* | [🔵 MINEUR] Argon2 à 64 MB par vérification : risque d'épuisement mémoire | 🔴 ouvert | P2 | 🔐 sécurité | IA | `backend/auth/password.py:8` | Lancer de nombreux `POST /api/auth/login` simultanés | Recalibrer (~19 MB, t=2, p=1, norme OWASP actuelle) + maintien du rate-limit | DoS mémoire possible sur les petites instances | | *BUG-038* | [🔵 MINEUR] Argon2 à 64 MB par vérification : risque d'épuisement mémoire | 🟢 corrigé | P2 | 🔐 sécurité | IA | `backend/auth/password.py` | Lancer de nombreux `POST /api/auth/login` simultanés | Recalibré à `m=19456 Kio (19 Mio), t=2, p=1` (OWASP) ; anciens hachages valides + rehash auto. Test : `tests/test_auth.py::TestPasswordHashing::test_argon2_memory_recalibrated` | DoS mémoire possible sur les petites instances |
| *BUG-039* | [🔵 MINEUR] Enumération de comptes : 429 (verrouillé) vs 401 (inconnu) | 🔴 ouvert | P3 | 🔐 sécurité | IA | `backend/auth/router.py:120` | Tenter un login sur un compte verrouillé puis un nom inconnu | Répondre 401 uniforme avec un timing équivalent | Le statut HTTP distingue l'existence d'un compte | | *BUG-039* | [🔵 MINEUR] Enumération de comptes : 429 (verrouillé) vs 401 (inconnu) | 🟢 corrigé | P3 | 🔐 sécurité | IA | `backend/auth/router.py` | Tenter un login sur un compte verrouillé puis un nom inconnu | Login uniforme : inconnu / désactivé / verrouillé / rate-limit par compte → `401 Identifiants invalides` + hachage factice (timing équivalent) ; seul le rate-limit IP reste `429`. Tests : `tests/test_auth_api.py` (+3) | Le statut HTTP distinguait l'existence d'un compte |
| *BUG-040* | [🔵 MINEUR] Extraction PDF intégrale (100 ko) au scan de démarrage | 🔴 ouvert | P2 | ⚙️ backend | IA | `backend/indexer.py:465` | Démarrer sur un vault contenant de nombreux PDF | Analyser les PDF en tâche de fond / à la demande (lazy) | Ralentit fortement le démarrage et le rebuild d'index | | *BUG-040* | [🔵 MINEUR] Extraction PDF intégrale (100 ko) au scan de démarrage | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/indexer.py`, `backend/main.py` | Démarrer sur un vault contenant de nombreux PDF | `_scan_vault` ne lit que les métadonnées ; `enrich_pdf_texts()` extrait le texte après l'index (démarrage) et après chaque réindexation. Tests : `tests/test_pdf.py` (+3) | Ralentit fortement le démarrage et le rebuild d'index |
| *BUG-041* | [🟡 IMPORTANT] Assistant IA : échec sur un répertoire vide (« Aucun fichier markdown trouvé dans ce dossier ») au lieu de répondre | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `backend/bookslm_routes.py`, `backend/bookslm.py`, `frontend/js/bookslm.js` | Ouvrir l'assistant sur un dossier vide puis envoyer une question | `_resolve_system_prompt` dégrade vers le prompt Général + bloc « Dossier vide » (plus de 404) ; contexte applicatif `app_context` enrichi (documents ouverts, répertoire, recherche, fichiers récents) | Le 404 bloquait toute la requête. Feature #88, fiche `docs/features/ai-app-context.md`. Tests : `tests/test_bookslm.py` (+3), `tests/frontend/ai.test.mjs` | | *BUG-041* | [🟡 IMPORTANT] Assistant IA : échec sur un répertoire vide (« Aucun fichier markdown trouvé dans ce dossier ») au lieu de répondre | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `backend/bookslm_routes.py`, `backend/bookslm.py`, `frontend/js/bookslm.js` | Ouvrir l'assistant sur un dossier vide puis envoyer une question | `_resolve_system_prompt` dégrade vers le prompt Général + bloc « Dossier vide » (plus de 404) ; contexte applicatif `app_context` enrichi (documents ouverts, répertoire, recherche, fichiers récents) | Le 404 bloquait toute la requête. Feature #88, fiche `docs/features/ai-app-context.md`. Tests : `tests/test_bookslm.py` (+3), `tests/frontend/ai.test.mjs` |
| *BUG-042* | [🟡 IMPORTANT] Assistant IA : liens de fichiers non fiables (« File not found: ») — pas de règle déterministe nom / dossier / chemin | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Cliquer les liens de fichiers/dossiers dans une réponse de l'assistant (noms avec espaces et/ou accents, chemin préfixé par le nom du vault) | `_classifyPath` distingue `name` (copie presse-papiers) / `dir` (révélation arborescence) / `file` (ouverture) ; `_activatePath()` résout le chemin contre l'index du vault (exact → suffixe → basename unique) avant d'agir ; espaces + accents pris en charge (classes Unicode `\p{L}\p{N}\p{M}`, comparaison normalisée NFC, markdown `<…>`/`%20`, code inline, mentions brutes confirmées par l'index) ; `_splitVaultPrefix` retire un préfixe `Vault/…` et ouvre dans ce vault (`_fetchPathsForVault`) | Les liens morts ouvraient un fichier inexistant. Feature #88. Tests : `tests/frontend/ai.test.mjs` (+11) | | *BUG-042* | [🟡 IMPORTANT] Assistant IA : liens de fichiers non fiables (« File not found: ») — pas de règle déterministe nom / dossier / chemin | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Cliquer les liens de fichiers/dossiers dans une réponse de l'assistant (noms avec espaces et/ou accents, chemin préfixé par le nom du vault) | `_classifyPath` distingue `name` (copie presse-papiers) / `dir` (révélation arborescence) / `file` (ouverture) ; `_activatePath()` résout le chemin contre l'index du vault (exact → suffixe → basename unique) avant d'agir ; espaces + accents pris en charge (classes Unicode `\p{L}\p{N}\p{M}`, comparaison normalisée NFC, markdown `<…>`/`%20`, code inline, mentions brutes confirmées par l'index) ; `_splitVaultPrefix` retire un préfixe `Vault/…` et ouvre dans ce vault (`_fetchPathsForVault`) | Les liens morts ouvraient un fichier inexistant. Feature #88. Tests : `tests/frontend/ai.test.mjs` (+11) |
| *BUG-043* | [🟡 IMPORTANT] Assistant IA : la liste des fournisseurs de la barre latérale ne suit pas les ajouts/retraits de clés API dans la configuration du projet | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/ai.js`, `frontend/js/bookslm.js`, `frontend/js/config.js` | Ajouter (ou supprimer) une clé de fournisseur AI dans la configuration puis observer le menu Fournisseur de l'assistant sans recharger la page | Le picker lit `/api/ai/status` **une seule fois**, à sa construction, et le panneau de l'assistant est un singleton monté pour toute la session → liste figée. Nouveau `refreshAIPickers()` (exporté par `ai.js`) qui reconstruit chaque picker monté dans son emplacement `.ai-picker-slot` (conservé même sans fournisseur configuré, donc un premier fournisseur s'y monte aussi) ; appelé après `saveAIKeys()` et `deleteAIKey()` (`config.js`) ; une sélection dont le fournisseur n'est plus configuré est purgée de `obsigate_ai_picker` (retour au défaut + modèle effacé au lieu d'un nom fantôme) | Il fallait recharger la page pour voir un nouveau fournisseur (ou en voir disparaître un). Feature #82. Tests : `tests/frontend/ai.test.mjs` (+4) | | *BUG-043* | [🟡 IMPORTANT] Assistant IA : la liste des fournisseurs de la barre latérale ne suit pas les ajouts/retraits de clés API dans la configuration du projet | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/ai.js`, `frontend/js/bookslm.js`, `frontend/js/config.js` | Ajouter (ou supprimer) une clé de fournisseur AI dans la configuration puis observer le menu Fournisseur de l'assistant sans recharger la page | Le picker lit `/api/ai/status` **une seule fois**, à sa construction, et le panneau de l'assistant est un singleton monté pour toute la session → liste figée. Nouveau `refreshAIPickers()` (exporté par `ai.js`) qui reconstruit chaque picker monté dans son emplacement `.ai-picker-slot` (conservé même sans fournisseur configuré, donc un premier fournisseur s'y monte aussi) ; appelé après `saveAIKeys()` et `deleteAIKey()` (`config.js`) ; une sélection dont le fournisseur n'est plus configuré est purgée de `obsigate_ai_picker` (retour au défaut + modèle effacé au lieu d'un nom fantôme) | Il fallait recharger la page pour voir un nouveau fournisseur (ou en voir disparaître un). Feature #82. Tests : `tests/frontend/ai.test.mjs` (+4) |
@@ -168,6 +168,12 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-056* | [🟡 IMPORTANT] Éditeur Forge en plein écran : l'Assistant IA s'ouvre en arrière-plan et reste invisible | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/editor-poc.html`, `frontend/js/sync.js`, `tests/frontend/forge-completion.test.mjs`, `tests/frontend/editor-inline.test.mjs` | Forge : passer en plein écran puis cliquer le bouton « Assistant IA » (ou `Ctrl+J`) — le panneau s'ouvre dans le document parent, masqué par l'iframe plein écran | Sortie du plein écran **avant** d'ouvrir le panneau, des deux côtés : côté iframe (`openAssistant` → `document.exitFullscreen()` puis `postMessage` à la résolution) **et** côté parent (`sync.js` sur `forge-open-ai` → `document.exitFullscreen()` puis `openForCurrentContext()`), car le plein écran peut être détenu par le document parent et non par l'iframe (dans ce cas `document.fullscreenElement` est nul dans l'iframe et sa sortie échoue). Tests : `forge-completion.test.mjs` (+1), `editor-inline.test.mjs` (+1) | Le panneau assistant est monté dans `document.body` du parent : l'API Fullscreen ne rend que l'élément plein écran et ses descendants, donc il ne peut pas s'afficher au-dessus de l'iframe Forge en plein écran. La sortie côté iframe seule ne suffisait pas quand le parent détient le plein écran | | *BUG-056* | [🟡 IMPORTANT] Éditeur Forge en plein écran : l'Assistant IA s'ouvre en arrière-plan et reste invisible | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/editor-poc.html`, `frontend/js/sync.js`, `tests/frontend/forge-completion.test.mjs`, `tests/frontend/editor-inline.test.mjs` | Forge : passer en plein écran puis cliquer le bouton « Assistant IA » (ou `Ctrl+J`) — le panneau s'ouvre dans le document parent, masqué par l'iframe plein écran | Sortie du plein écran **avant** d'ouvrir le panneau, des deux côtés : côté iframe (`openAssistant` → `document.exitFullscreen()` puis `postMessage` à la résolution) **et** côté parent (`sync.js` sur `forge-open-ai` → `document.exitFullscreen()` puis `openForCurrentContext()`), car le plein écran peut être détenu par le document parent et non par l'iframe (dans ce cas `document.fullscreenElement` est nul dans l'iframe et sa sortie échoue). Tests : `forge-completion.test.mjs` (+1), `editor-inline.test.mjs` (+1) | Le panneau assistant est monté dans `document.body` du parent : l'API Fullscreen ne rend que l'élément plein écran et ses descendants, donc il ne peut pas s'afficher au-dessus de l'iframe Forge en plein écran. La sortie côté iframe seule ne suffisait pas quand le parent détient le plein écran |
| *BUG-057* | [🟡 IMPORTANT] Assistant IA : le bouton « Ajouter » est inopérant dans l'éditeur Forge (fonctionne seulement dans « Editer ») | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js`, `frontend/editor-poc.html` | Ouvrir un document dans Forge, demander une réponse à l'assistant puis cliquer « Ajouter » | `_insertIntoEditor()` cible Forge (`#forge-iframe`) : `postMessage({ type: 'parent-insert', text })` ; `editor-poc.html` insère au curseur (`insertAtCursor`) et marque le tampon modifié. Repli textarea inclus. Tests : `tests/frontend/ai.test.mjs` (+3), `tests/frontend/editor-inline.test.mjs` (+1) | `state.editorView` (CodeMirror) est nul en Forge : le clic affichait « Aucun document ouvert dans l'éditeur » | | *BUG-057* | [🟡 IMPORTANT] Assistant IA : le bouton « Ajouter » est inopérant dans l'éditeur Forge (fonctionne seulement dans « Editer ») | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js`, `frontend/editor-poc.html` | Ouvrir un document dans Forge, demander une réponse à l'assistant puis cliquer « Ajouter » | `_insertIntoEditor()` cible Forge (`#forge-iframe`) : `postMessage({ type: 'parent-insert', text })` ; `editor-poc.html` insère au curseur (`insertAtCursor`) et marque le tampon modifié. Repli textarea inclus. Tests : `tests/frontend/ai.test.mjs` (+3), `tests/frontend/editor-inline.test.mjs` (+1) | `state.editorView` (CodeMirror) est nul en Forge : le clic affichait « Aucun document ouvert dans l'éditeur » |
| *BUG-058* | [🔵 MINEUR] Éditeur « Editer » : la barre de numérotation de ligne ne suit pas la couleur du thème (gutter clair `#f5f5f5` en thème sombre) | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/style.css` | Ouvrir un document → Editer en thème sombre : la colonne des numéros de ligne reste gris clair alors que le fond de l'éditeur est sombre | Thème du gutter CodeMirror via les variables CSS (`color-mix(var(--text-primary) …)` pour le fond, `--text-secondary` pour les numéros, `--border` pour la séparation, `--text-primary` pour la ligne active) au lieu des valeurs codées en dur de CodeMirror ; test de non-régression dans `tests/frontend/editor-inline.test.mjs`. Vérifié Playwright (instance de test) : sombre `color(srgb 0.90 0.93 0.95 / 0.05)` + bordure `#21262d`, clair `color(srgb 0.12 0.14 0.16 / 0.05)` + bordure `#d0d7de` | CodeMirror applique `background:#f5f5f5` par défaut, indépendamment du thème ObsiGate ; en mode sombre le fond de l'éditeur suit `--bg-secondary` mais pas le gutter | | *BUG-058* | [🔵 MINEUR] Éditeur « Editer » : la barre de numérotation de ligne ne suit pas la couleur du thème (gutter clair `#f5f5f5` en thème sombre) | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/style.css` | Ouvrir un document → Editer en thème sombre : la colonne des numéros de ligne reste gris clair alors que le fond de l'éditeur est sombre | Thème du gutter CodeMirror via les variables CSS (`color-mix(var(--text-primary) …)` pour le fond, `--text-secondary` pour les numéros, `--border` pour la séparation, `--text-primary` pour la ligne active) au lieu des valeurs codées en dur de CodeMirror ; test de non-régression dans `tests/frontend/editor-inline.test.mjs`. Vérifié Playwright (instance de test) : sombre `color(srgb 0.90 0.93 0.95 / 0.05)` + bordure `#21262d`, clair `color(srgb 0.12 0.14 0.16 / 0.05)` + bordure `#d0d7de` | CodeMirror applique `background:#f5f5f5` par défaut, indépendamment du thème ObsiGate ; en mode sombre le fond de l'éditeur suit `--bg-secondary` mais pas le gutter |
| *BUG-060* | [🟡 IMPORTANT] Viewer PDF : l'affichage des pages ne fonctionne pas — seule la barre d'outils « PDF — N pages » s'affiche, le contenu reste vide | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/viewer.js`, `tests/frontend/pdf-viewer.test.mjs` (nouveau), `tests/e2e/pdf-viewer.spec.js` (nouveau) | Cliquer un fichier `.pdf` dans l'arborescence | `frontend/js/viewer.js` : le rendu PDF passe de `<embed type="application/pdf">` à `<iframe>` (autorisée par `frame-src 'self'`, le stream étant same-origin). Tests : `tests/frontend/pdf-viewer.test.mjs` (+6) et `tests/e2e/pdf-viewer.spec.js` (fixture `test_vault/sample-pdf.pdf`) | Cause : la CSP durcie en BUG-034 pose `object-src 'none'`, directive qui gouverne `<embed>`/`<object>` → le lecteur PDF natif était bloqué (barre d'outils rendue, corps vide). Le test E2E échoue bien avec l'ancien `<embed>`. `object-src 'none'` conservé (le correctif ne désarme pas la CSP) |
| *BUG-061* | [🟡 IMPORTANT] Assistant IA : le bouton « Plein écran » n'agrandit plus le panneau | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/style.css`, `tests/frontend/ai.test.mjs` | Ouvrir l'assistant, redimensionner le panneau, puis cliquer « Plein écran » | La largeur du panneau est écrite en ligne par la poignée de redimensionnement / la largeur persistée (`localStorage`) ; l'inline l'emportait sur `.bookslm-panel.fullscreen { width: 100vw }`. Ajout de `!important` sur la règle plein écran. Tests : `ai.test.mjs` (+1 : classe basculée + règle CSS). Vérifié Playwright : 640 px → 1400 px (viewport) |
| *BUG-062* | [🟡 IMPORTANT] Viewer PDF : le document ne prend pas toute la largeur quand la navigation est masquée | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/style.css`, `tests/frontend/pdf-viewer.test.mjs`, `tests/e2e/pdf-viewer.spec.js` | Ouvrir un PDF puis masquer la barre de navigation gauche | La règle `.sidebar.hidden ~ .content-wrapper .content-area { max-width: 1200px }` (colonne de lecture centrée) s'appliquait aussi aux viewers plein cadre. Ajout de `.content-area:has(.pdf-viewer-container)` (et `.image-viewer-container`) avec `max-width: none; margin: 0`. Test E2E : `max-width` calculé = `none`, conteneur = largeur du contenu |
| *BUG-063* | [🟡 IMPORTANT] Viewer PDF : la table des matières s'affiche mais ne navigue pas | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/viewer.js`, `tests/frontend/pdf-viewer.test.mjs`, `tests/e2e/pdf-viewer.spec.js` (fixture `test_vault/sample-pdf-toc.pdf`) | Ouvrir un PDF avec signets, puis cliquer une entrée de la TOC | Deux causes : (1) `contentWindow.location.hash='page=N'` n'atteint pas le document (lecteur PDF natif dans une fenêtre `about:blank`) ; (2) un simple changement de fragment sur `iframe.src` est une navigation same-document **ignorée** par le lecteur natif. `navigatePdfToPage()` (liens `data-page` + listeners, plus d'`onclick` inline) recharge réellement l'iframe via un paramètre de query qui change (`&_pdfpage=<ts>#page=N`). Test E2E : `src` finit par `&_pdfpage=<n>#page=3`. Vérifié en Chrome *headful* : page 1 → page 8 → page 1 (captures identiques au retour) | Le fragment seul ne suffisait pas : Chrome applique `#page=N` au **chargement**, pas lors d'un changement de fragment |
| *BUG-064* | [🟡 IMPORTANT] Éditeur Excalidraw : le diagramme ne s'affiche jamais (canvas vide), pour tout fichier `.excalidraw` / `.excalidraw.md` | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/excalidraw-editor.html`, `backend/main.py`, `tests/frontend/excalidraw-viewer.test.mjs`, `tests/test_security_hardening.py`, `tests/e2e/excalidraw.spec.js`, `test_vault/diagram-app-export.excalidraw` | Ouvrir un `.excalidraw` (ou `.excalidraw.md`) dans ObsiGate | Deux causes : (1) la feuille de style d'Excalidraw n'était jamais chargée → éditeur non stylisé + `.excalidraw` sans hauteur fixe → boucle de resize jusqu'au plafond `2^25` (33 554 432 px) → scène blanche. Correctif : `<link>` CSS depuis esm.sh + `style-src` CSP autorisant `https://esm.sh`. (2) `appState.collaborators` objet JSON → `collaborators.forEach is not a function` ; `sanitizeAppState()` reconvertit en `Map` et écarte `width/height/offsetLeft/offsetTop`. | Vérifié navigateur : hauteur canvas 525 px (avant 33 554 432), dessin affiché, UI stylisée, 0 erreur. E2E + tests statiques CSP/CSS ajoutés. |
| *BUG-065* | [🟡 IMPORTANT] Éditeur Excalidraw : l'auto-save recharge la page en pleine édition | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/excalidraw-viewer.js`, `frontend/js/utils.js`, `frontend/excalidraw-editor.html`, `tests/frontend/excalidraw-viewer.test.mjs` | Ouvrir un `.excalidraw` puis modifier un élément : au bout de 2 s la vue se recharge | Chaque modification déclenchait un `PUT save` 2 s plus tard → SSE `index_updated` → `reloadExternalWrite` → `openFile` → **recréation de l'iframe** (refresh visible). Auto-save supprimée : sauvegarde explicite (bouton 💾 / Ctrl+S). `reloadExternalWrite` ignore le fichier si un iframe Excalidraw est ouvert (`iframe[data-excalidraw-vault/path]`). Le badge « Modified » ne réagit plus aux changements d'`appState` (resize/zoom) mais à la signature des éléments. | Vérifié Playwright : plus de refresh, badge stable après bascule plein écran. Test statique (absence de `requestSave`/`saveTimer`). |
| | | | | | | | | | | | | | | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches) ### TODOs techniques (améliorations / nouvelles tâches)
@@ -230,6 +236,14 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-09-17 | BUG-057, #102 | Correction + feature | `frontend/js/bookslm.js`, `frontend/editor-poc.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `docs/archive/COMPLETED_v1-v2.md`, `docs/ROADMAP.md`, `CHANGELOG.md` | **BUG-057** : le bouton « Ajouter » de l'assistant ne ciblait que `state.editorView` (CodeMirror) ; en Forge il affichait « Aucun document ouvert dans l'éditeur ». `_insertIntoEditor()` gère désormais les trois surfaces : CodeMirror, l'iframe Forge (`postMessage({ type: 'parent-insert', text })` → `insertAtCursor` dans `editor-poc.html`) et le textarea de repli. **#102** : chaque bloc de code d'une réponse reçoit un bouton « Ajouter la section » (`.bookslm-code-insert`, révélé au survol) qui insère le contenu du bloc sans les délimiteurs ` ``` `. Vérifié : `ai.test.mjs` 91/91 (+3), `editor-inline.test.mjs` 43/43 (+1), `forge-completion.test.mjs` 32/32, unit 9/9, validate-imports 38 modules, pytest 1101 passed / 6 skipped, ruff 0, mypy 0. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-17 | BUG-057, #102 | Correction + feature | `frontend/js/bookslm.js`, `frontend/editor-poc.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `docs/archive/COMPLETED_v1-v2.md`, `docs/ROADMAP.md`, `CHANGELOG.md` | **BUG-057** : le bouton « Ajouter » de l'assistant ne ciblait que `state.editorView` (CodeMirror) ; en Forge il affichait « Aucun document ouvert dans l'éditeur ». `_insertIntoEditor()` gère désormais les trois surfaces : CodeMirror, l'iframe Forge (`postMessage({ type: 'parent-insert', text })` → `insertAtCursor` dans `editor-poc.html`) et le textarea de repli. **#102** : chaque bloc de code d'une réponse reçoit un bouton « Ajouter la section » (`.bookslm-code-insert`, révélé au survol) qui insère le contenu du bloc sans les délimiteurs ` ``` `. Vérifié : `ai.test.mjs` 91/91 (+3), `editor-inline.test.mjs` 43/43 (+1), `forge-completion.test.mjs` 32/32, unit 9/9, validate-imports 38 modules, pytest 1101 passed / 6 skipped, ruff 0, mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-17 | BUG-058 | Correction | `frontend/style.css`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-058** : la barre de numérotation de ligne de l'éditeur « Editer » ne suivait pas le thème — CodeMirror peint `.cm-gutters` avec des valeurs claires codées en dur (`#f5f5f5`, bordure `#ddd`), visibles en thème sombre. Correctif : le gutter dérive des variables CSS ObsiGate (`background: color-mix(in srgb, var(--text-primary) 5%, transparent)`, `color: var(--text-secondary)`, `border-right: 1px solid var(--border)`, ligne active `color-mix(… 10% …)` / `--text-primary`), donc il suit les 15 thèmes et les 4 modes. Vérifié : `editor-inline.test.mjs` 44/44 (+1), unit 9/9, validate-imports 38 modules, pytest 1101 passed / 6 skipped, ruff 0, mypy 0, et Playwright sur l'instance de test (route `style.css` remplacée par le fichier local) — sombre `color(srgb 0.90 0.93 0.95 / 0.05)` + bordure `#21262d`, clair `color(srgb 0.12 0.14 0.16 / 0.05)` + bordure `#d0d7de`, plus de `rgb(245,245,245)`. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-17 | BUG-058 | Correction | `frontend/style.css`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-058** : la barre de numérotation de ligne de l'éditeur « Editer » ne suivait pas le thème — CodeMirror peint `.cm-gutters` avec des valeurs claires codées en dur (`#f5f5f5`, bordure `#ddd`), visibles en thème sombre. Correctif : le gutter dérive des variables CSS ObsiGate (`background: color-mix(in srgb, var(--text-primary) 5%, transparent)`, `color: var(--text-secondary)`, `border-right: 1px solid var(--border)`, ligne active `color-mix(… 10% …)` / `--text-primary`), donc il suit les 15 thèmes et les 4 modes. Vérifié : `editor-inline.test.mjs` 44/44 (+1), unit 9/9, validate-imports 38 modules, pytest 1101 passed / 6 skipped, ruff 0, mypy 0, et Playwright sur l'instance de test (route `style.css` remplacée par le fichier local) — sombre `color(srgb 0.90 0.93 0.95 / 0.05)` + bordure `#21262d`, clair `color(srgb 0.12 0.14 0.16 / 0.05)` + bordure `#d0d7de`, plus de `rgb(245,245,245)`. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-17 | BUG-059 | Correction | `frontend/js/bookslm.js`, `tests/frontend/ai.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-059** : dans une conversation ouverte (post ancré en haut), **tout clic** dans la fenêtre de messages — lien de fichier, étapes, sélection de texte — faisait sauter toute la conversation au bas de la fenêtre. Cause : le gestionnaire `mousedown` de dépintage (prévu pour la molette/tactile/poignée de scroll) se déclenchait aussi sur un simple clic, et le retrait du padding d'ancre (`paddingBottom`) bornait le `scrollTop` à la nouvelle hauteur max → saut au bas. Correctif : helper pur `isScrollbarPress(target, clientX, container)` — un appui ne dépine que s'il vise la **poignée de scroll** (cible = conteneur + zone de gouttière droite) ; molette et tactile conservent leur comportement. Vérifié : `ai.test.mjs` 92/92 (+1), unit 9/9, validate-imports 38 modules, pytest / ruff / mypy inchangés côté backend. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-17 | BUG-059 | Correction | `frontend/js/bookslm.js`, `tests/frontend/ai.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-059** : dans une conversation ouverte (post ancré en haut), **tout clic** dans la fenêtre de messages — lien de fichier, étapes, sélection de texte — faisait sauter toute la conversation au bas de la fenêtre. Cause : le gestionnaire `mousedown` de dépintage (prévu pour la molette/tactile/poignée de scroll) se déclenchait aussi sur un simple clic, et le retrait du padding d'ancre (`paddingBottom`) bornait le `scrollTop` à la nouvelle hauteur max → saut au bas. Correctif : helper pur `isScrollbarPress(target, clientX, container)` — un appui ne dépine que s'il vise la **poignée de scroll** (cible = conteneur + zone de gouttière droite) ; molette et tactile conservent leur comportement. Vérifié : `ai.test.mjs` 92/92 (+1), unit 9/9, validate-imports 38 modules, pytest / ruff / mypy inchangés côté backend. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-17 | BUG-060 | Correction | `frontend/js/viewer.js`, `.gitea/workflows/ci.yml`, `tests/frontend/pdf-viewer.test.mjs` (nouveau), `tests/e2e/pdf-viewer.spec.js` (nouveau), `test_vault/sample-pdf.pdf` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-060** : l'ouverture d'un PDF n'affichait aucune page (barre d'outils « PDF — N pages » présente, corps vide). Cause : la CSP durcie en BUG-034 pose `object-src 'none'` — directive qui gouverne `<embed>`/`<object>` — alors que le viewer rendait le PDF via `<embed type="application/pdf">` : le lecteur natif était bloqué. Correctif : rendu dans une `<iframe>` (autorisée par `frame-src 'self'`, le stream `/api/file/{vault}/pdf/stream` étant same-origin) ; `object-src 'none'` conservé. Tests : `pdf-viewer.test.mjs` 6/6 (statique : pas d'`<embed>`, CSP `frame-src 'self'`, iframe pleine hauteur), `pdf-viewer.spec.js` (E2E : iframe + stream `application/pdf` 200/206 + zéro violation CSP ; échoue bien avec l'ancien `<embed>`). Vérifié : pytest 1184 passed / 6 skipped, frontend 14 suites JSDOM vertes, validate-imports 38 modules, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-17 | BUG-035, BUG-036, BUG-037, BUG-038, BUG-039, BUG-040 | Correction | `backend/secret_redactor.py`, `backend/collab.py`, `backend/auth/{middleware,password,router}.py`, `backend/indexer.py`, `backend/main.py`, `tests/test_api_main.py`, `tests/test_auth.py`, `tests/test_auth_api.py`, `tests/test_collab.py`, `tests/test_pdf.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **Lot de 6 bugs mineurs (P2/P3)** : BUG-035 masquage hex conditionné au contexte (git/SHA épargnés) ; BUG-036 jeton WebSocket cookie-only (plus de `?token=`) + plafond de trame 16 Mio ; BUG-037 garde-fou au démarrage (refus d'un bind public sans auth sauf `OBSIGATE_ALLOW_INSECURE=true`) ; BUG-038 Argon2 recalibré 19 Mio/t=2/p=1 ; BUG-039 login uniforme 401 (fini 429/403 distinctifs) ; BUG-040 extraction PDF différée via `enrich_pdf_texts()`. Vérifié : pytest 1204 passed / 6 skipped, ruff 0, mypy 0 (77 fichiers), frontend validate-imports 38 modules + unit 9/9. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-17 | BUG-061, BUG-062, BUG-063 | Correction | `frontend/style.css`, `frontend/js/viewer.js`, `tests/frontend/ai.test.mjs`, `tests/frontend/pdf-viewer.test.mjs`, `tests/e2e/pdf-viewer.spec.js`, `test_vault/sample-pdf-toc.pdf` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **Viewer PDF & assistant IA** : BUG-061 le bouton plein écran du panneau assistant l'emportait mal sur la largeur inline (redimensionnement/persistée) → `width: 100vw !important` ; BUG-062 le plafond de lecture 1200 px s'appliquait au PDF quand la navigation était masquée → `:has(.pdf-viewer-container)` en `max-width:none` ; BUG-063 la TOC PDF ne naviguait pas (`contentWindow` = `about:blank`) → `navigatePdfToPage()` recharge l'iframe avec `#page=N`, liens `data-page` sans `onclick` inline. Vérifié : `ai.test.mjs` 93/93, `pdf-viewer.test.mjs` 8/8, validate-imports 38 modules (311 exports), unit 9/9, E2E `pdf-viewer.spec.js` 3/3, et Playwright sur l'instance de test (plein écran 640→1400 px, `src` → `#page=3`, `max-width:none`). | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-17 | BUG-063 (complément) | Correction | `frontend/js/viewer.js`, `tests/frontend/pdf-viewer.test.mjs`, `tests/e2e/pdf-viewer.spec.js`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-063 non résolu au premier correctif** : définir `iframe.src = base + '#page=N'` ne change que le fragment → navigation same-document que le lecteur PDF natif ignore. Diagnostic en Chrome *headful* (comparaison de captures) : fragment présent au chargement = OK ; changement de fragment après chargement = aucun effet ; changement de query + fragment = OK. `navigatePdfToPage()` ajoute donc un paramètre de query horodaté (`&_pdfpage=<ts>#page=N`) pour forcer un vrai rechargement. Vérifié via l'UI de l'app (Chrome headful) : page 1 → page 8 → retour page 1 (hash de capture identique au retour). Tests : `pdf-viewer.test.mjs` 8/8, E2E `pdf-viewer.spec.js` 3/3. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-17 | BUG-064 | Correction | `frontend/excalidraw-editor.html`, `backend/main.py`, `tests/frontend/excalidraw-viewer.test.mjs`, `tests/test_security_hardening.py`, `tests/e2e/excalidraw.spec.js`, `test_vault/diagram-app-export.excalidraw` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-064** : aucun diagramme Excalidraw ne s'affichait (canvas vide). Diagnostic navigateur : `.excalidraw` sans hauteur fixe → boucle de redimensionnement 525 → 56 181 → **33 554 432 px** (`2^25`, plafond Excalidraw) ; canvas de 33 Mpx impossible à dessiner → scène blanche. **Cause 1** : la feuille de style `@excalidraw/excalidraw` n'était jamais chargée (seuls 18 règles CSS présentes, toutes ObsiGate) — l'éditeur était non stylisé. Correctif : `<link rel="stylesheet" href="https://esm.sh/@excalidraw/[email protected]/dist/prod/index.css">` + `https://esm.sh` ajouté à `style-src` de la CSP. **Cause 2** : `appState.collaborators` (Map sérialisée en objet JSON par l'app/plugin) faisait planter Excalidraw 0.18 (`collaborators.forEach is not a function`) ; `sanitizeAppState()` reconvertit en `Map` et écarte la géométrie de viewport importée (`width/height/offsetLeft/offsetTop`). Vérifié Playwright sur l'instance de test (port 2020) : hauteur canvas 525 px, rectangle + losange affichés, UI stylisée, 0 `pageerror`. Tests : `excalidraw-viewer.test.mjs` 8/8 (dont 3 nouveaux), `TestCspExcalidrawStylesheet` (pytest), E2E (hauteur de canvas bornée). | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-17 | BUG-064 (complément) | Correction | `frontend/style.css`, `tests/frontend/excalidraw-viewer.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-064 (complément)** : quand la barre de navigation gauche est masquée, le viewer Excalidraw restait borné à la colonne de lecture centrée de 1200 px. La règle `.sidebar.hidden ~ .content-wrapper .content-area { max-width: 1200px }` s'appliquait au viewer comme aux notes. Ajout de `.content-area:has(iframe[src*="excalidraw-editor.html"])` en `max-width: none; margin: 0` (même traitement que les viewers PDF/image, BUG-062). Vérifié Playwright (viewport 1400 px) : contenu 1115 → 1400 px, iframe 1035 → 1320 px, `max-width` calculé `none`. Test statique ajouté (`excalidraw-viewer.test.mjs` 9/9). | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-17 | BUG-065, #78 (complément) | Correction + feature | `frontend/js/excalidraw-viewer.js`, `frontend/js/utils.js`, `frontend/excalidraw-editor.html`, `tests/frontend/excalidraw-viewer.test.mjs`, `docs/features/excalidraw.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-065** : l'auto-save Excalidraw (débounce 2 s) déclenchait `PUT save` → SSE `index_updated` → `reloadExternalWrite` → `openFile` → recréation de l'iframe = refresh visible pendant le dessin. Auto-save retirée (`excalidraw-viewer.js` : plus de `requestSave`/`saveTimer`), sauvegarde explicite (bouton 💾 / Ctrl+S) ; `reloadExternalWrite` (utils.js) court-circuite le re-rendu si un iframe Excalidraw est ouvert sur ce fichier (attributs `data-excalidraw-vault`/`data-excalidraw-path`) ; le badge « Modified » suit désormais une signature des éléments (`id:versionNonce`) au lieu de tout `onChange` — resize/zoom/plein écran ne marquent plus le fichier modifié. **#78 (complément)** : bouton **plein écran** `#btn-fullscreen` dans la barre d'outils de l'éditeur (`requestFullscreen` sur le document de l'iframe) + iframe créée avec `allow="fullscreen" allowfullscreen`. Vérifié Playwright : bascule plein écran OK (`document.fullscreenElement` true→false), badge non modifié après bascule ; tests statiques `excalidraw-viewer.test.mjs` 12/12, validate-imports 38 modules, unit 9/9. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-17 | #78 (complément) | UI | `frontend/excalidraw-editor.html`, `docs/features/excalidraw.md`, `CHANGELOG.md` | **#78 (complément)** : la barre d'outils de l'éditeur Excalidraw passe en **colonne d'icônes** (34×34 px, SVG seuls), **collée au bord droit** (`right: 0` ; `top: 45%` ; empilement vertical), avec `title`/`aria-label`. L'icône du bouton Save est remplacée par une coche pendant 1,2 s après une sauvegarde réussie. Badge « Modifié » réduit à une pastille. Vérifié Playwright : bord droit au bord de l'iframe, haut 45 %, 4 boutons empilés ; bascule plein écran OK, cycle d'icône Save + `PUT save` observés. | 🟢 livré (en attente vérif utilisateur) |
--- ---
+4 -3
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap # ObsiGate — Roadmap
> **Version :** 2.11.0 | **Dernière mise à jour :** 2026-09-17 > **Version :** 2.12.1 | **Dernière mise à jour :** 2026-09-18
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées. > vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -186,6 +186,7 @@
| 102 | Assistant IA — bouton « Ajouter la section » par bloc de code (insertion du bloc seul) | 2.9.0 | [archive](./archive/COMPLETED_v1-v2.md) | | 102 | Assistant IA — bouton « Ajouter la section » par bloc de code (insertion du bloc seul) | 2.9.0 | [archive](./archive/COMPLETED_v1-v2.md) |
| 92 | Assistant IA — Écosystème d'outils phase 2 (recherche à clé, cache/retry, Playwright, crawl, Gitea/GitHub, documents XLSX/DOCX/CSV/PDF) | 2.10.0 | [features/ai-tools-roadmap.md](./features/ai-tools-roadmap.md) | | 92 | Assistant IA — Écosystème d'outils phase 2 (recherche à clé, cache/retry, Playwright, crawl, Gitea/GitHub, documents XLSX/DOCX/CSV/PDF) | 2.10.0 | [features/ai-tools-roadmap.md](./features/ai-tools-roadmap.md) |
| 103 | Configuration — clés utilisateur des sources connectées & recherche à clé (page Configurations, `data/api_keys.json`, priorité sur l'env) | 2.11.0 | [features/ai-tools-roadmap.md](./features/ai-tools-roadmap.md) | | 103 | Configuration — clés utilisateur des sources connectées & recherche à clé (page Configurations, `data/api_keys.json`, priorité sur l'env) | 2.11.0 | [features/ai-tools-roadmap.md](./features/ai-tools-roadmap.md) |
| 104 | Configuration — Redesign UI de la section « Clés API IA » : recherche fournisseurs, carte défaut 2 colonnes + badges de capacités, cartes dépliables, footer d'actions sticky | 2.12.0 | [features/ai-keys-ui.md](./features/ai-keys-ui.md) |
--- ---
@@ -193,10 +194,10 @@
| Priorité | Items | Effort total estimé | | Priorité | Items | Effort total estimé |
|---|---|---| |---|---|---|
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93, #94–100, #102, #92 | ~114 jours réalisés | | ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93, #94–100, #102–104, #92 | ~115 jours réalisés |
| 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour | | 🔵 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 | | ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
| ⚪ P0/P1 restant | #85-87 Refonte architecturale, performance, CI/CD (issues BUG-035 → BUG-040) | ~15-23 jours | | ⚪ P0/P1 restant | #85-87 Refonte architecturale, performance, CI/CD (BUG-035 → BUG-040 corrigés) | ~15-23 jours |
| **Total restant** | **7 items + finitions** | **~27-42 jours** | | **Total restant** | **7 items + finitions** | **~27-42 jours** |
--- ---
+88
View File
@@ -0,0 +1,88 @@
# #104 — Configuration : Redesign UI de la section « Clés API Intelligence Artificielle »
> **Statut :** ✅ livré (v2.12.0) · **Zone :** `frontend/index.html`, `frontend/js/config.js`,
> `frontend/js/ai.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`
## Problème
La section « 🤖 Clés API IA » du panneau de configuration était une longue liste plate de
champs de saisie (DeepSeek, OpenRouter, Gemini, NVIDIA, QwenCloud, Xiaomi, Mistral) sans
structure ni hiérarchie visuelle : les sélecteurs défaut, les clés et les modèles se
cotoyaient au même niveau, les badges « Configuré » étaient collés à chaque champ, et les
boutons d'action se perdaient en milieu de section.
## Objectifs
Interface plus professionnelle, mieux structurée, moins fatigante visuellement (style
SaaS moderne, dark mode, cartes + accordéons), et qui reste utilisable quand la liste de
fournisseurs s'allonge.
## Structure livrée
### 1. En-tête de section
- Titre + sous-titre explicatif (i18n `config.ai_header_desc`).
- **Barre de recherche** (`#cfg-ai-search`) filtrant les cartes fournisseurs via
`filterAIProviders()` — normalisation insensible à la casse **et aux accents**
(`_sidebarNorm`), correspondance sur le nom affiché ou l'identifiant. État vide
« Aucun fournisseur ne correspond » (`#cfg-ai-providers-empty`).
### 2. Carte « Configuration par défaut »
- Carte visuellement distincte (`.ai-default-card`) : titre en petites capitales.
- Grille **2 colonnes** : « Fournisseur par défaut » / « Modèle par défaut »
(`#cfg-ai-default-provider`, `#cfg-ai-default-model`).
- **Capacités du modèle en badges colorés** : nouveau
`renderCapabilityBadges(caps)` (`frontend/js/ai.js`) qui n'affiche que les capacités
actives sous forme de tags (`.ai-cap-badge`), au lieu de la checklist ☑/□
(`renderCapabilityList`, conservée pour les pickers de l'assistant).
### 3. Fournisseurs d'API — cartes dépliables
Rendu dynamique par `_renderAIProviderCards()` depuis `AI_PROVIDER_NAMES` + nouveau
`AI_PROVIDER_META` (nom affiché, placeholder spécifique au fournisseur). Par carte :
- **Replié** : logo (initiale dans une pastille), nom, **badge de statut**
(« Configuré » vert / « Non configuré » gris, `.ai-provider-badge.configured`) et
**corbeille discrète** (`cfg-<provider>-delete`, visible uniquement si une clé existe ;
`stopPropagation` pour ne pas déplier la carte ; confirmation conservée).
- **Déplié** : libellés **au-dessus** des champs (`.ai-field-label`), **API key à 60 % /
modèle à 40 %** (grille `3fr 2fr`), placeholder par fournisseur.
- Accessibilité : en-tête en `role="button"` + `tabindex="0"` (clavier Entrée/Espace),
`aria-expanded` synchronisé, chevron animé, focus visible.
- Les boutons « Configuré / × Supprimer » redondants dans les champs sont supprimés —
le statut vit dans l'en-tête de la carte.
### 4. Barre d'actions
- Footer **sticky** en bas de section (`.ai-keys-footer`) : « Sauvegarder » (primaire
`.config-btn-save`) et « Tester » (outline `.config-btn-secondary`), plus le span de
statut du test. Toujours accessible pendant le défilement du panneau.
## Compatibilité
- Les ID `cfg-<provider>-key`, `cfg-<provider>-model`, `cfg-<provider>-badge`,
`cfg-<provider>-delete`, `cfg-ai-default-*`, `cfg-ai-status` sont conservés :
`saveAIKeys()`, `testAIKeys()`, `deleteAIKey()` et le câblage des événements de
`initConfigModal()` ne changent pas, ni la resynchronisation des pickers IA
(`refreshAIPickers()` après save/suppression, BUG-043).
- i18n : nouvelles clés `config.ai_header_desc`, `config.ai_search_placeholder`,
`config.ai_default_section`, `config.ai_providers_title`, `config.ai_providers_empty`,
`config.ai_status_configured`, `config.ai_status_not_configured`,
`config.ai_delete_key_title` (FR + EN).
## Styles
`.ai-keys-*`, `.ai-provider-*`, `.ai-cap-badge`, `.ai-field*` — uniquement des variables
CSS existantes (`--surface`, `--bg-secondary`, `--border`, `--accent`, `--success`,
`--danger`, `--accent-bg`…), focus visibles, responsive 1 colonne < 600 px.
## Tests
- `tests/frontend/config-ai-keys.test.mjs` (JSDOM, 7 tests) : rendu des 7 cartes,
badges de statut selon les clés masquées renvoyées par `GET /api/config/ai-keys`,
bascule replié/déplié (classe `open` + `aria-expanded`), filtre de recherche
(casse/accents + état vide), collecte et POST des clés saisies par `saveAIKeys()`,
suppression avec confirmation (DELETE sur l'env name), badges de capacités.
- Suites existantes (38 modules, validate-imports, ai.test.mjs, sidebar-filters…) : vertes.
- E2E Playwright (chromium-desktop) : 91 passed / 3 skipped, 100 % sans retries.
+5 -2
View File
@@ -109,11 +109,14 @@ Serveur → client :
## Sécurité ## Sécurité
- Authentification obligatoire si `OBSIGATE_AUTH_ENABLED=true` (cookie ou `?token=`). - Authentification obligatoire si `OBSIGATE_AUTH_ENABLED=true` : le jeton est lu depuis le cookie
HttpOnly `access_token` (envoyé lors du handshake same-origin). Le jeton en query string
(`?token=`) n'est **plus accepté** (BUG-036 : URLs journalisées par les proxies).
- Vérification `check_vault_access()` par connexion (un utilisateur ne peut pas rejoindre une room - Vérification `check_vault_access()` par connexion (un utilisateur ne peut pas rejoindre une room
d'une vault non autorisée). d'une vault non autorisée).
- `resolve_safe_path()` empêche toute traversée de chemin (`../../`). - `resolve_safe_path()` empêche toute traversée de chemin (`../../`).
- Bornes anti-abus : `MAX_UPDATE_BYTES` (8 Mo) par mise à jour, `MAX_TEXT_CHARS` (8 Mio) par snapshot. - Bornes anti-abus : `MAX_UPDATE_BYTES` (8 Mo) par mise à jour, `MAX_TEXT_CHARS` (8 Mio) par snapshot,
`MAX_MESSAGE_CHARS` (16 Mio) par trame brute.
- Le serveur ne décode pas le binaire Yjs : il le stocke et le relaie tel quel (pas de surface - Le serveur ne décode pas le binaire Yjs : il le stocke et le relaie tel quel (pas de surface
d'attaque supplémentaire côté parsing). d'attaque supplémentaire côté parsing).
+10 -7
View File
@@ -1,6 +1,6 @@
# #78 — Éditeur Excalidraw — Ouverture et édition de fichiers .excalidraw # #78 — Éditeur Excalidraw — Ouverture et édition de fichiers .excalidraw
> **Statut :** ✅ Terminé (2026-09-10 — éditeur iframe complet, détection, création, autosave, support `.excalidraw.md`, B5 extraction texte pour la recherche, C8 création via menu contextuel, F3 E2E `tests/e2e/excalidraw.spec.js`, doc H1-H3. F2 non retenu. BUG-002 corrigé) > **Statut :** ✅ Terminé (2026-09-10 — éditeur iframe complet, détection, création, autosave, support `.excalidraw.md`, B5 extraction texte pour la recherche, C8 création via menu contextuel, F3 E2E `tests/e2e/excalidraw.spec.js`, doc H1-H3. F2 non retenu. BUG-002 et BUG-064 corrigés. 2026-09 : A9 bouton **plein écran** ajouté, auto-save retirée au profit d'une sauvegarde explicite (BUG-065))
> **Effort :** 3-4 jours | **Impact :** 🟡 > **Effort :** 3-4 jours | **Impact :** 🟡
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog — 2.2.0](../../CHANGELOG.md) > **Références :** [Roadmap](../ROADMAP.md) · [Changelog — 2.2.0](../../CHANGELOG.md)
@@ -56,17 +56,18 @@
</script> </script>
``` ```
- [x] **A5. Rendu du composant** : Monter `<ExcalidrawLib.Excalidraw>` dans le conteneur avec les `initialData` reçues. Configurer les callbacks `onChange` pour détecter les modifications. - [x] **A5. Rendu du composant** : Monter `<ExcalidrawLib.Excalidraw>` dans le conteneur avec les `initialData` reçues. Configurer les callbacks `onChange` pour détecter les modifications.
- [x] **A6. Barre d'outils minimaliste** (dans l'iframe, superposée en haut à droite) : - [x] **A6. Barre d'outils minimaliste** (dans l'iframe ; depuis 2026-09 : **colonne d'icônes** collée au bord droit (`right: 0`), début à `45%` de la hauteur, empilement vertical) :
- Bouton « 💾 Sauvegarder » → envoie les données au parent - Bouton « Sauvegarder » (icône disquette → coche après sauvegarde) → envoie les données au parent
- Badge « Modifié » (disparaît après sauvegarde) - Boutons « Export PNG » (icône image) et « Export SVG » (icône vectorielle) — infobulles au survol
- Indicateur de thème 🌙/☀️ - Bouton plein écran (A9)
- Optionnel : bouton « Export PNG » et « Export SVG » (natif Excalidraw) - Badge « Modifié » réduit à une pastille au-dessus des boutons
- [x] **A7. Communication postMessage** : - [x] **A7. Communication postMessage** :
- Réception : écouter `message` → si `type === "init"`, charger `data.elements` + `data.appState` + `data.files` dans l'état Excalidraw. Si `type === "theme"`, basculer `theme` (dark/light). - Réception : écouter `message` → si `type === "init"`, charger `data.elements` + `data.appState` + `data.files` dans l'état Excalidraw. Si `type === "theme"`, basculer `theme` (dark/light).
- Émission : `postMessage({type: "save", data: {elements, appState, files}}, "*")` quand l'utilisateur sauvegarde. - Émission : `postMessage({type: "save", data: {elements, appState, files}}, "*")` quand l'utilisateur sauvegarde.
- Émission : `postMessage({type: "ready"}, "*")` au chargement pour signaler que l'iframe est prête. - Émission : `postMessage({type: "ready"}, "*")` au chargement pour signaler que l'iframe est prête.
- Émission : `postMessage({type: "modified", dirty: true/false}, "*")` pour l'indicateur de modification. - Émission : `postMessage({type: "modified", dirty: true/false}, "*")` pour l'indicateur de modification.
- [x] **A8. Gestion des erreurs** : Si les données sont invalides (JSON corrompu, pas un fichier Excalidraw), afficher un message d'erreur stylisé dans l'iframe. - [x] **A8. Gestion des erreurs** : Si les données sont invalides (JSON corrompu, pas un fichier Excalidraw), afficher un message d'erreur stylisé dans l'iframe.
- [x] **A9. Bouton plein écran** (ajouté 2026-09) : bouton `#btn-fullscreen` dans la barre d'outils de l'iframe → `document.documentElement.requestFullscreen()` (l'iframe parent est créée avec `allow="fullscreen" allowfullscreen`) ; l'icône bascule entrer/sortir via `fullscreenchange`. La feuille de style Excalidraw étant chargée, le canvas suit le redimensionnement. Test statique : `tests/frontend/excalidraw-viewer.test.mjs`.
## B. Backend — Détection et API (0.5 jour) ## B. Backend — Détection et API (0.5 jour)
- [x] **B1. Ajout à `SUPPORTED_EXTENSIONS`** : Ajouter `.excalidraw` dans `backend/indexer.py:56` pour que les fichiers apparaissent dans l'arborescence et soient indexés. - [x] **B1. Ajout à `SUPPORTED_EXTENSIONS`** : Ajouter `.excalidraw` dans `backend/indexer.py:56` pour que les fichiers apparaissent dans l'arborescence et soient indexés.
@@ -100,7 +101,7 @@
- Pour les fichiers `.excalidraw` : remplacer « Éditer (Forge) » par « Ouvrir dans Excalidraw.com » (lien externe, nouvel onglet) - Pour les fichiers `.excalidraw` : remplacer « Éditer (Forge) » par « Ouvrir dans Excalidraw.com » (lien externe, nouvel onglet)
- Garder « Télécharger » (.excalidraw) et « pop-out » - Garder « Télécharger » (.excalidraw) et « pop-out »
- Badge « Excalidraw » avec icône `pen-tool` - Badge « Excalidraw » avec icône `pen-tool`
- [x] **C4. Auto-save** : Débounce 2 secondes après la dernière modification dans l'iframe → sauvegarde automatique silencieuse (comme l'éditeur markdown #29). L'iframe émet `modified` → le parent démarre un timer → au bout de 2s sans nouvelle modification → `postMessage({type: "requestSave"})` → l'iframe répond avec `save` → le parent écrit via l'API. - [x] **C4. Sauvegarde explicite uniquement** (modifié 2026-09 : l'auto-save a été **retirée**, BUG-065) : l'iframe émet `modified` → le badge « Modified » s'affiche, mais **aucune sauvegarde automatique** n'est déclenchée. La sauvegarde se fait par le bouton « 💾 Save » de l'iframe ou `Ctrl+S`. Raison : chaque écriture déclenche l'événement SSE `index_updated`, qui re-rendait la vue et **rechargeait l'iframe** (refresh visible en pleine édition).
- [x] **C5. Raccourci Ctrl+S** : L'iframe intercepte Ctrl+S → envoie `save` au parent → le parent sauvegarde → confirmation visuelle (toast « Excalidraw sauvegardé »). - [x] **C5. Raccourci Ctrl+S** : L'iframe intercepte Ctrl+S → envoie `save` au parent → le parent sauvegarde → confirmation visuelle (toast « Excalidraw sauvegardé »).
- [x] **C6. Compatibilité Split View (#75)** : L'iframe s'affiche dans le content-area du panneau actif. Le `PaneTabManager` gère le cache : quand on switch d'onglet, l'état de l'iframe est préservé (elle reste dans le DOM, juste masquée). Plusieurs iframes Excalidraw peuvent coexister dans différents panneaux. - [x] **C6. Compatibilité Split View (#75)** : L'iframe s'affiche dans le content-area du panneau actif. Le `PaneTabManager` gère le cache : quand on switch d'onglet, l'état de l'iframe est préservé (elle reste dans le DOM, juste masquée). Plusieurs iframes Excalidraw peuvent coexister dans différents panneaux.
- [x] **C7. Création via la modale « Nouveau fichier »** : Dans `frontend/js/ui.js`, fonction `showCreateFileModal()` : - [x] **C7. Création via la modale « Nouveau fichier »** : Dans `frontend/js/ui.js`, fonction `showCreateFileModal()` :
@@ -141,6 +142,8 @@
- **Taille du bundle** : React + ReactDOM + Excalidraw ≈ 2.5 Mo minifié. Chargé depuis `esm.sh` (CDN global, cache HTTP). L'impact n'est perceptible qu'à la première ouverture d'un `.excalidraw`. Solution : précharger l'iframe en arrière-plan (`<link rel="prefetch">`) après le chargement de l'app. - **Taille du bundle** : React + ReactDOM + Excalidraw ≈ 2.5 Mo minifié. Chargé depuis `esm.sh` (CDN global, cache HTTP). L'impact n'est perceptible qu'à la première ouverture d'un `.excalidraw`. Solution : précharger l'iframe en arrière-plan (`<link rel="prefetch">`) après le chargement de l'app.
- **Performance React dans iframe** : React dans une iframe fonctionne parfaitement — c'est un contexte JavaScript indépendant. Testé sur Chrome, Firefox, Safari, Edge. - **Performance React dans iframe** : React dans une iframe fonctionne parfaitement — c'est un contexte JavaScript indépendant. Testé sur Chrome, Firefox, Safari, Edge.
- **CORS et esm.sh** : Les modules ESM depuis `esm.sh` sont servis avec les headers CORS appropriés. L'iframe est same-origin (`/frontend/excalidraw-editor.html`) donc pas de problème. - **CORS et esm.sh** : Les modules ESM depuis `esm.sh` sont servis avec les headers CORS appropriés. L'iframe est same-origin (`/frontend/excalidraw-editor.html`) donc pas de problème.
- **Compatibilité des exports de l'app Excalidraw** (BUG-064) : `appState.collaborators` est une `Map` qu'Excalidraw sérialise en objet JSON (`{}`) ; elle doit être reconvertie en `Map` (`sanitizeAppState()` dans `frontend/excalidraw-editor.html`) avant `initialData`, sinon Excalidraw 0.18 plante (`collaborators.forEach is not a function`). La géométrie de viewport (`width`, `height`, `offsetLeft`, `offsetTop`) est également écartée : ce sont des valeurs mesurées côté fenêtre source, qu'Excalidraw recalcule. Couvert par un test E2E (`diagram-app-export.excalidraw`).
- **Feuille de style Excalidraw obligatoire** (BUG-064) : `@excalidraw/excalidraw` n'injecte pas son CSS automatiquement — il faut le charger explicitement (`<link>` vers `…/@excalidraw/[email protected]/dist/prod/index.css`). Sans lui, l'éditeur est non stylisé **et** `.excalidraw` n'a pas de hauteur fixe, ce qui déclenche une boucle de redimensionnement jusqu'au plafond `2^25` (33 554 432 px) : le canvas devient indessinable et la scène reste blanche. Le CDN `esm.sh` doit donc figurer dans `style-src` de la CSP (`backend/main.py`). Garde-fous : `tests/frontend/excalidraw-viewer.test.mjs` et `TestCspExcalidrawStylesheet`.
- **Mises à jour d'Excalidraw** : La version est épinglée (`@0.18.0`). Pour mettre à jour, changer le numéro dans le HTML + tester. Le format de données `.excalidraw` est stable (v2 depuis 2021). - **Mises à jour d'Excalidraw** : La version est épinglée (`@0.18.0`). Pour mettre à jour, changer le numéro dans le HTML + tester. Le format de données `.excalidraw` est stable (v2 depuis 2021).
- **Sécurité postMessage** : Vérifier `event.origin` dans les deux sens. L'iframe n'accepte que les messages de `window.parent`. Le parent n'accepte que les messages de l'iframe connue. Pas de `"*"` en production. - **Sécurité postMessage** : Vérifier `event.origin` dans les deux sens. L'iframe n'accepte que les messages de `window.parent`. Le parent n'accepte que les messages de l'iframe connue. Pas de `"*"` en production.
- **Tauri Desktop (#77)** : L'iframe se charge depuis le filesystem local (`tauri://localhost/frontend/excalidraw-editor.html`). Les imports ESM depuis `esm.sh` fonctionnent si le réseau est disponible. Pour le mode offline, bundler Excalidraw dans l'app desktop (à traiter dans #77, pas ici). - **Tauri Desktop (#77)** : L'iframe se charge depuis le filesystem local (`tauri://localhost/frontend/excalidraw-editor.html`). Les imports ESM depuis `esm.sh` fonctionnent si le réseau est disponible. Pour le mode offline, bundler Excalidraw dans l'app desktop (à traiter dans #77, pas ici).
+1
View File
@@ -8,6 +8,7 @@
- **Implémentation réelle (vérifiée 2026-09-07) :** - **Implémentation réelle (vérifiée 2026-09-07) :**
- **Bugs corrigés (2026-09) :** `api_pdf_stream` crashait en 500 (`NameError: current_user` jamais injecté) ; l'indexation incrémentale du watcher faisait `read_text()` sur les PDFs (garbage) ; Range/206 et `pdf/info` absents malgré le texte ci-dessous. - **Bugs corrigés (2026-09) :** `api_pdf_stream` crashait en 500 (`NameError: current_user` jamais injecté) ; l'indexation incrémentale du watcher faisait `read_text()` sur les PDFs (garbage) ; Range/206 et `pdf/info` absents malgré le texte ci-dessous.
- **BUG-060 (2026-09-17) :** l'affichage inline ne fonctionnait plus — la CSP durcie en BUG-034 (`object-src 'none'`) bloquait l'`<embed>` du viewer (barre d'outils rendue, corps vide). Le rendu passe par une `<iframe>` (autorisée par `frame-src 'self'`), conforme à E1. Tests : `tests/frontend/pdf-viewer.test.mjs` + `tests/e2e/pdf-viewer.spec.js`.
- `GET /api/file/{vault}/pdf/info` — métadonnées seules sans transférer le document (C3) - `GET /api/file/{vault}/pdf/info` — métadonnées seules sans transférer le document (C3)
- Stream avec `Accept-Ranges` + 206 Partial Content (single range, suffix-range, 416) (C2) - Stream avec `Accept-Ranges` + 206 Partial Content (single range, suffix-range, 416) (C2)
- `OBSIGATE_PDF_MAX_SIZE_MB` (50) + `OBSIGATE_PDF_EXTRACT_TIMEOUT` (30s via thread-pool) (B4/G3) - `OBSIGATE_PDF_MAX_SIZE_MB` (50) + `OBSIGATE_PDF_EXTRACT_TIMEOUT` (30s via thread-pool) (B4/G3)
+112 -18
View File
@@ -7,34 +7,48 @@
<meta http-equiv="Expires" content="0"> <meta http-equiv="Expires" content="0">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Excalidraw Editor</title> <title>Excalidraw Editor</title>
<!-- Excalidraw's stylesheet MUST be loaded: without it the editor is
unstyled AND `.excalidraw` has no fixed height, so Excalidraw's
ResizeObserver feedback loop grows the canvas to the 2^25 hard cap and
the scene renders blank. Loaded from esm.sh (same origin as the JS
modules, already allowed by `font-src` for the relative font URLs). -->
<link rel="stylesheet" href="https://esm.sh/@excalidraw/[email protected]/dist/prod/index.css">
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #root { width: 100%; height: 100%; overflow: hidden; } html, body, #root { width: 100%; height: 100%; overflow: hidden; }
body { background: #ffffff; } body { background: #ffffff; }
/* Toolbar overlay in top-right corner */ /* Icon-only toolbar, vertical, flush against the right edge:
right edge at 100% of the viewport width, group starts at 45% of the
viewport height from the top. */
#excalidraw-toolbar { #excalidraw-toolbar {
position: fixed; position: fixed;
top: 8px; right: 0;
right: 12px; top: 45%;
z-index: 1000; z-index: 1000;
display: flex; display: flex;
flex-direction: column;
gap: 6px; gap: 6px;
align-items: center; align-items: center;
} }
#excalidraw-toolbar button { #excalidraw-toolbar button {
padding: 5px 10px; width: 34px;
height: 34px;
padding: 0;
border: 1px solid #d0d0d0; border: 1px solid #d0d0d0;
border-radius: 6px; border-radius: 6px;
background: #ffffff; background: #ffffff;
cursor: pointer; cursor: pointer;
font-size: 12px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; justify-content: center;
transition: background 0.15s; transition: background 0.15s;
} }
#excalidraw-toolbar button svg {
width: 18px;
height: 18px;
display: block;
}
#excalidraw-toolbar button:hover { background: #f0f0f0; } #excalidraw-toolbar button:hover { background: #f0f0f0; }
#excalidraw-toolbar button.primary { #excalidraw-toolbar button.primary {
background: #6965db; background: #6965db;
@@ -43,11 +57,12 @@
} }
#excalidraw-toolbar button.primary:hover { background: #5b57c4; } #excalidraw-toolbar button.primary:hover { background: #5b57c4; }
/* Dirty indicator */ /* Dirty indicator (small dot above the buttons) */
#dirty-badge { #dirty-badge {
font-size: 11px; font-size: 12px;
line-height: 1;
color: #e07b39; color: #e07b39;
font-weight: 500; font-weight: 700;
display: none; display: none;
} }
#dirty-badge.visible { display: inline; } #dirty-badge.visible { display: inline; }
@@ -86,10 +101,11 @@
<div id="loading">Loading Excalidraw…</div> <div id="loading">Loading Excalidraw…</div>
<div id="root"></div> <div id="root"></div>
<div id="excalidraw-toolbar"> <div id="excalidraw-toolbar">
<span id="dirty-badge">● Modified</span> <span id="dirty-badge" title="Modified">●</span>
<button id="btn-save" class="primary" title="Save (Ctrl+S)">💾 Save</button> <button id="btn-save" class="primary" title="Save (Ctrl+S)" aria-label="Save"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg></button>
<button id="btn-export-png" title="Export PNG">🖼 PNG</button> <button id="btn-export-png" title="Export PNG" aria-label="Export PNG"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg></button>
<button id="btn-export-svg" title="Export SVG">📐 SVG</button> <button id="btn-export-svg" title="Export SVG" aria-label="Export SVG"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 7a24 24 0 0 1 0 10M4.5 7a24 24 0 0 0 0 10M7 19.5a24 24 0 0 1 10 0M7 4.5a24 24 0 0 0 10 0"/><rect x="2" y="2" width="5" height="5" rx="1"/><rect x="17" y="2" width="5" height="5" rx="1"/><rect x="17" y="17" width="5" height="5" rx="1"/><rect x="2" y="17" width="5" height="5" rx="1"/></svg></button>
<button id="btn-fullscreen" title="Fullscreen" aria-label="Fullscreen"></button>
</div> </div>
<!-- Excalidraw is loaded from esm.sh WITHOUT the `?alias=react:…` query. <!-- Excalidraw is loaded from esm.sh WITHOUT the `?alias=react:…` query.
@@ -129,6 +145,11 @@
const btnSave = document.getElementById("btn-save"); const btnSave = document.getElementById("btn-save");
const btnExportPng = document.getElementById("btn-export-png"); const btnExportPng = document.getElementById("btn-export-png");
const btnExportSvg = document.getElementById("btn-export-svg"); const btnExportSvg = document.getElementById("btn-export-svg");
const btnFullscreen = document.getElementById("btn-fullscreen");
// Icon swapped in on a successful save (floppy → checkmark → floppy).
const SAVE_ICON = btnSave.innerHTML;
const CHECK_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
// --- postMessage helpers --- // --- postMessage helpers ---
function sendToParent(msg) { function sendToParent(msg) {
@@ -177,8 +198,8 @@
} }
sendToParent({ type: "save", data }); sendToParent({ type: "save", data });
markClean(); markClean();
btnSave.textContent = "💾 Saved!"; btnSave.innerHTML = CHECK_ICON;
setTimeout(() => { btnSave.textContent = "💾 Save"; }, 1200); setTimeout(() => { btnSave.innerHTML = SAVE_ICON; }, 1200);
} }
function setTheme(theme) { function setTheme(theme) {
@@ -189,9 +210,45 @@
} }
} }
// Excalidraw serializes its Map-typed appState fields (notably
// `collaborators`) to plain JSON objects on save — that is what files
// exported by the Excalidraw app / Obsidian plugin contain. Feeding such a
// plain object back through `initialData.appState` makes Excalidraw 0.18
// call `.forEach()` on it and crash with
// "e.appState.collaborators.forEach is not a function", leaving the canvas
// blank. Restore the expected Map shape (and drop anything unusable).
function sanitizeAppState(appState) {
if (!appState || typeof appState !== "object") return {};
// Viewport geometry is computed by Excalidraw from the container size.
// Files exported by the Excalidraw app carry whatever the *source* window
// measured (Obsidian pane, browser tab, …) and can contain absurd values
// (e.g. `height: 22369622`). Importing them makes Excalidraw size its
// canvas beyond the browser limit, so the scene renders off-screen /
// blank. Drop them and let Excalidraw recompute.
for (const key of ["width", "height", "offsetLeft", "offsetTop"]) {
delete appState[key];
}
if (appState.collaborators && !(appState.collaborators instanceof Map)) {
try {
appState.collaborators = new Map(Object.entries(appState.collaborators));
} catch (e) {
appState.collaborators = new Map();
}
}
return appState;
}
// Signature of the drawn content only. Excalidraw's onChange also fires for
// appState-only changes (resize, fullscreen, zoom, scroll); those must not
// flag the diagram as modified.
function sceneSignature(elements) {
return (elements || []).map((el) => `${el.id}:${el.versionNonce}`).join("|");
}
// --- Excalidraw component --- // --- Excalidraw component ---
function App({ initialData, theme }) { function App({ initialData, theme }) {
const [appState, setAppState] = React.useState(null); const [appState, setAppState] = React.useState(null);
const lastSigRef = React.useRef(null);
// Excalidraw 0.18 exposes its imperative API through the `excalidrawAPI` // Excalidraw 0.18 exposes its imperative API through the `excalidrawAPI`
// prop, called with the API object once mounted (NOT the legacy // prop, called with the API object once mounted (NOT the legacy
@@ -204,13 +261,20 @@
if (theme === "dark") { if (theme === "dark") {
api.updateScene({ appState: { theme: "dark" } }); api.updateScene({ appState: { theme: "dark" } });
} }
// Stop ignoring changes once the initial mount settles. // Stop ignoring changes once the initial mount settles, and snapshot
setTimeout(() => { ignoreChanges = false; }, 800); // the loaded scene so a later appState-only change is not "dirty".
setTimeout(() => {
ignoreChanges = false;
lastSigRef.current = sceneSignature(api.getSceneElements());
}, 800);
} }
}, [theme]); }, [theme]);
const onChange = React.useCallback((elements, state, files) => { const onChange = React.useCallback((elements, state, files) => {
if (ignoreChanges) return; if (ignoreChanges) return;
const sig = sceneSignature(elements);
if (sig === lastSigRef.current) return;
lastSigRef.current = sig;
markDirty(); markDirty();
}, []); }, []);
@@ -257,6 +321,7 @@
appState = msg.data.appState || {}; appState = msg.data.appState || {};
files = msg.data.files || {}; files = msg.data.files || {};
} }
appState = sanitizeAppState(appState);
const initialData = { elements, appState, files }; const initialData = { elements, appState, files };
currentTheme = msg.theme || "light"; currentTheme = msg.theme || "light";
setTheme(currentTheme); setTheme(currentTheme);
@@ -340,6 +405,35 @@
console.error("SVG export failed:", err); console.error("SVG export failed:", err);
} }
}); });
// --- Fullscreen ---
// The parent iframe is created with `allow="fullscreen" allowfullscreen`, so
// requesting fullscreen on this document makes the whole editor fill the
// screen. Excalidraw's ResizeObserver then grows the canvas to match.
const FS_ENTER = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>';
const FS_EXIT = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/></svg>';
function toggleFullscreen() {
if (!document.fullscreenElement) {
const req = document.documentElement.requestFullscreen
&& document.documentElement.requestFullscreen();
if (req && typeof req.catch === "function") {
req.catch((err) => console.warn("Fullscreen request failed:", err));
}
} else if (document.exitFullscreen) {
document.exitFullscreen();
}
}
document.addEventListener("fullscreenchange", () => {
const on = !!document.fullscreenElement;
btnFullscreen.innerHTML = on ? FS_EXIT : FS_ENTER;
btnFullscreen.title = on ? "Exit fullscreen" : "Fullscreen";
btnFullscreen.classList.toggle("active", on);
});
btnFullscreen.innerHTML = FS_ENTER;
btnFullscreen.addEventListener("click", toggleFullscreen);
</script> </script>
</body> </body>
</html> </html>
+80 -199
View File
@@ -2038,210 +2038,95 @@
</button> </button>
</section> </section>
<!-- Cles API AI --> <!-- Cles API AI (#104 — accordéon fournisseurs) -->
<section <section
class="config-section help-section" class="config-section help-section"
id="cfg-ai" id="cfg-ai"
> >
<h2>🤖 Cles API Intelligence Artificielle</h2> <div class="ai-keys-header">
<p class="config-description"> <div class="ai-keys-header-text">
Configurez vos cles API. Cliquez sur <h2 data-i18n="config.section_cles-api-intelligence-artificielle">🤖 Cles API Intelligence Artificielle</h2>
<strong data-i18n="config.test">Tester</strong> pour charger les <p class="config-description" data-i18n="config.ai_header_desc">
modeles. Configurez vos cles API fournisseur par fournisseur. Dépliez une carte pour saisir une
</p> clé, puis cliquez sur <strong data-i18n="config.test">Tester</strong> pour charger les
<div class="config-row"> modeles.
<label </p>
class="config-label" </div>
for="cfg-ai-default-provider" <div class="ai-keys-search">
data-i18n="config.ai_default_provider" <i data-lucide="search" class="icon"></i>
>Fournisseur par defaut</label <input
> type="search"
<select id="cfg-ai-search"
id="cfg-ai-default-provider" data-i18n-placeholder="config.ai_search_placeholder"
class="config-select" placeholder="Rechercher un fournisseur…"
style="width: 200px" autocomplete="off"
> />
<option value="">--</option> </div>
</select>
</div> </div>
<div class="config-row">
<label <div class="ai-default-card">
class="config-label" <div class="ai-card-title" data-i18n="config.ai_default_section">Configuration par defaut</div>
for="cfg-ai-default-model" <div class="ai-default-grid">
data-i18n="config.ai_default_model" <div class="ai-field">
>Modele par defaut</label <label
> class="ai-field-label"
<select for="cfg-ai-default-provider"
id="cfg-ai-default-model" data-i18n="config.ai_default_provider"
class="config-select" >Fournisseur par defaut</label
style="width: 220px" >
> <select
<option value="">--</option> id="cfg-ai-default-provider"
</select> class="config-select"
>
<option value="">--</option>
</select>
</div>
<div class="ai-field">
<label
class="ai-field-label"
for="cfg-ai-default-model"
data-i18n="config.ai_default_model"
>Modele par defaut</label
>
<select
id="cfg-ai-default-model"
class="config-select"
>
<option value="">--</option>
</select>
</div>
</div>
<div class="ai-field">
<span
class="ai-field-label"
data-i18n="config.ai_capabilities"
>Capacites du modele</span
>
<div
id="cfg-ai-default-model-caps"
class="ai-caps-badges"
></div>
</div>
</div> </div>
<div class="config-row" style="align-items: flex-start">
<label <div class="ai-providers-header">
class="config-label" <h3
data-i18n="config.ai_capabilities" class="ai-card-title"
>Capacites du modele</label data-i18n="config.ai_providers_title"
> >
<div Fournisseurs d'API
id="cfg-ai-default-model-caps" </h3>
class="ai-picker-caps"
style="flex: 1"
></div>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-deepseek-key"
>DeepSeek API Key</label
>
<input
type="password"
id="cfg-deepseek-key"
class="config-input"
placeholder="sk-..."
autocomplete="off"
/>
<select
id="cfg-deepseek-model"
class="config-select"
style="width: 200px"
>
<option value="">-- Modele --</option>
</select>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-openrouter-key"
>OpenRouter API Key</label
>
<input
type="password"
id="cfg-openrouter-key"
class="config-input"
placeholder="sk-or-..."
autocomplete="off"
/>
<select
id="cfg-openrouter-model"
class="config-select"
style="width: 200px"
>
<option value="">-- Modele --</option>
</select>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-gemini-key"
>Gemini API Key</label
>
<input
type="password"
id="cfg-gemini-key"
class="config-input"
placeholder="AIza..."
autocomplete="off"
/>
<select
id="cfg-gemini-model"
class="config-select"
style="width: 200px"
>
<option value="">-- Modele --</option>
</select>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-nvidia-key"
>NVIDIA API Key</label
>
<input
type="password"
id="cfg-nvidia-key"
class="config-input"
placeholder="nvapi-..."
autocomplete="off"
/>
<select
id="cfg-nvidia-model"
class="config-select"
style="width: 200px"
>
<option value="">-- Modele --</option>
</select>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-qwencloud-key"
>QwenCloud API Key</label
>
<input
type="password"
id="cfg-qwencloud-key"
class="config-input"
placeholder="sk-..."
autocomplete="off"
/>
<select
id="cfg-qwencloud-model"
class="config-select"
style="width: 200px"
>
<option value="">-- Modele --</option>
</select>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-xiaomi-key"
>Xiaomi API Key</label
>
<input
type="password"
id="cfg-xiaomi-key"
class="config-input"
placeholder="xm-..."
autocomplete="off"
/>
<select
id="cfg-xiaomi-model"
class="config-select"
style="width: 200px"
>
<option value="">-- Modele --</option>
</select>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-mistral-key"
>Mistral API Key</label
>
<input
type="password"
id="cfg-mistral-key"
class="config-input"
placeholder="sk-..."
autocomplete="off"
/>
<select
id="cfg-mistral-model"
class="config-select"
style="width: 200px"
>
<option value="">-- Modele --</option>
</select>
</div> </div>
<div id="cfg-ai-providers" class="ai-providers-list"></div>
<div <div
class="config-actions-row" id="cfg-ai-providers-empty"
style="margin-top: 16px" class="ai-providers-empty hidden"
data-i18n="config.ai_providers_empty"
> >
Aucun fournisseur ne correspond
</div>
<div class="ai-keys-footer">
<button class="config-btn-save" <button class="config-btn-save"
id="cfg-save-ai-keys" data-i18n="help.shortcut_save"> id="cfg-save-ai-keys" data-i18n="help.shortcut_save">
Sauvegarder Sauvegarder
@@ -2252,11 +2137,7 @@
</button> </button>
<span <span
id="cfg-ai-status" id="cfg-ai-status"
style=" class="ai-keys-status"
font-size: 12px;
color: var(--text-muted);
margin-left: 12px;
"
></span> ></span>
</div> </div>
</section> </section>
+19
View File
@@ -82,6 +82,24 @@ function renderCapabilityList(caps) {
return box; return box;
} }
/**
* Render only the *enabled* capabilities as colored tags (#104). Used by the
* config panel where a compact read-only summary reads better than checkboxes.
*/
function renderCapabilityBadges(caps) {
const box = document.createElement('div');
box.className = 'ai-caps-badges';
if (!caps) return box;
AI_CAPABILITY_KEYS.forEach((key) => {
if (!caps[key]) return;
const item = document.createElement('span');
item.className = 'ai-cap-badge';
item.textContent = t(`ai.cap_${key}`);
box.appendChild(item);
});
return box;
}
/** Accent-insensitive, case-insensitive normalization for model search. */ /** Accent-insensitive, case-insensitive normalization for model search. */
function _normalizeText(value) { function _normalizeText(value) {
return String(value || '') return String(value || '')
@@ -1019,6 +1037,7 @@ export {
AI_CAPABILITY_KEYS, AI_CAPABILITY_KEYS,
getModelCapabilities, getModelCapabilities,
renderCapabilityList, renderCapabilityList,
renderCapabilityBadges,
_buildPickerUI as buildAIPickerUI, _buildPickerUI as buildAIPickerUI,
refreshAIPickers, refreshAIPickers,
PICKER_SLOT_CLASS, PICKER_SLOT_CLASS,
+129 -39
View File
@@ -6,7 +6,7 @@ import { syncVaultSelectors, setSelectedVaultContext, refreshSidebarForContext,
import { escapeHtml, safeCreateIcons } from './utils.js'; import { escapeHtml, safeCreateIcons } from './utils.js';
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js'; import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
import { t, setLocale, getLocale } from './i18n.js'; import { t, setLocale, getLocale } from './i18n.js';
import { getModelCapabilities, renderCapabilityList, refreshAIPickers } from './ai.js'; import { getModelCapabilities, renderCapabilityBadges, refreshAIPickers } from './ai.js';
let _recentTimestampTimer = null; let _recentTimestampTimer = null;
let _recentFilesCache = []; let _recentFilesCache = [];
@@ -766,6 +766,11 @@ function initConfigModal() {
if (saveAIKeysBtn) saveAIKeysBtn.addEventListener("click", saveAIKeys); if (saveAIKeysBtn) saveAIKeysBtn.addEventListener("click", saveAIKeys);
const testAIKeysBtn = document.getElementById("cfg-test-ai-keys"); const testAIKeysBtn = document.getElementById("cfg-test-ai-keys");
if (testAIKeysBtn) testAIKeysBtn.addEventListener("click", testAIKeys); if (testAIKeysBtn) testAIKeysBtn.addEventListener("click", testAIKeys);
// Provider search filter (#104)
const aiProviderSearch = document.getElementById("cfg-ai-search");
if (aiProviderSearch) {
aiProviderSearch.addEventListener("input", () => filterAIProviders(aiProviderSearch.value));
}
// Tool & connected-source keys (#103) // Tool & connected-source keys (#103)
const saveToolKeysBtn = document.getElementById("cfg-save-tool-keys"); const saveToolKeysBtn = document.getElementById("cfg-save-tool-keys");
if (saveToolKeysBtn) saveToolKeysBtn.addEventListener("click", saveToolKeys); if (saveToolKeysBtn) saveToolKeysBtn.addEventListener("click", saveToolKeys);
@@ -1510,7 +1515,7 @@ function updateRegexPreview() {
} }
// ── AI Keys management ── // ── AI Keys management (#104 — accordion redesign) ──
const AI_KEY_MAP = { const AI_KEY_MAP = {
"cfg-deepseek-key": "DEEPSEEK_API_KEY", "cfg-deepseek-key": "DEEPSEEK_API_KEY",
"cfg-openrouter-key": "OPENROUTER_API_KEY", "cfg-openrouter-key": "OPENROUTER_API_KEY",
@@ -1522,57 +1527,140 @@ const AI_KEY_MAP = {
}; };
const AI_PROVIDER_NAMES = ["deepseek","openrouter","gemini","nvidia","qwencloud","xiaomi","mistral"]; const AI_PROVIDER_NAMES = ["deepseek","openrouter","gemini","nvidia","qwencloud","xiaomi","mistral"];
function _ensureAIKeyUI() { // Display metadata for the provider accordion cards (#104).
for (const [inputId] of Object.entries(AI_KEY_MAP)) { const AI_PROVIDER_META = {
const input = document.getElementById(inputId); deepseek: { name: "DeepSeek", placeholder: "sk-..." },
if (!input) continue; openrouter: { name: "OpenRouter", placeholder: "sk-or-..." },
const row = input.closest(".config-row"); gemini: { name: "Gemini", placeholder: "AIza..." },
if (!row || row.dataset.enhanced) continue; nvidia: { name: "NVIDIA", placeholder: "nvapi-..." },
row.dataset.enhanced = "1"; qwencloud: { name: "QwenCloud", placeholder: "sk-..." },
row.style.cssText += "display:flex;align-items:center;gap:8px;flex-wrap:wrap;"; xiaomi: { name: "Xiaomi", placeholder: "xm-..." },
const badge = document.createElement("span"); mistral: { name: "Mistral", placeholder: "sk-..." },
badge.id = inputId.replace("-key", "-badge"); };
badge.style.cssText = "font-size:11px;padding:2px 8px;border-radius:10px;white-space:nowrap;";
row.appendChild(badge); function _renderAIProviderCards() {
const delBtn = document.createElement("button"); const host = document.getElementById("cfg-ai-providers");
delBtn.type = "button"; if (!host) return;
delBtn.id = inputId.replace("-key", "-delete"); host.innerHTML = "";
delBtn.className = "config-btn-secondary"; for (const p of AI_PROVIDER_NAMES) {
delBtn.style.cssText = "font-size:11px;padding:4px 10px;color:var(--danger,#e74c3c);border-color:var(--danger,#e74c3c);cursor:pointer;display:none;"; const meta = AI_PROVIDER_META[p] || { name: p, placeholder: "sk-..." };
delBtn.textContent = "\u00d7 Supprimer"; const card = el("div", { class: "ai-provider-card", "data-provider": p });
delBtn.addEventListener("click", () => deleteAIKey(inputId));
row.appendChild(delBtn); // Header row (div + role=button so the per-provider delete button can
// live inside without nesting two interactive elements).
const head = el("div", {
class: "ai-provider-head",
role: "button",
tabindex: "0",
"aria-expanded": "false",
});
head.appendChild(el("span", { class: "ai-provider-logo", "aria-hidden": "true" }, [document.createTextNode(meta.name.charAt(0))]));
head.appendChild(el("span", { class: "ai-provider-name" }, [document.createTextNode(meta.name)]));
head.appendChild(el("span", { class: "ai-provider-badge", id: `cfg-${p}-badge` }));
const delBtn = el("button", {
type: "button",
class: "ai-provider-delete",
id: `cfg-${p}-delete`,
title: t("config.ai_delete_key_title"),
});
delBtn.style.display = "none";
delBtn.appendChild(icon("trash-2", 14));
delBtn.addEventListener("click", (e) => {
e.stopPropagation();
deleteAIKey(`cfg-${p}-key`);
});
head.appendChild(delBtn);
const chevron = icon("chevron-down", 16);
chevron.classList.add("ai-provider-chevron");
head.appendChild(chevron);
const toggle = () => _toggleAICard(card);
head.addEventListener("click", toggle);
head.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); }
});
// Collapsible body: API key (60%) + model (40%), labels above inputs.
const body = el("div", { class: "ai-provider-body hidden" });
const fields = el("div", { class: "ai-provider-fields" });
const keyField = el("div", { class: "ai-field ai-field-key" });
keyField.appendChild(el("label", { class: "ai-field-label", for: `cfg-${p}-key` }, [document.createTextNode(`${meta.name} API Key`)]));
const keyInput = document.createElement("input");
keyInput.type = "password";
keyInput.id = `cfg-${p}-key`;
keyInput.className = "config-input";
keyInput.placeholder = meta.placeholder;
keyInput.autocomplete = "off";
keyField.appendChild(keyInput);
const modelField = el("div", { class: "ai-field ai-field-model" });
modelField.appendChild(el("label", { class: "ai-field-label", for: `cfg-${p}-model` }, [document.createTextNode(t("config.ai_model"))]));
const modelSel = document.createElement("select");
modelSel.id = `cfg-${p}-model`;
modelSel.className = "config-select";
modelSel.innerHTML = '<option value="">-- Modele --</option>';
modelField.appendChild(modelSel);
fields.appendChild(keyField);
fields.appendChild(modelField);
body.appendChild(fields);
card.appendChild(head);
card.appendChild(body);
host.appendChild(card);
} }
safeCreateIcons();
}
function _toggleAICard(card) {
const open = card.classList.toggle("open");
const body = card.querySelector(".ai-provider-body");
if (body) body.classList.toggle("hidden", !open);
const head = card.querySelector(".ai-provider-head");
if (head) head.setAttribute("aria-expanded", open ? "true" : "false");
}
/** Filter the provider accordion cards by the section search input (#104). */
export function filterAIProviders(query) {
const host = document.getElementById("cfg-ai-providers");
const emptyMsg = document.getElementById("cfg-ai-providers-empty");
if (!host) return;
const q = _sidebarNorm(query);
let visible = 0;
host.querySelectorAll(".ai-provider-card").forEach((card) => {
const p = card.dataset.provider;
const meta = AI_PROVIDER_META[p] || { name: p };
const match = !q || _sidebarNorm(meta.name).includes(q) || p.includes(q);
card.style.display = match ? "" : "none";
if (match) visible++;
});
if (emptyMsg) emptyMsg.classList.toggle("hidden", visible > 0);
} }
function _setAIKeyBadge(inputId, hasKey) { function _setAIKeyBadge(inputId, hasKey) {
const badge = document.getElementById(inputId.replace("-key", "-badge")); const provider = inputId.replace("-key", "");
const delBtn = document.getElementById(inputId.replace("-key", "-delete")); const badge = document.getElementById(`${provider}-badge`);
const delBtn = document.getElementById(`${provider}-delete`);
if (badge) { if (badge) {
if (hasKey) { badge.textContent = hasKey
badge.textContent = "\u2713 Configur\u00e9"; ? "\u2713 " + t("config.ai_status_configured")
badge.style.background = "var(--success-bg, #27ae6022)"; : t("config.ai_status_not_configured");
badge.style.color = "var(--success, #27ae60)"; badge.classList.toggle("configured", !!hasKey);
badge.style.border = "1px solid var(--success, #27ae60)";
} else {
badge.textContent = "Non configur\u00e9";
badge.style.background = "var(--muted-bg, #ffffff10)";
badge.style.color = "var(--text-muted, #888)";
badge.style.border = "1px solid var(--border, #444)";
}
} }
if (delBtn) delBtn.style.display = hasKey ? "inline-block" : "none"; if (delBtn) delBtn.style.display = hasKey ? "inline-flex" : "none";
} }
async function loadAIKeys() { async function loadAIKeys() {
_ensureAIKeyUI(); _renderAIProviderCards();
try { try {
const data = await api("/api/config/ai-keys"); const data = await api("/api/config/ai-keys");
for (const [inputId, envName] of Object.entries(AI_KEY_MAP)) { for (const [inputId, envName] of Object.entries(AI_KEY_MAP)) {
const input = document.getElementById(inputId); const input = document.getElementById(inputId);
const val = data[envName] || ""; const val = data[envName] || "";
if (input) { if (input) {
input.placeholder = val || (inputId.includes("gemini") ? "AIza..." : inputId.includes("openrouter") ? "sk-or-..." : "sk-..."); const meta = AI_PROVIDER_META[inputId.replace("-key", "")] || {};
input.placeholder = val || meta.placeholder || "sk-...";
} }
_setAIKeyBadge(inputId, !!val); _setAIKeyBadge(inputId, !!val);
} }
@@ -1626,7 +1714,7 @@ async function _renderConfigModelCaps(provider, model) {
if (!provider || !model) return; if (!provider || !model) return;
const caps = await getModelCapabilities(provider, model); const caps = await getModelCapabilities(provider, model);
if (!caps) return; if (!caps) return;
host.appendChild(renderCapabilityList(caps)); host.appendChild(renderCapabilityBadges(caps));
} }
async function saveAIKeys() { async function saveAIKeys() {
@@ -1830,6 +1918,8 @@ export {
initProfile, initProfile,
switchSidebarTab, switchSidebarTab,
populateVersions, populateVersions,
loadAIKeys,
saveAIKeys,
}; };
// Populate every version display (header badge, About modal, help guide footer) // Populate every version display (header badge, About modal, help guide footer)
+17 -14
View File
@@ -5,8 +5,11 @@
* excalidraw-editor.html. Communication between parent and iframe * excalidraw-editor.html. Communication between parent and iframe
* via postMessage: * via postMessage:
* *
* Parent → Iframe: init {data, theme} | theme {theme} | requestSave * Parent → Iframe: init {data, theme} | theme {theme}
* Iframe → Parent: ready | save {data} | modified {dirty} * Iframe → Parent: ready | save {data} | modified {dirty}
*
* Saves are explicit only (in-editor Save button / Ctrl+S): no autosave, so
* writing the file never triggers an `index_updated` reload of the viewer.
*/ */
import { api } from './auth.js'; import { api } from './auth.js';
@@ -35,7 +38,15 @@ export function renderExcalidraw(container, data, vaultName, filePath, opts = {}
iframe.src = '/static/excalidraw-editor.html?v=' + Date.now(); iframe.src = '/static/excalidraw-editor.html?v=' + Date.now();
iframe.sandbox.add('allow-scripts'); iframe.sandbox.add('allow-scripts');
iframe.sandbox.add('allow-same-origin'); iframe.sandbox.add('allow-same-origin');
// Let the editor's own Fullscreen button work (native Fullscreen API inside
// the sandboxed iframe).
iframe.setAttribute('allow', 'fullscreen');
iframe.setAttribute('allowfullscreen', '');
iframe.style.cssText = 'width:100%;height:100%;border:none;'; iframe.style.cssText = 'width:100%;height:100%;border:none;';
// Identify the owning document so sync.js can avoid re-rendering (and thus
// reloading) this iframe after its own save triggers an `index_updated`.
iframe.dataset.excalidrawVault = vaultName;
iframe.dataset.excalidrawPath = filePath;
// Clean container and insert iframe // Clean container and insert iframe
container.innerHTML = ''; container.innerHTML = '';
@@ -49,7 +60,6 @@ export function renderExcalidraw(container, data, vaultName, filePath, opts = {}
path: filePath, path: filePath,
isDirty: false, isDirty: false,
ready: false, ready: false,
saveTimer: null,
}; };
_activeEditors.set(editorId, editorState); _activeEditors.set(editorId, editorState);
@@ -94,16 +104,11 @@ export function renderExcalidraw(container, data, vaultName, filePath, opts = {}
break; break;
case 'modified': case 'modified':
// No autosave: saving writes the file, which emits `index_updated` and
// reloads the viewer (visible page refresh) — and can interrupt the
// user mid-drawing. The scene is saved explicitly via the in-editor
// Save button or Ctrl+S. We only track the dirty state.
editorState.isDirty = msg.dirty === true; editorState.isDirty = msg.dirty === true;
// If dirty, start auto-save timer (2s debounce)
if (editorState.isDirty) {
if (editorState.saveTimer) clearTimeout(editorState.saveTimer);
editorState.saveTimer = setTimeout(() => {
if (editorState.isDirty && editorState.ready) {
iframe.contentWindow.postMessage({ type: 'requestSave' }, '*');
}
}, 2000);
}
break; break;
} }
}); });
@@ -154,9 +159,7 @@ export function notifyExcalidrawThemeChange(theme) {
* Clean up an editor instance (e.g., when tab is closed). * Clean up an editor instance (e.g., when tab is closed).
*/ */
export function destroyExcalidrawEditor(editorId) { export function destroyExcalidrawEditor(editorId) {
const state = _activeEditors.get(editorId); if (_activeEditors.has(editorId)) {
if (state) {
if (state.saveTimer) clearTimeout(state.saveTimer);
_activeEditors.delete(editorId); _activeEditors.delete(editorId);
} }
} }
+7
View File
@@ -693,6 +693,13 @@ async function reloadExternalWrite(vault, path, force = false) {
} }
// Not editing: refresh the read view when it shows the written document. // Not editing: refresh the read view when it shows the written document.
if (state.currentVault === vault && state.currentPath === path) { if (state.currentVault === vault && state.currentPath === path) {
// Excalidraw owns its iframe: re-rendering would recreate it (visible page
// refresh) and discard the in-editor scene. Its own save already persisted
// the file, so there is nothing to reload here.
const openExcalidraw = Array.from(
document.querySelectorAll("iframe[data-excalidraw-path]")
).some((f) => f.dataset.excalidrawVault === vault && f.dataset.excalidrawPath === path);
if (openExcalidraw) return;
_invalidateActiveTabCache(vault, path); _invalidateActiveTabCache(vault, path);
openFile(vault, path); openFile(vault, path);
} }
+30 -2
View File
@@ -518,6 +518,28 @@ function applyPrettyHighlight(codeEl, lang, text) {
} }
} }
/**
* Jump the inline PDF viewer to a specific page.
*
* The browser's built-in PDF viewer lives in an ``about:blank`` content window
* (so ``contentWindow.location.hash`` never reaches the document) **and** it
* ignores a same-document fragment navigation: changing only ``#page=N`` on the
* iframe ``src`` does not move the page. A changing query parameter forces a
* real reload, and the ``#page=N`` fragment is then honoured at load — the only
* reliable way to target a page with the native viewer.
*
* @param {HTMLElement} area - Content area containing the ``.pdf-iframe``.
* @param {string|number} page - 1-based page number from the PDF outline.
*/
export function navigatePdfToPage(area, page) {
const iframe = area && area.querySelector('.pdf-iframe');
if (!iframe || page === null || page === undefined || page === '') return;
const base = iframe.getAttribute('data-pdf-url') || iframe.src.split('#')[0];
iframe.setAttribute('data-pdf-url', base);
const sep = base.includes('?') ? '&' : '?';
iframe.src = `${base}${sep}_pdfpage=${Date.now()}#page=${page}`;
}
export function renderFile(data) { export function renderFile(data) {
// #93 — An inline edition session (#editor-container mounted in the content // #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 // area) is destroyed by this very re-render: release it first so the editor
@@ -537,7 +559,7 @@ export function renderFile(data) {
tocHtml = '<div class="pdf-toc"><h3>Table des matières</h3><ul>'; tocHtml = '<div class="pdf-toc"><h3>Table des matières</h3><ul>';
for (const item of toc) { for (const item of toc) {
const indent = (item.level - 1) * 16; const indent = (item.level - 1) * 16;
tocHtml += `<li style="padding-left:${indent}px"><a href="#" onclick="document.querySelector('.pdf-iframe').contentWindow.location.hash='page=${item.page}';return false">${escapeHtml(item.title)}</a> <span class="toc-page">p.${item.page}</span></li>`; tocHtml += `<li style="padding-left:${indent}px"><a href="#" data-page="${item.page}">${escapeHtml(item.title)}</a> <span class="toc-page">p.${item.page}</span></li>`;
} }
tocHtml += '</ul></div>'; tocHtml += '</ul></div>';
} }
@@ -555,9 +577,15 @@ export function renderFile(data) {
</div> </div>
<div class="pdf-body"> <div class="pdf-body">
${tocHtml} ${tocHtml}
<embed src="${pdfUrl}" class="pdf-iframe" type="application/pdf" title="${escapeHtml(data.title)}"></embed> <iframe src="${pdfUrl}" data-pdf-url="${pdfUrl}" class="pdf-iframe" title="${escapeHtml(data.title)}"></iframe>
</div> </div>
</div>`; </div>`;
area.querySelectorAll('.pdf-toc a[data-page]').forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault();
navigatePdfToPage(area, link.getAttribute('data-page'));
});
});
lucide.createIcons(); lucide.createIcons();
return; return;
} }
+8
View File
@@ -363,6 +363,14 @@
"config.ai_keys_desc": "Configure API keys for the AI editor.", "config.ai_keys_desc": "Configure API keys for the AI editor.",
"config.ai_model": "Model", "config.ai_model": "Model",
"config.ai_openrouter_label": "OpenRouter API Key", "config.ai_openrouter_label": "OpenRouter API Key",
"config.ai_header_desc": "Configure your API keys provider by provider. Expand a card to enter a key, then click Test to load the models.",
"config.ai_search_placeholder": "Search a provider…",
"config.ai_default_section": "Default configuration",
"config.ai_providers_title": "API providers",
"config.ai_providers_empty": "No matching provider",
"config.ai_status_configured": "Configured",
"config.ai_status_not_configured": "Not configured",
"config.ai_delete_key_title": "Delete the API key",
"config.api_keys_saved": "API keys saved", "config.api_keys_saved": "API keys saved",
"config.section_sources": "🔗 Connected sources & search", "config.section_sources": "🔗 Connected sources & search",
"config.sources_desc": "Keys used by the AI assistant tools (keyed web search: Tavily, Brave, SerpAPI, Exa; connected sources: Gitea, GitHub). They are stored server-side and take precedence over environment variables.", "config.sources_desc": "Keys used by the AI assistant tools (keyed web search: Tavily, Brave, SerpAPI, Exa; connected sources: Gitea, GitHub). They are stored server-side and take precedence over environment variables.",
+8
View File
@@ -363,6 +363,14 @@
"config.ai_keys_desc": "Configurez les clés API pour l'éditeur IA.", "config.ai_keys_desc": "Configurez les clés API pour l'éditeur IA.",
"config.ai_model": "Modèle", "config.ai_model": "Modèle",
"config.ai_openrouter_label": "OpenRouter API Key", "config.ai_openrouter_label": "OpenRouter API Key",
"config.ai_header_desc": "Configurez vos clés API fournisseur par fournisseur. Dépliez une carte pour saisir une clé, puis cliquez sur Tester pour charger les modèles.",
"config.ai_search_placeholder": "Rechercher un fournisseur…",
"config.ai_default_section": "Configuration par défaut",
"config.ai_providers_title": "Fournisseurs d'API",
"config.ai_providers_empty": "Aucun fournisseur ne correspond",
"config.ai_status_configured": "Configuré",
"config.ai_status_not_configured": "Non configuré",
"config.ai_delete_key_title": "Supprimer la clé API",
"config.api_keys_saved": "Clés API sauvegardées", "config.api_keys_saved": "Clés API sauvegardées",
"config.section_sources": "🔗 Sources connectées & recherche", "config.section_sources": "🔗 Sources connectées & recherche",
"config.sources_desc": "Clés utilisées par les outils de l'Assistant IA (recherche web à clé : Tavily, Brave, SerpAPI, Exa ; sources connectées : Gitea, GitHub). Elles sont stockées sur le serveur et priment sur les variables d'environnement.", "config.sources_desc": "Clés utilisées par les outils de l'Assistant IA (recherche web à clé : Tavily, Brave, SerpAPI, Exa ; sources connectées : Gitea, GitHub). Elles sont stockées sur le serveur et priment sur les variables d'environnement.",
+264 -1
View File
@@ -1473,6 +1473,15 @@ select {
margin: 0 auto; margin: 0 auto;
max-width: 1200px; max-width: 1200px;
} }
/* Full-bleed viewers (PDF, images, Excalidraw) must use the whole width when
the navigation sidebar is hidden instead of the centered reading column. */
.sidebar.hidden ~ .content-wrapper .content-area:has(.pdf-viewer-container),
.sidebar.hidden ~ .content-wrapper .content-area:has(.image-viewer-container),
.sidebar.hidden ~ .content-wrapper .content-area:has(iframe[src*="excalidraw-editor.html"]) {
margin: 0;
max-width: none;
}
.content-area::-webkit-scrollbar { .content-area::-webkit-scrollbar {
width: 8px; width: 8px;
} }
@@ -4338,6 +4347,258 @@ body.resizing-v {
background: var(--bg-hover); background: var(--bg-hover);
} }
/* --- AI keys section: accordion redesign (#104) --- */
.ai-keys-header {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 18px;
}
.ai-keys-header-text {
flex: 1 1 320px;
min-width: 0;
}
.ai-keys-header-text h2 {
margin-bottom: 4px;
}
.ai-keys-search {
position: relative;
flex: 0 1 260px;
min-width: 200px;
}
.ai-keys-search .icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
width: 14px;
height: 14px;
color: var(--text-muted);
pointer-events: none;
}
.ai-keys-search input {
width: 100%;
padding: 8px 12px 8px 32px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 0.8rem;
outline: none;
transition: border-color 150ms ease;
}
.ai-keys-search input:focus {
border-color: var(--accent);
}
.ai-card-title {
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.6px;
text-transform: uppercase;
color: var(--text-secondary);
}
.ai-default-card {
background: var(--surface, #1e1e24);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px 18px;
margin-bottom: 22px;
}
.ai-default-card > .ai-card-title {
margin-bottom: 12px;
}
.ai-default-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.ai-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.ai-field-label {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary);
letter-spacing: 0.2px;
}
.ai-field .config-select,
.ai-field .config-input {
width: 100%;
}
.ai-caps-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 22px;
}
.ai-cap-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 600;
background: var(--accent-bg);
color: var(--accent);
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
}
.ai-providers-header {
margin: 0 0 10px;
}
.ai-providers-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.ai-provider-card {
background: var(--surface, #1e1e24);
border: 1px solid var(--border);
border-radius: 10px;
transition: border-color 150ms ease;
}
.ai-provider-card:hover {
border-color: var(--border-md);
}
.ai-provider-card.open {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
}
.ai-provider-head {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
cursor: pointer;
user-select: none;
border-radius: 10px;
}
.ai-provider-head:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.ai-provider-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: var(--accent-bg);
color: var(--accent);
font-weight: 700;
font-size: 0.85rem;
flex: none;
}
.ai-provider-name {
flex: 1;
font-size: 0.88rem;
font-weight: 600;
color: var(--text-primary);
min-width: 0;
}
.ai-provider-badge {
font-size: 0.68rem;
padding: 2px 9px;
border-radius: 999px;
white-space: nowrap;
background: var(--muted-bg, rgba(255, 255, 255, 0.06));
color: var(--text-muted);
border: 1px solid var(--border);
}
.ai-provider-badge.configured {
background: var(--success-bg);
color: var(--success);
border-color: color-mix(in srgb, var(--success) 40%, transparent);
}
.ai-provider-delete {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
flex: none;
transition: color 150ms ease, background 150ms ease;
}
.ai-provider-delete:hover {
color: var(--danger);
background: var(--danger-bg);
}
.ai-provider-chevron {
flex: none;
color: var(--text-muted);
transition: transform 200ms ease;
}
.ai-provider-card.open .ai-provider-chevron {
transform: rotate(180deg);
}
.ai-provider-body {
padding: 0 14px 14px;
}
.ai-provider-body.hidden {
display: none;
}
.ai-provider-fields {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.ai-providers-empty {
padding: 14px;
text-align: center;
color: var(--text-muted);
font-size: 0.8rem;
border: 1px dashed var(--border);
border-radius: 10px;
}
.ai-keys-footer {
position: sticky;
bottom: 0;
z-index: 5;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-top: 20px;
padding: 12px 16px;
background: var(--surface, #1e1e24);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.25);
}
.ai-keys-status {
flex: 1;
font-size: 0.75rem;
color: var(--text-muted);
min-width: 0;
}
@media (max-width: 600px) {
.ai-default-grid,
.ai-provider-fields {
grid-template-columns: 1fr;
}
.ai-keys-search {
flex: 1 1 100%;
}
}
/* --- Config diagnostics panel --- */ /* --- Config diagnostics panel --- */
.config-diagnostics { .config-diagnostics {
background: var(--code-bg); background: var(--code-bg);
@@ -9492,7 +9753,9 @@ body.popup-mode .content-area {
.bookslm-toolbar .ai-picker { margin-left: 0; padding-left: 0; border-left: none; .bookslm-toolbar .ai-picker { margin-left: 0; padding-left: 0; border-left: none;
flex-wrap: wrap; row-gap: 4px; } flex-wrap: wrap; row-gap: 4px; }
.bookslm-toolbar .ai-picker select { max-width: 220px; } .bookslm-toolbar .ai-picker select { max-width: 220px; }
.bookslm-panel.fullscreen { width: 100vw; } /* `!important` is required: the panel width is also written inline by the
resize handle / persisted width, and an inline style would otherwise win. */
.bookslm-panel.fullscreen { width: 100vw !important; }
.bookslm-status { padding: 6px 16px; font-size: 12px; color: var(--text-secondary); .bookslm-status { padding: 6px 16px; font-size: 12px; color: var(--text-secondary);
border-bottom: 1px solid var(--border); display: flex; gap: 12px; align-items: center; } border-bottom: 1px solid var(--border); display: flex; gap: 12px; align-items: center; }
.bookslm-status .bookslm-status-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .bookslm-status .bookslm-status-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+1 -1
View File
@@ -11,7 +11,7 @@
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is * cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
* a separate store. Bumping SW_VERSION invalidates it on every release. * a separate store. Bumping SW_VERSION invalidates it on every release.
*/ */
const SW_VERSION = 'v20'; const SW_VERSION = 'v21';
const CODE_CACHE = `obsigate-code-${SW_VERSION}`; const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`; const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
const API_CACHE = `obsigate-api-${SW_VERSION}`; const API_CACHE = `obsigate-api-${SW_VERSION}`;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "obsigate", "name": "obsigate",
"version": "2.11.0", "version": "2.12.1",
"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.", "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", "main": "patch.js",
"directories": { "directories": {
+87
View File
@@ -0,0 +1,87 @@
{
"type": "excalidraw",
"version": 2,
"elements": [
{
"id": "app-rect-1",
"type": "rectangle",
"x": 100,
"y": 100,
"width": 300,
"height": 150,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a0",
"roundness": { "type": 3 },
"seed": 975148614,
"version": 73,
"versionNonce": 1826427930,
"isDeleted": false,
"boundElements": [],
"updated": 1789695570496,
"created": 1789695569357,
"link": null,
"locked": false
}
],
"appState": {
"showWelcomeScreen": false,
"theme": "dark",
"collaborators": {},
"currentChartType": "bar",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
"currentItemFontFamily": 5,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemRoundness": "round",
"currentItemArrowType": "round",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"activeTool": {
"type": "selection",
"customType": null,
"locked": false,
"lastActiveTool": null
},
"penMode": false,
"penDetected": false,
"errorMessage": null,
"exportBackground": true,
"exportScale": 1,
"exportEmbedScene": false,
"exportWithDarkMode": false,
"gridSize": 20,
"gridStep": 5,
"gridModeEnabled": false,
"isBindingEnabled": true,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"name": "App Export Fixture",
"previousSelectedElementIds": {},
"scrollX": 0,
"scrollY": 0,
"selectedElementIds": {},
"selectedGroupIds": {},
"viewBackgroundColor": "#ffffff",
"zenModeEnabled": false,
"zoom": { "value": 1 },
"viewModeEnabled": false
},
"files": {}
}
+130
View File
@@ -0,0 +1,130 @@
%PDF-1.3
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/Contents 13 0 R /MediaBox [ 0 0 612 792 ] /Parent 12 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
4 0 obj
<<
/Contents 14 0 R /MediaBox [ 0 0 612 792 ] /Parent 12 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/Contents 15 0 R /MediaBox [ 0 0 612 792 ] /Parent 12 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/Outlines 8 0 R /PageMode /UseNone /Pages 12 0 R /Type /Catalog
>>
endobj
7 0 obj
<<
/Author (anonymous) /CreationDate (D:20260917204040-04'00') /Creator (anonymous) /Keywords () /ModDate (D:20260917204040-04'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (unspecified) /Title (untitled) /Trapped /False
>>
endobj
8 0 obj
<<
/Count 3 /First 9 0 R /Last 11 0 R /Type /Outlines
>>
endobj
9 0 obj
<<
/Dest [ 3 0 R /Fit ] /Next 10 0 R /Parent 8 0 R /Title (Page One)
>>
endobj
10 0 obj
<<
/Dest [ 4 0 R /Fit ] /Next 11 0 R /Parent 8 0 R /Prev 9 0 R /Title (Page Two)
>>
endobj
11 0 obj
<<
/Dest [ 5 0 R /Fit ] /Parent 8 0 R /Prev 10 0 R /Title (Page Three)
>>
endobj
12 0 obj
<<
/Count 3 /Kids [ 3 0 R 4 0 R 5 0 R ] /Type /Pages
>>
endobj
13 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 122
>>
stream
Gap@Db6gL2'Lh3!@LZ0U8'7>U;'tH2cm;<+UO9dKg:K5pXY%ILno7bT=/&<K"<EU]9[SSm*P9LuAr1A`Y./=ub]S+JZn3-Xqn.)>^t)3an^%I4h`&g_Y!<hT~>endstream
endobj
14 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 124
>>
stream
GapQh0E=F,0U\H3T\pNYT^QKk?tc>IP,;W#U1^23ihPEM_?CW4KISi<![7`#OB_qus.nXJpV`4oKb/`HKs]']P1$(("^Qh6`:R"4,>ElR/;4WeODY4S!3T'6Jc~>endstream
endobj
15 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 124
>>
stream
GapQh0E=F,0U\H3T\pNYT^QKk?tc>IP,;W#U1^23ihPEM_?CW4KISi<![7`#OB_qus.nXJpV`4oKb/`HKs]']P1$(("^Qh6`:R"4@nhZ=/;4WeODY4S!3TQDK)~>endstream
endobj
xref
0 16
0000000000 65535 f
0000000061 00000 n
0000000092 00000 n
0000000199 00000 n
0000000394 00000 n
0000000589 00000 n
0000000784 00000 n
0000000869 00000 n
0000001130 00000 n
0000001202 00000 n
0000001289 00000 n
0000001389 00000 n
0000001479 00000 n
0000001551 00000 n
0000001764 00000 n
0000001979 00000 n
trailer
<<
/ID
[<6132df6a3beacba675b566627d60fb2e><6132df6a3beacba675b566627d60fb2e>]
% ReportLab generated PDF document -- digest (opensource)
/Info 7 0 R
/Root 6 0 R
/Size 16
>>
startxref
2194
%%EOF
+106
View File
@@ -0,0 +1,106 @@
%PDF-1.3
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/Contents 9 0 R /MediaBox [ 0 0 612 792 ] /Parent 8 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
4 0 obj
<<
/Contents 10 0 R /MediaBox [ 0 0 612 792 ] /Parent 8 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/Contents 11 0 R /MediaBox [ 0 0 612 792 ] /Parent 8 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/PageMode /UseNone /Pages 8 0 R /Type /Catalog
>>
endobj
7 0 obj
<<
/Author (anonymous) /CreationDate (D:20260917153218-04'00') /Creator (anonymous) /Keywords () /ModDate (D:20260917153218-04'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (unspecified) /Title (untitled) /Trapped /False
>>
endobj
8 0 obj
<<
/Count 3 /Kids [ 3 0 R 4 0 R 5 0 R ] /Type /Pages
>>
endobj
9 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 135
>>
stream
GapQh0E=F,0U\H3T\pNYT^QKk?tc>IP,;W#U1^23ihPEM_?CU^!/VX4lBrg6%A?7Y)Zc(P6:e82L4<*@VL)cDW^;5oRmL:j77h2jWe.B?6"5/?Jt]&.8<uSu(.bn9(BF;Q*M*~>endstream
endobj
10 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 135
>>
stream
GapQh0E=F,0U\H3T\pNYT^QKk?tc>IP,;W#U1^23ihPEM_?CU^!/VX4lBrg6%A?7Y)Zc(P6:e82L4<*@VL)cDW^;5oRmL:j77h2jWe.B?6"5/?Jruos8<uSu(.bn9(BF;_*M3~>endstream
endobj
11 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 135
>>
stream
GapQh0E=F,0U\H3T\pNYT^QKk?tc>IP,;W#U1^23ihPEM_?CU^!/VX4lBrg6%A?7Y)Zc(P6:e82L4<*@VL)cDW^;5oRmL:j77h2jWe.B?6"5/?K!D1>8<uSu(.bn9(BF;m*M<~>endstream
endobj
xref
0 12
0000000000 65535 f
0000000061 00000 n
0000000092 00000 n
0000000199 00000 n
0000000392 00000 n
0000000586 00000 n
0000000780 00000 n
0000000848 00000 n
0000001109 00000 n
0000001180 00000 n
0000001405 00000 n
0000001631 00000 n
trailer
<<
/ID
[<464fc7cfdf793a5b6d29e3d0d043c5a7><464fc7cfdf793a5b6d29e3d0d043c5a7>]
% ReportLab generated PDF document -- digest (opensource)
/Info 7 0 R
/Root 6 0 R
/Size 12
>>
startxref
1857
%%EOF
+35
View File
@@ -68,6 +68,41 @@ test.describe('Excalidraw — .excalidraw file support', () => {
await expect(canvas).toBeVisible({ timeout: 20000 }); await expect(canvas).toBeVisible({ timeout: 20000 });
}); });
test('opens an Excalidraw-app export (collaborators as JSON object) — canvas renders', async ({ page }) => {
// Regression: files exported by the Excalidraw app / Obsidian plugin store
// `appState.collaborators` as a plain `{}` (a Map serialized to JSON).
// Passing it back to `initialData` made Excalidraw 0.18 crash with
// "e.appState.collaborators.forEach is not a function" and left the canvas
// blank (0 <canvas> in the iframe).
await openFile(page, 'TestVault', 'diagram-app-export.excalidraw');
await waitForExcalidrawIframe(page);
const frame = page.locator('.content-area iframe').contentFrame();
const canvas = frame.locator('canvas').first();
await expect(canvas).toBeVisible({ timeout: 20000 });
});
test('loads Excalidraw stylesheet — canvas is sized to the viewport (not the 2^25 cap)', async ({ page }) => {
// Regression: without `@excalidraw/excalidraw`'s stylesheet, `.excalidraw`
// has no fixed height; Excalidraw's ResizeObserver feedback loop then grows
// the canvas to its 2^25 hard cap (33554432px), which the browser cannot
// draw → the scene stays blank even though the data was loaded.
await openFile(page, 'TestVault', 'diagram.excalidraw');
await waitForExcalidrawIframe(page);
const frame = page.locator('.content-area iframe').contentFrame();
const canvas = frame.locator('canvas').first();
await expect(canvas).toBeVisible({ timeout: 20000 });
const dims = await canvas.evaluate((el) => ({ w: el.width, h: el.height }));
expect(dims.h).toBeGreaterThan(100);
expect(dims.h).toBeLessThan(32768);
const wrapH = await frame.locator('.excalidraw').first()
.evaluate((el) => parseFloat(getComputedStyle(el).height));
expect(wrapH).toBeLessThan(32768);
});
test('opens .excalidraw.md (Obsidian plugin format) — decompresses and renders', async ({ page }) => { test('opens .excalidraw.md (Obsidian plugin format) — decompresses and renders', async ({ page }) => {
await openFile(page, 'TestVault', 'diagram.excalidraw.md'); await openFile(page, 'TestVault', 'diagram.excalidraw.md');
const iframe = await waitForExcalidrawIframe(page); const iframe = await waitForExcalidrawIframe(page);
+139
View File
@@ -0,0 +1,139 @@
/**
* E2E tests for ObsiGate PDF inline viewer (BUG-060).
*
* Regression : la CSP posée par BUG-034 (`object-src 'none'`) bloque l'élément
* `<embed>` qui servait le PDF. Le viewer doit rendre le stream dans une
* `<iframe>` (autorisée par `frame-src 'self'`).
*
* Fixtures : `test_vault/sample-pdf.pdf` (3 pages, texte simple).
*
* Run (local):
* BASE_URL=http://localhost:2029 npx playwright test tests/e2e/pdf-viewer.spec.js
* BASE_URL=http://localhost:2029 npx playwright test tests/e2e/pdf-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}"]`);
// Le vault peut être replié (auth activée) : l'étendre avant de chercher le fichier.
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 });
await page.waitForTimeout(500);
}
test.describe('PDF viewer — affichage inline (BUG-060)', () => {
test('ouvre un PDF dans une iframe (pas d\'<embed>) et le stream charge sans violation CSP', async ({ page }) => {
const cspViolations = [];
page.on('console', (msg) => {
const text = msg.text();
if (text.includes('Content Security Policy') && (text.includes('object-src') || text.includes('Refused'))) {
cspViolations.push(text);
}
});
await login(page);
// Le stream est demandé au moment où le viewer monte l'iframe : enregistrer
// l'écoute AVANT d'ouvrir le fichier (sinon la réponse est déjà passée).
const streamResponsePromise = page.waitForResponse(
(r) => r.url().includes('/pdf/stream') && (r.status() === 200 || r.status() === 206),
{ timeout: 15000 },
);
await openFile(page, 'TestVault', 'sample-pdf.pdf');
const iframe = page.locator('#content-area .pdf-iframe');
await expect(iframe).toBeVisible({ timeout: 15000 });
await expect(iframe).toHaveAttribute('src', /\/api\/file\/TestVault\/pdf\/stream\?path=/);
// La balise doit être une iframe (le <embed>/<object> serait bloqué par CSP)
const tagName = await iframe.evaluate((el) => el.tagName);
expect(tagName).toBe('IFRAME');
expect(await page.locator('#content-area embed, #content-area object').count()).toBe(0);
// La barre d'outils indique le nombre de pages du PDF
await expect(page.locator('.pdf-info')).toContainText('3 pages');
// Le stream est bien servi en application/pdf (200 ou 206 Range)
const streamResp = await streamResponsePromise;
expect(streamResp.headers()['content-type'] || '').toContain('application/pdf');
// Le cadre embarque réellement le document PDF (navigateur natif)
const pdfFrame = await iframe.contentFrame();
expect(pdfFrame).not.toBeNull();
// Aucune violation CSP liée à object-src pendant l'ouverture
expect(cspViolations).toEqual([]);
});
});
test.describe('PDF viewer — TOC & plein largeur', () => {
test('les entrées de la TOC rechargent l\'iframe sur la page ciblée (#page=N)', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-pdf-toc.pdf');
const tocLinks = page.locator('#content-area .pdf-toc a[data-page]');
await expect(tocLinks).toHaveCount(3, { timeout: 10000 });
await page.locator('#content-area .pdf-toc a[data-page="3"]').click();
// Le changement de query force un rechargement (un simple changement de
// fragment est ignoré par le lecteur PDF natif), puis #page=3 est appliqué.
await expect(page.locator('#content-area .pdf-iframe')).toHaveAttribute(
'src',
/\/pdf\/stream\?path=.*&_pdfpage=\d+#page=3$/,
{ timeout: 5000 },
);
});
test('le PDF occupe toute la largeur quand la navigation est masquée', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-pdf-toc.pdf');
await expect(page.locator('#content-area .pdf-viewer-container')).toBeVisible({ timeout: 10000 });
// Masquer la barre de navigation (bouton réel).
await page.locator('#sidebar-toggle-btn').click();
await expect(page.locator('#sidebar')).toHaveClass(/hidden/);
const widths = await page.evaluate(() => {
const area = document.getElementById('content-area');
const cs = getComputedStyle(area);
const container = document.querySelector('.pdf-viewer-container');
const contentWidth =
area.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
return {
container: container.getBoundingClientRect().width,
contentWidth,
maxWidth: cs.maxWidth,
};
});
// Le plafond de lecture (1200px) ne doit plus s'appliquer au viewer PDF.
expect(widths.maxWidth).toBe('none');
expect(Math.abs(widths.container - widths.contentWidth)).toBeLessThan(2);
});
});
+23
View File
@@ -786,6 +786,29 @@ async function main() {
panel.remove(); panel.remove();
}); });
// ── 10b. Fullscreen toggle must beat the persisted inline width ──
await test("fullscreen button toggles the panel and CSS lifts the inline width", async () => {
const { readFileSync } = await import("node:fs");
const css = readFileSync(path.resolve(JS_DIR, "..", "style.css"), "utf-8");
assert.match(
css,
/\.bookslm-panel\.fullscreen\s*\{\s*width:\s*100vw\s*!important;/,
"the fullscreen width must override the inline width written by the resize handle",
);
const b = new BooksLM();
const panel = b._render();
document.body.appendChild(panel);
const btn = panel.querySelector(".bookslm-btn-fullscreen");
btn.click();
assert.ok(panel.classList.contains("fullscreen"), "fullscreen class added on first click");
assert.equal(b._isFullscreen, true);
btn.click();
assert.ok(!panel.classList.contains("fullscreen"), "fullscreen class removed on second click");
assert.equal(b._isFullscreen, false);
panel.remove();
});
// ── 11. Formatted markdown rendering ── // ── 11. Formatted markdown rendering ──
await test("_renderMarkdown renders headings, lists, code and tables", () => { await test("_renderMarkdown renders headings, lists, code and tables", () => {
const b = new BooksLM(); const b = new BooksLM();
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env node
/**
* ObsiGate - JSDOM tests for the AI keys section redesign in the config
* panel (#104): provider accordion cards, status badges, delete button,
* provider search filter, save collection and capability badges.
*
* Usage: node tests/frontend/config-ai-keys.test.mjs
*/
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { readFileSync } from "node:fs";
import { fileURLToPath, pathToFileURL } from "node:url";
import path from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const JS_DIR = path.resolve(__dirname, "..", "..", "frontend", "js");
const dom = new JSDOM(
`<!DOCTYPE html>
<html><body>
<section id="cfg-ai" class="config-section">
<input type="search" id="cfg-ai-search" />
<select id="cfg-ai-default-provider"><option value="">--</option></select>
<select id="cfg-ai-default-model"><option value="">--</option></select>
<div id="cfg-ai-default-model-caps"></div>
<div id="cfg-ai-providers" class="ai-providers-list"></div>
<div id="cfg-ai-providers-empty" class="ai-providers-empty hidden"></div>
<button id="cfg-save-ai-keys">Sauvegarder</button>
<button id="cfg-test-ai-keys">Tester</button>
<span id="cfg-ai-status"></span>
</section>
</body></html>`,
{ url: "http://localhost/", pretendToBeVisual: true },
);
const w = dom.window;
globalThis.window = w;
globalThis.document = w.document;
globalThis.HTMLElement = w.HTMLElement;
globalThis.Element = w.Element;
globalThis.Node = w.Node;
globalThis.Event = w.Event;
globalThis.CustomEvent = w.CustomEvent;
globalThis.KeyboardEvent = w.KeyboardEvent;
globalThis.localStorage = w.localStorage;
globalThis.sessionStorage = w.sessionStorage;
Object.defineProperty(globalThis, "navigator", {
value: w.navigator,
configurable: true,
writable: true,
});
globalThis.MutationObserver = w.MutationObserver;
globalThis.getComputedStyle = w.getComputedStyle.bind(w);
globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0);
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
let testCount = 0;
let passCount = 0;
async function test(name, fn) {
testCount++;
try {
await fn();
passCount++;
console.log(` ✓ ${name}`);
} catch (err) {
console.error(` ✗ ${name}\n ${err.message}`);
process.exitCode = 1;
}
}
// ── Fetch mocks ────────────────────────────────────────────────────────────
const POSTS = [];
const MASKED_KEYS = {
DEEPSEEK_API_KEY: "sk-…abcd",
MISTRAL_API_KEY: "sk-…mist",
};
// Real FR locale served for /static/locales/fr.json so t() assertions work.
const FR_LOCALE = JSON.parse(
readFileSync(path.resolve(JS_DIR, "..", "locales", "fr.json"), "utf8"),
);
globalThis.fetch = async (url, opts = {}) => {
const u = String(url);
const method = (opts.method || "GET").toUpperCase();
if (method === "POST") POSTS.push({ url: u, body: opts.body || "" });
if (u.includes("/static/locales/fr.json")) {
return { ok: true, status: 200, json: async () => FR_LOCALE };
}
if (u.includes("/api/config/ai-keys/test")) {
return { ok: true, status: 200, json: async () => ({}) };
}
if (u.includes("/api/config/ai-keys")) {
return { ok: true, status: 200, json: async () => MASKED_KEYS };
}
if (u.endsWith("/api/config") && method === "GET") {
return {
ok: true,
status: 200,
json: async () => ({ ai_default_provider: "deepseek", ai_default_models: {} }),
};
}
if (u.includes("/api/config/ai-models")) {
return {
ok: true,
status: 200,
json: async () => ({ models: ["deepseek-chat", "deepseek-reasoner"] }),
};
}
if (u.includes("/api/ai/model-capabilities")) {
return {
ok: true,
status: 200,
json: async () => ({ capabilities: { chat: true, vision: true, embeddings: false } }),
};
}
return { ok: true, status: 200, json: async () => ({}) };
};
const configMod = await import(pathToFileURL(path.join(JS_DIR, "config.js")).href);
const aiMod = await import(pathToFileURL(path.join(JS_DIR, "ai.js")).href);
const i18nMod = await import(pathToFileURL(path.join(JS_DIR, "i18n.js")).href);
await i18nMod.initI18n();
const { loadAIKeys, saveAIKeys, filterAIProviders } = configMod;
const { renderCapabilityBadges } = aiMod;
const cards = () => Array.from(document.querySelectorAll("#cfg-ai-providers .ai-provider-card"));
const visibleCards = () => cards().filter((c) => c.style.display !== "none");
console.log("AI keys section redesign (#104) — accordion, badges, filter, save");
await test("loadAIKeys renders one accordion card per provider", async () => {
await loadAIKeys();
assert.equal(cards().length, 7, "seven provider cards rendered");
assert.equal(
document.querySelector('[data-provider="deepseek"] .ai-provider-name').textContent,
"DeepSeek",
"display name rendered in the card header",
);
assert.equal(
document.querySelector('[data-provider="deepseek"] .ai-provider-logo').textContent,
"D",
"logo shows the provider initial",
);
assert.ok(
document.getElementById("cfg-deepseek-key") &&
document.getElementById("cfg-deepseek-model"),
"key input and model select are rendered inside the card body",
);
});
await test("status badges reflect the stored keys (header only)", async () => {
const okBadge = document.getElementById("cfg-deepseek-badge");
const koBadge = document.getElementById("cfg-gemini-badge");
assert.ok(okBadge.textContent.includes("Configuré"), "configured label shown");
assert.ok(okBadge.classList.contains("configured"), "configured badge styled");
assert.ok(koBadge.textContent.includes("Non configuré"), "not-configured label shown");
assert.ok(!koBadge.classList.contains("configured"), "not-configured badge neutral");
const del = document.getElementById("cfg-deepseek-delete");
const delKo = document.getElementById("cfg-gemini-delete");
assert.equal(del.style.display, "inline-flex", "trash visible when configured");
assert.equal(delKo.style.display, "none", "trash hidden when not configured");
});
await test("clicking a card header toggles its body", async () => {
const card = document.querySelector('[data-provider="gemini"]');
const head = card.querySelector(".ai-provider-head");
const body = card.querySelector(".ai-provider-body");
assert.ok(body.classList.contains("hidden"), "body collapsed by default");
head.dispatchEvent(new w.Event("click", { bubbles: true }));
assert.ok(!body.classList.contains("hidden"), "body expanded after click");
assert.equal(card.classList.contains("open"), true, "card flagged open");
assert.equal(head.getAttribute("aria-expanded"), "true", "aria-expanded synced");
head.dispatchEvent(new w.Event("click", { bubbles: true }));
assert.ok(body.classList.contains("hidden"), "body collapsed again");
});
await test("filterAIProviders narrows cards case/accent-insensitively", async () => {
filterAIProviders("MISTRAL");
assert.equal(visibleCards().length, 1, "one card matches 'MISTRAL'");
filterAIProviders("qwencloud");
assert.equal(visibleCards().length, 1, "one card matches 'qwencloud'");
filterAIProviders("zzz-inexistant");
assert.equal(visibleCards().length, 0, "no card matches");
assert.ok(
!document.getElementById("cfg-ai-providers-empty").classList.contains("hidden"),
"empty-state message shown when nothing matches",
);
filterAIProviders("");
assert.equal(visibleCards().length, 7, "clearing restores all cards");
assert.ok(
document.getElementById("cfg-ai-providers-empty").classList.contains("hidden"),
"empty-state message hidden again",
);
});
await test("saveAIKeys collects typed keys and posts them", async () => {
POSTS.length = 0;
const geminiInput = document.getElementById("cfg-gemini-key");
geminiInput.value = " test-gemini-key ";
await saveAIKeys();
const aiPost = POSTS.find((p) => p.url.includes("/api/config/ai-keys") && p.url.includes("/test") === false);
assert.ok(aiPost, "a POST was sent to /api/config/ai-keys");
const sent = JSON.parse(aiPost.body);
assert.equal(sent.GEMINI_API_KEY, "test-gemini-key", "trimmed key sent under its env name");
assert.ok(!sent.DEEPSEEK_API_KEY, "empty inputs are not re-sent");
});
await test("delete button calls deleteAIKey with confirmation", async () => {
let deleted = null;
let confirmed = false;
globalThis.confirm = () => { confirmed = true; return true; };
const origFetch = globalThis.fetch;
globalThis.fetch = async (url, opts = {}) => {
const u = String(url);
if ((opts.method || "").toUpperCase() === "DELETE" && u.includes("/api/config/ai-keys/")) {
deleted = u;
return { ok: true, status: 200, json: async () => ({}) };
}
return origFetch(url, opts);
};
document.getElementById("cfg-deepseek-delete").click();
assert.ok(confirmed, "a confirmation was requested");
assert.ok(deleted && deleted.includes("DEEPSEEK_API_KEY"), "DELETE sent for the provider env key");
globalThis.fetch = origFetch;
});
await test("renderCapabilityBadges renders only enabled capabilities", () => {
const box = renderCapabilityBadges({ chat: true, vision: true, embeddings: false });
const badges = Array.from(box.querySelectorAll(".ai-cap-badge"));
assert.equal(badges.length, 2, "only enabled capabilities rendered");
assert.equal(box.className, "ai-caps-badges", "badge box class applied");
});
// ── Summary ──
console.log(`\n${passCount}/${testCount} tests passed`);
if (passCount !== testCount) {
process.exit(1);
}
+91
View File
@@ -12,6 +12,7 @@ import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import path from "node:path"; import path from "node:path";
import fs from "node:fs";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@@ -138,5 +139,95 @@ await test("destroyExcalidrawEditor is idempotent (no-op for unknown id)", () =>
destroyExcalidrawEditor("excalidraw-999"); destroyExcalidrawEditor("excalidraw-999");
}); });
// ── Static regression guards (BUG-064) ──────────────────────────────────────
const editorHtml = fs.readFileSync(
path.join(REPO_ROOT, "frontend", "excalidraw-editor.html"),
"utf8"
);
const mainPy = fs.readFileSync(path.join(REPO_ROOT, "backend", "main.py"), "utf8");
const styleCss = fs.readFileSync(path.join(REPO_ROOT, "frontend", "style.css"), "utf8");
const viewerJs = fs.readFileSync(
path.join(REPO_ROOT, "frontend", "js", "excalidraw-viewer.js"),
"utf8"
);
await test("excalidraw-editor.html loads the Excalidraw stylesheet", () => {
// Without it the editor is unstyled AND the container enters a resize
// feedback loop up to the 2^25 canvas cap → blank scene.
assert.match(
editorHtml,
/https:\/\/esm\.sh\/@excalidraw\/excalidraw@[\d.]+\/dist\/prod\/index\.css/,
"the Excalidraw CSS must be linked from esm.sh"
);
});
await test("backend CSP style-src allows esm.sh (Excalidraw stylesheet)", () => {
const csp = mainPy.match(/style-src ([^";]+);/);
assert.ok(csp, "CSP style-src directive not found");
assert.ok(
csp[1].includes("https://esm.sh"),
`style-src must allow https://esm.sh (found: ${csp[1]})`
);
});
await test("excalidraw-editor.html sanitizes appState (collaborators + viewport geometry)", () => {
assert.match(editorHtml, /function sanitizeAppState/, "sanitizeAppState helper missing");
assert.match(editorHtml, /collaborators instanceof Map/, "collaborators must be restored as a Map");
assert.match(
editorHtml,
/"width", "height", "offsetLeft", "offsetTop"/,
"container-derived viewport geometry must be dropped"
);
});
await test("style.css — Excalidraw goes full width when the sidebar is hidden", () => {
// Mirrors the PDF/image full-bleed rule: the centered 1200px reading column
// must not apply to the Excalidraw viewer.
const rule = styleCss.match(
/\.content-area:has\(iframe\[src\*="excalidraw-editor\.html"\]\)\s*\{([^}]*)\}/
);
assert.ok(rule, "Excalidraw full-bleed rule not found");
assert.match(rule[1], /max-width:\s*none/, "the 1200px reading cap must be lifted");
assert.match(rule[1], /margin:\s*0/, "the centered margin must be removed");
});
await test("excalidraw-viewer.js has no autosave (no requestSave)", () => {
// Autosave writes the file, which emits `index_updated` and reloads the
// viewer — a visible page refresh that also interrupts drawing. Saves are
// explicit (in-editor Save button / Ctrl+S) only.
assert.doesNotMatch(viewerJs, /requestSave/, "the viewer must not request automatic saves");
assert.doesNotMatch(viewerJs, /saveTimer/, "no autosave debounce timer should remain");
});
await test("excalidraw-viewer.js allows fullscreen in the iframe", () => {
assert.match(
viewerJs,
/setAttribute\(\s*['"]allow['"]\s*,\s*['"]fullscreen['"]\s*\)/,
"iframe must set allow=fullscreen"
);
assert.match(
viewerJs,
/setAttribute\(\s*['"]allowfullscreen['"]\s*,\s*['"]['"]\s*\)/,
"iframe must set the allowfullscreen attribute"
);
});
await test("excalidraw-editor.html exposes a fullscreen button", () => {
assert.match(editorHtml, /id="btn-fullscreen"/, "fullscreen button missing");
assert.match(
editorHtml,
/documentElement\.requestFullscreen/,
"the button must request fullscreen on the editor document"
);
});
await test("excalidraw-editor.html — toolbar flush right at 45%", () => {
const rule = editorHtml.match(/#excalidraw-toolbar\s*\{([^}]*)\}/);
assert.ok(rule, "#excalidraw-toolbar rule not found");
assert.match(rule[1], /right:\s*0\b/, "toolbar must be flush against the right edge");
assert.match(rule[1], /top:\s*45%/, "group must start at 45% of the height");
assert.match(rule[1], /flex-direction:\s*column/, "buttons must stay stacked vertically");
});
console.log(`\n${passCount}/${testCount} excalidraw-viewer tests passed\n`); console.log(`\n${passCount}/${testCount} excalidraw-viewer tests passed\n`);
process.exit(passCount === testCount ? 0 : 1); process.exit(passCount === testCount ? 0 : 1);
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env node
/**
* ObsiGate — Viewer PDF non-regression tests (BUG-060).
*
* Static checks on the source of the PDF inline viewer:
* - BUG-060 : the CSP set by BUG-034 puts `object-src 'none'`, which blocks
* `<embed>`/`<object>`. The PDF body was therefore never rendered (blank
* pages). The viewer must now use an `<iframe>` — permitted by
* `frame-src 'self'` since the PDF stream URL is same-origin.
*
* Usage: node tests/frontend/pdf-viewer.test.mjs
*/
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
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 main = readFileSync(path.join(ROOT, "backend", "main.py"), "utf8");
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
function test(label, fn) {
try {
fn();
console.log(" \u2713 " + label);
} catch (err) {
console.error(" \u2717 " + label + "\n " + err.message);
process.exitCode = 1;
}
}
// ── viewer.js : le rendu PDF passe par une iframe ─────────────────────────
test("viewer.js — PDF branch renders the stream in an <iframe class=pdf-iframe>", () => {
const block = viewer.match(/if \(data\.is_pdf\) \{([\s\S]*?)\n \}/);
assert.ok(block, "PDF render block not found");
assert.match(
block[1],
/<iframe src="\$\{pdfUrl\}" data-pdf-url="\$\{pdfUrl\}" class="pdf-iframe"/,
"PDF must use <iframe>, not <embed>/<object> (CSP object-src 'none' otherwise blocks it)",
);
assert.match(
block[1],
/<iframe src="\$\{pdfUrl\}"/,
"iframe src must come from the /pdf/stream URL",
);
});
test("viewer.js — no <embed>/<object> left in the source", () => {
assert.doesNotMatch(viewer, /<embed\b/i, "<embed> is blocked by CSP object-src 'none'");
assert.doesNotMatch(viewer, /<object\b/i, "<object> is blocked by CSP object-src 'none'");
});
test("viewer.js — TOC links carry a data-page and never poke contentWindow", () => {
assert.match(
viewer,
/<a href="#" data-page="\$\{item\.page\}">/,
"TOC links must expose the target page via data-page",
);
assert.doesNotMatch(
viewer,
/contentWindow\.location\.hash\s*=/,
"contentWindow is about:blank in the native PDF viewer: hash navigation never reaches the document",
);
});
test("viewer.js — navigatePdfToPage forces a reload with the #page fragment", () => {
const fn = viewer.match(/export function navigatePdfToPage\(area, page\) \{([\s\S]*?)\n\}/);
assert.ok(fn, "navigatePdfToPage helper not found");
assert.match(fn[1], /data-pdf-url/, "the base URL must be preserved without the fragment");
assert.match(
fn[1],
/_pdfpage=\$\{Date\.now\(\)\}#page=\$\{page\}/,
"the query must change to force a reload (a fragment-only change is ignored by the native viewer)",
);
});
test("style.css — full-bleed viewers ignore the centered reading width", () => {
assert.match(
css,
/\.sidebar\.hidden ~ \.content-wrapper \.content-area:has\(\.pdf-viewer-container\)/,
"PDF viewer must fill the width when the navigation sidebar is hidden",
);
const rule = css.match(
/\.content-area:has\(\.pdf-viewer-container\)[\s\S]*?\{([^}]*)\}/,
);
assert.ok(rule, "full-bleed rule not found");
assert.match(rule[1], /max-width:\s*none/, "the 1200px reading cap must be lifted");
});
// ── backend : la CSP autorise le cadre same-origin ─────────────────────────
test("backend CSP — frame-src 'self' allows same-origin iframes", () => {
const csp = main.match(/frame-src ([^";]+);/);
assert.ok(csp, "CSP frame-src directive not found");
assert.ok(
csp[1].includes("'self'"),
`frame-src must allow 'self' (found: ${csp[1]}) — otherwise the PDF iframe is blocked`,
);
});
test("backend CSP — object-src 'none' stays in place (no defusing)", () => {
assert.match(
main,
/object-src 'none';/,
"object-src must stay locked to 'none': the fix is moving to <iframe>, not weakening CSP",
);
});
// ── style.css : l'iframe garde une hauteur utile ───────────────────────────
test("style.css — .pdf-iframe fills the viewer body", () => {
const rule = css.match(/\.pdf-iframe\s*\{([^}]*)\}/);
assert.ok(rule, ".pdf-iframe rule not found");
assert.match(rule[1], /flex:\s*1/, "iframe must stretch to fill the available height");
assert.match(rule[1], /min-height/, "iframe must keep its minimum height");
});
if (process.exitCode) {
console.error("\nPDF viewer tests FAILED");
} else {
console.log("\nAll PDF viewer tests passed.");
}
+27
View File
@@ -743,6 +743,33 @@ class TestSecretRedactor:
result = redact_file_content("hello world this is safe") result = redact_file_content("hello world this is safe")
assert result == "hello world this is safe" assert result == "hello world this is safe"
def test_git_sha_not_redacted(self):
"""BUG-035: a bare git commit SHA must not be mangled."""
from backend.secret_redactor import redact_file_content
sha = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678"
text = f"commit {sha}\nMerge: {sha}"
assert redact_file_content(text) == text
def test_sha256_checksum_not_redacted(self):
from backend.secret_redactor import redact_file_content
digest = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
text = f"sha256:{digest} file.tar.gz"
assert redact_file_content(text) == text
def test_hex_secret_in_context_is_redacted(self):
from backend.secret_redactor import redact_file_content
secret = "0123456789abcdef0123456789abcdef01234567"
result = redact_file_content(f"api_key={secret}")
assert secret not in result
assert "MASQUÉ" in result
def test_ambiguous_bare_hex_left_intact(self):
"""A 40-char hex with no secret/hash keyword stays untouched."""
from backend.secret_redactor import redact_file_content
blob = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
text = f"value {blob} end"
assert redact_file_content(text) == text
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
# Static / PWA caching (Cloudflare / mobile freshness) # Static / PWA caching (Cloudflare / mobile freshness)
+74
View File
@@ -44,6 +44,22 @@ class TestPasswordHashing:
result = hash_password("ab") result = hash_password("ab")
assert result is not None assert result is not None
def test_argon2_memory_recalibrated(self):
"""BUG-038: memory cost must stay at the OWASP 19 MiB recommendation."""
from backend.auth.password import (
ARGON2_MEMORY_COST_KIB,
ARGON2_PARALLELISM,
ARGON2_TIME_COST,
ph,
)
assert ARGON2_MEMORY_COST_KIB == 19456
assert ARGON2_TIME_COST == 2
assert ARGON2_PARALLELISM == 1
assert ph.memory_cost == 19456
assert ph.time_cost == 2
assert ph.parallelism == 1
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
# JWT Handler # JWT Handler
@@ -279,3 +295,61 @@ class TestMiddleware:
assert check_vault_access("Vault1", user) is True assert check_vault_access("Vault1", user) is True
assert check_vault_access("Vault3", user) is False assert check_vault_access("Vault3", user) is False
assert check_vault_access("Vault1", nobody) is False assert check_vault_access("Vault1", nobody) is False
# ═══════════════════════════════════════════════════════════════════
# Insecure (auth-disabled) deployment guard — BUG-037
# ═══════════════════════════════════════════════════════════════════
class TestInsecureAuthGuard:
def test_is_loopback_host(self):
from backend.auth.middleware import is_loopback_host
assert is_loopback_host(None) is True
assert is_loopback_host("127.0.0.1") is True
assert is_loopback_host("::1") is True
assert is_loopback_host("[::1]") is True
assert is_loopback_host("localhost") is True
assert is_loopback_host("0.0.0.0") is False
assert is_loopback_host("192.168.1.10") is False
def test_bind_host_from_argv(self):
from backend.auth.middleware import bind_host_from_argv
assert bind_host_from_argv(
["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8080"]
) == "0.0.0.0"
assert bind_host_from_argv(["uvicorn", "app", "--host=127.0.0.1"]) == "127.0.0.1"
assert bind_host_from_argv(["uvicorn", "app"]) is None
def test_guard_refuses_public_bind_without_optin(self, monkeypatch):
from backend import main
monkeypatch.setenv("OBSIGATE_AUTH_ENABLED", "false")
monkeypatch.delenv("OBSIGATE_ALLOW_INSECURE", raising=False)
monkeypatch.setattr("sys.argv", ["uvicorn", "backend.main:app", "--host", "0.0.0.0"])
with pytest.raises(RuntimeError):
main._guard_insecure_auth()
def test_guard_allows_loopback(self, monkeypatch):
from backend import main
monkeypatch.setenv("OBSIGATE_AUTH_ENABLED", "false")
monkeypatch.delenv("OBSIGATE_ALLOW_INSECURE", raising=False)
monkeypatch.setattr("sys.argv", ["uvicorn", "backend.main:app", "--host", "127.0.0.1"])
main._guard_insecure_auth() # must not raise
def test_guard_allows_explicit_optin(self, monkeypatch):
from backend import main
monkeypatch.setenv("OBSIGATE_AUTH_ENABLED", "false")
monkeypatch.setenv("OBSIGATE_ALLOW_INSECURE", "true")
monkeypatch.setattr("sys.argv", ["uvicorn", "backend.main:app", "--host", "0.0.0.0"])
main._guard_insecure_auth() # must not raise
def test_guard_noop_when_auth_enabled(self, monkeypatch):
from backend import main
monkeypatch.setenv("OBSIGATE_AUTH_ENABLED", "true")
monkeypatch.setattr("sys.argv", ["uvicorn", "backend.main:app", "--host", "0.0.0.0"])
main._guard_insecure_auth() # must not raise
+36
View File
@@ -137,6 +137,42 @@ class TestLogin:
}) })
assert resp.status_code == 401 assert resp.status_code == 401
def test_locked_account_returns_401_not_429(self, auth_client, monkeypatch):
"""BUG-039: a locked account must be indistinguishable from an unknown one."""
import backend.auth.router as auth_router
monkeypatch.setattr(auth_router, "is_locked", lambda username: True)
resp = auth_client.post("/api/auth/login", json={
"username": "admin",
"password": "chab30",
})
assert resp.status_code == 401
assert "verrouill" not in resp.json()["detail"].lower()
def test_account_rate_limited_returns_401(self, auth_client, monkeypatch):
"""BUG-039: per-account throttling must not reveal the account exists."""
import backend.auth.router as auth_router
monkeypatch.setattr(auth_router, "is_account_rate_limited", lambda username: True)
resp = auth_client.post("/api/auth/login", json={
"username": "admin",
"password": "chab30",
})
assert resp.status_code == 401
def test_inactive_account_returns_401(self, auth_client, monkeypatch):
"""BUG-039: a disabled account answers like an unknown user."""
import backend.auth.router as auth_router
monkeypatch.setattr(auth_router, "get_user", lambda username: {
"username": username, "active": False, "password_hash": "x",
})
resp = auth_client.post("/api/auth/login", json={
"username": "admin",
"password": "chab30",
})
assert resp.status_code == 401
def test_login_remember_me(self, auth_client): def test_login_remember_me(self, auth_client):
resp = auth_client.post("/api/auth/login", json={ resp = auth_client.post("/api/auth/login", json={
"username": "admin", "username": "admin",
+51
View File
@@ -73,6 +73,36 @@ def test_authenticate_websocket_invalid_token_returns_none(monkeypatch):
assert authenticate_websocket(_StubWebSocket(cookies={"access_token": "garbage"})) is None assert authenticate_websocket(_StubWebSocket(cookies={"access_token": "garbage"})) is None
def test_authenticate_websocket_query_token_rejected(monkeypatch):
"""BUG-036: the access token must never be accepted from the query string."""
monkeypatch.setenv("OBSIGATE_AUTH_ENABLED", "true")
from backend.auth.jwt_handler import create_access_token
token = create_access_token({
"username": "u", "role": "user", "vaults": ["*"], "display_name": "U",
})
ws = _StubWebSocket(query={"token": token})
assert authenticate_websocket(ws) is None
def test_authenticate_websocket_cookie_token_accepted(monkeypatch):
"""The HttpOnly access_token cookie remains the supported transport."""
monkeypatch.setenv("OBSIGATE_AUTH_ENABLED", "true")
import backend.auth.user_store as user_store
from backend.auth.jwt_handler import create_access_token
token = create_access_token({
"username": "u", "role": "user", "vaults": ["*"], "display_name": "U",
})
monkeypatch.setattr(user_store, "get_user", lambda username: {
"username": username, "role": "user", "vaults": ["*"],
"display_name": "U", "active": True,
})
user = authenticate_websocket(_StubWebSocket(cookies={"access_token": token}))
assert user is not None
assert user["username"] == "u"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Manager unit tests (no WebSocket transport) # Manager unit tests (no WebSocket transport)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -127,6 +157,27 @@ async def test_on_message_rejects_oversized_update(tmp_path: Path):
assert room.updates == [] assert room.updates == []
@pytest.mark.asyncio
async def test_on_message_rejects_oversized_raw(tmp_path: Path):
"""BUG-036: oversized raw frames are dropped before parsing."""
target = tmp_path / "note.md"
target.write_text("x", encoding="utf-8")
manager = _make_manager(tmp_path)
room = CollabRoom(vault="V", path="note.md", file_path=target)
client = _FakeClient(conn_id=1)
import backend.collab as collab_mod
original = collab_mod.MAX_MESSAGE_CHARS
try:
collab_mod.MAX_MESSAGE_CHARS = 10
await manager._on_message(room, client, json.dumps({"type": "text", "text": "hello"}))
finally:
collab_mod.MAX_MESSAGE_CHARS = original
assert room.pending_text is None
class _FakeWebSocket: class _FakeWebSocket:
def __init__(self): def __init__(self):
self.sent: list[dict] = [] self.sent: list[dict] = []
+90 -3
View File
@@ -275,6 +275,7 @@ class TestPdfIndexing:
"""When a vault directory is scanned with .pdf files, they appear in files list. """When a vault directory is scanned with .pdf files, they appear in files list.
Uses the public _scan_vault() helper directly — no global state needed. Uses the public _scan_vault() helper directly — no global state needed.
BUG-040: text extraction is deferred, so the scan only carries metadata.
""" """
from backend.indexer import _scan_vault from backend.indexer import _scan_vault
@@ -286,10 +287,96 @@ class TestPdfIndexing:
names = {f["path"] for f in result["files"]} names = {f["path"] for f in result["files"]}
assert "a.pdf" in names assert "a.pdf" in names
assert "b.pdf" in names assert "b.pdf" in names
# The PDF content should have been extracted. # The scan defers the expensive text extraction.
a_file = next(f for f in result["files"] if f["path"] == "a.pdf") a_file = next(f for f in result["files"] if f["path"] == "a.pdf")
assert "ObsiGate test PDF" in (a_file.get("content") or "") assert a_file["content"] == ""
assert "uniqueword0" in (a_file.get("content") or "") assert a_file["pdf_text_pending"] is True
class TestPdfLazyEnrichment:
"""BUG-040: PDF text is extracted in a deferred background pass."""
def test_scan_defers_pdf_text_extraction(self, pdf_dir: Path, tmp_path: Path):
from backend.indexer import _scan_vault
vault_root = tmp_path / "vault"
vault_root.mkdir()
shutil.copy2(pdf_dir / "simple.pdf", vault_root / "a.pdf")
result = _scan_vault("v", str(vault_root), {})
a_file = next(f for f in result["files"] if f["path"] == "a.pdf")
assert a_file["content"] == ""
assert a_file["content_preview"] == ""
assert a_file["pdf_text_pending"] is True
@pytest.mark.asyncio
async def test_enrich_pdf_texts_fills_content_and_clears_flag(
self, pdf_dir: Path, tmp_path: Path
):
import backend.indexer as idx
vault_root = tmp_path / "vault"
vault_root.mkdir()
shutil.copy2(pdf_dir / "simple.pdf", vault_root / "a.pdf")
file_info = {
"path": "a.pdf",
"title": "a",
"tags": [],
"content": "",
"content_preview": "",
"size": 0,
"modified": "",
"extension": ".pdf",
"pdf_text_pending": True,
}
with idx._index_lock:
idx.index["LazyV"] = {
"files": [file_info],
"tags": {},
"path": str(vault_root),
"paths": [],
}
try:
count = await idx.enrich_pdf_texts("LazyV")
assert count == 1
assert "ObsiGate test PDF" in file_info["content"]
assert "uniqueword0" in file_info["content"]
assert file_info["content_preview"]
assert "pdf_text_pending" not in file_info
finally:
with idx._index_lock:
idx.index.pop("LazyV", None)
@pytest.mark.asyncio
async def test_enrich_pdf_texts_skips_other_vaults(self, pdf_dir: Path, tmp_path: Path):
import backend.indexer as idx
vault_root = tmp_path / "vault"
vault_root.mkdir()
shutil.copy2(pdf_dir / "simple.pdf", vault_root / "a.pdf")
file_info = {
"path": "a.pdf",
"title": "a",
"tags": [],
"content": "",
"content_preview": "",
"size": 0,
"modified": "",
"extension": ".pdf",
"pdf_text_pending": True,
}
with idx._index_lock:
idx.index["OtherV"] = {
"files": [file_info],
"tags": {},
"path": str(vault_root),
"paths": [],
}
try:
assert await idx.enrich_pdf_texts("LazyV") == 0
assert file_info["content"] == ""
finally:
with idx._index_lock:
idx.index.pop("OtherV", None)
# ── Search filter `ext:` ─────────────────────────────────────────────────── # ── Search filter `ext:` ───────────────────────────────────────────────────
+22
View File
@@ -317,3 +317,25 @@ class TestTokenInvalidation:
"/api/auth/me", headers={"Authorization": f"Bearer {token}"} "/api/auth/me", headers={"Authorization": f"Bearer {token}"}
) )
assert me.status_code == 401 assert me.status_code == 401
# ═══════════════════════════════════════════════════════════════════
# BUG-064 — Excalidraw editor stylesheet (esm.sh) must pass CSP
# ═══════════════════════════════════════════════════════════════════
class TestCspExcalidrawStylesheet:
def test_style_src_allows_esm_sh(self, client):
"""The Excalidraw editor loads its stylesheet from esm.sh.
Without this directive the CSS is blocked, the editor is unstyled and
its container enters a resize feedback loop up to the 2^25 canvas cap,
leaving the scene blank (BUG-064).
"""
resp = client.get("/api/health")
csp = resp.headers.get("content-security-policy", "")
style_src = next(
(d.strip() for d in csp.split(";") if d.strip().startswith("style-src")),
"",
)
assert style_src, "CSP style-src directive not found"
assert "https://esm.sh" in style_src