Compare commits

..
7 Commits
Author SHA1 Message Date
bruno 24229316c7 fix: CI lint — upload.test.mjs rejoint l'étape JSDOM (jsdom) BUG-082
CI / lint (push) Failing after 1m30s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 1m33s
2026-09-27 09:23:51 -04:00
bruno 7d70e0fb75 fix: harnais E2E local anti-blocage BUG-080
CI / lint (push) Failing after 1m51s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 1m33s
2026-09-27 09:08:58 -04:00
bruno d70ecd0968 securite: #87 T5b nonces CSP prets pour bascule (sans changement) 2026-09-26 22:44:12 -04:00
bruno 36a4030c09 securite: #87 T5a supprime 16 handlers inline au profit de listeners (CSP inchangee) 2026-09-26 22:32:01 -04:00
bruno 330462e7a5 docs: #87 T4 consigne resultats E2E locaux (108/108 desktop, 10/10 mobiles cibles) 2026-09-26 22:22:31 -04:00
bruno 922dfa2e79 test: #87 T4 E2E XSS partage et lecteur + script serveur E2E avec progression 2026-09-26 21:46:16 -04:00
bruno 34fce932cb securite: #87 T3 cookies Secure centralises + CORS atteste (defaut inchange) 2026-09-26 18:37:19 -04:00
32 changed files with 963 additions and 136 deletions
+3
View File
@@ -13,6 +13,9 @@ OBSIGATE_ADMIN_PASSWORD=chab30
# OBSIGATE_ALLOW_INSECURE=false
# Sécurité des cookies (activer si derrière HTTPS)
# false par défaut : les navigateurs ignorent les cookies `Secure` en HTTP,
# ce qui casserait les logins en local. En production (TLS + bind réseau),
# posez true — un avertissement est loggé au démarrage sinon (#87).
# OBSIGATE_SECURE_COOKIES=false
# Tokens TTL en secondes
+3 -2
View File
@@ -44,13 +44,12 @@ jobs:
node tests/frontend/config-mobile.test.mjs
node tests/frontend/settings-order-avatar.test.mjs
node tests/frontend/mobile-toolbar.test.mjs
node tests/frontend/upload.test.mjs
node tests/frontend/pretty.test.mjs
node tests/frontend/media-viewer.test.mjs
node tests/frontend/mfa-settings.test.mjs
node tests/frontend/config-ai-keys.test.mjs
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition)
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition + Upload)
run: |
cd tests/frontend
if [ -d node_modules ]; then
@@ -68,6 +67,7 @@ jobs:
node toolbar-order.test.mjs
node editor-inline.test.mjs
node ai-quick-actions.test.mjs
node upload.test.mjs
else
echo "tests/frontend/node_modules missing - installing jsdom"
npm install --no-audit --no-fund --silent
@@ -85,6 +85,7 @@ jobs:
node toolbar-order.test.mjs
node editor-inline.test.mjs
node ai-quick-actions.test.mjs
node upload.test.mjs
fi
# ── Tests ─────────────────────────────────────────────────────────
+141 -45
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.28.2**.
> [Unreleased](#unreleased). La dernière version livrée est **2.28.9**.
---
@@ -14,6 +14,91 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.28.9] — 2026-09-27
### Corrigé
- **BUG-082 — CI `lint` rouge : `upload.test.mjs` exige `jsdom`.**
`tests/frontend/upload.test.mjs` (import statique `jsdom`, introduit par
`#89`) était exécuté dans l'étape frontend racine où `jsdom` n'est jamais
installé (`ERR_MODULE_NOT_FOUND`, rouge depuis `7bee4a2`). Déplacé dans
l'étape JSDOM (les deux branches, après install si besoin) ; seul fichier
de l'étape racine avec import statique `jsdom`, les autres suites racine
n'en ont pas besoin.
---
## [2.28.8] — 2026-09-27
### Corrigé
- **BUG-080 — harnais E2E local anti-blocage (plus de run pendu toute la nuit).**
`run-e2e-local.ps1/.sh` : `npx --yes` (jamais de prompt interactif),
installation Chromium sautée si déjà présent (`E2E_INSTALL_BROWSERS=1`
pour forcer), étapes `install`/`test` bornées (`E2E_TIMEOUT_SEC`,
défaut 900 s / 600 s, exit 124 au dépassement) ; `playwright.config.ts` :
`globalTimeout` (15 min en local, 30 min en CI, `E2E_GLOBAL_TIMEOUT_MS`
pour surcharger) ; `e2e-server.ps1` : pidfile resynchronisé sur le vrai
propriétaire du port et `stop` qui tue l'arbre complet (fini les serveurs
orphelins qui squattent le port 2029). Garde-fou : `tests/test_e2e_harness.py`.
---
## [2.28.7] — 2026-09-26
### Ajouté
- **#87 (T5b) — nonces CSP prêts pour la bascule (sans changement).**
Nonce frais par réponse dans `script-src` (`backend/csp.py`), injecté
dans les 6 pages HTML servies (dont la nouvelle route
`/excalidraw-editor.html`, utilisée par l'iframe du viewer au lieu de
`/static/`) et la page de partage ; `tests/test_csp_nonce.py` (unicité,
concordance en-tête/HTML). `unsafe-inline` conservé jusqu'en T5c.
---
## [2.28.6] — 2026-09-26
### Modifié
- **#87 (T5a) — 16 handlers inline convertis en listeners (CSP inchangée).**
`onclick`/`onerror` de `index.html` et des vues JS (`config`, `plugins`,
`sync`, `viewer`, `auth`) remplacés par `addEventListener` ; suites
frontend vertes (unit, ai, config-mobile, pdf-viewer, mfa-settings,
sidebar-filters).
---
## [2.28.5] — 2026-09-26
---
## [2.28.4] — 2026-09-26
### Ajouté
- **#87 (T4) — E2E XSS et serveur E2E piloté.**
`tests/e2e/xss.spec.js` : page publique `/s/{token}` (titre/frontmatter
hostile échappé, JSON neutralisé, aucun JS exécuté) et lecteur markdown
(sanitizer, aucun `on*`/`javascript:` vivant) — 2/2 verts en local.
`scripts/e2e-server.ps1` : `start|stop|status|logs` avec progression
visible et fichier PID (fini les serveurs orphelins sur le port 2029).
---
## [2.28.3] — 2026-09-26
### Ajouté
- **#87 (T3) — cookies `Secure` et CORS explicites.**
Helper `is_secure_cookies()` centralisé (défaut `false` conservé pour ne
pas casser les logins HTTP locaux) + avertissement au démarrage sur bind
non-loopback sans `Secure` ; `tests/test_security_headers.py` atteste
l'absence de CORS permissif (same-origin par défaut du navigateur) et les
en-têtes de durcissement.
---
## [2.28.2] — 2026-09-26
### Ajouté
@@ -43,50 +128,6 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.28.0] — 2026-09-26
---
## [2.27.12] — 2026-09-26
---
## [2.27.11] — 2026-09-26
---
## [2.27.10] — 2026-09-26
---
## [2.27.9] — 2026-09-26
---
## [2.27.8] — 2026-09-26
---
## [2.27.7] — 2026-09-26
---
## [2.27.6] — 2026-09-26
---
## [2.27.5] — 2026-09-26
---
## [2.27.4] — 2026-09-26
---
## [2.27.3] — 2026-09-26
---
## [2.27.2] — 2026-09-26
### Modifié
- **#85 (T10) — persistance d'état et clôture de la refonte architecturale.**
@@ -99,6 +140,11 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
câblés, rien à coder). Index non persisté : rebuild différentiel #86
suffisant (décision documentée). Fiche `docs/features/archi-refonte-85.md`,
#85 sorti du backlog (index roadmap).
## [2.27.12] — 2026-09-26
### Modifié
- **#85 (T9) — extraction realtime + render hors du monolithe `backend/main.py`.**
Le stream SSE `/api/events` et le WebSocket `/ws/collab/*` sont servis par
`backend/routers/realtime.py`, le pipeline markdown (mistune, wikilinks,
@@ -106,30 +152,55 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
couplage différé). `main.py` (4 827 → ~760 lignes) ne contient plus que
l'assemblage : lifespan, middlewares, montage des 16 routers, racine
`/api`, statique/SPA et cales de compatibilité testées.
## [2.27.11] — 2026-09-26
### Modifié
- **#85 (T8) — extraction vaults/history/conflicts hors du monolithe `backend/main.py`.**
13 routes servies par `backend/routers/vaults.py`, `history.py` et
`conflicts.py` ; `VaultInfo`/`BookmarkToggleRequest` dans `schemas.py`,
handle watcher partagé dans `backend/watcher_state.py`.
`tests/test_api_main.py` importe `humanize_mtime` depuis son module
canonique (`services.recent`).
## [2.27.10] — 2026-09-26
### Modifié
- **#85 (T7) — extraction du domaine `config` hors du monolithe `backend/main.py`.**
`/api/config`, ai-keys (get/post/delete/test), tool-keys (×3), ai-models,
diagnostics et dashboard sont servis par `backend/routers/config.py`
(`_FALLBACK_MODELS`, store clés et config déplacés ; `main` réimporte
`_load_config` pour son lifespan, les fixtures de tests inchangées).
`tests/test_ai_models.py` patch désormais la référence du router.
## [2.27.9] — 2026-09-26
### Modifié
- **#85 (T6c) — extraction media/pdf/export/guide hors du monolithe `backend/main.py`.**
file/pdf, exports (html/md-bundle/epub), guide/download, pdf/stream|info,
image, media+thumb, attachments (rescan/stats), vault settings (get/post/all)
et vault files sont servis par `backend/routers/files_media.py` ; le helper
Range partagé vit dans `backend/routers/helpers.py` (tags OpenAPI inchangés,
tests statiques frontend `media-viewer`/`image-viewer` réalignés).
## [2.27.8] — 2026-09-26
### Modifié
- **#85 (T6b) — extraction mutations fichiers/dossiers hors du monolithe `backend/main.py`.**
`PUT .../save|xlsx/save`, `DELETE/POST/PATCH /api/file`, `POST/PATCH/DELETE
/api/directory`, `POST /api/move`, `POST .../batch-upload` sont servis par
le nouveau `backend/routers/files_write.py` (effets de bord inchangés :
audit, index, SSE, webhooks, plugins, historique) ; 15 modèles dans
`schemas.py`.
## [2.27.7] — 2026-09-26
### Modifié
- **#85 (T6a) — extraction lecture fichiers hors du monolithe `backend/main.py`.**
`/api/browse/{vault}`, `/api/file/{vault}/raw|download|backlinks` et
`GET /api/file/{vault}` (vue rendue tous formats) sont servis par le
@@ -138,6 +209,11 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
`backend/routers/helpers.py` (partagés avec les tranches suivantes).
Correctif au passage : décorateur orphelin `/s/{token}` resté en T3 et
double-enregistrement de `/api/conflicts` supprimés.
## [2.27.6] — 2026-09-26
### Modifié
- **#85 (T5) — extraction du domaine `search` hors du monolithe `backend/main.py`.**
Les 11 routes (`/api/search`, `/advanced`, `/replace`, `/tags`,
`/tree-search`, `/vault/{vault}/paths`, `/suggest`, `/tags/suggest`,
@@ -145,22 +221,42 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
par le nouveau `backend/routers/search.py` ; les modèles search dans
`schemas.py` et le pool de threads dans `backend/search_executor.py`
(même dimensionnement, même cycle de vie) — comportement inchangé.
## [2.27.5] — 2026-09-26
### Modifié
- **#85 (T4) — extraction du domaine `backups` hors du monolithe `backend/main.py`.**
Les 9 routes (`/api/file/{vault}/backups|diff|restore`, `/api/backups`,
`/delete`, `/purge`, `/content`, `/compress`, `/auto`) sont servies par le
nouveau `backend/routers/backups.py` ; `Diff/Restore*` déménagent dans
`schemas.py` et le singleton SSE dans `backend/sse.py` (partagé avec
`main`) — comportement inchangé, aucun impact utilisateur.
## [2.27.4] — 2026-09-26
### Modifié
- **#85 (T3) — extraction du domaine `sharing` hors du monolithe `backend/main.py`.**
`POST /api/share/{vault}`, `GET /api/shares`, `DELETE /api/share/{share_id}`
et les pages publiques `/s/{token}`, `/s/{token}/raw`, `/s/{token}/pdf`
sont servis par le nouveau `backend/routers/sharing.py` — chemins,
réponses, tags OpenAPI et authentification inchangés (aucun impact
utilisateur).
## [2.27.3] — 2026-09-26
### Modifié
- **#85 (T2) — extraction du domaine `webhooks` hors du monolithe `backend/main.py`.**
Le CRUD `GET/POST/PATCH/DELETE /api/webhooks` (admin) est servi par le
nouveau `backend/routers/webhooks.py` — chemins, réponses, tags OpenAPI et
authentification inchangés (aucun impact utilisateur).
## [2.27.2] — 2026-09-26
### Modifié
- **#85 (T1) — extraction du domaine `health` hors du monolithe `backend/main.py`.**
`GET /api/health` et `GET /api/health/detailed` (admin) sont servis par le
nouveau `backend/routers/health.py` (monté dans `main.py`) et le modèle
+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.
[![Version](https://img.shields.io/badge/Version-2.28.2-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.28.9-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.28.2).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.28.9).
---
*Projet : ObsiGate | Version : 2.28.2 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.28.9 | Dernière mise à jour : Septembre 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.
[![Version](https://img.shields.io/badge/Version-2.28.2-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.28.9-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.28.2).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.28.9).
---
*Project: ObsiGate | Version: 2.28.2 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.28.9 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.28.2
2.28.9
+14 -5
View File
@@ -5,6 +5,7 @@
import base64
import binascii
import logging
import os
import re
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
@@ -56,6 +57,17 @@ logger = logging.getLogger("obsigate.auth.router")
router = APIRouter(prefix="/api/auth", tags=["auth"])
def is_secure_cookies() -> bool:
"""True when auth cookies must carry the ``Secure`` flag (#87 T3).
Opt-in via ``OBSIGATE_SECURE_COOKIES=true`` (required behind TLS).
Default stays ``false`` so logins keep working over plain HTTP on
trusted loopback deployments — browsers drop ``Secure`` cookies sent
over HTTP, which would silently break localhost logins.
"""
return os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
# ── Pydantic request models ──────────────────────────────────────────
class LoginRequest(BaseModel):
@@ -229,9 +241,8 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
access_token = create_access_token(user)
refresh_token, refresh_jti = create_refresh_token(username, remember=remember_me)
import os
max_age = 2592000 if remember_me else 604800 # 30d or 7d
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
secure = is_secure_cookies()
response.set_cookie(
key="refresh_token",
value=refresh_token,
@@ -300,9 +311,7 @@ async def refresh_token_endpoint(request: Request, response: Response):
if stale:
raise HTTPException(401, "Session expirée, veuillez vous reconnecter")
import os
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
secure = is_secure_cookies()
remember_me = bool(payload.get("remember", False))
# BUG-027: rotate the refresh token — the old one is now single-use.
+35
View File
@@ -0,0 +1,35 @@
"""Content-Security-Policy nonces (ROADMAP #87, tranche 5b).
Chaque réponse HTTP reçoit un nonce frais (``request.state.csp_nonce``)
injecté dans ``script-src``. Les routes servant du HTML avec des scripts
inline (index, popout, admin, editor-poc, excalidraw, page de partage)
l'injectent dans le balisage via :func:`inject_csp_nonce` — mêmes
emplacements, aucun script déplacé.
Tant que ``'unsafe-inline'`` reste dans la politique (retrait en T5c),
l'injection est inerte : elle prépare la bascule sans changer le
comportement.
"""
from __future__ import annotations
import re
import secrets
# Balises <script> exécutables sans `src` et sans nonce existant :
# `<script>`, `<script type="module">`, `<script type="importmap">`.
# Les blocs non-JS (ex. `type="text/plain"`) et les scripts externes
# (`src=…`, couverts par 'self'/hôtes CDN) sont laissés intacts.
_SCRIPT_TAG_RE = re.compile(
r"<script(?=>|\s+type=\"(?:module|importmap)\"\s*>)",
)
def new_nonce() -> str:
"""Generate a fresh per-response CSP nonce."""
return secrets.token_urlsafe(16)
def inject_csp_nonce(html: str, nonce: str) -> str:
"""Add ``nonce="…"`` to bare executable inline ``<script>`` tags."""
return _SCRIPT_TAG_RE.sub(f'<script nonce="{nonce}"', html)
+49 -9
View File
@@ -167,6 +167,13 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Add security headers to all HTTP responses."""
async def dispatch(self, request, call_next):
from backend.csp import new_nonce
# Nonce CSP frais par réponse (#87 T5b) : injecté dans script-src et
# dans le balisage HTML par les routes (backend.csp.inject_csp_nonce).
# 'unsafe-inline' est conservé jusqu'en T5c (bascule avec validation E2E).
nonce = new_nonce()
request.state.csp_nonce = nonce
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
@@ -177,7 +184,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
if "Content-Security-Policy" not in response.headers:
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
f"script-src 'self' 'unsafe-inline' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
"img-src 'self' data: blob:; "
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
@@ -249,6 +256,17 @@ async def lifespan(app: FastAPI):
# BUG-037: refuse to expose an unauthenticated instance on a public bind.
_guard_insecure_auth()
# #87 T3 : avertir quand les cookies d'auth circulent sans flag Secure
# sur un bind non-loopback (transactions observables en clair).
from backend.auth.middleware import bind_host_from_argv, is_auth_enabled, is_loopback_host
from backend.auth.router import is_secure_cookies
if is_auth_enabled() and not is_secure_cookies() and not is_loopback_host(bind_host_from_argv()):
logger.warning(
"Cookies d'authentification sans flag `Secure` sur un bind non-loopback : "
"activez TLS et posez OBSIGATE_SECURE_COOKIES=true en production."
)
# Bootstrap admin account if needed
bootstrap_admin()
@@ -689,6 +707,15 @@ def _get_backup_dir(vault_name: str, relative_path: str) -> Path:
# Static files & SPA fallback
# ---------------------------------------------------------------------------
def _html_with_nonce(request: Request, name: str) -> str:
"""Read a frontend HTML file and inject the per-response CSP nonce (#87 T5b)."""
from backend.csp import inject_csp_nonce
return inject_csp_nonce(
(FRONTEND_DIR / name).read_text(encoding="utf-8"),
request.state.csp_nonce,
)
if FRONTEND_DIR.exists():
# ``Cache-Control`` for /static is set by SecurityHeadersMiddleware (no-cache).
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
@@ -721,23 +748,36 @@ if FRONTEND_DIR.exists():
raise HTTPException(status_code=404, detail="Manifest not found")
@app.get("/popout/{vault_name}/{path:path}")
async def serve_popout(vault_name: str, path: str):
async def serve_popout(request: Request, vault_name: str, path: str):
"""Serve the minimalist popout page for a specific file."""
popout_file = FRONTEND_DIR / "popout.html"
if popout_file.exists():
return HTMLResponse(content=popout_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"})
return HTMLResponse(content=_html_with_nonce(request, "popout.html"), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Popout template not found")
@app.get("/editor-poc")
async def serve_editor_poc():
async def serve_editor_poc(request: Request):
"""Serve the standalone Editor POC page (multi-zone toolbar demo)."""
poc_file = FRONTEND_DIR / "editor-poc.html"
if poc_file.exists():
return HTMLResponse(content=poc_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"})
return HTMLResponse(content=_html_with_nonce(request, "editor-poc.html"), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Editor POC not found")
@app.get("/excalidraw-editor.html", include_in_schema=False)
async def serve_excalidraw_editor(request: Request):
"""Serve the Excalidraw editor with CSP nonce (#87 T5b).
Remplace l'accès direct via ``/static/`` (utilisé par l'iframe du
viewer) : sans injection, les scripts inline seraient bloqués dès
le retrait de ``'unsafe-inline'`` (T5c).
"""
exca_file = FRONTEND_DIR / "excalidraw-editor.html"
if exca_file.exists():
return HTMLResponse(content=_html_with_nonce(request, "excalidraw-editor.html"), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Excalidraw editor not found")
@app.get("/admin.html", response_class=HTMLResponse)
async def serve_admin_page(_current_user=Depends(require_admin)):
async def serve_admin_page(request: Request, _current_user=Depends(require_admin)):
"""Serve the admin dashboard page (ROADMAP #71) — admin-gated.
Must be declared BEFORE the SPA catch-all ``/{full_path:path}`` or the
@@ -746,13 +786,13 @@ if FRONTEND_DIR.exists():
"""
admin_file = FRONTEND_DIR / "admin.html"
if admin_file.exists():
return HTMLResponse(content=admin_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"})
return HTMLResponse(content=_html_with_nonce(request, "admin.html"), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Admin page not found")
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
async def serve_spa(request: Request, full_path: str):
"""Serve the SPA index.html for all non-API routes."""
index_file = FRONTEND_DIR / "index.html"
if index_file.exists():
return HTMLResponse(content=index_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"})
return HTMLResponse(content=_html_with_nonce(request, "index.html"), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Frontend not found")
+17 -7
View File
@@ -21,7 +21,7 @@ import logging
from pathlib import Path
import frontmatter
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
from fastapi.responses import FileResponse, HTMLResponse, Response
from backend.auth.middleware import check_vault_access, require_auth
@@ -164,8 +164,10 @@ async def public_share_raw(token: str):
@router.get("/s/{token}", response_class=HTMLResponse)
async def public_share_view(token: str):
async def public_share_view(request: Request, token: str):
"""Public share view — no authentication required."""
from backend.csp import inject_csp_nonce
share = get_share_by_token(token)
if not share:
raise HTTPException(404, "Share not found or expired")
@@ -230,7 +232,9 @@ async def public_share_view(token: str):
if fm_items:
fm_html = f'<div class="fm-section"><div class="fm-header">Frontmatter</div><div class="fm-body">{"".join(fm_items)}</div></div>'
return HTMLResponse(f"""<!DOCTYPE html><html lang="fr" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
return HTMLResponse(
inject_csp_nonce(
f"""<!DOCTYPE html><html lang="fr" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title_esc} — ObsiGate Share</title>
<style>
:root {{ --bg:#1a1a2e; --bg-card:#16213e; --text:#e0e0e0; --text-muted:#888; --accent:#6366f1; --border:#2a2a4a; --banner-bg:var(--accent); --banner-text:#fff; }}
@@ -274,15 +278,15 @@ body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:
</div>
<div class="toolbar">
<span class="toolbar-title">{title_esc}</span>
<button class="toolbar-btn" onclick="toggleTheme()" title="Thème clair/sombre">
<button class="toolbar-btn" data-share-theme title="Thème clair/sombre">
<svg id="theme-icon-dark" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<svg id="theme-icon-light" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
</button>
<button class="toolbar-btn" onclick="exportMD()" title="Télécharger en Markdown">
<button class="toolbar-btn" data-share-md title="Télécharger en Markdown">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
.md
</button>
<button class="toolbar-btn" onclick="location.href=location.pathname+'/pdf'" title="Télécharger en PDF">
<button class="toolbar-btn" data-share-pdf title="Télécharger en PDF">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
PDF
</button>
@@ -293,4 +297,10 @@ body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:
function toggleTheme(){{var t=document.documentElement;var isDark=t.dataset.theme==="dark";t.dataset.theme=isDark?"light":"dark";document.getElementById("theme-icon-dark").style.display=isDark?"none":"";document.getElementById("theme-icon-light").style.display=isDark?"":"none";localStorage.setItem("obsigate-share-theme",t.dataset.theme)}}
(function(){{var s=localStorage.getItem("obsigate-share-theme");if(!s)s="dark";document.documentElement.dataset.theme=s;var isDark=s==="dark";document.getElementById("theme-icon-dark").style.display=isDark?"":"none";document.getElementById("theme-icon-light").style.display=isDark?"none":""}})();
function exportMD(){{var raw=JSON.parse(document.getElementById("raw-content").textContent);var b=new Blob([raw],{{type:"text/markdown"}});var a=document.createElement("a");a.href=URL.createObjectURL(b);a.download={title_download_js};a.click()}}
</script></body></html>""")
document.querySelector("[data-share-theme]").addEventListener("click",toggleTheme);
document.querySelector("[data-share-md]").addEventListener("click",exportMD);
document.querySelector("[data-share-pdf]").addEventListener("click",function(){{location.href=location.pathname+"/pdf"}});
</script></body></html>""",
request.state.csp_nonce,
),
)
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.28.2"
version = "2.28.9"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.28.2"
version = "2.28.9"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.28.2",
"version": "2.28.9",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+5
View File
@@ -188,6 +188,9 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-076* | [🟡 IMPORTANT] Assistant IA : après une action de l'agent, l'arborescence et le document ouvert ne sont pas rafraîchis dynamiquement | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : créer/supprimer un fichier ou dossier, modifier le document ouvert → l'UI ne bouge pas | `frontend/js/bookslm.js` : `MUTATING_TOOLS`/`FILE_WRITE_TOOLS`, refresh d'arborescence débouncé sur event `tool`, `_notifyFileWritten` étendu (xlsx/docx/csv/pdf). Tests : `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs` | Aucun refresh explicite sur les événements `tool` mutateurs (repose uniquement sur le watcher SSE) ; `_notifyFileWritten` ignore les créations de documents (xlsx/docx/csv/pdf) |
| *BUG-077* | [🟡 IMPORTANT] Assistant IA : aucun bouton « Stop » pour arrêter l'exécution de l'agent à tout moment | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : lancer une longue tâche → le bouton Envoyer est désactivé, impossible d'arrêter (seule la fermeture du panneau abort) | `frontend/js/bookslm.js` + `frontend/style.css` : bouton d'envoi → Stop (`_syncSendButton`/`_stopGeneration`/`_markStopped`), i18n `ai.stop`/`ai.stopped`. Tests : `tests/frontend/ai.test.mjs` (+2) | `_abortCtrl` n'est déclenché que par `close()` ; aucun signal d'arrêt côté client pendant le stream |
| *BUG-078* | [🟡 IMPORTANT] Fichiers de code : la coloration syntaxique (highlight.js) disparaît — les feuilles de thème sont basculées à partir de la **clé** de thème au lieu du **mode** | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/themes.js`, `frontend/js/ui.js`, `tests/frontend/unit.test.mjs` | Ouvrir un fichier `.py`/`.sh`/`.ps1`/`.yml` : le code s'affiche en texte brut, sans couleurs | `frontend/js/themes.js` : `applyTheme` bascule `hljs-theme-dark`/`hljs-theme-light` selon le **mode** (`isDark`). `frontend/js/ui.js` : `initTheme`/`applyTheme` résolvent le mode persisté (`obsigate-theme-mode`) au lieu de traiter la clé (`defaut-obsigate`) comme un mode. Test : `unit.test.mjs` (+1). | Les deux feuilles étaient désactivées car `defaut-obsigate !== "dark"` et `!== "light"` ; résultat **non déterministe** selon l'ordre `UI.initTheme()` (clé) / `Sync.init()` → `themes.initThemes()` (mode). Vérifié Playwright : 5/5 chargements colorés (`.py`), sépia/contraste élevé sur la palette claire |
| *BUG-082* | CI `lint` rouge : `tests/frontend/upload.test.mjs` (import statique `jsdom`) exécuté dans l'étape racine où `jsdom` n'est jamais installé | 🟢 corrigé | P0 | 🧩 tests | IA | `.gitea/workflows/ci.yml`, `tests/frontend/upload.test.mjs` | CI job `lint` → `ERR_MODULE_NOT_FOUND: jsdom` (introduit par `7bee4a2`, jamais vert depuis) | `upload.test.mjs` déplacé dans l'étape JSDOM (les deux branches) ; vérifié : étape racine verte + `upload` vert depuis `tests/frontend/` | Seul fichier de l'étape racine avec import statique jsdom ; `jsdom` ne vit que dans `tests/frontend/node_modules` (installé par l'étape JSDOM). Correctif : déplacer `upload.test.mjs` dans l'étape JSDOM |
| *BUG-081* | `GET /api/auth/mfa/status` → 500 quand l'auth est désactivée (`user` None, `AttributeError` sur `user.get`) | 🔴 ouvert | P1 | ⚙️ backend | IA | `backend/auth/router.py::mfa_status` (l.821-831) | Auth désactivée : `curl http://127.0.0.1:2029/api/auth/mfa/status` → 500 (reproduit live 2026-09-27, `e2e-server.err.log` l.116-183) | — | `require_auth` laisse passer le pseudo-user anonymous, `get_user(username)` → None non gardé. Trouvé via les logs E2E pendant BUG-080 |
| *BUG-080* | [🔴 BLOQUANT] E2E locaux bloqués toute la nuit : `npm run test:e2e:ps` ne termine jamais (serveurs orphelins sur le port 2029, `npx playwright install` sans `--yes` ni garde-fou, suite ~130 tests sans timeout global) | 🟢 corrigé | P0 | 🧩 tests | IA | `scripts/run-e2e-local.ps1`, `scripts/run-e2e-local.sh`, `scripts/e2e-server.ps1`, `playwright.config.ts`, `tests/test_e2e_harness.py` (nouveau) | `npm run test:e2e:ps` | `run-e2e-local` : `npx --yes`, skip install Chromium si présent (`E2E_INSTALL_BROWSERS=1`), timeouts `E2E_TIMEOUT_SEC` (900)/`E2E_BROWSER_INSTALL_TIMEOUT_SEC` (600, exit 124) ; `playwright.config.ts` : `globalTimeout` 15 min local / 30 min CI (`E2E_GLOBAL_TIMEOUT_MS`) ; `e2e-server.ps1` : pidfile = vrai owner du port, `stop` tue l'arbre complet. Tests : `tests/test_e2e_harness.py` (8/8), cycle start/stop live (pidfile cohérent, port libéré) | Constat 2026-09-27 : `e2e-server.ps1 start` OK (READY 12 s) mais run suivant pendu toute la nuit ; 2 python orphelins (PID 81180 parent + 81936 sur le port, pidfile périmé). Double processus systématique (parent `.venv` parqué + enfant qui sert — aussi sur flowdeck/3.13 : environnemental, sans impact après correctif). Trouvé au passage : BUG-081 (`/api/auth/mfa/status` → 500 auth désactivée) |
| *BUG-079* | `GET /api/diagnostics` → 500 « dictionary changed size during iteration » (stats d'index) | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` | Charger la page de diagnostic pendant une indexation : `GET /api/diagnostics` → 500 | `backend/main.py` (`api_diagnostics`) : snapshot avant itération — `list(index.items())` et `inv.word_index.copy()` (copie C atomique sous le GIL) ; test de non-régression `tests/test_api_main.py::TestConfig::test_diagnostics_concurrent_index_writes` | Le handler itérait les dicts en direct alors que l'indexeur les modifiait depuis un autre thread (rebuild initial dans `_search_executor`, hooks incrémentaux `add_document`/`remove_document`) → `RuntimeError` dans le générateur → 500. Test déterministe (`RaceDict` fait grossir le dict en cours d'itération) : échoue sans le correctif, passe avec. Vérifié : pytest 1305 passed / 6 skipped, ruff 0, mypy 0 |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -273,6 +276,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-09-24 | #115, #117, BUG-078 | Feature + correction | `frontend/js/themes.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/index.html`, `frontend/style.css`, `frontend/popout.html`, `frontend/locales/{fr,en}.json`, `frontend/icons/avatar/*` (nouveau), `tests/frontend/unit.test.mjs`, `tests/frontend/toolbar-order.test.mjs`, `tests/frontend/settings-order-avatar.test.mjs`, `docs/features/viewer-toolbar-highlight-avatars.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md` | **#115** barre d'outils de lecture épinglée : `viewer.js`/`popout.html` sortent `.file-actions` de `.file-header` dans un `.file-toolbar` enfant direct de `.content-area` (`position: sticky; top: 0`), masqué en mode lecture. **BUG-078** coloration syntaxique : le basculement des feuilles highlight.js suit le **mode** (`themes.applyTheme` + `ui.initTheme/applyTheme` lisent `obsigate-theme-mode`) au lieu de la clé de thème qui désactivait les deux feuilles. **#117** avatars prédéfinis : galerie de 12 images (`frontend/icons/avatar/`) dans `#cfg-profile`, clic → recadrage 256 px (pipeline import) + `PATCH /api/auth/me`, avatars actifs surlignés (`obsigate-avatar-preset`), import personnalisé et suppression conservés. Vérifié : Playwright (coloration 5/5 déterministe, toolbar épinglée à `barTop` constant au défilement), `unit.test.mjs` 12/12, `toolbar-order` 13/13, `settings-order-avatar` 12/12, JSDOM editor-inline/pane-manager/mobile-editor/image-viewer/pdf-viewer/config-mobile/media-viewer/excalidraw verts, pytest 1304 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-24 | BUG-079 | Correction | `backend/main.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-079** : `GET /api/diagnostics` renvoyait 500 « dictionary changed size during iteration ». Le handler itérait `inv.word_index.values()` et `index.items()` en direct alors que l'indexeur les modifiait depuis un autre thread (rebuild initial dans `_search_executor`, hooks incrémentaux `add_document`/`remove_document`) → `RuntimeError` dans le générateur. Correctif : **snapshot avant itération** (`list(index.items())`, `inv.word_index.copy()`) — copie C atomique sous le GIL, pas de verrou ajouté. Test de non-régression déterministe (`RaceDict` fait grossir le dict pendant l'itération ; échoue sans le correctif, passe avec). Vérifié : pytest 1305 passed / 6 skipped, ruff 0, mypy 0 (80 fichiers), validate-imports 40 modules, unit 12/12. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-27 | BUG-080, BUG-081 | Correction + enregistrement | `scripts/run-e2e-local.ps1`, `scripts/run-e2e-local.sh`, `scripts/e2e-server.ps1`, `playwright.config.ts`, `tests/test_e2e_harness.py` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-080** : run E2E local pendu toute la nuit → harnais anti-blocage : `npx --yes` (plus de prompt interactif), install Chromium sautée si présent (`E2E_INSTALL_BROWSERS=1`), timeouts `E2E_TIMEOUT_SEC` (900)/`E2E_BROWSER_INSTALL_TIMEOUT_SEC` (600, exit 124), `globalTimeout` Playwright (15 min local / 30 min CI, `E2E_GLOBAL_TIMEOUT_MS`), pidfile resynchronisé sur le vrai owner du port + `stop` qui tue l'arbre complet (orphelins 81180/81936 nettoyés, port 2029 libéré). Diagnostic : double processus systématique (parent `.venv` parqué + enfant qui sert — environnemental, aussi sur flowdeck/3.13). **BUG-081** (ouvert, non traité) : `GET /api/auth/mfa/status` → 500 auth désactivée (`user` None, `router.py:827`, reproduit live). Vérifié : `test_e2e_harness.py` 8/8, cycle start/stop live (pidfile cohérent, port libéré). | 🟢 corrigé (en attente vérif utilisateur) ; BUG-081 🔴 ouvert |
| 2026-09-27 | BUG-082 | Correction CI | `.gitea/workflows/ci.yml`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-082** : `lint` rouge (`ERR_MODULE_NOT_FOUND: jsdom` sur `upload.test.mjs`, rouge depuis `7bee4a2`) — seul fichier de l'étape frontend racine avec import statique `jsdom`, alors que `jsdom` n'est installé que dans `tests/frontend/node_modules` (étape JSDOM). `upload.test.mjs` déplacé dans l'étape JSDOM (les deux branches). Vérifié : étape racine verte (validate-imports, unit, pretty, media-viewer, mfa-settings, config-ai-keys) + `upload` vert depuis `tests/frontend/`. | 🟢 corrigé (en attente vérif utilisateur) |
---
+3 -3
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.28.2 | **Dernière mise à jour :** 2026-09-26
> **Version :** 2.28.9 | **Dernière mise à jour :** 2026-09-27
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -70,9 +70,9 @@
- **Statut :** 🔵 en cours depuis 2026-09-26 — par tranches. **T1 livrée (v2.28.1) :** bandit bloquant (`nosec` justifiés B324/B404/B603/B607/B406, B105 exclu comme `pyproject`), `npm audit` bloquant (0 vulnérabilité), 5 suites frontend intégrées au CI (`upload`, `pretty`, `media-viewer`, `mfa-settings`, `config-ai-keys`). pip-audit reste consultatif (montées starlette/weasyprint à qualifier).
- **Description :** renforcer le pipeline (`.gitea/workflows/ci.yml`, `desktop-build.yml`) pour le rendre bloquant par défaut et accompagner les phases 1 → 3. Constat 2026-09-26 : job `security` non bloquant (`bandit`/`pip-audit` en `|| echo`, ni semgrep ni trivy), E2E limité à `chromium-desktop`, 5 suites frontend hors CI.
- **Sous-tâches :**
- [ ] Jobs CI sécurité **bloquants** (bandit/semgrep/trivy, audits pip/npm) + tests E2E XSS (page de partage + lecteur markdown)
- [ ] Jobs CI sécurité **bloquants** (bandit/semgrep/trivy, audits pip/npm) + tests E2E XSS (page de partage + lecteur markdown) — **T4 livrée :** `tests/e2e/xss.spec.js` (BUG-021/022, 2/2 vert) + `scripts/e2e-server.ps1` (cycle de vie serveur E2E avec progression `start|stop|status|logs`) + validation locale projet `chromium-desktop` : **108/108 verts** (obsigate 44, split 37, viewers 24, xss/header 3), mobiles ciblés 10/10
- [ ] Tests de concurrence (`users.json`), fuzzing de timing regex, couverture des composants critiques ; intégrer au CI les 5 suites frontend hors CI (`upload`, `pretty`, `media-viewer`, `mfa-settings`, `config-ai-keys`) — **T2 livrée (v2.28.2) :** `tests/test_hardening_concurrency.py` (users.json concurrent + budget temps regex) ; 5 suites au CI (T1)
- [ ] Finir BUG-034 (migration CSP **nonce**, conversion des handlers inline), `Secure` cookies à `true` par défaut, politique CORS same-origin explicite ; confirmer la rotation de la clé DeepSeek (BUG-006, clé dans l'historique Git)
- [ ] Finir BUG-034 (migration CSP **nonce**, conversion des handlers inline), `Secure` cookies à `true` par défaut, politique CORS same-origin explicite ; confirmer la rotation de la clé DeepSeek (BUG-006, clé dans l'historique Git) — **T3 livrée (v2.28.3)** (helper + avertissement + CORS attesté) ; **T5a livrée (v2.28.6)** (16 handlers inline → listeners, CSP inchangée) ; **T5b livrée :** nonce frais par réponse (`backend/csp.py`, `script-src`), injection dans les 6 pages HTML (dont nouvelle route `/excalidraw-editor.html`), `unsafe-inline` conservé (inerte)
- [ ] Revue périodique des dépendances ; documentation utilisateur FR/EN synchronisée ; contrôle automatisé de la conformité au DoD
---
+1 -31
View File
@@ -1576,17 +1576,6 @@
class="help-search-clear"
id="config-search-clear"
title="Effacer"
onclick="
var s =
document.getElementById(
'config-nav-search',
);
if (s) {
s.value = '';
s.dispatchEvent(new Event('input'));
s.focus();
}
"
>
X
</button>
@@ -1698,7 +1687,7 @@
<input type="text" id="profile-name" class="config-input" placeholder="Votre nom" maxlength="60">
</div>
<button class="config-save-btn" id="profile-save" data-i18n="config.save">Enregistrer</button>
<button class="config-save-btn" id="profile-logout" style="background:var(--danger-bg);color:var(--danger);border-color:var(--danger);margin-left:8px" data-i18n="config.logout" onclick="if(window.handleLogout)window.handleLogout();else{doLogoutFallback()}">Déconnexion</button>
<button class="config-save-btn" id="profile-logout" style="background:var(--danger-bg);color:var(--danger);border-color:var(--danger);margin-left:8px" data-i18n="config.logout">Déconnexion</button>
<span class="profile-saved" id="profile-saved" style="display:none" data-i18n="config.saved">✓ Sauvegardé</span>
</div>
</section>
@@ -3005,14 +2994,6 @@
id="help-hamburger"
title="Sommaire"
aria-label="Afficher le sommaire"
onclick="
var n = document.getElementById('help-nav');
if (n) {
var d = n.style.display;
n.style.display =
d === 'none' || d === '' ? 'flex' : 'none';
}
"
>
<i
data-lucide="menu"
@@ -3077,17 +3058,6 @@
id="help-search-clear"
title="Effacer la recherche"
aria-label="Effacer"
onclick="
var s =
document.getElementById(
'help-nav-search',
);
if (s) {
s.value = '';
s.dispatchEvent(new Event('input'));
s.focus();
}
"
>
✕
</button>
+12 -2
View File
@@ -1305,8 +1305,7 @@ async function _startMfaSetup() {
// secret when the backend has no QR generator available.
const qrImg = data.qr_data_url
? `<img id="mfa-qr-img" alt="QR Code" class="mfa-qr-code-img"
src="${data.qr_data_url}"
onerror="this.style.display='none';document.getElementById('mfa-qr-fallback').style.display='block';">`
src="${data.qr_data_url}">`
: "";
const fallbackStyle = data.qr_data_url ? "display:none" : "";
flowArea.innerHTML = `
@@ -1335,6 +1334,17 @@ async function _startMfaSetup() {
codeInput.value = codeInput.value.replace(/[^0-9]/g, "");
});
// QR fallback (#87, ex-onerror inline) : si l'image ne charge pas,
// afficher la saisie manuelle du secret.
const qrImgEl = document.getElementById("mfa-qr-img");
if (qrImgEl) {
qrImgEl.addEventListener("error", () => {
qrImgEl.style.display = "none";
const fallback = document.getElementById("mfa-qr-fallback");
if (fallback) fallback.style.display = "block";
});
}
document.getElementById("mfa-confirm-btn").addEventListener("click", async () => {
const code = codeInput.value.trim();
if (code.length !== 6) return;
+39 -3
View File
@@ -363,6 +363,27 @@ function initHelpModal() {
}
});
// Help TOC hamburger + search clear (#87, ex-onclick inline in index.html).
var helpHamburger = document.getElementById("help-hamburger");
if (helpHamburger) {
helpHamburger.addEventListener("click", function() {
var n = document.getElementById("help-nav");
if (n) {
var d = n.style.display;
n.style.display = d === "none" || d === "" ? "flex" : "none";
}
});
}
var helpSearch = document.getElementById("help-nav-search");
var helpSearchClear = document.getElementById("help-search-clear");
if (helpSearchClear && helpSearch) {
helpSearchClear.addEventListener("click", function() {
helpSearch.value = "";
helpSearch.dispatchEvent(new Event("input"));
helpSearch.focus();
});
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.classList.contains("active")) {
closeHelpModal();
@@ -883,7 +904,7 @@ function initConfigModal() {
});
}
// Logout button — handled inline in index.html (onclick)
// Logout button — wired here with addEventListener (#87, no inline onclick)
// Config nav search
var cfgSearch = document.getElementById("config-nav-search");
@@ -901,6 +922,16 @@ function initConfigModal() {
});
}
// Config search clear button (#87, ex-onclick inline).
var cfgSearchClear = document.getElementById("config-search-clear");
if (cfgSearchClear && cfgSearch) {
cfgSearchClear.addEventListener("click", function() {
cfgSearch.value = "";
cfgSearch.dispatchEvent(new Event("input"));
cfgSearch.focus();
});
}
// BUG-071/#114: mobile table of contents. #config-nav shares the .help-nav
// rule that hides it below 768px, but — unlike the help modal — the config
// modal had no toggle to reveal it, leaving mobile users with no way to
@@ -1575,13 +1606,15 @@ export async function openShareDialog(vault, path) {
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:4px">${escapeHtml(vault)}/${escapeHtml(path)}</p>
${expiresInfo}
<p style="font-size:0.75rem;color:var(--text-muted);margin-bottom:8px">${existingShare.access_count} vue(s)</p>
<input type="text" class="share-url-input" value="${url}" readonly onclick="this.select()">
<input type="text" class="share-url-input" value="${url}" readonly>
<div class="share-dialog-actions">
<button class="share-copy-btn">📋 Copier le lien</button>
<button class="share-revoke-btn">🗑 Révoquer</button>
<button class="share-close-btn">Fermer</button>
</div>
</div>`;
const shareUrlInput = div.querySelector(".share-url-input");
if (shareUrlInput) shareUrlInput.addEventListener("click", function() { shareUrlInput.select(); });
div.querySelector(".share-copy-btn").addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(url);
@@ -2525,7 +2558,10 @@ function initProfile() {
} catch(e) {}
});
// Logout button — handled inline in index.html (onclick)
// Logout button (#87, ex-onclick inline in index.html).
if (logoutBtn && window.handleLogout) {
logoutBtn.addEventListener('click', function() { window.handleLogout(); });
}
// ── Avatar (#113) ────────────────────────────────────────────────
var avatarField = document.getElementById('profile-avatar-field');
+1 -1
View File
@@ -35,7 +35,7 @@ export function renderExcalidraw(container, data, vaultName, filePath, opts = {}
// Build the iframe
const iframe = document.createElement('iframe');
iframe.id = editorId;
iframe.src = '/static/excalidraw-editor.html?v=' + Date.now();
iframe.src = '/excalidraw-editor.html?v=' + Date.now();
iframe.sandbox.add('allow-scripts');
iframe.sandbox.add('allow-same-origin');
// Let the editor's own Fullscreen button work (native Fullscreen API inside
+7 -6
View File
@@ -478,8 +478,8 @@ function openTemplateModal() {
'</div>' +
'</div>' +
'<div class="modal-footer">' +
'<button class="btn btn-primary" data-i18n="common.close" onclick="this.closest(\'.modal-overlay\').remove()">Close</button>' +
'<button class="btn btn-secondary" onclick="copyTemplate()">Copy to Clipboard</button>' +
'<button class="btn btn-primary btn-modal-close" data-i18n="common.close">Close</button>' +
'<button class="btn btn-secondary btn-copy-template">Copy to Clipboard</button>' +
'</div>' +
'</div>';
@@ -487,11 +487,11 @@ function openTemplateModal() {
modal.querySelector('.modal-close')?.addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
window.copyTemplate = () => {
modal.querySelector('.btn-modal-close')?.addEventListener('click', () => modal.remove());
modal.querySelector('.btn-copy-template')?.addEventListener('click', () => {
navigator.clipboard.writeText(JSON.stringify(template, null, 2));
showToast('Template copied to clipboard', 'success');
};
});
});
}
@@ -508,12 +508,13 @@ function openCodeModal(name, code) {
'<pre class="code-block" style="max-height: 500px; overflow: auto;">' + escapeHtml(code) + '</pre>' +
'</div>' +
'<div class="modal-footer">' +
'<button class="btn btn-primary" data-i18n="common.close" onclick="this.closest(\'.modal-overlay\').remove()">Close</button>' +
'<button class="btn btn-primary btn-modal-close" data-i18n="common.close">Close</button>' +
'</div>' +
'</div>';
document.body.appendChild(modal);
modal.querySelector('.modal-close')?.addEventListener('click', () => modal.remove());
modal.querySelector('.btn-modal-close')?.addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
}
+8 -2
View File
@@ -543,10 +543,16 @@ function showUpdateNotification() {
message.innerHTML = `
<div class="pwa-update-content">
<span>Une nouvelle version d'ObsiGate est disponible !</span>
<button class="pwa-update-btn" onclick="window.location.reload()">Mettre à jour</button>
<button class="pwa-update-dismiss" onclick="this.parentElement.parentElement.remove()">×</button>
<button class="pwa-update-btn">Mettre à jour</button>
<button class="pwa-update-dismiss">×</button>
</div>
`;
message.querySelector(".pwa-update-btn").addEventListener("click", function() {
window.location.reload();
});
message.querySelector(".pwa-update-dismiss").addEventListener("click", function() {
message.remove();
});
document.body.appendChild(message);
// Auto-dismiss after 30 seconds
+7 -2
View File
@@ -1140,10 +1140,10 @@ export function renderFile(data) {
<div class="pdf-viewer-container">
<div class="pdf-toolbar">
<span class="pdf-info">PDF — ${pages} pages</span>
<button class="btn-action" onclick="window.open('${pdfUrl}', '_blank')">
<button class="btn-action" data-pdf-url="${pdfUrl}">
<i data-lucide="external-link" style="width:14px;height:14px"></i> Plein écran
</button>
<button class="btn-action" onclick="window.open('/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}', '_blank')">
<button class="btn-action" data-download-url="/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}">
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
</button>
</div>
@@ -1152,6 +1152,11 @@ export function renderFile(data) {
<iframe src="${pdfUrl}" data-pdf-url="${pdfUrl}" class="pdf-iframe" title="${escapeHtml(data.title)}"></iframe>
</div>
</div>`;
area.querySelectorAll('.pdf-toolbar .btn-action').forEach((btn) => {
btn.addEventListener('click', () => {
window.open(btn.dataset.pdfUrl || btn.dataset.downloadUrl, '_blank');
});
});
area.querySelectorAll('.pdf-toc a[data-page]').forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.28.2",
"version": "2.28.9",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
+8
View File
@@ -9,6 +9,14 @@ export default defineConfig({
reporter: process.env.CI ? 'github' : 'list',
timeout: 60000,
expect: { timeout: 10000 },
// BUG-080 : la suite (~130 tests, workers: 1) ne doit jamais pendre toute
// la nuit. Au-delà du timeout global, Playwright abandonne avec un échec
// explicite au lieu de bloquer. Surchargable : E2E_GLOBAL_TIMEOUT_MS.
globalTimeout: Number(
process.env.E2E_GLOBAL_TIMEOUT_MS ??
(process.env.CI ? 30 * 60 * 1000 : 15 * 60 * 1000),
),
reportSlowTests: process.env.CI ? null : { max: 5, threshold: 30000 },
use: {
baseURL: process.env.BASE_URL || 'http://localhost:2029',
+161
View File
@@ -0,0 +1,161 @@
<#
.SYNOPSIS
ObsiGate — cycle de vie du serveur E2E local, avec progression visible.
.DESCRIPTION
Remplace le one-liner opaque de démarrage : chaque étape affiche sa
progression (port, PID, attente du health check seconde par seconde,
version servie). Memes conditions que le job CI `e2e` et que
`scripts/run-e2e-local.ps1` : uvicorn natif, auth désactivée, fixtures
TestVault/TestDir, port 2029.
Le PID est persisté dans `data/e2e-server.pid` pour un arrêt propre
(`stop`) — plus de serveurs orphelins qui squattent le port.
.EXAMPLE
./scripts/e2e-server.ps1 start # démarre + attend READY (défaut)
./scripts/e2e-server.ps1 status # port, PID, version servie
./scripts/e2e-server.ps1 logs # queues des logs serveur
./scripts/e2e-server.ps1 stop # arrête le serveur + libère le port
#>
[CmdletBinding()]
param(
[Parameter(Position = 0)]
[ValidateSet("start", "stop", "status", "logs")]
[string]$Command = "start",
[string]$Port = $(if ($env:E2E_PORT) { $env:E2E_PORT } else { "2029" })
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $Root
$BaseUrl = "http://127.0.0.1:$Port"
$Python = ".\.venv\Scripts\python.exe"
$PidFile = "data/e2e-server.pid"
$OutLog = "data/e2e-server.log"
$ErrLog = "data/e2e-server.err.log"
function Get-PortOwner {
$conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $conn) { return $null }
$proc = Get-Process -Id $conn.OwningProcess -ErrorAction SilentlyContinue
return @{ Pid = $conn.OwningProcess; Name = $(if ($proc) { $proc.ProcessName } else { "?" }) }
}
function Stop-Server {
param([string]$Why = "")
$killed = @()
if (Test-Path -LiteralPath $PidFile) {
$srvPid = (Get-Content -LiteralPath $PidFile -TotalCount 1).Trim()
if ($srvPid -match '^\d+$') {
# BUG-080 : le PID enregistré peut avoir ré-exécuté uvicorn dans un
# processus enfant (constaté : parent .venv + enfant uv-python sur
# le port) — tuer l'arbre complet, pas seulement la racine.
Get-CimInstance Win32_Process -Filter "ParentProcessId=$srvPid" -ErrorAction SilentlyContinue |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue; $killed += $_.ProcessId }
Stop-Process -Id $srvPid -Force -ErrorAction SilentlyContinue
$killed += $srvPid
}
Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue
}
$owner = Get-PortOwner
if ($owner) {
Stop-Process -Id $owner.Pid -Force -ErrorAction SilentlyContinue
$killed += $owner.Pid
}
if ($killed.Count) { Write-Host "[OK] Serveur arrêté (PID $($killed -join ', ')). $Why" }
else { Write-Host "[OK] Aucun serveur en cours (port $Port libre)." }
}
switch ($Command) {
"stop" {
Write-Host "[1/1] Arrêt du serveur E2E (port $Port)..."
Stop-Server
}
"status" {
$owner = Get-PortOwner
if (-not $owner) { Write-Host "[INFO] Port $Port libre, aucun serveur."; break }
Write-Host "[INFO] Port $Port occupé par PID $($owner.Pid) ($($owner.Name))."
try {
$health = Invoke-WebRequest -Uri "$BaseUrl/api/health" -TimeoutSec 5 -UseBasicParsing |
Select-Object -ExpandProperty Content | ConvertFrom-Json
Write-Host "[OK] Health 200 — version $($health.version), $($health.total_files) fichiers indexés."
} catch {
Write-Host "[WARN] Processus présent mais health injoignable : $($_.Exception.Message)"
}
}
"logs" {
Write-Host "===== $OutLog (stdout) ====="
Get-Content -LiteralPath $OutLog -Tail 15 -ErrorAction SilentlyContinue
Write-Host "===== $ErrLog (stderr) ====="
Get-Content -LiteralPath $ErrLog -Tail 25 -ErrorAction SilentlyContinue
}
"start" {
Write-Host "[1/4] Port $Port..."
$owner = Get-PortOwner
if ($owner) {
Write-Host "[ERR] Port $Port déjà occupé par PID $($owner.Pid) ($($owner.Name))."
Write-Host " Lancez d'abord : ./scripts/e2e-server.ps1 stop"
exit 1
}
Write-Host " libre."
Write-Host "[2/4] Interpréteur $Python..."
if (-not (Test-Path -LiteralPath $Python)) {
Write-Host "[ERR] $Python introuvable. Créez le venv (voir AGENTS.md)."
exit 1
}
Write-Host " présent."
New-Item -ItemType Directory -Force -Path "data" | Out-Null
Write-Host "[3/4] Démarrage uvicorn (auth désactivée, TestVault/TestDir)..."
$env:OBSIGATE_AUTH_ENABLED = "false"
$env:VAULT_1_NAME = "TestVault"
$env:VAULT_1_PATH = (Resolve-Path -LiteralPath "test_vault").Path
$env:DIR_1_NAME = "TestDir"
$env:DIR_1_PATH = (Resolve-Path -LiteralPath "test_dir").Path
$server = Start-Process -FilePath $Python `
-ArgumentList "-m", "uvicorn", "backend.main:app", "--host", "127.0.0.1", "--port", $Port `
-RedirectStandardOutput $OutLog -RedirectStandardError $ErrLog `
-PassThru -WindowStyle Hidden
$server.Id | Set-Content -LiteralPath $PidFile
Write-Host " PID $($server.Id) (logs : $OutLog / $ErrLog)."
Write-Host "[4/4] Attente du health check (30 s max)..."
$ready = $false
for ($i = 1; $i -le 30; $i++) {
try {
Invoke-WebRequest -Uri "$BaseUrl/api/health" -TimeoutSec 2 -UseBasicParsing | Out-Null
$ready = $true
break
} catch {
if ($server.HasExited) {
Write-Host "[ERR] Le serveur a quitté (code $($server.ExitCode)). Fin du log :"
Get-Content -LiteralPath $ErrLog -Tail 15 -ErrorAction SilentlyContinue
exit 1
}
if ($i % 5 -eq 0) { Write-Host " ... $i/30 s (indexation en cours, voir $ErrLog)" }
Start-Sleep -Seconds 1
}
}
if (-not $ready) {
Write-Host "[ERR] Injoignable après 30 s. Fin du log :"
Get-Content -LiteralPath $ErrLog -Tail 15 -ErrorAction SilentlyContinue
exit 1
}
$health = Invoke-WebRequest -Uri "$BaseUrl/api/health" -TimeoutSec 5 -UseBasicParsing |
Select-Object -ExpandProperty Content | ConvertFrom-Json
# BUG-080 : le PID `Start-Process` peut ne pas être celui qui écoute
# (ré-exécution enfant constatée) — persister le vrai propriétaire du
# port pour un `stop` fiable, sans serveurs orphelins.
$owner = Get-PortOwner
if ($owner) { $owner.Pid | Set-Content -LiteralPath $PidFile }
Write-Host "[OK] READY — ObsiGate v$($health.version) sur $BaseUrl ($($health.total_files) fichiers)."
}
}
+39 -3
View File
@@ -34,6 +34,35 @@ $BaseUrl = "http://127.0.0.1:$Port"
$ServerLog = "data/e2e-server.log"
$ServerErrLog = "data/e2e-server.err.log"
# BUG-080 : garde-fous anti-blocage (un run E2E ne doit jamais pendre toute la nuit).
$TestTimeoutSec = if ($env:E2E_TIMEOUT_SEC) { [int]$env:E2E_TIMEOUT_SEC } else { 900 }
$BrowserTimeoutSec = if ($env:E2E_BROWSER_INSTALL_TIMEOUT_SEC) { [int]$env:E2E_BROWSER_INSTALL_TIMEOUT_SEC } else { 600 }
function Invoke-NativeWithTimeout([string]$Label, [int]$TimeoutSec, [string]$Exe, [string[]]$Args) {
# Lance un processus natif en gardant la sortie console en direct, et le
# tue après $TimeoutSec s'il n'a pas terminé (exit 124, comme `timeout`).
$stamp = Get-Date -Format "HH:mm:ss"
Write-Host "[$stamp] $Label (timeout ${TimeoutSec}s)..."
$proc = Start-Process -FilePath $Exe -ArgumentList $Args -NoNewWindow -PassThru
$proc | Wait-Process -Timeout $TimeoutSec -ErrorAction SilentlyContinue
if (-not $proc.HasExited) {
Write-Host "[ERR] $Label : timeout après ${TimeoutSec}s, arrêt du processus (PID $($proc.Id))."
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
return 124
}
return $proc.ExitCode
}
function Test-ChromiumInstalled {
$base = Join-Path $env:USERPROFILE "AppData\Local\ms-playwright"
if (-not (Test-Path -LiteralPath $base)) { return $false }
$hit = Get-ChildItem -LiteralPath $base -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like "chromium-*" } |
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "chrome-win\chrome.exe") } |
Select-Object -First 1
return ($null -ne $hit)
}
function Assert-Command([string]$Name, [string]$Hint) {
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "[ERR] $Name introuvable. $Hint"
@@ -102,13 +131,20 @@ try {
Write-Host "[OK] Serveur prêt."
# ----- Browsers Playwright (no-op s'ils sont déjà installés) -----
npx playwright install chromium
# BUG-080 : `--yes` (jamais de prompt interactif npx qui pend), skip si un
# chromium est déjà présent (sauf E2E_INSTALL_BROWSERS=1), timeout dédié.
if (($env:E2E_INSTALL_BROWSERS -eq "1") -or (-not (Test-ChromiumInstalled))) {
$code = Invoke-NativeWithTimeout "npx playwright install chromium" $BrowserTimeoutSec "npx" @("--yes", "playwright", "install", "chromium")
if ($code -ne 0) { exit $code }
} else {
Write-Host "[INFO] Chromium Playwright déjà installé, étape sautée (E2E_INSTALL_BROWSERS=1 pour forcer)."
}
# ----- Exécution de la suite (projet CI : chromium-desktop) -----
Write-Host "[INFO] BASE_URL=$BaseUrl npx playwright test --project=chromium-desktop $($PlaywrightArgs -join ' ')"
$env:BASE_URL = $BaseUrl
& npx playwright test --project=chromium-desktop @PlaywrightArgs
$exitCode = $LASTEXITCODE
$testArgs = @("--yes", "playwright", "test", "--project=chromium-desktop") + @($PlaywrightArgs)
$exitCode = Invoke-NativeWithTimeout "playwright test" $TestTimeoutSec "npx" $testArgs
} finally {
Write-Host "[INFO] Arrêt du serveur (PID $($server.Id))..."
if (-not $server.HasExited) { Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue }
+23 -2
View File
@@ -23,6 +23,21 @@ cd "$(dirname "$0")/.."
PORT="${E2E_PORT:-2029}"
BASE_URL="http://127.0.0.1:$PORT"
SERVER_LOG="data/e2e-server.log"
# BUG-080 : garde-fous anti-blocage (un run E2E ne doit jamais pendre toute la nuit).
E2E_TIMEOUT_SEC="${E2E_TIMEOUT_SEC:-900}"
E2E_BROWSER_INSTALL_TIMEOUT_SEC="${E2E_BROWSER_INSTALL_TIMEOUT_SEC:-600}"
# Exécute "$@" avec un timeout dur (exit 124 comme `timeout`), sans timeout si
# la commande `timeout` est absente (ex. macOS sans coreutils).
run_with_timeout() {
local limit="$1"; shift
if command -v timeout &>/dev/null; then
timeout "$limit" "$@"
else
echo "[WARN] commande 'timeout' absente : $1 sans limite de ${limit}s" >&2
"$@"
fi
}
# ----- Prérequis -----
if ! command -v uv &>/dev/null; then
@@ -102,8 +117,14 @@ curl -sf "$BASE_URL/api/health" >/dev/null || {
}
# ----- Browsers Playwright (no-op s'ils sont déjà installés) -----
npx playwright install chromium
# BUG-080 : `--yes` (jamais de prompt interactif npx qui pend), skip si un
# chromium est déjà présent (sauf E2E_INSTALL_BROWSERS=1), timeout dédié.
if [[ "${E2E_INSTALL_BROWSERS:-0}" == "1" ]] || ! ls -d ~/.cache/ms-playwright/chromium-*/chrome-linux/chrome &>/dev/null; then
run_with_timeout "$E2E_BROWSER_INSTALL_TIMEOUT_SEC" npx --yes playwright install chromium
else
echo "[INFO] Chromium Playwright déjà installé, étape sautée (E2E_INSTALL_BROWSERS=1 pour forcer)."
fi
# ----- Exécution de la suite (projet CI : chromium-desktop) -----
echo "[INFO] BASE_URL=$BASE_URL npx playwright test --project=chromium-desktop $*"
BASE_URL="$BASE_URL" npx playwright test --project=chromium-desktop "$@"
BASE_URL="$BASE_URL" run_with_timeout "$E2E_TIMEOUT_SEC" npx --yes playwright test --project=chromium-desktop "$@"
+142
View File
@@ -0,0 +1,142 @@
/**
* E2E tests — XSS stocké : page publique de partage + lecteur markdown (#87 T4).
*
* Non-régression BUG-021 (sanitizer serveur du rendu markdown) et BUG-022
* (échappement `title`/frontmatter + neutralisation `</script>` sur `/s/{token}`).
* Chaque test arme un guetteur de dialogues `alert` : le moindre JS exécuté
* fait échouer le test, en plus des assertions DOM (contenu échappé, aucun
* attribut `on*` vivant).
*
* Run (local, instance de test port 2029, auth désactivée — cf. scripts/run-e2e-local.ps1) :
* BASE_URL=http://localhost:2029 npx playwright test tests/e2e/xss.spec.js --project=chromium-desktop
*/
import { test, expect } from '@playwright/test';
const BASE = process.env.BASE_URL || 'http://localhost:2029';
const VAULT = 'TestVault';
const XSS_FILE = 'e2e-xss-probe.md';
const XSS_TITLE = '<img src=x onerror="window.__xss_title=1">';
const XSS_BODY = [
'# Sonde XSS',
'',
'<img src=x onerror="window.__xss_body=1">',
'',
'<script>window.__xss_script=1</script>',
'',
'[xss](javascript:window.__xss_js=1)',
].join('\n');
async function api(request, method, path, data) {
const resp = await request.fetch(`${BASE}${path}`, {
method,
data,
headers: { 'Content-Type': 'application/json' },
});
if (!resp.ok()) {
throw new Error(`${method} ${path} → ${resp.status()} ${await resp.text()}`);
}
return resp.json();
}
async function precleanProbeFile(request) {
// Idempotence : un run précédent interrompu a pu laisser le fichier sonde.
await request.fetch(`${BASE}/api/file/${VAULT}?path=${encodeURIComponent(XSS_FILE)}`, {
method: 'DELETE',
}).catch(() => {});
}
async function armAlertTrap(page) {
const dialogs = [];
page.on('dialog', async (d) => {
dialogs.push(d.message());
await d.dismiss();
});
return dialogs;
}
async function openFile(page, vault, filePath) {
const treeItem = page.locator(`.tree-item[data-vault="${vault}"][data-path="${filePath}"]`);
if (!(await treeItem.count())) {
await page.locator(`.tree-item.vault-item[data-vault="${vault}"]`).first().click();
await treeItem.waitFor({ state: 'attached', timeout: 8000 });
}
await treeItem.dblclick({ timeout: 5000 });
}
test.describe('XSS — page publique de partage (/s/{token}, BUG-022)', () => {
test('un titre/frontmatter hostile est échappé et aucun script ne tourne', async ({ page, request }) => {
const dialogs = await armAlertTrap(page);
await precleanProbeFile(request);
await api(request, 'POST', `/api/file/${VAULT}`, {
path: XSS_FILE,
// Titre entre quotes simples YAML (les doubles quotes internes restent
// des caractères ordinaires et arrivent intactes au backend).
content: `---\ntitle: '${XSS_TITLE}'\n---\n\n${XSS_BODY}\n`,
});
const share = await api(request, 'POST', `/api/share/${VAULT}`, { path: XSS_FILE });
await page.goto(`${BASE}/s/${share.token}`);
await expect(page.locator('.share-banner')).toBeVisible({ timeout: 10000 });
// Le titre affiché est le texte brut (balise neutralisée), pas un <img> vivant.
await expect(page.locator('.toolbar-title')).toContainText('<img src=x', { timeout: 5000 });
expect(await page.locator('.toolbar-title img').count()).toBe(0);
expect(await page.locator('img[onerror]').count()).toBe(0);
// Les 2 <script> de la page sont son code statique : le JSON embarqué
// (`#raw-content`) doit être neutralisé (aucun `</script>` littéral).
const rawEmbedded = await page.evaluate(() => {
const el = document.getElementById('raw-content');
return { text: el ? el.textContent : null };
});
expect(rawEmbedded.text).not.toBeNull();
expect(rawEmbedded.text).not.toContain('</script');
expect(rawEmbedded.text).toContain('\\u003c');
// Aucun payload n'a tourné (titre, corps, bloc script, lien javascript:).
const flags = await page.evaluate(() => ({
title: window.__xss_title,
body: window.__xss_body,
script: window.__xss_script,
js: window.__xss_js,
}));
expect(flags).toEqual({ title: undefined, body: undefined, script: undefined, js: undefined });
expect(dialogs).toEqual([]);
await api(request, 'DELETE', `/api/share/${share.id}`);
await api(request, 'DELETE', `/api/file/${VAULT}?path=${encodeURIComponent(XSS_FILE)}`);
});
});
test.describe('XSS — lecteur markdown (BUG-021)', () => {
test('le HTML injecté dans une note est neutralisé à l\'affichage', async ({ page, request }) => {
const dialogs = await armAlertTrap(page);
await precleanProbeFile(request);
await api(request, 'POST', `/api/file/${VAULT}`, { path: XSS_FILE, content: `${XSS_BODY}\n` });
await page.goto(BASE);
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
await openFile(page, VAULT, XSS_FILE);
const content = page.locator('#content-area');
await expect(content).toContainText('Sonde XSS', { timeout: 10000 });
// Le sanitizer serveur a retiré les vecteurs : pas d'onerror, pas de script,
// pas de lien javascript: exécutable dans la zone de lecture.
expect(await content.locator('img[onerror]').count()).toBe(0);
expect(await content.locator('script').count()).toBe(0);
expect(await content.locator('a[href^="javascript:"]').count()).toBe(0);
const flags = await page.evaluate(() => ({
body: window.__xss_body,
script: window.__xss_script,
js: window.__xss_js,
}));
expect(flags).toEqual({ body: undefined, script: undefined, js: undefined });
expect(dialogs).toEqual([]);
await api(request, 'DELETE', `/api/file/${VAULT}?path=${encodeURIComponent(XSS_FILE)}`);
});
});
+3 -2
View File
@@ -100,7 +100,7 @@ await test("module exports renderExcalidraw + helpers", () => {
assert.equal(typeof destroyExcalidrawEditor, "function");
});
await test("renderExcalidraw creates an iframe with sandbox + static src", () => {
await test("renderExcalidraw creates an iframe with sandbox + routed src", () => {
const container = document.getElementById("content-area");
const data = {
is_excalidraw: true,
@@ -111,7 +111,8 @@ await test("renderExcalidraw creates an iframe with sandbox + static src", () =>
renderExcalidraw(container, data, "TestVault", "diagram.excalidraw");
const iframe = container.querySelector("iframe");
assert.ok(iframe, "iframe should be created");
assert.ok(iframe.src.includes("/static/excalidraw-editor.html"), `src: ${iframe.src}`);
assert.ok(iframe.src.includes("/excalidraw-editor.html"), `src: ${iframe.src}`);
assert.ok(!iframe.src.includes("/static/excalidraw-editor.html"), `route avec nonce CSP: ${iframe.src}`);
assert.ok(iframe.sandbox.contains("allow-scripts"), "sandbox allow-scripts");
assert.ok(iframe.sandbox.contains("allow-same-origin"), "sandbox allow-same-origin");
assert.match(iframe.style.cssText, /100%/);
+82
View File
@@ -0,0 +1,82 @@
"""Tests — nonces CSP (ROADMAP #87 T5b).
- `inject_csp_nonce` ne touche que les scripts inline exécutables
(`<script>`, `type="module"` / `type="importmap"` sans `src`), jamais les
blocs de données (`type="text/plain"`) ni les scripts externes.
- Chaque page HTML servie avec des scripts inline les porte tous avec un
nonce après injection.
"""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
NONCE = "TESTNONCE1234567890"
def _read(name: str) -> str:
return (ROOT / "frontend" / name).read_text(encoding="utf-8")
def test_inject_only_bare_executable_scripts():
from backend.csp import inject_csp_nonce
html = (
"<script>var a = 1;</script>"
'<script type="module">import x from "y";</script>'
'<script type="importmap">{"imports": {}}</script>'
'<script type="module" src="/static/js/app.js"></script>'
'<script src="https://cdnjs.cloudflare.com/x.js"></script>'
'<script id="raw-content" type="text/plain">hello</script>'
'<script nonce="OLD">var b = 2;</script>'
)
out = inject_csp_nonce(html, NONCE)
assert out.count(f'nonce="{NONCE}"') == 3
assert '<script src="https://cdnjs.cloudflare.com/x.js">' in out
assert '<script id="raw-content" type="text/plain">' in out
assert '<script nonce="OLD">' in out
def test_new_nonce_unique_per_call():
from backend.csp import new_nonce
assert new_nonce() != new_nonce()
def test_all_pages_fully_nonced():
"""Aucun script inline exécutable sans nonce après injection (sauf src=)."""
from backend.csp import inject_csp_nonce
for name in ("index.html", "popout.html", "admin.html", "editor-poc.html", "excalidraw-editor.html"):
out = inject_csp_nonce(_read(name), NONCE)
bare = re.findall(r"<script>", out)
assert not bare, f"{name} : scripts sans nonce restants"
inline_mods = [m for m in re.findall(r'<script type="(?:module|importmap)">', out)]
assert not inline_mods, f"{name} : modules/importmap sans nonce restants"
def _nonce_of(csp: str) -> str | None:
m = re.search(r"'nonce-([^']+)'", csp or "")
return m.group(1) if m else None
def test_nonce_header_fresh_per_response(client):
"""Chaque réponse porte un nonce frais dans `script-src`."""
r1 = client.get("/")
r2 = client.get("/")
n1, n2 = _nonce_of(r1.headers.get("content-security-policy")), _nonce_of(
r2.headers.get("content-security-policy")
)
assert n1 and n2 and n1 != n2
def test_nonce_matches_injected_html(client):
"""Le nonce de l'en-tête est celui injecté dans le HTML (`/`, excalidraw)."""
for path in ("/", "/excalidraw-editor.html"):
resp = client.get(path)
assert resp.status_code == 200, path
nonce = _nonce_of(resp.headers.get("content-security-policy"))
assert nonce, path
assert f'nonce="{nonce}"' in resp.text, path
+97
View File
@@ -0,0 +1,97 @@
"""Garde-fous anti-blocage du harnais E2E local (BUG-080).
Contexte : un run `npm run test:e2e:ps` est resté pendu toute la nuit —
serveurs orphelins sur le port 2029, `npx` sans `--yes` (prompt interactif
qui attend indéfiniment), installation des navigateurs systématique et suite
Playwright (~130 tests, workers: 1) sans aucun timeout global.
Ces tests statiques vérifient que chaque couche du harnais possède son
garde-fou, afin qu'un run E2E échoue vite au lieu de bloquer indéfiniment.
"""
from __future__ import annotations
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
def _read(rel: str) -> str:
return (REPO_ROOT / rel).read_text(encoding="utf-8")
class TestE2ELocalPs:
SCRIPT = "scripts/run-e2e-local.ps1"
def test_npx_never_prompts(self):
"""`npx --yes` partout : aucun prompt « Ok to proceed? » qui pend."""
content = _read(self.SCRIPT)
# Aucune invocation nue `npx ...` / `& npx ...` (toujours via le helper
# avec `--yes`) ; les mentions dans commentaires/Write-Host sont OK.
bare = [
line.strip()
for line in content.splitlines()
if re.match(r"^\s*(?:&\s*)?npx\s", line)
]
assert not bare, f"invocations npx nues (sans --yes) : {bare}"
assert content.count("--yes") >= 2, "au moins install + test en --yes"
def test_browser_install_skippable(self):
"""Install navigateurs sautée si chromium déjà présent (sauf forçage)."""
content = _read(self.SCRIPT)
assert "Test-ChromiumInstalled" in content
assert "E2E_INSTALL_BROWSERS" in content
def test_test_step_has_timeout(self):
"""L'étape `playwright test` est bornée (E2E_TIMEOUT_SEC, défaut 900)."""
content = _read(self.SCRIPT)
assert "E2E_TIMEOUT_SEC" in content
assert "Wait-Process -Timeout" in content
assert re.search(r"E2E_TIMEOUT_SEC.*else\s*\{\s*900\s*\}", content), (
"défaut E2E_TIMEOUT_SEC=900 attendu"
)
class TestE2ELocalSh:
SCRIPT = "scripts/run-e2e-local.sh"
def test_npx_never_prompts(self):
content = _read(self.SCRIPT)
for line in content.splitlines():
stripped = line.strip()
if stripped.startswith("#") or stripped.startswith("echo") or "npx" not in stripped:
continue
if "playwright" in stripped:
assert "--yes" in stripped, f"appel npx sans --yes : {stripped}"
def test_test_step_has_timeout(self):
content = _read(self.SCRIPT)
assert "E2E_TIMEOUT_SEC" in content
assert "run_with_timeout" in content
class TestE2EServerPs:
SCRIPT = "scripts/e2e-server.ps1"
def test_pidfile_refreshed_with_port_owner(self):
"""Le pidfile est resynchronisé sur le vrai PID d'écoute après READY."""
content = _read(self.SCRIPT)
assert "Get-PortOwner" in content
ready_pos = content.find("[OK] READY")
assert ready_pos != -1
assert "Set-Content -LiteralPath $PidFile" in content[ready_pos - 600:ready_pos]
def test_stop_kills_process_tree(self):
"""`stop` tue aussi les enfants du PID enregistré (pas d'orphelins)."""
content = _read(self.SCRIPT)
assert "ParentProcessId=$srvPid" in content
class TestPlaywrightConfig:
CONFIG = "playwright.config.ts"
def test_global_timeout_set(self):
"""Timeout global : la suite abandonne au lieu de pendre toute la nuit."""
content = _read(self.CONFIG)
assert "globalTimeout" in content
assert "E2E_GLOBAL_TIMEOUT_MS" in content
+52
View File
@@ -0,0 +1,52 @@
"""Tests — cookies Secure, CORS same-origin implicite, avertissement bind (ROADMAP #87 T3).
- `is_secure_cookies()` suit `OBSIGATE_SECURE_COOKIES` (défaut `false` :
compatibilité logins en HTTP local — les navigateurs ignorent les cookies
`Secure` en clair).
- Aucun en-tête CORS permissif n'est émis : sans `CORSMiddleware`, les
navigateurs appliquent le same-origin par défaut (politique explicite par
l'absence — vérifiée ici pour qu'un ajout futur de CORS soit conscient).
"""
from __future__ import annotations
def test_secure_cookies_default_false(monkeypatch):
"""Défaut `false` (logins HTTP locaux préservés)."""
from backend.auth.router import is_secure_cookies
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
assert is_secure_cookies() is False
def test_secure_cookies_opt_in(monkeypatch):
"""`OBSIGATE_SECURE_COOKIES=true` → flag actif (insensible à la casse)."""
from backend.auth.router import is_secure_cookies
for value in ("true", "True", "TRUE", "1", "yes"):
monkeypatch.setenv("OBSIGATE_SECURE_COOKIES", value)
assert is_secure_cookies() is (value.lower() == "true")
def test_no_cors_headers_on_api(client):
"""Pas de `Access-Control-Allow-Origin` : same-origin imposé par le navigateur."""
resp = client.get("/api/health")
assert resp.status_code == 200
assert "access-control-allow-origin" not in {k.lower() for k in resp.headers}
def test_no_cors_headers_on_public_share(client):
"""Idem sur la page publique de partage."""
resp = client.get("/s/jeton-inexistant")
assert resp.status_code == 404
assert "access-control-allow-origin" not in {k.lower() for k in resp.headers}
def test_security_headers_present(client):
"""En-têtes de durcissement posés par le middleware (non-régression)."""
resp = client.get("/api/health")
assert resp.headers.get("x-content-type-options") == "nosniff"
assert resp.headers.get("x-frame-options") == "SAMEORIGIN"
csp = resp.headers.get("content-security-policy", "")
assert "object-src 'none'" in csp
assert "frame-ancestors 'self'" in csp