Compare commits

...
6 Commits
Author SHA1 Message Date
bruno 8ad8eaac71 test: spec E2E mobile pour la page Configurations BUG-071
CI / lint (push) Successful in 1m54s
CI / security (push) Successful in 1m21s
CI / test (push) Successful in 3m58s
CI / build (push) Failing after 1m22s
CI / e2e (push) Skipped
2026-09-22 22:03:42 -04:00
bruno dd9224e685 fix: page Configurations inutilisable en mode mobile BUG-071
CI / lint (push) Successful in 1m57s
CI / security (push) Successful in 1m20s
CI / test (push) Successful in 4m20s
CI / build (push) Successful in 1m20s
CI / e2e (push) Successful in 11m51s
2026-09-22 21:24:56 -04:00
bruno aeb7516445 fix: activation WebAuthn impossible BUG-070 (rp_id/origines derives requete, challenges multiples)
CI / lint (push) Successful in 1m59s
CI / security (push) Successful in 1m35s
CI / test (push) Successful in 4m7s
CI / build (push) Successful in 1m16s
CI / e2e (push) Successful in 12m12s
2026-09-22 20:54:01 -04:00
bruno bca0fdd941 fix: login 2FA bloque sans erreur BUG-069 (challenge montait dans .login-box inexistant -> .login-card + erreur visible)
CI / lint (push) Successful in 1m56s
CI / security (push) Successful in 1m20s
CI / test (push) Successful in 4m17s
CI / build (push) Successful in 1m16s
CI / e2e (push) Successful in 12m35s
2026-09-22 20:38:37 -04:00
bruno 60da957f13 fix: section Securite du compte incomplete BUG-068 (boutons theme, QR local, mot de passe, recovery WebAuthn)
CI / lint (push) Successful in 2m25s
CI / security (push) Successful in 1m20s
CI / test (push) Successful in 3m43s
CI / build (push) Successful in 2m9s
CI / e2e (push) Successful in 12m7s
2026-09-22 20:07:25 -04:00
bruno f621620593 feat: optimisation globale des performances #86 (scan differentiel, excalidraw differe, garde-fou replace; inverted index/PDF lazy/caps regex deja livres via BUG-033/040/025)
CI / lint (push) Successful in 1m54s
CI / security (push) Successful in 1m22s
CI / test (push) Successful in 4m23s
CI / build (push) Successful in 1m17s
CI / e2e (push) Successful in 12m9s
2026-09-22 19:04:26 -04:00
31 changed files with 1649 additions and 126 deletions
+4 -1
View File
@@ -51,7 +51,10 @@ OBSIGATE_ADMIN_PASSWORD=chab30
# OBSIGATE_PDF_MAX_SIZE_MB=50 # PDFs plus volumineux = texte non indexé # OBSIGATE_PDF_MAX_SIZE_MB=50 # PDFs plus volumineux = texte non indexé
# OBSIGATE_PDF_EXTRACT_TIMEOUT=30 # secondes avant abandon de l'extraction # OBSIGATE_PDF_EXTRACT_TIMEOUT=30 # secondes avant abandon de l'extraction
# WebAuthn / MFA (ROADMAP #64) — nécessaire hors localhost # WebAuthn / MFA (ROADMAP #64) — par défaut rp_id/origines sont dérivés de la
# requête (hôte exact, port inclus) : rien à configurer en accès direct.
# À renseigner uniquement pour un accès via reverse-proxy sous un autre nom
# (avec OBSIGATE_TRUST_PROXY=true pour X-Forwarded-Host/Proto) :
# OBSIGATE_WEBAUTHN_RP_ID=obsigate.example.com # OBSIGATE_WEBAUTHN_RP_ID=obsigate.example.com
# OBSIGATE_WEBAUTHN_RP_NAME=ObsiGate # OBSIGATE_WEBAUTHN_RP_NAME=ObsiGate
# OBSIGATE_WEBAUTHN_ORIGINS=https://obsigate.example.com # OBSIGATE_WEBAUTHN_ORIGINS=https://obsigate.example.com
+1
View File
@@ -40,6 +40,7 @@ jobs:
node tests/frontend/unit.test.mjs node tests/frontend/unit.test.mjs
node tests/frontend/pdf-viewer.test.mjs node tests/frontend/pdf-viewer.test.mjs
node tests/frontend/forge-completion.test.mjs node tests/frontend/forge-completion.test.mjs
node tests/frontend/config-mobile.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)
run: | run: |
+116 -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.15.0**. > [Unreleased](#unreleased). La dernière version livrée est **2.16.5**.
--- ---
@@ -14,6 +14,121 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
--- ---
## [2.16.5] — 2026-09-22
### Corrigé
- **BUG-071 (complément) - spec E2E mobile de la page Configurations** :
`tests/e2e/config-mobile.spec.js` (nouveau, projet `chromium-mobile`,
ignoré en `chromium-desktop` comme `mobile-editor.spec.js`) : le hamburger
révèle le sommaire, le choix d'une section y défile + lien actif + repli
auto, aucun débordement horizontal à 393px. Vérifié en local contre
l'instance de test (port 2029, auth désactivée) : 3/3.
---
## [2.16.4] — 2026-09-22
### Corrigé
- **BUG-071 - Page « Configurations » inutilisable en mode mobile** : trois
causes. (1) Le sommaire (`#config-nav`) partageait la règle `.help-nav`
qui le masque sous 768px, mais — contrairement au Guide — la modale
n'avait aucun bouton pour l'afficher : aucun moyen d'atteindre une section.
Nouvel hamburger `#config-hamburger` dans l'en-tête (même traitement
`.help-hamburger` que le Guide, libellé traduit `config.toc_toggle`
FR/EN). (2) Les liens du sommaire étaient des ancres brutes sans JS :
`config.js` les intercepte désormais (défilement doux vers la section dans
la modale, lien actif, repli automatique du sommaire sur mobile, réinit à
l'ouverture). (3) Les grilles 2 colonnes (fournisseur/modèle IA, clé/modèle
par fournisseur), les rangées d'ajout à largeurs fixes (jetons, webhooks)
et les lignes webhook/jeton/partage en flex une ligne débordaient en
360px : bloc CSS mobile scopé `#config-modal` (1 colonne, wrap, largeurs
inline neutralisées, cibles tactiles 44px, sommaire plafonné à 46vh).
`data-i18n-attr` accepte désormais plusieurs paires `attr:clé` séparées
par `;` (titre + aria-label traduits). Tests :
`tests/frontend/config-mobile.test.mjs` (nouveau, 11 — hamburger, i18n,
câblage JS, CSS mobile, garde-fou ancres mortes façon BUG-067),
enregistré dans le CI.
---
## [2.16.3] — 2026-09-22
### Corrigé
- **BUG-070 - Activation clé physique WebAuthn impossible (« Validation du
credential WebAuthn échouée »)** : deux causes. (1) Les valeurs par défaut
(`rp_id localhost`, origines `http://localhost` sans port) rejetaient toute
URL réelle — logs : `Unexpected client data origin "http://localhost:2020",
expected one of ['http://localhost']`. `rp_id`/origines sont désormais
dérivés de la requête (hôte exact, port inclus ; `X-Forwarded-Host/Proto`
si `OBSIGATE_TRUST_PROXY=true`), la config explicite restant prioritaire
(`backend/auth/webauthn_mfa.py::resolve_relying_party`, appliqué aux 4
endpoints d'enregistrement et de login). (2) Challenge à usage unique
fragile au double-clic/retry (`challenge was not expected`) : les 5
derniers challenges sont conservés et la vérification accepte le challenge
correspondant à la cérémonie en cours. `.env.example` documente le nouveau
comportement. Vérifié au navigateur avec authentificateur virtuel
(Playwright CDP, instance Docker) : enregistrement 200 + clé listée, puis
clé de test retirée. Tests : `tests/test_webauthn.py` (+8 : résolution RP,
forwarded, retry, roundtrip sans config).
---
## [2.16.2] — 2026-09-22
### Corrigé
- **BUG-069 - Login 2FA bloqué sans erreur** : après user+mot de passe corrects
sur un compte avec 2FA, la page de login restait affichée sans erreur et le
challenge MFA n'apparaissait jamais. Cause : `showMfaChallenge`
(`frontend/js/auth.js`) montait le challenge dans `.login-box`, inexistant
dans `index.html` (marquage réel : `#login-screen > .login-card`) →
`return` silencieux. Correctif : montage dans `.login-card` (repli
`#login-screen`) + erreur visible (`mfa.challenge_unavailable`, FR/EN) au
lieu d'un retour silencieux si le point de montage manque. Vérifié de bout
en bout au navigateur (Playwright, instance Docker) : challenge affiché,
code erroné → erreur, code valide → connecté. Tests :
`tests/frontend/mfa-settings.test.mjs` (+2 contrôles d'ancrage DOM).
---
## [2.16.1] — 2026-09-22
### Corrigé
- **BUG-068 - Configuration : section « 🔒 Sécurité du compte » inachevée** :
boutons `config-btn-primary` / `config-btn-danger` définis depuis les
variables du thème (`frontend/style.css`) ; QR code TOTP généré en local par
le backend (`POST /api/auth/mfa/totp/setup` → `qr_data_url`, SVG `data:`
via `segno`, `backend/requirements.txt`) au lieu de l'image tierce bloquée
par la CSP (`img-src 'self' data: blob:`, secret TOTP exposé) ; codes de
récupération affichés aussi à la première activation WebAuthn ; carte
« Mot de passe » (changement via `POST /api/auth/change-password`) et
échappement des libellés de clés WebAuthn. Tests :
`tests/test_mfa.py::test_mfa_setup_returns_local_qr_data_url`,
`tests/frontend/mfa-settings.test.mjs` (nouveau, 9 contrôles).
---
## [2.16.0] — 2026-09-22
### Modifié
- **#86 - Optimisation globale des performances (phase 3)** : ferme les deux derniers
points de la phase 3 (recherche via inverted index, PDF lazy et caps regex déjà livrés
via BUG-033/BUG-040/BUG-025). Scan **différentiel** : `_scan_vault` réutilise les
entrées inchangées (`size` + `modified`) d'un snapshot précédent — seuls `os.walk` +
`stat` tournent à chaque rebuild (`build_index`, `reload_single_vault`). Extraction
**excalidraw différée** : le scan ne lit plus les `.excalidraw` / `.excalidraw.md`
(flag `excalidraw_text_pending`), `enrich_pdf_texts()` extrait leur texte après index
comme pour les PDF. Garde-fou `MAX_REPLACE_FILE_BYTES` (5 Mio) sur `replace_in_files`.
Tests : `tests/test_perf_phase3.py` (9). Voir
[docs/features/perf-phase3-86.md](./docs/features/perf-phase3-86.md).
---
## [2.15.0] — 2026-09-22 ## [2.15.0] — 2026-09-22
### 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.15.0-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.16.5-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/)
@@ -927,8 +927,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.15.0). Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.16.5).
--- ---
*Projet : ObsiGate | Version : 2.15.0 | Dernière mise à jour : Juin 2026* *Projet : ObsiGate | Version : 2.16.5 | 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.15.0-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.16.5-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/)
@@ -1096,8 +1096,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.15.0). See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.16.5).
--- ---
*Project: ObsiGate | Version: 2.15.0 | Last updated: May 2026* *Project: ObsiGate | Version: 2.16.5 | Last updated: May 2026*
+1 -1
View File
@@ -1 +1 @@
2.15.0 2.16.5
+38 -11
View File
@@ -448,7 +448,9 @@ class MfaEnableRequest(BaseModel):
async def mfa_totp_setup(current_user=Depends(require_auth)): async def mfa_totp_setup(current_user=Depends(require_auth)):
"""Generate a TOTP secret and QR URI for MFA setup. """Generate a TOTP secret and QR URI for MFA setup.
Returns the secret and otpauth URI — client displays QR code. Returns the secret, the otpauth URI and a ready-to-display QR code
(`qr_data_url`, SVG `data:` URI — no third-party service, CSP-safe).
Does NOT enable MFA yet; call /mfa/totp/enable after first successful verify. Does NOT enable MFA yet; call /mfa/totp/enable after first successful verify.
""" """
from .user_store import update_user from .user_store import update_user
@@ -458,10 +460,21 @@ async def mfa_totp_setup(current_user=Depends(require_auth)):
update_user(current_user["username"], { update_user(current_user["username"], {
"mfa_secret_pending": secret, "mfa_secret_pending": secret,
}) })
# BUG-068: the QR code is generated locally (segno, stdlib-free SVG data
# URI). The previous client-side https://api.qrserver.com image was blocked
# by the CSP (img-src 'self' data: blob:) and leaked the otpauth URI —
# including the TOTP secret — to a third party.
qr_data_url: str | None = None
try:
import segno
qr_data_url = segno.make(qr_uri).svg_data_uri(scale=5)
except Exception:
qr_data_url = None
return { return {
"secret": secret, "secret": secret,
"qr_uri": qr_uri, "qr_uri": qr_uri,
"otpauth_uri": qr_uri, "otpauth_uri": qr_uri,
"qr_data_url": qr_data_url,
} }
@@ -563,18 +576,25 @@ class WebauthnRemoveRequest(BaseModel):
@router.post("/mfa/webauthn/register/options") @router.post("/mfa/webauthn/register/options")
async def mfa_webauthn_register_options(current_user=Depends(require_auth)): async def mfa_webauthn_register_options(request: Request,
current_user=Depends(require_auth)):
"""Start WebAuthn key enrolment — returns publicKey creation options for the browser.""" """Start WebAuthn key enrolment — returns publicKey creation options for the browser."""
from .webauthn_mfa import begin_registration from .webauthn_mfa import begin_registration, resolve_relying_party
# BUG-070: rp_id/origins derive from the request (exact host incl. port)
# unless explicitly configured — the old localhost defaults rejected
# every real access URL ("Unexpected client data origin").
rp, _ = resolve_relying_party(request)
options = begin_registration(current_user["username"], options = begin_registration(current_user["username"],
current_user.get("display_name", "")) current_user.get("display_name", ""),
rp_id_override=rp)
return {"options": options} return {"options": options}
@router.post("/mfa/webauthn/register") @router.post("/mfa/webauthn/register")
async def mfa_webauthn_register( async def mfa_webauthn_register(
req: WebauthnRegisterRequest, req: WebauthnRegisterRequest,
request: Request,
current_user=Depends(require_auth), current_user=Depends(require_auth),
): ):
"""Verify the created credential, store it, and enable MFA if not already on. """Verify the created credential, store it, and enable MFA if not already on.
@@ -584,14 +604,17 @@ async def mfa_webauthn_register(
from datetime import datetime, timezone from datetime import datetime, timezone
from .user_store import get_user, update_user from .user_store import get_user, update_user
from .webauthn_mfa import complete_registration from .webauthn_mfa import complete_registration, resolve_relying_party
user = get_user(current_user["username"]) user = get_user(current_user["username"])
if user is None: if user is None:
raise HTTPException(404, "Utilisateur introuvable") raise HTTPException(404, "Utilisateur introuvable")
rp, origins = resolve_relying_party(request)
try: try:
record = complete_registration(current_user["username"], req.credential, record = complete_registration(current_user["username"], req.credential,
label=req.label) label=req.label,
rp_id_override=rp,
origins_override=origins)
except ValueError as e: except ValueError as e:
raise HTTPException(400, str(e)) raise HTTPException(400, str(e))
except Exception as e: except Exception as e:
@@ -670,7 +693,7 @@ async def mfa_webauthn_remove(
@router.post("/mfa/webauthn/options") @router.post("/mfa/webauthn/options")
async def mfa_webauthn_login_options(body: dict = Body(...)): async def mfa_webauthn_login_options(request: Request, body: dict = Body(...)):
"""Unauthenticated: begin the login assertion for a user with registered keys. """Unauthenticated: begin the login assertion for a user with registered keys.
Enumeration-safe: always 200 — returns null options (caller falls back to Enumeration-safe: always 200 — returns null options (caller falls back to
@@ -682,8 +705,9 @@ async def mfa_webauthn_login_options(body: dict = Body(...)):
if not user or not user.get("mfa_enabled") or not creds: if not user or not user.get("mfa_enabled") or not creds:
return {"mfa_method": "totp", "options": None} return {"mfa_method": "totp", "options": None}
from .webauthn_mfa import begin_authentication from .webauthn_mfa import begin_authentication, resolve_relying_party
options = begin_authentication(username, creds) rp, _ = resolve_relying_party(request)
options = begin_authentication(username, creds, rp_id_override=rp)
if options is None: if options is None:
return {"mfa_method": "totp", "options": None} return {"mfa_method": "totp", "options": None}
return {"mfa_method": "webauthn", "options": options} return {"mfa_method": "webauthn", "options": options}
@@ -697,7 +721,7 @@ async def mfa_webauthn_verify(
): ):
"""Unauthenticated: verify the WebAuthn assertion and issue JWT tokens.""" """Unauthenticated: verify the WebAuthn assertion and issue JWT tokens."""
from .user_store import get_user, update_user from .user_store import get_user, update_user
from .webauthn_mfa import complete_authentication from .webauthn_mfa import complete_authentication, resolve_relying_party
client_ip = _enforce_mfa_rate_limit(request, body.username) client_ip = _enforce_mfa_rate_limit(request, body.username)
@@ -708,13 +732,16 @@ async def mfa_webauthn_verify(
if not user.get("mfa_enabled"): if not user.get("mfa_enabled"):
raise HTTPException(400, "MFA non activé pour cet utilisateur") raise HTTPException(400, "MFA non activé pour cet utilisateur")
rp, origins = resolve_relying_party(request)
creds = user.get("webauthn_credentials", []) creds = user.get("webauthn_credentials", [])
try: try:
credential_id = body.credential.get("id", "") credential_id = body.credential.get("id", "")
stored = next((c for c in creds if c.get("credential_id") == credential_id), None) stored = next((c for c in creds if c.get("credential_id") == credential_id), None)
if stored is None: if stored is None:
raise ValueError("Credential non enregistré") raise ValueError("Credential non enregistré")
new_count = complete_authentication(body.username, body.credential, stored) new_count = complete_authentication(body.username, body.credential, stored,
rp_id_override=rp,
origins_override=origins)
except ValueError as e: except ValueError as e:
_record_mfa_failure(client_ip, body.username) _record_mfa_failure(client_ip, body.username)
raise HTTPException(401, str(e)) raise HTTPException(401, str(e))
+157 -36
View File
@@ -38,8 +38,16 @@ logger = logging.getLogger("obsigate.auth.webauthn")
# Challenge lifetime: clients have 3 minutes to complete the ceremony. # Challenge lifetime: clients have 3 minutes to complete the ceremony.
CHALLENGE_TTL_SECONDS = 180 CHALLENGE_TTL_SECONDS = 180
# In-memory pending challenges: key -> (challenge_bytes, expires_at) # How many outstanding challenges to keep per key. BUG-070: a single slot made
_pending: dict[str, tuple[bytes, float]] = {} # the flow fragile — a double-click on "add key" (or any retry) overwrote the
# pending challenge and the in-flight ceremony failed with
# "Client data challenge was not expected challenge". The verifier now accepts
# any recent challenge for the key.
MAX_PENDING_PER_KEY = 5
# In-memory pending challenges: key -> [(challenge_bytes, expires_at), ...]
# (newest last)
_pending: dict[str, list[tuple[bytes, float]]] = {}
def rp_id() -> str: def rp_id() -> str:
@@ -55,24 +63,100 @@ def expected_origins() -> list[str]:
return [o.strip() for o in raw.split(",") if o.strip()] return [o.strip() for o in raw.split(",") if o.strip()]
def resolve_relying_party(request: Any = None) -> tuple[str, list[str]]:
"""Resolve the WebAuthn (rp_id, expected_origins) for a ceremony.
BUG-070: the previous defaults (rp_id ``localhost``, origins
``http://localhost``) rejected every real-world access URL — any port
(``http://localhost:2020``), ``127.0.0.1``, a LAN host or a public domain
failed verification with "Unexpected client data origin".
Explicit configuration still wins: when ``OBSIGATE_WEBAUTHN_RP_ID`` /
``OBSIGATE_WEBAUTHN_ORIGINS`` are set they are used unchanged. Otherwise
the values are derived from the incoming request (exact ``Host``, port
included, since the browser origin carries non-default ports).
Behind a reverse proxy the external host/proto come from
``X-Forwarded-Host`` / ``X-Forwarded-Proto``, honored only when
``OBSIGATE_TRUST_PROXY=true`` (same rule as ``get_client_ip``).
"""
env_rp = os.environ.get("OBSIGATE_WEBAUTHN_RP_ID")
env_raw = os.environ.get("OBSIGATE_WEBAUTHN_ORIGINS")
if request is None:
return (env_rp or "localhost",
[o.strip() for o in env_raw.split(",") if o.strip()]
if env_raw else ["http://localhost"])
from backend.services.net import is_trusted_proxy
if is_trusted_proxy():
fwd_host = request.headers.get("x-forwarded-host", "")
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "")
fwd_proto = request.headers.get("x-forwarded-proto", "")
scheme = fwd_proto.split(",")[0].strip() or request.url.scheme
else:
host = request.headers.get("host", "")
scheme = request.url.scheme
if not host:
url = request.url
host = url.netloc or url.hostname or ""
scheme = scheme or url.scheme or "http"
rp = env_rp or _hostname_only(host) or "localhost"
if env_raw:
origins = [o.strip() for o in env_raw.split(",") if o.strip()]
else:
origins = [f"{scheme or 'http'}://{host}"] if host else ["http://localhost"]
return rp, origins
def _hostname_only(host: str) -> str:
"""Strip the port (and IPv6 brackets) from a Host header value."""
host = host.strip()
if host.startswith("["): # [::1]:8080 or [::1]
end = host.find("]")
return host[1:end] if end > 0 else host
if host.count(":") == 1:
name, _, port = host.partition(":")
return name if port.isdigit() else host
return host
def _prune_expired() -> None: def _prune_expired() -> None:
now = time.time() now = time.time()
for key in [k for k, (_, exp) in _pending.items() if exp < now]: for key in list(_pending):
_pending.pop(key, None) remaining = [(c, exp) for c, exp in _pending[key] if exp >= now]
if remaining:
_pending[key] = remaining
else:
_pending.pop(key, None)
def _store_challenge(key: str) -> bytes: def _store_challenge(key: str) -> bytes:
_prune_expired() _prune_expired()
challenge = secrets.token_bytes(32) challenge = secrets.token_bytes(32)
_pending[key] = (challenge, time.time() + CHALLENGE_TTL_SECONDS) slot = _pending.setdefault(key, [])
slot.append((challenge, time.time() + CHALLENGE_TTL_SECONDS))
del slot[:-MAX_PENDING_PER_KEY] # keep only the most recent ones
return challenge return challenge
def _take_challenge(key: str) -> bytes | None: def _take_challenge(key: str) -> bytes | None:
"""Pop a challenge (single-use). Returns None if missing/expired.""" """Pop the newest challenge (single-use). Returns None if missing/expired."""
_prune_expired() _prune_expired()
entry = _pending.pop(key, None) slot = _pending.get(key)
return entry[0] if entry else None if not slot:
return None
challenge, _ = slot.pop()
if not slot:
_pending.pop(key, None)
return challenge
def _take_all_challenges(key: str) -> list[bytes]:
"""Pop every outstanding challenge for *key* (newest last)."""
_prune_expired()
slot = _pending.pop(key, None)
return [c for c, _ in slot] if slot else []
def clear_pending(username: str) -> None: def clear_pending(username: str) -> None:
@@ -83,9 +167,12 @@ def clear_pending(username: str) -> None:
# ── Registration (enrol a key in settings) ───────────────────────────── # ── Registration (enrol a key in settings) ─────────────────────────────
def begin_registration(username: str, display_name: str) -> dict: def begin_registration(username: str, display_name: str,
rp_id_override: str | None = None,
origins_override: list[str] | None = None) -> dict:
_ = origins_override # origins only matter at verification time
options = generate_registration_options( options = generate_registration_options(
rp_id=rp_id(), rp_id=rp_id_override or rp_id(),
rp_name=rp_name(), rp_name=rp_name(),
user_name=username, user_name=username,
user_display_name=display_name or username, user_display_name=display_name or username,
@@ -98,19 +185,44 @@ def begin_registration(username: str, display_name: str) -> dict:
return _finalize_options(options) return _finalize_options(options)
def complete_registration(username: str, credential_json: dict[str, Any], def _verify_with_any_challenge(key: str, verify_one: Any, empty_message: str) -> Any:
label: str = "") -> dict: """Run *verify_one(challenge)* against every outstanding challenge.
challenge = _take_challenge(f"{username}:register")
if challenge is None:
raise ValueError("Session d'enregistrement expirée — recommencez")
Returns the first success; re-raises the last error when all fail.
BUG-070: lets an in-flight ceremony survive a re-requested options call
(double-click / retry) that stored a newer challenge afterwards.
"""
challenges = _take_all_challenges(key)
if not challenges:
raise ValueError(empty_message)
last_error: Exception | None = None
for challenge in challenges:
try:
return verify_one(challenge)
except Exception as e: # try the next candidate challenge
last_error = e
assert last_error is not None
raise last_error
def complete_registration(username: str, credential_json: dict[str, Any],
label: str = "", rp_id_override: str | None = None,
origins_override: list[str] | None = None) -> dict:
credential = parse_registration_credential_json(credential_json) credential = parse_registration_credential_json(credential_json)
verification = verify_registration_response( effective_rp = rp_id_override or rp_id()
credential=credential, effective_origins = origins_override or expected_origins()
expected_challenge=challenge,
expected_rp_id=rp_id(), def _verify(challenge: bytes) -> Any:
expected_origin=expected_origins(), return verify_registration_response(
) credential=credential,
expected_challenge=challenge,
expected_rp_id=effective_rp,
expected_origin=effective_origins,
)
verification = _verify_with_any_challenge(
f"{username}:register", _verify,
"Session d'enregistrement expirée — recommencez")
transports = credential.response.transports or [] transports = credential.response.transports or []
label = (label or str(credential_json.get("label") or "")).strip() or "Security key" label = (label or str(credential_json.get("label") or "")).strip() or "Security key"
@@ -126,9 +238,12 @@ def complete_registration(username: str, credential_json: dict[str, Any],
# ── Authentication (assertion at login) ──────────────────────────────── # ── Authentication (assertion at login) ────────────────────────────────
def begin_authentication(username: str, credentials: list[dict]) -> dict | None: def begin_authentication(username: str, credentials: list[dict],
rp_id_override: str | None = None,
origins_override: list[str] | None = None) -> dict | None:
if not credentials: if not credentials:
return None return None
_ = origins_override # origins only matter at verification time
from webauthn.helpers.structs import PublicKeyCredentialDescriptor from webauthn.helpers.structs import PublicKeyCredentialDescriptor
allow = [ allow = [
@@ -136,7 +251,7 @@ def begin_authentication(username: str, credentials: list[dict]) -> dict | None:
for c in credentials for c in credentials
] ]
options = generate_authentication_options( options = generate_authentication_options(
rp_id=rp_id(), rp_id=rp_id_override or rp_id(),
challenge=_store_challenge(f"{username}:login"), challenge=_store_challenge(f"{username}:login"),
allow_credentials=allow, allow_credentials=allow,
) )
@@ -147,21 +262,27 @@ def complete_authentication(
username: str, username: str,
credential_json: dict[str, Any], credential_json: dict[str, Any],
stored: dict, stored: dict,
rp_id_override: str | None = None,
origins_override: list[str] | None = None,
) -> int: ) -> int:
"""Verify an assertion. Returns the new sign_count. Raises ValueError on failure.""" """Verify an assertion. Returns the new sign_count. Raises on failure."""
challenge = _take_challenge(f"{username}:login")
if challenge is None:
raise ValueError("Session expirée — rechargez la page")
credential = parse_authentication_credential_json(credential_json) credential = parse_authentication_credential_json(credential_json)
verification = verify_authentication_response( effective_rp = rp_id_override or rp_id()
credential=credential, effective_origins = origins_override or expected_origins()
expected_challenge=challenge,
expected_rp_id=rp_id(), def _verify(challenge: bytes) -> Any:
expected_origin=expected_origins(), return verify_authentication_response(
credential_public_key=base64url_to_bytes(stored["public_key"]), credential=credential,
credential_current_sign_count=int(stored.get("sign_count", 0)), expected_challenge=challenge,
) expected_rp_id=effective_rp,
expected_origin=effective_origins,
credential_public_key=base64url_to_bytes(stored["public_key"]),
credential_current_sign_count=int(stored.get("sign_count", 0)),
)
verification = _verify_with_any_challenge(
f"{username}:login", _verify,
"Session expirée — rechargez la page")
return int(verification.new_sign_count) return int(verification.new_sign_count)
+132 -30
View File
@@ -397,31 +397,52 @@ def parse_markdown_file(raw: str) -> frontmatter.Post:
return frontmatter.Post(content) return frontmatter.Post(content)
def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | None = None) -> dict[str, Any]: def _scan_vault(
vault_name: str,
vault_path: str,
vault_cfg: dict[str, Any] | None = None,
previous_files: dict[str, dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Synchronously scan a single vault directory and build file index. """Synchronously scan a single vault directory and build file index.
Walks the vault tree, reads supported files, extracts metadata Walks the vault tree, reads supported files, extracts metadata
(tags, title, content preview) and stores a capped content snapshot (tags, title, content preview) and stores a capped content snapshot
for in-memory full-text search. for in-memory full-text search.
All files and directories are indexed, including hidden files (starting with '.'). All files and directories are indexed, including hidden files (starting with '.').
Differential scan (#86): when ``previous_files`` maps a relative path to
its previous ``file_info`` dict, entries whose ``size`` and ``modified``
timestamp are unchanged are reused verbatim (no disk read, no re-parse).
Only the cheap ``os.walk`` + ``stat`` runs on every pass; heavy content
extraction (PDF metadata excepted — always cheap) is skipped for
unchanged files. This replaces the full ``rglob`` re-read on rebuilds.
Excalidraw diagrams (#86, like PDFs since BUG-040) are deferred: the scan
only records the title and sets ``excalidraw_text_pending``; the expensive
JSON/lz-string text extraction runs in ``enrich_pdf_texts()`` after the
index is queryable.
Args: Args:
vault_name: Display name of the vault. vault_name: Display name of the vault.
vault_path: Absolute filesystem path to the vault root. vault_path: Absolute filesystem path to the vault root.
vault_cfg: Optional vault configuration dict (unused for indexing, kept for compatibility). vault_cfg: Optional vault configuration dict (unused for indexing, kept for compatibility).
previous_files: Optional ``{relative_path: file_info}`` snapshot from a
previous scan used for differential reuse.
Returns: Returns:
Dict with keys ``files`` (list), ``tags`` (counter dict), ``path`` (str), ``paths`` (list). Dict with keys ``files`` (list), ``tags`` (counter dict), ``path`` (str),
``paths`` (list) and ``reused`` (int, differential hits).
""" """
vault_root = Path(vault_path) vault_root = Path(vault_path)
files: list[dict[str, Any]] = [] files: list[dict[str, Any]] = []
tag_counts: dict[str, int] = {} tag_counts: dict[str, int] = {}
paths: list[dict[str, str]] = [] paths: list[dict[str, str]] = []
reused = 0
if not vault_root.exists(): if not vault_root.exists():
logger.warning(f"Vault path does not exist: {vault_path}") logger.warning(f"Vault path does not exist: {vault_path}")
return {"files": [], "tags": {}, "path": vault_path, "paths": []} return {"files": [], "tags": {}, "path": vault_path, "paths": [], "reused": 0}
root_resolved = vault_root.resolve(strict=False) root_resolved = vault_root.resolve(strict=False)
@@ -479,9 +500,37 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
stat = fpath.stat() stat = fpath.stat()
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat() modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
# #86 differential scan: reuse the previous entry when neither
# size nor mtime changed — skips the disk read + parse below.
if previous_files:
prev = previous_files.get(rel_path_str)
if (
prev is not None
and prev.get("size") == stat.st_size
and prev.get("modified") == modified
):
file_info = {**prev, "tags": list(prev.get("tags", []))}
files.append(file_info)
for tag in file_info.get("tags", []):
tag_counts[tag] = tag_counts.get(tag, 0) + 1
reused += 1
# The global backlink index is rebuilt on every scan,
# so re-register this file's wikilinks from its
# (cached) content instead of re-reading the disk.
if file_info.get("extension") == ".md" and file_info.get("content"):
try:
_extract_wikilinks_for_backlinks(
vault_name, file_info["path"],
file_info.get("title", ""), file_info["content"],
)
except Exception:
pass
continue
# PDF handling — special path (binary, uses pdf_reader) # PDF handling — special path (binary, uses pdf_reader)
tags: list[str] = [] tags: list[str] = []
pdf_text_pending = False pdf_text_pending = False
excalidraw_text_pending = False
if ext == ".pdf": if ext == ".pdf":
from backend.pdf_reader import extract_pdf_metadata from backend.pdf_reader import extract_pdf_metadata
# BUG-040: only the (cheap) metadata is read during the # BUG-040: only the (cheap) metadata is read during the
@@ -494,10 +543,13 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
content_preview = "" content_preview = ""
pdf_text_pending = True 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") # #86: defer the expensive JSON/lz-string text extraction
raw = extract_excalidraw_indexable(raw) # (read + decompress + element walk) to ``enrich_pdf_texts``
# so the scan stays cheap; title comes from the filename.
raw = ""
title = fpath.stem.replace(".excalidraw", "").replace("-", " ").replace("_", " ") title = fpath.stem.replace(".excalidraw", "").replace("-", " ").replace("_", " ")
content_preview = raw[:200].strip() content_preview = ""
excalidraw_text_pending = True
else: else:
raw = fpath.read_text(encoding="utf-8", errors="replace") raw = fpath.read_text(encoding="utf-8", errors="replace")
title = fpath.stem.replace("-", " ").replace("_", " ") title = fpath.stem.replace("-", " ").replace("_", " ")
@@ -528,6 +580,8 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
} }
if pdf_text_pending: if pdf_text_pending:
file_info["pdf_text_pending"] = True file_info["pdf_text_pending"] = True
if excalidraw_text_pending:
file_info["excalidraw_text_pending"] = True
files.append(file_info) files.append(file_info)
for tag in tags: for tag in tags:
@@ -540,28 +594,49 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
logger.error(f"Error indexing {fpath}: {e}") logger.error(f"Error indexing {fpath}: {e}")
continue continue
logger.info(f"Vault '{vault_name}': indexed {len(files)} files, {len(paths)} paths, {len(tag_counts)} unique tags") logger.info(
return {"files": files, "tags": tag_counts, "path": vault_path, "paths": paths, "config": {}} f"Vault '{vault_name}': indexed {len(files)} files "
f"({reused} reused), {len(paths)} paths, {len(tag_counts)} unique tags"
)
return {"files": files, "tags": tag_counts, "path": vault_path, "paths": paths, "config": {}, "reused": reused}
def _read_excalidraw_indexable_text(file_path: Path) -> str:
"""Read an excalidraw file and return its indexable text (blocking helper).
Runs inside an executor via ``enrich_pdf_texts`` so the lz-string
decompression of large diagrams never blocks the event loop.
"""
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
try:
return extract_excalidraw_indexable(raw)
except Exception: # pragma: no cover - defensive
return ""
async def enrich_pdf_texts(vault_name: str | None = None) -> int: async def enrich_pdf_texts(vault_name: str | None = None) -> int:
"""Extract text from PDFs whose extraction was deferred during the scan (BUG-040). """Extract text deferred during the scan: PDFs (BUG-040) + excalidraw (#86).
``_scan_vault`` only reads PDF metadata so a vault with many or large PDFs ``_scan_vault`` only reads PDF metadata and excalidraw filenames so a vault
starts serving immediately. This coroutine runs *after* the index (and the with many or large heavy files starts serving immediately. This coroutine
inverted index) is ready, extracts the missing text off the event loop and runs *after* the index (and the inverted index) is ready, extracts the
updates the in-memory entry plus the incremental index hooks. missing text off the event loop and updates the in-memory entry plus the
incremental index hooks.
Args: Args:
vault_name: Restrict the pass to a single vault; ``None`` covers every vault_name: Restrict the pass to a single vault; ``None`` covers every
indexed vault. indexed vault.
Returns: Returns:
Number of deferred PDFs whose text extraction was attempted. Number of deferred files (PDF + excalidraw) whose text extraction was
attempted.
""" """
from backend.pdf_reader import extract_pdf_text from backend.pdf_reader import extract_pdf_text
pending: list[tuple[str, dict[str, Any], Path]] = [] pending: list[tuple[str, dict[str, Any], Path, str]] = []
with _index_lock: with _index_lock:
for name, vault_data in index.items(): for name, vault_data in index.items():
if vault_name is not None and name != vault_name: if vault_name is not None and name != vault_name:
@@ -569,32 +644,38 @@ async def enrich_pdf_texts(vault_name: str | None = None) -> int:
vault_root = Path(vault_data.get("path", "")) vault_root = Path(vault_data.get("path", ""))
for file_info in vault_data.get("files", []): for file_info in vault_data.get("files", []):
if file_info.get("pdf_text_pending"): if file_info.get("pdf_text_pending"):
pending.append((name, file_info, vault_root / file_info["path"])) pending.append((name, file_info, vault_root / file_info["path"], "pdf"))
elif file_info.get("excalidraw_text_pending"):
pending.append((name, file_info, vault_root / file_info["path"], "excalidraw"))
if not pending: if not pending:
return 0 return 0
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
enriched = 0 enriched = 0
for name, file_info, file_path in pending: for name, file_info, file_path, kind in pending:
try: try:
raw = await loop.run_in_executor(None, extract_pdf_text, file_path, 100000) if kind == "pdf":
raw = await loop.run_in_executor(None, extract_pdf_text, file_path, 100000)
else:
raw = await loop.run_in_executor(None, _read_excalidraw_indexable_text, file_path)
except Exception as exc: # pragma: no cover - defensive except Exception as exc: # pragma: no cover - defensive
logger.warning("PDF enrichment failed for %s: %s", file_path, exc) logger.warning("Deferred text enrichment failed for %s: %s", file_path, exc)
raw = "" raw = ""
file_info["content"] = raw[:SEARCH_CONTENT_LIMIT] file_info["content"] = raw[:SEARCH_CONTENT_LIMIT]
file_info["content_preview"] = raw[:200].strip() file_info["content_preview"] = raw[:200].strip()
file_info.pop("pdf_text_pending", None) file_info.pop("pdf_text_pending", None)
file_info.pop("excalidraw_text_pending", None)
enriched += 1 enriched += 1
if _on_index_change: if _on_index_change:
try: try:
_on_index_change("add", name, file_info["path"], file_info) _on_index_change("add", name, file_info["path"], file_info)
except Exception as exc: # pragma: no cover - defensive except Exception as exc: # pragma: no cover - defensive
logger.warning( logger.warning(
"Index hook failed after PDF enrichment for %s: %s", file_path, exc "Index hook failed after deferred enrichment for %s: %s", file_path, exc
) )
logger.info("PDF enrichment: extracted text for %d deferred PDF(s)", enriched) logger.info("Deferred text enrichment: extracted text for %d file(s)", enriched)
return enriched return enriched
@@ -603,16 +684,24 @@ async def build_index(progress_callback=None) -> None:
Runs vault scans concurrently, inserting them incrementally into the global index. Runs vault scans concurrently, inserting them incrementally into the global index.
Notifies progress via the provided callback. Notifies progress via the provided callback.
#86 differential rebuild: the previous per-vault ``{path: file_info}``
snapshots are captured before the clear and handed to ``_scan_vault`` so
unchanged files (same size + mtime) are reused without disk re-reads.
""" """
global index, vault_config global index, vault_config
vault_config.clear() vault_config.clear()
vault_config.update(load_vault_config()) vault_config.update(load_vault_config())
# Note: vault_settings are now only used for UI display preferences (hideHiddenFiles) # Note: vault_settings are now only used for UI display preferences (hideHiddenFiles)
# Indexing always includes all files regardless of settings # Indexing always includes all files regardless of settings
global _index_generation global _index_generation
with _index_lock: with _index_lock:
previous_snapshot: dict[str, dict[str, dict[str, Any]]] = {
name: {f["path"]: f for f in vdata.get("files", [])}
for name, vdata in index.items()
}
index.clear() index.clear()
_file_lookup.clear() _file_lookup.clear()
path_index.clear() path_index.clear()
@@ -631,8 +720,13 @@ async def build_index(progress_callback=None) -> None:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
async def _process_vault(name: str, config: dict[str, Any]): async def _process_vault(name: str, config: dict[str, Any]):
import functools
vault_path = config["path"] vault_path = config["path"]
vault_data = await loop.run_in_executor(None, _scan_vault, name, vault_path, config) scan = functools.partial(
_scan_vault, name, vault_path, config, previous_snapshot.get(name)
)
vault_data = await loop.run_in_executor(None, scan)
vault_data["config"] = config vault_data["config"] = config
# Build lookup entries for the new vault # Build lookup entries for the new vault
@@ -695,7 +789,7 @@ 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. # BUG-040/#86: complete the deferred PDF + excalidraw extraction.
await enrich_pdf_texts() await enrich_pdf_texts()
stats = {} stats = {}
for name, data in index.items(): for name, data in index.items():
@@ -724,14 +818,22 @@ async def reload_single_vault(vault_name: str) -> dict[str, Any]:
raise ValueError(f"Vault '{vault_name}' not found in configuration") raise ValueError(f"Vault '{vault_name}' not found in configuration")
config = vault_config[vault_name] config = vault_config[vault_name]
# #86 differential rescan: snapshot this vault's entries before removal so
# unchanged files are reused without disk re-reads.
with _index_lock:
_previous = {f["path"]: f for f in index.get(vault_name, {}).get("files", [])}
# Remove old vault data from index structures # Remove old vault data from index structures
await remove_vault_from_index(vault_name) await remove_vault_from_index(vault_name)
# Re-add the vault with updated configuration # Re-add the vault with updated configuration
import functools
vault_path = config["path"] vault_path = config["path"]
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
vault_data = await loop.run_in_executor(None, _scan_vault, vault_name, vault_path, config) scan = functools.partial(_scan_vault, vault_name, vault_path, config, _previous)
vault_data = await loop.run_in_executor(None, scan)
vault_data["config"] = config vault_data["config"] = config
# Build lookup entries for the vault # Build lookup entries for the vault
@@ -761,7 +863,7 @@ async def reload_single_vault(vault_name: str) -> dict[str, Any]:
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. # BUG-040/#86: complete the deferred PDF + excalidraw extraction.
await enrich_pdf_texts(vault_name) 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"])}
+1
View File
@@ -15,6 +15,7 @@ weasyprint>=60.0
httpx>=0.27.0 httpx>=0.27.0
pypdf>=4.0 pypdf>=4.0
pyotp>=2.10.0 pyotp>=2.10.0
segno>=1.5.0
webauthn==2.6.0 webauthn==2.6.0
psutil>=5.9 psutil>=5.9
pywebpush>=2.3.0 pywebpush>=2.3.0
+15
View File
@@ -26,6 +26,11 @@ from backend.services.vaults import get_vault_root
logger = logging.getLogger("obsigate.services.mutations") logger = logging.getLogger("obsigate.services.mutations")
# #86: per-file size cap for find/replace passes (CPU guard — complements the
# BUG-025 regex caps). Files larger than this are skipped instead of being
# read fully into memory and scanned with a user-supplied pattern.
MAX_REPLACE_FILE_BYTES = 5_000_000
# Skeleton injected into empty ``.excalidraw`` files (mirrors the route logic). # Skeleton injected into empty ``.excalidraw`` files (mirrors the route logic).
_EXCALIDRAW_SKELETON = ( _EXCALIDRAW_SKELETON = (
'{"type":"excalidraw","version":2,"elements":[],' '{"type":"excalidraw","version":2,"elements":[],'
@@ -509,6 +514,16 @@ def replace_in_files(
continue continue
if not file_path.exists() or not file_path.is_file(): if not file_path.exists() or not file_path.is_file():
continue continue
# #86 CPU guard: skip files too large to scan safely in one pass.
try:
if file_path.stat().st_size > MAX_REPLACE_FILE_BYTES:
logger.warning(
"replace_in_files: skipping oversized file %s/%s (%d bytes)",
result_vault, result["path"], file_path.stat().st_size,
)
continue
except OSError:
continue
try: try:
original = file_path.read_text(encoding="utf-8", errors="replace") original = file_path.read_text(encoding="utf-8", errors="replace")
except OSError: except OSError:
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]] [[package]]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.15.0" version = "2.16.5"
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.15.0" version = "2.16.5"
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.15.0", "version": "2.16.5",
"identifier": "com.obsigate.desktop", "identifier": "com.obsigate.desktop",
"build": { "build": {
"frontendDist": "../frontend", "frontendDist": "../frontend",
+9
View File
@@ -176,6 +176,10 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-065* | [🟡 IMPORTANT] Éditeur Excalidraw : l'auto-save recharge la page en pleine édition | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/excalidraw-viewer.js`, `frontend/js/utils.js`, `frontend/excalidraw-editor.html`, `tests/frontend/excalidraw-viewer.test.mjs` | Ouvrir un `.excalidraw` puis modifier un élément : au bout de 2 s la vue se recharge | Chaque modification déclenchait un `PUT save` 2 s plus tard → SSE `index_updated` → `reloadExternalWrite` → `openFile` → **recréation de l'iframe** (refresh visible). Auto-save supprimée : sauvegarde explicite (bouton 💾 / Ctrl+S). `reloadExternalWrite` ignore le fichier si un iframe Excalidraw est ouvert (`iframe[data-excalidraw-vault/path]`). Le badge « Modified » ne réagit plus aux changements d'`appState` (resize/zoom) mais à la signature des éléments. | Vérifié Playwright : plus de refresh, badge stable après bascule plein écran. Test statique (absence de `requestSave`/`saveTimer`). | | *BUG-065* | [🟡 IMPORTANT] Éditeur Excalidraw : l'auto-save recharge la page en pleine édition | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/excalidraw-viewer.js`, `frontend/js/utils.js`, `frontend/excalidraw-editor.html`, `tests/frontend/excalidraw-viewer.test.mjs` | Ouvrir un `.excalidraw` puis modifier un élément : au bout de 2 s la vue se recharge | Chaque modification déclenchait un `PUT save` 2 s plus tard → SSE `index_updated` → `reloadExternalWrite` → `openFile` → **recréation de l'iframe** (refresh visible). Auto-save supprimée : sauvegarde explicite (bouton 💾 / Ctrl+S). `reloadExternalWrite` ignore le fichier si un iframe Excalidraw est ouvert (`iframe[data-excalidraw-vault/path]`). Le badge « Modified » ne réagit plus aux changements d'`appState` (resize/zoom) mais à la signature des éléments. | Vérifié Playwright : plus de refresh, badge stable après bascule plein écran. Test statique (absence de `requestSave`/`saveTimer`). |
| *BUG-066* | [🔵 MINEUR] Configuration : icônes manquantes dans la table des matières (« Fichiers cachés », « Partages publics ») | 🟢 corrigé | P3 | 📱 frontend | IA | `frontend/locales/{fr,en}.json` | Ouvrir Configuration → observer le sommaire : les entrées « Fichiers cachés » et « Partages publics » n'ont pas d'icône | `config.section_hidden` → « 🗂️ Fichiers cachés » / « 🗂️ Hidden files », `config.section_shares` → « 📤 Partages publics » (EN avait déjà l'icône). Test : `tests/frontend/unit.test.mjs` (+1 : toutes les entrées du sommaire portent une icône FR/EN) | Les libellés du sommaire utilisent des clés i18n distinctes des titres de section (`auto.f8ba6127`, `config.section_partages-publics`) qui, elles, avaient l'icône | | *BUG-066* | [🔵 MINEUR] Configuration : icônes manquantes dans la table des matières (« Fichiers cachés », « Partages publics ») | 🟢 corrigé | P3 | 📱 frontend | IA | `frontend/locales/{fr,en}.json` | Ouvrir Configuration → observer le sommaire : les entrées « Fichiers cachés » et « Partages publics » n'ont pas d'icône | `config.section_hidden` → « 🗂️ Fichiers cachés » / « 🗂️ Hidden files », `config.section_shares` → « 📤 Partages publics » (EN avait déjà l'icône). Test : `tests/frontend/unit.test.mjs` (+1 : toutes les entrées du sommaire portent une icône FR/EN) | Les libellés du sommaire utilisent des clés i18n distinctes des titres de section (`auto.f8ba6127`, `config.section_partages-publics`) qui, elles, avaient l'icône |
| *BUG-067* | [🔵 MINEUR] Guide d'utilisation : l'entrée « 📱 Mobile » du sommaire ne fait rien (section absente) | 🟢 corrigé | P3 | 📱 frontend | IA | `frontend/index.html` | Ouvrir le Guide → cliquer « 📱 Mobile » dans le sommaire : rien ne se passe | L'ancre `#help-mobile-editor` était présente dans la TOC mais aucune section `id="help-mobile-editor"` n'existait (l'édition mobile n'était qu'un h3 de `help-edition`). Fix #105 : section dédiée créée avec ancre + entrée de nav cohérente. | Vérifié par test statique `tests/test_guide.py::test_nav_anchors_resolve` | | *BUG-067* | [🔵 MINEUR] Guide d'utilisation : l'entrée « 📱 Mobile » du sommaire ne fait rien (section absente) | 🟢 corrigé | P3 | 📱 frontend | IA | `frontend/index.html` | Ouvrir le Guide → cliquer « 📱 Mobile » dans le sommaire : rien ne se passe | L'ancre `#help-mobile-editor` était présente dans la TOC mais aucune section `id="help-mobile-editor"` n'existait (l'édition mobile n'était qu'un h3 de `help-edition`). Fix #105 : section dédiée créée avec ancre + entrée de nav cohérente. | Vérifié par test statique `tests/test_guide.py::test_nav_anchors_resolve` |
| *BUG-068* | Configuration — section « 🔒 Sécurité du compte » inachevée : boutons hors thème, QR code invisible, fiabilité des fonctions à valider | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/js/auth.js`, `frontend/style.css`, `backend/auth/router.py` | Configuration → 🔒 Sécurité du compte | `frontend/style.css` (+`config-btn-primary`/`danger` thème), `backend/auth/router.py` (`qr_data_url` segno local), `frontend/js/auth.js` (QR local + fallback, recovery WebAuthn, carte mot de passe, escapeHtml labels), locales FR/EN, `backend/requirements.txt` (+segno) ; tests `tests/test_mfa.py` (+1) + `tests/frontend/mfa-settings.test.mjs` (nouveau, 9) | pytest 1241 passed / 6 skipped, ruff 0, mypy 0, frontend unit + validate-imports verts |
| *BUG-069* | Login 2FA bloqué sans erreur : après user+pwd corrects, la page de login reste affichée et le challenge MFA n'apparaît jamais | 🟢 corrigé | P0 | 📱 frontend | IA | `frontend/js/auth.js`, `frontend/index.html` | Activer 2FA → logout → login (bon user+pwd) | `frontend/js/auth.js` (`showMfaChallenge` → `.login-card` + erreur `mfa.challenge_unavailable` si montage impossible), locales FR/EN ; tests `tests/frontend/mfa-settings.test.mjs` (+2) | Reproduit au navigateur avant correctif (challenge jamais affiché), vérifié après : challenge affiché, code erroné → erreur, code valide (200) → app ; frontend mfa-settings 11/11, unit + validate-imports verts |
| *BUG-070* | Activation clé physique WebAuthn impossible : « Validation du credential WebAuthn échouée » à chaque tentative | 🟢 corrigé | P0 | ⚙️ backend | IA | `backend/auth/webauthn_mfa.py`, `backend/auth/router.py` | Config → Sécurité → Ajouter une clé → cérémonie navigateur → 400 | `resolve_relying_party()` (rp_id/origines dérivés de la requête, config explicite prioritaire, forwarded si TRUST_PROXY) sur les 4 endpoints ; challenges multiples (5 derniers) acceptés ; `.env.example` ; tests `tests/test_webauthn.py` (+8) | Logs : origin `http://localhost:2020` rejetée + challenge mismatch au retry. Vérifié navigateur (authentificateur virtuel CDP) : register 200 + clé listée, clé de test retirée (admin de nouveau TOTP seul) ; pytest 1249 passed, ruff/mypy 0 |
| *BUG-071* | Configuration « Configurations » inutilisable en mode mobile : sommaire masqué sans bouton d'accès, navigation par ancre sans JS, grilles 2 colonnes et rangées d'ajout qui débordent (≤768px) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/index.html`, `frontend/js/config.js`, `frontend/js/i18n.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json` | Mobile (≤768px) : ouvrir Configurations → aucun sommaire ni moyen d'atteindre une section ; champs « Clés IA » / jetons / webhooks débordent | `index.html` (+`#config-hamburger` `.help-hamburger`, `config.toc_toggle` FR/EN) ; `config.js` (toggle, scroll doux + actif + repli auto mobile, reset à l'ouverture) ; `i18n.js` (`data-i18n-attr` multi-paires `;`) ; `style.css` (bloc mobile `#config-modal` : sommaire haut 46vh, grilles 1fr, add-rows wrap + `!important`, items wrap, 44px) ; tests `tests/frontend/config-mobile.test.mjs` (nouveau, 11) + CI ; E2E `tests/e2e/config-mobile.spec.js` (nouveau, 3/3 projet chromium-mobile, ignoré en desktop) | pytest 1249 passed / 6 skipped, ruff 0, mypy 0, validate-imports 39 modules, unit 10/10, JSDOM ai 93/93 + sidebar 6/6 + mobile 35/35 + ai-keys 7/7 |
| | | | | | | | | | | | | | | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches) ### TODOs techniques (améliorations / nouvelles tâches)
@@ -249,6 +253,11 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-09-18 | BUG-066 | Correction | `frontend/locales/fr.json`, `frontend/locales/en.json`, `tests/frontend/unit.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-066** : la table des matières de la page de configuration n'affichait aucune icône pour « Fichiers cachés » et « Partages publics ». Les libellés du sommaire proviennent de clés i18n (`config.section_hidden`, `config.section_shares`) distinctes des titres de section qui, eux, portaient déjà l'icône. Alignement : 🗂️ / 📤 en FR **et** EN. Test de non-régression : `unit.test.mjs` vérifie que **toutes** les entrées `.help-nav-link` du sommaire portent une icône dans les deux langues (17/17). Vérifié : `unit.test.mjs` 10/10, `validate-imports` 38 modules. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-18 | BUG-066 | Correction | `frontend/locales/fr.json`, `frontend/locales/en.json`, `tests/frontend/unit.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-066** : la table des matières de la page de configuration n'affichait aucune icône pour « Fichiers cachés » et « Partages publics ». Les libellés du sommaire proviennent de clés i18n (`config.section_hidden`, `config.section_shares`) distinctes des titres de section qui, eux, portaient déjà l'icône. Alignement : 🗂️ / 📤 en FR **et** EN. Test de non-régression : `unit.test.mjs` vérifie que **toutes** les entrées `.help-nav-link` du sommaire portent une icône dans les deux langues (17/17). Vérifié : `unit.test.mjs` 10/10, `validate-imports` 38 modules. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-18 | #105, BUG-067 | Documentation + correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/main.py`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **#105** : audit complet de couverture du Guide d'utilisation — 8 nouvelles sections (Architecture + diagramme Mermaid, API & intégrations, Diagrammes Mermaid & Excalidraw, Hors-ligne & synchronisation, Collaboration temps réel, Application desktop, Bibliothèque & signets, Multilingue) et compléments (recherche sémantique, MFA/WebAuthn, notifications push, exports HTML/ePub/ZIP, PDF, vue multi-panneaux, admin). Téléchargement du guide en Markdown et PDF (`GET /api/guide/download?format=md|pdf`, FR/EN, rendu par le moteur d'export existant). Guide plus large en desktop. **BUG-067** : ancre morte `#help-mobile-editor` → section dédiée créée. | 🟢 corrigé (en attente vérif utilisateur) | 2026-09-18 | #105, BUG-067 | Documentation + correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/main.py`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **#105** : audit complet de couverture du Guide d'utilisation — 8 nouvelles sections (Architecture + diagramme Mermaid, API & intégrations, Diagrammes Mermaid & Excalidraw, Hors-ligne & synchronisation, Collaboration temps réel, Application desktop, Bibliothèque & signets, Multilingue) et compléments (recherche sémantique, MFA/WebAuthn, notifications push, exports HTML/ePub/ZIP, PDF, vue multi-panneaux, admin). Téléchargement du guide en Markdown et PDF (`GET /api/guide/download?format=md|pdf`, FR/EN, rendu par le moteur d'export existant). Guide plus large en desktop. **BUG-067** : ancre morte `#help-mobile-editor` → section dédiée créée. | 🟢 corrigé (en attente vérif utilisateur)
| 2026-09-18 | #105 (ajustements) | Amélioration | `frontend/index.html`, `frontend/js/config.js`, `frontend/sw.js`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/pdf_export.py`, `Dockerfile`, `scripts/build_guide_diagrams.py`, `scripts/render_guide_diagram.mjs`, `scripts/guide_content.py`, `backend/assets/guide_diagrams/df7366a40db6a5a2.png`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md` | **#105 (retour utilisateur)** : 1) boutons de téléchargement du guide passés en icônes seules (tooltips i18n conservés) ; 2) le diagramme Mermaid de la section Architecture est désormais rendu en **vraie image** dans le PDF (pipeline de pré-rendu PNG Chromium+mermaid v11, PNG commité sous `backend/assets/guide_diagrams/<sha1>.png`, résolu par `diagram_png_for()` ; le Markdown garde le fenced mermaid) ; 3) emoji du PDF rendus **en couleur** au lieu de rectangles : `fonts-noto-color-emoji` ajouté au Dockerfile + `"Noto Color Emoji"` en fin de pile de polices PDF. Vérifié : pytest 1218 (test_guide ×13), ruff/mypy 0, validate-imports 38, unit 10/10 ; PDF live conteneur 2020 : 24 pages, 0 glyphes tofu, diagramme 3568x1174 embarqué. | 🟢 livré | 2026-09-18 | #105 (ajustements) | Amélioration | `frontend/index.html`, `frontend/js/config.js`, `frontend/sw.js`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/pdf_export.py`, `Dockerfile`, `scripts/build_guide_diagrams.py`, `scripts/render_guide_diagram.mjs`, `scripts/guide_content.py`, `backend/assets/guide_diagrams/df7366a40db6a5a2.png`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md` | **#105 (retour utilisateur)** : 1) boutons de téléchargement du guide passés en icônes seules (tooltips i18n conservés) ; 2) le diagramme Mermaid de la section Architecture est désormais rendu en **vraie image** dans le PDF (pipeline de pré-rendu PNG Chromium+mermaid v11, PNG commité sous `backend/assets/guide_diagrams/<sha1>.png`, résolu par `diagram_png_for()` ; le Markdown garde le fenced mermaid) ; 3) emoji du PDF rendus **en couleur** au lieu de rectangles : `fonts-noto-color-emoji` ajouté au Dockerfile + `"Noto Color Emoji"` en fin de pile de polices PDF. Vérifié : pytest 1218 (test_guide ×13), ruff/mypy 0, validate-imports 38, unit 10/10 ; PDF live conteneur 2020 : 24 pages, 0 glyphes tofu, diagramme 3568x1174 embarqué. | 🟢 livré
| 2026-09-22 | BUG-068 | Correction | `backend/auth/router.py`, `backend/requirements.txt`, `frontend/js/auth.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_mfa.py`, `tests/frontend/mfa-settings.test.mjs` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-068** : section « 🔒 Sécurité du compte » finalisée. (1) Boutons hors thème : `config-btn-primary`/`config-btn-danger` n'existaient pas en CSS → définis depuis les variables du thème (+ états disabled). (2) QR invisible : l'image tierce était bloquée par la CSP (`img-src 'self' data: blob:`) et exposait le secret TOTP → QR SVG `data:` généré en local par le backend (`qr_data_url`, segno) avec repli saisie manuelle. (3) Codes de récupération perdus à la 1re activation WebAuthn → `_showRecoveryCodes(codes, targetId)` avec repli `webauthn-flow-area`. (4) Carte « Mot de passe » ajoutée (endpoint `change-password` existant, jusque-là sans UI) + échappement des libellés de clés WebAuthn. Vérifié : pytest 1241 passed / 6 skipped, ruff 0, mypy 0 (78 fichiers), `mfa-settings.test.mjs` 9/9, unit 10/10, validate-imports 39 modules. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-069 | Correction | `frontend/js/auth.js`, `frontend/locales/{fr,en}.json`, `tests/frontend/mfa-settings.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-069** : login 2FA bloqué sans erreur — après user+pwd corrects, `showMfaChallenge` cherchait `.login-box` (inexistant dans `index.html`, marquage réel `#login-screen > .login-card`) et faisait un `return` silencieux : page de login figée, aucune erreur. Correctif : montage dans `.login-card` (repli `#login-screen`) + erreur visible `mfa.challenge_unavailable` (FR/EN) si le point de montage manque. **Reproduit au navigateur** (Playwright, instance Docker `obsigate-test`, compte jetable avec TOTP) : avant → challenge jamais affiché ; après → challenge affiché, code erroné → erreur, code valide (verify 200) → app. Tests : `mfa-settings.test.mjs` 11/11 (+2 ancrage DOM), unit 10/10, validate-imports 39 modules. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-070 | Correction | `backend/auth/webauthn_mfa.py`, `backend/auth/router.py`, `.env.example`, `tests/test_webauthn.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-070** : activation WebAuthn rejetée en 400. (1) Défauts `localhost` sans port → `resolve_relying_party()` dérive rp_id/origines de la requête (config explicite prioritaire, forwarded sous TRUST_PROXY), appliqué aux endpoints register + login. (2) Challenge single-use → 5 derniers conservés, vérification contre le challenge de la cérémonie en cours. **Vérifié au navigateur** (authentificateur virtuel CDP, instance Docker) : register 200, clé listée, clé de test retirée. Tests : `test_webauthn.py` 19/19 (+8), suite complète 1249 passed / 6 skipped, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-071 | Correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/js/i18n.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs` (nouveau), `.gitea/workflows/ci.yml`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-071** : page « Configurations » inutilisable en mobile. (1) `#config-nav` masquée sous 768px sans toggle → hamburger `#config-hamburger` ajouté à l'en-tête (`.help-hamburger`, libellé `config.toc_toggle` FR/EN). (2) Ancres brutes sans JS → interception en `config.js` (scroll doux, lien actif, repli auto mobile, reset à l'ouverture). (3) Débordements 360px → bloc CSS mobile `#config-modal` (sommaire haut 46vh, grilles 1fr, add-rows wrap + largeurs inline neutralisées, items wrap, cibles 44px). `data-i18n-attr` multi-paires (`;`). Vérifié : `config-mobile.test.mjs` 11/11 (nouveau, au CI), pytest 1249 passed / 6 skipped, ruff/mypy 0, validate-imports 39 modules, unit 10/10, JSDOM ai 93/93 + ai-sidebar 6/6 + sidebar-filters 8/8 + mobile-editor 35/35 + config-ai-keys 7/7. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-071 (complément E2E) | Test | `tests/e2e/config-mobile.spec.js` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-071 (complément E2E)** : spec Playwright mobile (convention `mobile-editor.spec.js` : `test.skip` hors viewport ≤768px, donc inactive sur le projet `chromium-desktop` du CI). Vérifié en local sur l'instance de test (port 2029, auth désactivée) : hamburger → sommaire, sélection → scroll + actif + repli, 0 débordement horizontal à 393px (3/3 `chromium-mobile`, 3 ignorés en desktop) ; suite `mobile-editor.spec.js` intacte (3/3). | 🟢 corrigé (en attente vérif utilisateur) |
--- ---
+5 -13
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap # ObsiGate — Roadmap
> **Version :** 2.15.0 | **Dernière mise à jour :** 2026-09-22 > **Version :** 2.16.5 | **Dernière mise à jour :** 2026-09-22
> **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)**
@@ -107,15 +107,6 @@
- [ ] Persister index, JTI révoqués et compteurs de rate-limit (SQLite/Redis) - [ ] Persister index, JTI révoqués et compteurs de rate-limit (SQLite/Redis)
- [ ] Verrous asyncio autour de l'index global et des stores JSON ; service de partage public (expiration, révocation, quotas) - [ ] Verrous asyncio autour de l'index global et des stores JSON ; service de partage public (expiration, révocation, quotas)
### 86. Optimisation globale des performances (phase 3)
- **Effort :** 4-6 jours | **Impact :** 🟡 | **Zone :** backend (`search.py`, `indexer.py`, `mutations.py`)
- **Description :** brancher l'inverted index (déjà construit) sur la recherche simple et le tool IA `search_fulltext`, indexation incrémentale, extraction PDF lazy.
- **Sous-tâches :**
- [ ] Recherche simple + tool IA via l'inverted index (suppression du balayage O(N) en mémoire)
- [ ] Indexation incrémentale + scan différentiel au démarrage (remplace le `rglob` complet)
- [ ] Extraction PDF/excalidraw différée (hors scan) ; caps CPU sur les opérations regex
### 87. Amélioration continue — tests, CI/CD, revues de sécurité (phase 4) ### 87. Amélioration continue — tests, CI/CD, revues de sécurité (phase 4)
- **Effort :** 3-5 jours | **Impact :** 🟡 | **Zone :** `.gitea/workflows/`, `tests/` - **Effort :** 3-5 jours | **Impact :** 🟡 | **Zone :** `.gitea/workflows/`, `tests/`
@@ -190,6 +181,7 @@
| 105 | Guide d'utilisation — audit de couverture complet, téléchargement Markdown/PDF, guide desktop élargi, section Architecture (Mermaid) + BUG-067 | 2.13.0 | [features/guide-coverage-105.md](./features/guide-coverage-105.md) | | 105 | Guide d'utilisation — audit de couverture complet, téléchargement Markdown/PDF, guide desktop élargi, section Architecture (Mermaid) + BUG-067 | 2.13.0 | [features/guide-coverage-105.md](./features/guide-coverage-105.md) |
| 106 | Assistant IA — Actions instantanées contextuelles, catalogue « Toutes les actions » & frontmatter complet | 2.14.0 | [features/ai-quick-actions.md](./features/ai-quick-actions.md) | | 106 | Assistant IA — Actions instantanées contextuelles, catalogue « Toutes les actions » & frontmatter complet | 2.14.0 | [features/ai-quick-actions.md](./features/ai-quick-actions.md) |
| 107 | Configuration — Gestion des clés API & MCP : création/révocation de jetons longue durée (1 j, 1 mois, 6 mois, 1 an, sans fin), une seule clé pour l'API REST et le serveur MCP, « dernière utilisation », store `data/api_tokens.json` sans secret persisté | 2.15.0 | [features/api-mcp-tokens-107.md](./features/api-mcp-tokens-107.md) | | 107 | Configuration — Gestion des clés API & MCP : création/révocation de jetons longue durée (1 j, 1 mois, 6 mois, 1 an, sans fin), une seule clé pour l'API REST et le serveur MCP, « dernière utilisation », store `data/api_tokens.json` sans secret persisté | 2.15.0 | [features/api-mcp-tokens-107.md](./features/api-mcp-tokens-107.md) |
| 86 | Optimisation globale des performances (phase 3) — scan différentiel, excalidraw différé, garde-fou `replace` (inverted index / PDF lazy / caps regex déjà livrés via BUG-033/040/025) | 2.16.0 | [features/perf-phase3-86.md](./features/perf-phase3-86.md) |
--- ---
@@ -197,11 +189,11 @@
| 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–107, #92 | ~116 jours réalisés | | ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–107, #92 | ~120 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 (BUG-035 → BUG-040 corrigés) | ~15-23 jours | | ⚪ P0/P1 restant | #85, #87 Refonte architecturale, CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~11-17 jours |
| **Total restant** | **7 items + finitions** | **~27-42 jours** | | **Total restant** | **6 items + finitions** | **~23-36 jours** |
--- ---
+69
View File
@@ -0,0 +1,69 @@
# #86 — Optimisation globale des performances (phase 3)
> **Statut :** ✅ livré | **Zone :** backend (`indexer.py`, `mutations.py`)
> **Prérequis déjà livrés :** BUG-033 (recherche via inverted index), BUG-040 (PDF lazy),
> BUG-025 (caps regex).
## Contexte
La roadmap #86 demandait : recherche simple + tool IA via l'inverted index, indexation
incrémentale + scan différentiel au démarrage, extraction PDF/excalidraw différée et caps
CPU sur les opérations regex. Trois de ces cinq points étaient déjà couverts par des
correctifs antérieurs ; cette livraison ferme les deux points restants.
## Ce qui était déjà livré (rappel)
| Point #86 | Livré par | État |
|---|---|---|
| Recherche simple + `search_fulltext` via inverted index | BUG-033 | `search()` récupère ses candidats via l'inverted index (intersection + expansion de préfixes), repli scan pendant la construction |
| Extraction PDF différée | BUG-040 | `_scan_vault` ne lit que les métadonnées ; `enrich_pdf_texts()` extrait après index |
| Caps CPU regex | BUG-025 | `validate_regex` (longueur ≤ 500, rejet quantificateurs imbriqués), contenu tronqué à 200 kio, matchs plafonnés à 1 000 |
## Livré ici
### 1. Scan différentiel au démarrage (`backend/indexer.py`)
- `_scan_vault(..., previous_files)` : quand un snapshot `{relative_path: file_info}` est
fourni, toute entrée dont `size` **et** `modified` sont inchangés est réutilisée sans
lecture disque ni re-parse (copie du dict, tags recomptés, wikilinks ré-enregistrés
depuis le contenu caché pour reconstruire l'index de backlinks).
- `build_index()` capture le snapshot précédent avant le `clear()` et le transmet à
chaque scan de vault (via `functools.partial` pour l'executor) ; `reload_single_vault()`
fait de même pour son vault. Seuls le `os.walk` + `stat` (bon marché) tournent à
chaque passe ; le retour inclut `reused` (hits différentiels, loggé par vault).
- Premier démarrage (aucun snapshot) : comportement identique à avant.
### 2. Extraction excalidraw différée (`backend/indexer.py`)
- Le scan ne lit plus les `.excalidraw` / `.excalidraw.md` : titre dérivé du nom de
fichier, `content` vide, flag `excalidraw_text_pending` (plus de décompression
lz-string pendant le scan).
- `enrich_pdf_texts()` traite désormais les deux flags (`pdf` + `excalidraw`) via
`_read_excalidraw_indexable_text()` exécuté dans l'executor, avec notification du hook
d'index incrémental comme pour les PDF. Nom conservé pour compatibilité (tests,
`main.py`, `reload_index`, `reload_single_vault` inchangés côté appel).
- Le chemin incrémental fichier-à-fichier (`_index_single_file_sync`, watcher) reste
immédiat : un seul fichier ne justifie pas le différé.
### 3. Garde-fou taille sur `replace_in_files` (`backend/services/mutations.py`)
- Nouvelle constante `MAX_REPLACE_FILE_BYTES` (5 Mio) : tout fichier dépassant le plafond
est sauté (warning loggé) au lieu d'être lu intégralement puis balayé par le pattern
utilisateur. Complète les caps BUG-025 (qui couvrent la recherche, pas le remplacement
qui opère sur le contenu disque complet par nature).
## Tests
`tests/test_perf_phase3.py` (9 tests) : différé excalidraw au scan (`.excalidraw` +
`.excalidraw.md`), remplissage par l'enrichissement, `reused == 0` au premier scan,
réutilisation à l'identique, re-parse du fichier modifié, ajout/suppression, skip
`replace` sur fichier surdimensionné + cas passant nominal.
## Limites connues
- Pas de persistance disque de l'index : le différentiel joue sur les rebuilds dans le
même processus (`reload_index`, `reload_single_vault`), pas entre deux redémarrages
(persistance = #85, phase 2).
- La comparaison `size + mtime` ne détecte pas une modification qui conserverait taille
et mtime à la milliseconde près (cas pathologique, le watcher temps réel couvre les
modifications en cours d'exécution).
+12
View File
@@ -1486,6 +1486,18 @@
<div class="editor-modal" id="config-modal"> <div class="editor-modal" id="config-modal">
<div class="editor-container"> <div class="editor-container">
<div class="editor-header"> <div class="editor-header">
<button
class="help-hamburger"
id="config-hamburger"
data-i18n-attr="title:config.toc_toggle;aria-label:config.toc_toggle"
title="Afficher le sommaire"
aria-label="Afficher le sommaire"
>
<i
data-lucide="menu"
style="width: 18px; height: 18px"
></i>
</button>
<div class="editor-title" data-i18n="header.menu_config">Configurations</div> <div class="editor-title" data-i18n="header.menu_config">Configurations</div>
<div class="editor-actions"> <div class="editor-actions">
<button <button
+123 -15
View File
@@ -1,6 +1,6 @@
/* ObsiGate — Authentication: API helper, AuthManager, login form, AdminPanel */ /* ObsiGate — Authentication: API helper, AuthManager, login form, AdminPanel */
import { state } from './state.js'; import { state } from './state.js';
import { safeCreateIcons } from './utils.js'; import { safeCreateIcons, escapeHtml } from './utils.js';
import { showToast, closeHeaderMenu } from './ui.js'; import { showToast, closeHeaderMenu } from './ui.js';
import { t, getLocale, setLocale } from './i18n.js'; import { t, getLocale, setLocale } from './i18n.js';
import { showWelcome } from './viewer.js'; import { showWelcome } from './viewer.js';
@@ -266,6 +266,13 @@ const AuthManager = {
}); });
}, },
async changePassword(currentPassword, newPassword) {
return await api("/api/auth/change-password", {
method: "POST",
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
});
},
async logout() { async logout() {
try { try {
const token = this.getToken(); const token = this.getToken();
@@ -545,8 +552,22 @@ function _startWebauthnLogin(mfaSection, username, rememberMe) {
function showMfaChallenge(username, rememberMe, loginBtn, loginErrorEl, mfaMethod) { function showMfaChallenge(username, rememberMe, loginBtn, loginErrorEl, mfaMethod) {
const loginBox = document.querySelector(".login-box"); // BUG-069: the challenge used to mount into `.login-box`, which does not
if (!loginBox) return; // exist in index.html (the login markup is `#login-screen > .login-card >
// #login-form`) — querySelector returned null and the function silently
// returned, leaving the user stuck on the login page with no error after
// entering correct credentials. Mount into the real card, and never fail
// silently: surface the problem in the login error box instead.
const loginBox = document.querySelector(".login-card")
|| document.getElementById("login-screen");
if (!loginBox) {
const fallback = loginErrorEl || document.getElementById("login-error");
if (fallback) {
fallback.textContent = t("mfa.challenge_unavailable");
fallback.classList.remove("hidden");
}
return;
}
// Hide the normal login form // Hide the normal login form
const loginForm = document.getElementById("login-form"); const loginForm = document.getElementById("login-form");
@@ -1058,11 +1079,79 @@ async function initMfaSettings() {
}); });
} }
// Password change (BUG-068: the "Sécurité du compte" section had no way to
// change the password although POST /api/auth/change-password exists).
_renderPasswordSection(area);
// WebAuthn security keys section (ROADMAP #64) // WebAuthn security keys section (ROADMAP #64)
_renderWebauthnSection(area); _renderWebauthnSection(area);
} }
function _renderPasswordSection(container) {
if (!container || document.getElementById("password-settings")) return;
const section = document.createElement("div");
section.id = "password-settings";
section.className = "password-settings";
section.innerHTML = `
<h4 class="webauthn-title">${t("mfa.password_change_title")}</h4>
<p class="mfa-info-text">${t("mfa.password_change_desc")}</p>
<div class="form-group">
<label>${t("mfa.current_password_label")}</label>
<input type="password" id="pwd-current" class="config-input"
placeholder="${t('mfa.current_password_placeholder')}" autocomplete="current-password">
</div>
<div class="form-group">
<label>${t("mfa.new_password_label")}</label>
<input type="password" id="pwd-new" class="config-input"
placeholder="${t('mfa.new_password_placeholder')}" autocomplete="new-password">
</div>
<div class="form-group">
<label>${t("mfa.new_password_confirm_label")}</label>
<input type="password" id="pwd-confirm" class="config-input"
placeholder="${t('mfa.new_password_confirm_placeholder')}" autocomplete="new-password">
</div>
<div class="mfa-recovery-actions">
<button class="config-btn-primary" id="pwd-change-btn">${t("mfa.password_change_btn")}</button>
</div>
<p class="mfa-error hidden" id="pwd-change-error"></p>
`;
container.appendChild(section);
section.querySelector("#pwd-change-btn").addEventListener("click", async () => {
const errEl = section.querySelector("#pwd-change-error");
const current = section.querySelector("#pwd-current").value;
const next = section.querySelector("#pwd-new").value;
const confirm = section.querySelector("#pwd-confirm").value;
const btn = section.querySelector("#pwd-change-btn");
errEl.classList.add("hidden");
if (!current || !next || !confirm) {
errEl.textContent = t("mfa.fill_all_fields");
errEl.classList.remove("hidden");
return;
}
if (next !== confirm) {
errEl.textContent = t("mfa.password_mismatch");
errEl.classList.remove("hidden");
return;
}
btn.disabled = true;
try {
await AuthManager.changePassword(current, next);
showToast(t("mfa.password_changed"), "success");
section.querySelector("#pwd-current").value = "";
section.querySelector("#pwd-new").value = "";
section.querySelector("#pwd-confirm").value = "";
} catch (err) {
errEl.textContent = err.message || String(err);
errEl.classList.remove("hidden");
} finally {
btn.disabled = false;
}
});
}
async function _renderWebauthnSection(container) { async function _renderWebauthnSection(container) {
if (!container || !window.PublicKeyCredential) return; if (!container || !window.PublicKeyCredential) return;
@@ -1085,10 +1174,10 @@ async function _renderWebauthnSection(container) {
const listHtml = keys.length const listHtml = keys.length
? `<ul class="webauthn-key-list">${keys.map((k) => ` ? `<ul class="webauthn-key-list">${keys.map((k) => `
<li class="webauthn-key-item"> <li class="webauthn-key-item">
<span class="webauthn-key-label">🔑 ${k.label || "Security key"}</span> <span class="webauthn-key-label">🔑 ${escapeHtml(k.label || "Security key")}</span>
<span class="webauthn-key-meta">${(k.transports || []).join(", ") || "—"}</span> <span class="webauthn-key-meta">${escapeHtml((k.transports || []).join(", ") || "—")}</span>
<button class="config-btn-secondary config-btn-sm webauthn-key-remove" <button class="config-btn-secondary config-btn-sm webauthn-key-remove"
data-id="${k.credential_id}">${t("mfa.webauthn_remove")}</button> data-id="${escapeHtml(k.credential_id)}">${t("mfa.webauthn_remove")}</button>
</li>`).join("")}</ul>` </li>`).join("")}</ul>`
: `<p class="mfa-info-text">${t("mfa.webauthn_none")}</p>`; : `<p class="mfa-info-text">${t("mfa.webauthn_none")}</p>`;
@@ -1111,7 +1200,10 @@ async function _renderWebauthnSection(container) {
const label = prompt(t("mfa.webauthn_label_prompt"), "Ma clé"); const label = prompt(t("mfa.webauthn_label_prompt"), "Ma clé");
const result = await AuthManager.webauthnRegister(credential, label || "Security key"); const result = await AuthManager.webauthnRegister(credential, label || "Security key");
if (result.recovery_codes && result.recovery_codes.length) { if (result.recovery_codes && result.recovery_codes.length) {
_showRecoveryCodes(result.recovery_codes); // BUG-068: first-time WebAuthn enable issues recovery codes. There is
// no #mfa-setup-flow-area in the "already enabled" view, so render
// them into the WebAuthn flow area instead of losing them.
_showRecoveryCodes(result.recovery_codes, "webauthn-flow-area");
} else { } else {
showToast(t("mfa.webauthn_added"), "success"); showToast(t("mfa.webauthn_added"), "success");
} }
@@ -1145,16 +1237,28 @@ async function _startMfaSetup() {
try { try {
const data = await AuthManager.mfaSetup(); const data = await AuthManager.mfaSetup();
// BUG-068: the QR code comes from the backend as a local SVG data: URI
// (see POST /api/auth/mfa/totp/setup → qr_data_url). The previous
// third-party QR image was blocked by the CSP
// (img-src 'self' data: blob:) so it never displayed — and it leaked the
// otpauth URI (TOTP secret) to a third party. Fall back to the manual
// secret when the backend has no QR generator available.
const qrImg = data.qr_data_url
? `<img id="mfa-qr-img" alt="QR Code" class="mfa-qr-code-img"
src="${data.qr_data_url}"
onerror="this.style.display='none';document.getElementById('mfa-qr-fallback').style.display='block';">`
: "";
const fallbackStyle = data.qr_data_url ? "display:none" : "";
flowArea.innerHTML = ` flowArea.innerHTML = `
<div class="mfa-setup-card"> <div class="mfa-setup-card">
<h4>${t("mfa.scan_qr")}</h4> <h4>${t("mfa.scan_qr")}</h4>
<div class="mfa-qr-container"> <div class="mfa-qr-container">
<img id="mfa-qr-img" alt="QR Code" class="mfa-qr-code" ${qrImg}
src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(data.otpauth_uri)}"> <p class="mfa-info-text" id="mfa-qr-fallback" style="${fallbackStyle}">${t("mfa.qr_unavailable")}</p>
</div> </div>
<details class="mfa-secret-details"> <details class="mfa-secret-details" ${data.qr_data_url ? "" : "open"}>
<summary>${t("mfa.manual_entry")}</summary> <summary>${t("mfa.manual_entry")}</summary>
<code class="mfa-secret-code">${data.secret}</code> <code class="mfa-secret-code">${escapeHtml(data.secret)}</code>
</details> </details>
<div class="mfa-verify-section"> <div class="mfa-verify-section">
<label>${t("mfa.enter_code")}</label> <label>${t("mfa.enter_code")}</label>
@@ -1201,12 +1305,16 @@ async function _startMfaSetup() {
} }
function _showRecoveryCodes(codes) { function _showRecoveryCodes(codes, targetId) {
const flowArea = document.getElementById("mfa-setup-flow-area"); // BUG-068: the recovery codes must be visible wherever the enable flow ran.
const area = document.getElementById("mfa-setup-area"); // The TOTP flow owns #mfa-setup-flow-area, but the WebAuthn first-enable
// path (#webauthn-flow-area) has none — previously those codes were lost.
const flowArea = document.getElementById(targetId || "mfa-setup-flow-area")
|| document.getElementById("webauthn-flow-area")
|| document.getElementById("mfa-setup-area");
if (!flowArea) return; if (!flowArea) return;
const codesHtml = codes.map(c => `<code class="mfa-recovery-code">${c}</code>`).join("\n"); const codesHtml = codes.map(c => `<code class="mfa-recovery-code">${escapeHtml(c)}</code>`).join("\n");
flowArea.innerHTML = ` flowArea.innerHTML = `
<div class="mfa-recovery-card"> <div class="mfa-recovery-card">
<h4>🔑 ${t("mfa.recovery_codes_title")}</h4> <h4>🔑 ${t("mfa.recovery_codes_title")}</h4>
+42
View File
@@ -767,6 +767,10 @@ function initConfigModal() {
openBtn.addEventListener("click", async () => { openBtn.addEventListener("click", async () => {
modal.classList.add("active"); modal.classList.add("active");
closeHeaderMenu(); closeHeaderMenu();
// BUG-071: reset the TOC to the CSS default (mobile: hidden, desktop:
// visible) like the help modal does on open.
var configNavOnOpen = document.getElementById("config-nav");
if (configNavOnOpen) configNavOnOpen.style.display = '';
renderConfigFilters(); renderConfigFilters();
loadConfigFields(); loadConfigFields();
loadDiagnostics(); loadDiagnostics();
@@ -886,6 +890,44 @@ function initConfigModal() {
}); });
} }
// BUG-071: 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
// reach a section. The header hamburger opens it as a top block; picking
// a section smooth-scrolls inside the modal and collapses it on mobile.
var configNav = document.getElementById("config-nav");
var configHamburger = document.getElementById("config-hamburger");
function _isConfigMobile() { return window.innerWidth <= 768; }
function _setConfigNav(open) {
if (!configNav) return;
configNav.style.display = open ? "flex" : "none";
if (configHamburger) configHamburger.classList.toggle("active", !!open);
}
if (configHamburger) {
configHamburger.addEventListener("click", function(e) {
e.stopPropagation();
var hidden = !configNav || configNav.style.display === "none" || configNav.style.display === "";
_setConfigNav(hidden);
});
}
if (configNav) {
configNav.querySelectorAll(".help-nav-link").forEach(function(a) {
a.addEventListener("click", function(e) {
var hash = a.getAttribute("href");
if (!hash || hash.charAt(0) !== "#") return;
var target = document.getElementById(hash.slice(1));
if (!target) return;
e.preventDefault();
configNav.querySelectorAll(".help-nav-link").forEach(function(o) { o.classList.remove("active"); });
a.classList.add("active");
if (typeof target.scrollIntoView === "function") {
target.scrollIntoView({ behavior: "smooth", block: "start" });
}
if (_isConfigMobile()) _setConfigNav(false);
});
});
}
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.classList.contains("active")) { if (e.key === "Escape" && modal.classList.contains("active")) {
closeConfigModal(); closeConfigModal();
+13 -6
View File
@@ -5,6 +5,7 @@
* Static DOM: data-i18n="key" → textContent * Static DOM: data-i18n="key" → textContent
* data-i18n-placeholder="key" → placeholder * data-i18n-placeholder="key" → placeholder
* data-i18n-attr:title="key" → title attribute * data-i18n-attr:title="key" → title attribute
* data-i18n-attr="a:k1;b:k2" → several attributes (";"-separated)
* data-i18n-html="key" → innerHTML (use sparingly) * data-i18n-html="key" → innerHTML (use sparingly)
* Dynamic JS: import { t } from './i18n.js'; t('key', {param: 'val'}) * Dynamic JS: import { t } from './i18n.js'; t('key', {param: 'val'})
* Live reload: setLocale('en') updates every data-i18n element instantly. * Live reload: setLocale('en') updates every data-i18n element instantly.
@@ -183,13 +184,19 @@ function _applyDOM() {
el.innerHTML = t(key); el.innerHTML = t(key);
}); });
// data-i18n-attr:TITLE → sets any attribute // data-i18n-attr:ATTR:key[;ATTR:key…] → sets any attribute(s).
// Single-pair form (data-i18n-attr="title:key") is preserved; multiple
// pairs are separated with ";" (BUG-071: the config TOC toggle needs both
// title and aria-label translated).
document.querySelectorAll('[data-i18n-attr]').forEach(function (el) { document.querySelectorAll('[data-i18n-attr]').forEach(function (el) {
const raw = el.getAttribute('data-i18n-attr'); const raw = el.getAttribute('data-i18n-attr');
const colon = raw.indexOf(':'); raw.split(';').forEach(function (pair) {
if (colon === -1) return; const colon = pair.indexOf(':');
const attr = raw.substring(0, colon); if (colon === -1) return;
const key = raw.substring(colon + 1); const attr = pair.substring(0, colon).trim();
el.setAttribute(attr, t(key)); const key = pair.substring(colon + 1).trim();
if (!attr || !key) return;
el.setAttribute(attr, t(key));
});
}); });
} }
+14
View File
@@ -569,6 +569,7 @@
"config.test": "Test", "config.test": "Test",
"config.timeout_label": "Search timeout (ms)", "config.timeout_label": "Search timeout (ms)",
"config.title": "Settings", "config.title": "Settings",
"config.toc_toggle": "Show contents",
"config.title_boost": "Title boost", "config.title_boost": "Title boost",
"config.title_boost_hint": "Relevance multiplier for title matches", "config.title_boost_hint": "Relevance multiplier for title matches",
"config.title_boost_label": "Title boost", "config.title_boost_label": "Title boost",
@@ -1887,6 +1888,19 @@
"mfa.disable_confirm_btn": "Disable 2FA", "mfa.disable_confirm_btn": "Disable 2FA",
"mfa.disabled_success": "2FA has been disabled.", "mfa.disabled_success": "2FA has been disabled.",
"mfa.fill_all_fields": "Please fill in all fields.", "mfa.fill_all_fields": "Please fill in all fields.",
"mfa.qr_unavailable": "QR code unavailable — use manual entry below.",
"mfa.password_change_title": "Password",
"mfa.password_change_desc": "Change your account password (min. 8 characters). All other sessions are invalidated.",
"mfa.current_password_label": "Current password",
"mfa.current_password_placeholder": "Your current password",
"mfa.new_password_label": "New password",
"mfa.new_password_placeholder": "Min. 8 characters",
"mfa.new_password_confirm_label": "Confirm new password",
"mfa.new_password_confirm_placeholder": "Repeat the new password",
"mfa.password_change_btn": "Change password",
"mfa.password_mismatch": "The two passwords do not match.",
"mfa.password_changed": "Password updated.",
"mfa.challenge_unavailable": "Verification screen unavailable — please reload the page.",
"bookslm.title": "BooksLM", "bookslm.title": "BooksLM",
"bookslm.files_indexed": "{count} files indexed", "bookslm.files_indexed": "{count} files indexed",
"bookslm.chars_loaded": "{chars} chars loaded", "bookslm.chars_loaded": "{chars} chars loaded",
+14
View File
@@ -569,6 +569,7 @@
"config.test": "Tester", "config.test": "Tester",
"config.timeout_label": "Timeout recherche (ms)", "config.timeout_label": "Timeout recherche (ms)",
"config.title": "Configuration", "config.title": "Configuration",
"config.toc_toggle": "Afficher le sommaire",
"config.title_boost": "Boost titre", "config.title_boost": "Boost titre",
"config.title_boost_hint": "Multiplicateur de pertinence pour les correspondances dans le titre", "config.title_boost_hint": "Multiplicateur de pertinence pour les correspondances dans le titre",
"config.title_boost_label": "Boost titre", "config.title_boost_label": "Boost titre",
@@ -1887,6 +1888,19 @@
"mfa.disable_confirm_btn": "Désactiver la 2FA", "mfa.disable_confirm_btn": "Désactiver la 2FA",
"mfa.disabled_success": "La 2FA a été désactivée.", "mfa.disabled_success": "La 2FA a été désactivée.",
"mfa.fill_all_fields": "Veuillez remplir tous les champs.", "mfa.fill_all_fields": "Veuillez remplir tous les champs.",
"mfa.qr_unavailable": "QR code indisponible — utilisez la saisie manuelle ci-dessous.",
"mfa.password_change_title": "Mot de passe",
"mfa.password_change_desc": "Modifiez le mot de passe de votre compte (min. 8 caractères). Toutes les autres sessions sont invalidées.",
"mfa.current_password_label": "Mot de passe actuel",
"mfa.current_password_placeholder": "Votre mot de passe actuel",
"mfa.new_password_label": "Nouveau mot de passe",
"mfa.new_password_placeholder": "Min. 8 caractères",
"mfa.new_password_confirm_label": "Confirmer le nouveau mot de passe",
"mfa.new_password_confirm_placeholder": "Répétez le nouveau mot de passe",
"mfa.password_change_btn": "Changer le mot de passe",
"mfa.password_mismatch": "Les deux mots de passe ne correspondent pas.",
"mfa.password_changed": "Mot de passe mis à jour.",
"mfa.challenge_unavailable": "Écran de vérification indisponible — veuillez recharger la page.",
"bookslm.title": "BooksLM", "bookslm.title": "BooksLM",
"bookslm.files_indexed": "{count} fichiers indexés", "bookslm.files_indexed": "{count} fichiers indexés",
"bookslm.chars_loaded": "{chars} caractères chargés", "bookslm.chars_loaded": "{chars} caractères chargés",
+146
View File
@@ -4347,6 +4347,76 @@ body.resizing-v {
background: var(--bg-hover); background: var(--bg-hover);
} }
/* BUG-068: .config-btn-primary / .config-btn-danger were used by the account
security section (frontend/js/auth.js) but never defined — the buttons fell
back to the browser default and ignored the theme. Defined here with the
same conventions as .config-btn-save / .config-btn-secondary. */
.config-btn-primary {
padding: 8px 16px;
border: 1px solid var(--accent);
border-radius: 6px;
background: var(--accent);
color: #fff;
font-family: "JetBrains Mono", monospace;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: opacity 150ms;
}
.config-btn-primary:hover {
opacity: 0.9;
}
.config-btn-primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.config-btn-danger {
padding: 8px 16px;
border: 1px solid var(--danger, #e74c3c);
border-radius: 6px;
background: var(--danger-bg, #3d1a18);
color: var(--danger, #ff7b72);
font-family: "JetBrains Mono", monospace;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: opacity 150ms;
}
.config-btn-danger:hover {
opacity: 0.9;
}
.config-btn-danger:disabled {
opacity: 0.55;
cursor: not-allowed;
}
/* BUG-068: local QR code (backend SVG data: URI) + password section share the
security-tab card conventions. */
.mfa-qr-code-img {
max-width: 200px;
border-radius: 8px;
background: #fff;
padding: 8px;
}
.password-settings {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--border, #333);
}
.password-settings .form-group {
margin: 8px 0;
}
.password-settings .form-group label {
display: block;
font-size: 0.78rem;
color: var(--text-secondary, #aaa);
margin-bottom: 4px;
}
.password-settings .config-input {
width: 100%;
max-width: 320px;
}
/* --- AI keys section: accordion redesign (#104) --- */ /* --- AI keys section: accordion redesign (#104) --- */
.ai-keys-header { .ai-keys-header {
display: flex; display: flex;
@@ -4653,6 +4723,82 @@ body.resizing-v {
} }
} }
/* BUG-071: Configurations modal — mobile usability (viewport ≤ 768px).
#config-nav shares the .help-nav rule that hides it, but the config modal
had no toggle (unlike the help modal): the header hamburger
(#config-hamburger, same .help-hamburger treatment) reveals it as a
collapsible top block. Two-column grids and fixed-width add-rows are
stacked/wrapped so nothing overflows a 360px viewport. */
@media (max-width: 768px) {
/* TOC as a collapsible top block (JS toggles inline display flex/none,
which wins over the hiding rule); the list scrolls within a capped nav. */
#config-modal #config-nav {
width: 100%;
min-width: 0;
max-width: 100%;
border-right: none;
border-bottom: 1px solid var(--border);
max-height: 46vh;
}
/* Two-column grids → single column. */
#config-modal .ai-default-grid,
#config-modal .ai-provider-fields {
grid-template-columns: 1fr;
}
/* Add-rows (tokens, webhooks, tag filters) wrap instead of overflowing. */
#config-modal .config-add-row,
#config-modal .config-add-pattern {
flex-wrap: wrap;
}
#config-modal .config-add-row .config-input,
#config-modal .config-add-pattern .config-input,
#config-modal .config-add-row .config-select {
flex: 1 1 140px;
width: auto !important; /* override fixed inline widths (180/140/100px) */
min-width: 0;
}
#config-modal .config-add-row .config-btn-add,
#config-modal .config-add-pattern .config-btn-add {
flex: 1 1 auto;
min-height: 44px;
}
/* Webhook / token / share rows wrap; long URLs and meta take their own
line instead of squeezing the delete control off-screen. */
#config-modal .webhook-item,
#config-modal .token-item,
#config-modal .share-item {
flex-wrap: wrap;
}
#config-modal .webhook-url,
#config-modal .token-meta,
#config-modal .share-url {
flex: 1 1 100%;
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
}
#config-modal .webhook-delete,
#config-modal .token-delete,
#config-modal .share-revoke {
min-width: 44px;
min-height: 44px;
}
/* Sticky AI-keys footer: full-width touch-friendly buttons. */
#config-modal .ai-keys-footer .config-btn-save,
#config-modal .ai-keys-footer .config-btn-secondary {
flex: 1 1 100%;
min-height: 44px;
}
/* Long inline code in tips (ex. MCP usage snippet) must wrap. */
#config-modal .qh-tip {
flex-wrap: wrap;
}
#config-modal .qh-tip span {
min-width: 0;
overflow-wrap: anywhere;
}
}
/* --- Toast notifications --- */ /* --- Toast notifications --- */
.toast-container { .toast-container {
position: fixed; position: fixed;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "obsigate", "name": "obsigate",
"version": "2.15.0", "version": "2.16.5",
"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": {
+94
View File
@@ -0,0 +1,94 @@
/**
* E2E tests for the Configurations modal on mobile (BUG-071).
*
* Runs only under the `chromium-mobile` Playwright project (viewport ≤ 768px);
* skipped on the desktop project that the CI job executes — same convention
* as mobile-editor.spec.js.
*
* Covered:
* - the TOC hamburger (#config-hamburger) is visible and reveals #config-nav,
* which is hidden by default on mobile;
* - picking a TOC entry scrolls to the section, marks the link active and
* collapses the nav;
* - the modal content does not overflow horizontally at 393px.
*
* Run:
* npx playwright test tests/e2e/config-mobile.spec.js --project=chromium-mobile
* (ObsiGate listening on http://localhost:2029, auth disabled)
*/
import { test, expect } from '@playwright/test';
const MOBILE_MAX_WIDTH = 768;
async function boot(page) {
await page.goto('/');
await page.waitForSelector('#app:not(.hidden)', { timeout: 15000 });
await expect(page.locator('#header-menu-btn')).toBeVisible({ timeout: 15000 });
}
async function openConfigModal(page) {
await page.locator('#header-menu-btn').click();
await page.locator('#config-open-btn').click();
await expect(page.locator('#config-modal.active')).toBeVisible();
}
test.describe('Configurations modal on mobile (BUG-071)', () => {
test('hamburger reveals the table of contents', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openConfigModal(page);
// TOC hidden by default on mobile, hamburger visible.
await expect(page.locator('#config-hamburger')).toBeVisible();
await expect(page.locator('#config-nav')).toBeHidden();
await page.locator('#config-hamburger').click();
await expect(page.locator('#config-nav')).toBeVisible();
});
test('picking a section scrolls to it and collapses the nav', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openConfigModal(page);
await page.locator('#config-hamburger').click();
const link = page.locator('#config-nav a[href="#cfg-tokens"]');
await expect(link).toBeVisible();
await link.click();
// Nav collapses on mobile after selection…
await expect(page.locator('#config-nav')).toBeHidden();
// …the link is marked active…
await expect(link).toHaveClass(/active/);
// …and the section scrolls into view inside the modal (smooth scroll:
// poll for the settled position instead of racing the animation).
await expect
.poll(
async () => {
const box = await page.locator('#cfg-tokens').boundingBox();
const modalBox = await page.locator('#config-modal').boundingBox();
if (!box || !modalBox) return Number.POSITIVE_INFINITY;
return box.y - (modalBox.y + modalBox.height);
},
{ timeout: 8000 },
)
.toBeLessThanOrEqual(0);
});
test('no horizontal overflow at 393px', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openConfigModal(page);
for (const section of ['#cfg-ai', '#cfg-tokens', '#cfg-webhooks', '#cfg-partages-publics']) {
await page.locator('#config-hamburger').click();
await page.locator(`#config-nav a[href="${section}"]`).click();
}
const overflow = await page.evaluate(() => {
const scroller = document.getElementById('config-scroll');
return scroller.scrollWidth - scroller.clientWidth;
});
expect(overflow).toBeLessThanOrEqual(1);
});
});
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env node
/**
* ObsiGate — Configurations modal mobile usability non-regression tests (BUG-071).
*
* Static checks (no jsdom needed — runs in the "Frontend unit tests" CI step):
* - BUG-071a: #config-nav shared the .help-nav rule that hides it below
* 768px, but the config modal had no toggle (the help modal has
* #help-hamburger) → the table of contents was unreachable on mobile.
* The header must carry #config-hamburger (same .help-hamburger
* treatment: hidden on desktop, visible on mobile) wired in config.js.
* - BUG-071b: the TOC links were bare anchors with no JS — no active state,
* no auto-collapse on mobile, unreliable scrolling inside the modal.
* config.js must smooth-scroll to the section, mark it active and collapse
* the nav on mobile, and reset the nav on open.
* - BUG-071c: two-column grids (.ai-default-grid, .ai-provider-fields),
* fixed-width add-rows (.config-add-row, 180/140/100px inline widths) and
* single-line webhook/token/share items overflowed a 360px viewport.
* style.css must stack/wrap them below 768px with 44px touch targets.
* - BUG-071d: every #config-nav link target must exist (dead-anchor guard,
* same class of bug as BUG-067 for the help modal).
* - BUG-071e: data-i18n-attr supports several "attr:key" pairs (";"-
* separated) so the toggle carries translated title AND aria-label.
*
* Usage: node tests/frontend/config-mobile.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 indexHtml = readFileSync(path.join(ROOT, "frontend", "index.html"), "utf8");
const configJs = readFileSync(path.join(ROOT, "frontend", "js", "config.js"), "utf8");
const i18nJs = readFileSync(path.join(ROOT, "frontend", "js", "i18n.js"), "utf8");
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
const fr = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8"));
const en = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8"));
function test(label, fn) {
try {
fn();
console.log(" ✓ " + label);
} catch (err) {
console.error(" ✗ " + label + "\n " + err.message);
process.exitCode = 1;
}
}
// ── BUG-071a: header TOC toggle ─────────────────────────────────────────────
test("index.html — #config-hamburger exists in the config modal header", () => {
const modal = indexHtml.match(/<div class="editor-modal" id="config-modal">([\s\S]*?)<div class="editor-body help-body" id="config-body">/);
assert.ok(modal, "#config-modal with #config-body not found");
const header = modal[1].match(/<div class="editor-header">([\s\S]*?)<\/div>\s*<\/div>/);
assert.ok(header, "config modal .editor-header not found");
assert.match(header[1], /id="config-hamburger"/, "no #config-hamburger in the config header — TOC unreachable on mobile");
assert.match(header[1], /help-hamburger/, "the toggle must reuse .help-hamburger (desktop-hidden, mobile-visible)");
assert.match(header[1], /aria-label/, "the toggle needs an accessible label");
assert.match(header[1], /config\.toc_toggle/, "the toggle label must use the i18n key config.toc_toggle");
});
test("i18n — config.toc_toggle exists in FR and EN", () => {
assert.ok(fr["config.toc_toggle"], "fr.json missing config.toc_toggle");
assert.ok(en["config.toc_toggle"], "en.json missing config.toc_toggle");
assert.notEqual(fr["config.toc_toggle"], "config.toc_toggle", "FR value must be translated");
assert.notEqual(en["config.toc_toggle"], "config.toc_toggle", "EN value must be translated");
});
// ── BUG-071b: TOC behaviour in config.js ────────────────────────────────────
test("config.js — hamburger toggles #config-nav", () => {
assert.match(configJs, /getElementById\("config-hamburger"\)/, "no binding on #config-hamburger");
assert.match(configJs, /_setConfigNav\(/, "TOC open/close helper missing");
});
test("config.js — TOC links smooth-scroll, mark active, collapse on mobile", () => {
assert.match(configJs, /#config-nav[\s\S]{0,400}?help-nav-link/, "no handler on the #config-nav links");
assert.match(configJs, /scrollIntoView/, "section scroll must use scrollIntoView inside the modal");
assert.match(configJs, /innerWidth <= 768/, "the nav must auto-collapse on mobile viewports only");
});
test("config.js — TOC display reset when the modal opens", () => {
assert.match(configJs, /configNavOnOpen[\s\S]{0,120}?style\.display = ''/, "stale inline display would stick across sessions");
});
// ── BUG-071c: mobile CSS ────────────────────────────────────────────────────
test("style.css — config TOC becomes a capped top block on mobile", () => {
assert.match(css, /#config-modal #config-nav/, "no mobile rule scoped to #config-modal #config-nav");
assert.match(css, /#config-modal #config-nav[\s\S]{0,400}?max-height/, "the opened TOC must be height-capped so content stays reachable");
});
test("style.css — two-column config grids stack on mobile", () => {
assert.match(css, /#config-modal \.ai-default-grid/, ".ai-default-grid still 2 columns on mobile");
assert.match(css, /#config-modal \.ai-provider-fields/, ".ai-provider-fields still 3fr/2fr on mobile");
assert.match(css, /grid-template-columns: 1fr;/, "mobile grids must collapse to a single column");
});
test("style.css — add-rows wrap and fixed inline widths are neutralised", () => {
assert.match(css, /#config-modal \.config-add-row/, "no mobile rule for .config-add-row (token/webhook rows overflow)");
assert.match(css, /width: auto !important/, "fixed inline widths (180/140/100px) must be overridden on mobile");
assert.match(css, /min-height: 44px/, "mobile action controls need 44px touch targets");
});
test("style.css — webhook/token/share rows wrap on mobile", () => {
for (const cls of ["webhook-item", "token-item", "share-item"]) {
assert.match(css, new RegExp("#config-modal \\." + cls), `.${cls} has no mobile wrap rule`);
}
});
// ── BUG-071d: dead-anchor guard ─────────────────────────────────────────────
test("index.html — every #config-nav link resolves to an element id", () => {
const nav = indexHtml.match(/<nav class="help-nav" id="config-nav">([\s\S]*?)<\/nav>/);
assert.ok(nav, "#config-nav not found");
const hrefs = [...nav[1].matchAll(/href="(#[^"]+)"/g)].map((m) => m[1].slice(1));
assert.ok(hrefs.length > 0, "no links in #config-nav");
const missing = hrefs.filter((id) => !indexHtml.includes(`id="${id}"`));
assert.deepEqual(missing, [], `dead TOC anchors (cf. BUG-067): ${missing.join(", ")}`);
});
// ── BUG-071e: multi-pair data-i18n-attr ─────────────────────────────────────
test("i18n.js — data-i18n-attr supports several attr:key pairs", () => {
assert.match(i18nJs, /split\(['"];/, "pairs must be split on ';'");
assert.match(i18nJs, /el\.setAttribute\(attr, t\(key\)\)/, "each pair must set its attribute");
});
if (process.exitCode) {
console.error("\nConfig mobile tests FAILED");
} else {
console.log("\nAll config mobile tests passed.");
}
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env node
/**
* ObsiGate — Account security section non-regression tests (BUG-068).
*
* Static checks on the "🔒 Sécurité du compte" configuration section:
* - BUG-068a: the TOTP QR code must NOT depend on the third-party
* https://api.qrserver.com service (blocked by the CSP
* `img-src 'self' data: blob:`, so the QR never displayed — and the
* otpauth URI, TOTP secret included, leaked to a third party). The setup
* endpoint returns a local SVG data: URI (qr_data_url) instead.
* - BUG-068b: .config-btn-primary / .config-btn-danger are used by
* frontend/js/auth.js but were never defined — buttons fell back to the
* browser default and ignored the theme. They must exist and derive from
* theme variables.
* - BUG-068c: recovery codes issued on first-time WebAuthn enable were lost
* (no #mfa-setup-flow-area in the "already enabled" view).
* - BUG-068d: the section had no password change although
* POST /api/auth/change-password exists.
*
* Usage: node tests/frontend/mfa-settings.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 auth = readFileSync(path.join(ROOT, "frontend", "js", "auth.js"), "utf8");
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
const router = readFileSync(path.join(ROOT, "backend", "auth", "router.py"), "utf8");
const indexHtml = readFileSync(path.join(ROOT, "frontend", "index.html"), "utf8");
const fr = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8"));
const en = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8"));
function test(label, fn) {
try {
fn();
console.log(" ✓ " + label);
} catch (err) {
console.error(" ✗ " + label + "\n " + err.message);
process.exitCode = 1;
}
}
// ── QR code: local data URI, no third-party service ─────────────────────────
test("auth.js — no external QR service left (CSP blocks it, secret leaks)", () => {
assert.doesNotMatch(auth, /qrserver\.com/, "api.qrserver.com is blocked by img-src and leaks the otpauth URI");
assert.doesNotMatch(auth, /https:\/\/api\./, "no third-party https://api.* image may carry the TOTP secret");
});
test("auth.js — setup flow renders the backend qr_data_url with a fallback", () => {
assert.match(auth, /qr_data_url/, "the QR <img> must use the backend-provided qr_data_url");
assert.match(auth, /mfa-qr-fallback/, "a manual-entry fallback must show when no QR is available");
assert.match(auth, /mfa\.qr_unavailable/, "the fallback needs its i18n string");
});
test("backend — /mfa/totp/setup returns a local qr_data_url", () => {
assert.match(router, /qr_data_url/, "setup must include qr_data_url in its response");
assert.match(router, /svg_data_uri/, "the QR must be generated locally (segno SVG data URI)");
assert.match(router, /import segno/, "segno import must stay local with a graceful fallback");
});
// ── Buttons follow the theme ────────────────────────────────────────────────
for (const cls of ["config-btn-primary", "config-btn-danger"]) {
test(`style.css — .${cls} is defined from theme variables`, () => {
const rule = css.match(new RegExp(`\\.${cls}\\s*\\{([^}]*)\\}`));
assert.ok(rule, `.${cls} rule not found — buttons fall back to the browser default`);
assert.match(rule[1], /var\(--/, `.${cls} must derive from CSS theme variables, not hardcoded colors`);
});
}
test("style.css — themed buttons have disabled states", () => {
assert.match(css, /\.config-btn-primary:disabled/, ".config-btn-primary needs a disabled state");
assert.match(css, /\.config-btn-danger:disabled/, ".config-btn-danger needs a disabled state");
});
// ── Recovery codes are never lost ───────────────────────────────────────────
test("auth.js — _showRecoveryCodes falls back to the WebAuthn flow area", () => {
const fn = auth.match(/function _showRecoveryCodes\(codes(?:, targetId)?\) \{([\s\S]*?)\n\}/);
assert.ok(fn, "_showRecoveryCodes helper not found");
assert.match(fn[1], /webauthn-flow-area/, "codes issued on first WebAuthn enable must render without #mfa-setup-flow-area");
});
// ── Password change lives in the security section ───────────────────────────
test("auth.js — password change calls POST /api/auth/change-password", () => {
assert.match(auth, /\/api\/auth\/change-password/, "changePassword must hit the existing endpoint");
assert.match(auth, /_renderPasswordSection/, "the security section must render a password card");
});
test("i18n — password + QR strings exist in FR and EN", () => {
for (const key of [
"mfa.qr_unavailable",
"mfa.password_change_title",
"mfa.password_change_btn",
"mfa.password_mismatch",
"mfa.password_changed",
"mfa.challenge_unavailable",
]) {
assert.ok(fr[key], `fr.json missing ${key}`);
assert.ok(en[key], `en.json missing ${key}`);
}
});
// ── BUG-069: the MFA challenge must mount into a real DOM node ──────────────
test("auth.js — challenge mounts into .login-card (exists in index.html)", () => {
assert.doesNotMatch(
auth,
/querySelector\("\.login-box"\)/,
"showMfaChallenge queried .login-box, which never existed in index.html → silent return, login stuck with no error",
);
assert.match(
auth,
/querySelector\("\.login-card"\)/,
"the challenge must mount into the real login container",
);
assert.ok(
indexHtml.includes('class="login-card"'),
"index.html must contain the .login-card mount point",
);
});
test("auth.js — showMfaChallenge never fails silently", () => {
const fn = auth.match(/function showMfaChallenge\(username, rememberMe, loginBtn, loginErrorEl, mfaMethod\) \{([\s\S]*?)\n \/\/ WebAuthn second factor/);
assert.ok(fn, "showMfaChallenge helper not found");
assert.match(fn[1], /challenge_unavailable/, "a missing mount point must surface an error, not silently return");
assert.doesNotMatch(fn[1], /if \(!loginBox\) return;/, "bare silent return is forbidden in the challenge flow");
});
if (process.exitCode) {
console.error("\nMFA settings tests FAILED");
} else {
console.log("\nAll MFA settings tests passed.");
}
+16
View File
@@ -248,6 +248,22 @@ class TestMfaApiEndpoints:
assert "otpauth://totp/" in data["otpauth_uri"] assert "otpauth://totp/" in data["otpauth_uri"]
assert len(data["secret"]) >= 16 assert len(data["secret"]) >= 16
def test_mfa_setup_returns_local_qr_data_url(self, mfa_client):
"""BUG-068: the setup response carries a CSP-safe local QR code.
The previous client used an https://api.qrserver.com image, blocked by
the CSP (img-src 'self' data: blob:) — the QR never displayed — and
leaking the otpauth URI to a third party.
"""
token, _ = _login(mfa_client)
resp = mfa_client.post("/api/auth/mfa/totp/setup", headers=_auth_headers(token))
assert resp.status_code == 200
data = resp.json()
qr_data_url = data.get("qr_data_url")
assert qr_data_url, "setup must return a local qr_data_url"
assert qr_data_url.startswith("data:image/svg+xml"), qr_data_url[:60]
assert "qrserver.com" not in qr_data_url
def test_mfa_enable_flow(self, mfa_client): def test_mfa_enable_flow(self, mfa_client):
token, _ = _login(mfa_client) token, _ = _login(mfa_client)
headers = _auth_headers(token) headers = _auth_headers(token)
+220
View File
@@ -0,0 +1,220 @@
# tests/test_perf_phase3.py — Optimisation globale des performances (#86, phase 3)
"""Non-regression tests for the #86 performance work.
Covers the three remaining #86 items (the inverted-index search, the lazy PDF
extraction and the regex CPU caps were already delivered via BUG-033, BUG-040
and BUG-025):
- differential vault scan (unchanged files reused without disk re-read),
- deferred excalidraw text extraction (scan cheap, enrichment fills text),
- file-size guard on ``replace_in_files`` (oversized files skipped).
"""
import json
import os
from pathlib import Path
import pytest
def _excalidraw_doc(*texts: str) -> str:
elements = [
{
"id": f"el{i}",
"type": "text",
"text": text,
"x": 0,
"y": i * 20,
"width": 100,
"height": 20,
}
for i, text in enumerate(texts)
]
return json.dumps({"type": "excalidraw", "version": 2, "elements": elements})
# ═══════════════════════════════════════════════════════════════════
# Deferred excalidraw extraction
# ═══════════════════════════════════════════════════════════════════
class TestExcalidrawDeferred:
def test_scan_defers_excalidraw_text(self, tmp_path: Path):
"""The scan must not parse diagram JSON — content empty + pending flag."""
from backend.indexer import _scan_vault
vault = tmp_path / "vault"
vault.mkdir()
(vault / "diagram.excalidraw").write_text(
_excalidraw_doc("hello diagram"), encoding="utf-8"
)
result = _scan_vault("V", str(vault), {})
entry = next(f for f in result["files"] if f["path"] == "diagram.excalidraw")
assert entry["content"] == ""
assert entry["content_preview"] == ""
assert entry.get("excalidraw_text_pending") is True
assert entry["title"] == "diagram"
def test_scan_defers_excalidraw_md(self, tmp_path: Path):
"""``.excalidraw.md`` files are deferred too (no lz decompression at scan)."""
from backend.indexer import _scan_vault
vault = tmp_path / "vault"
vault.mkdir()
(vault / "board.excalidraw.md").write_text(
"---\ntitle: Board\n---\n" + _excalidraw_doc("board text"),
encoding="utf-8",
)
result = _scan_vault("V", str(vault), {})
entry = next(f for f in result["files"] if f["path"] == "board.excalidraw.md")
assert entry["content"] == ""
assert entry.get("excalidraw_text_pending") is True
@pytest.mark.asyncio
async def test_enrich_fills_excalidraw_text(self, tmp_path: Path):
"""The deferred pass extracts diagram text and clears the flag."""
import backend.indexer as idx
vault = tmp_path / "vault"
vault.mkdir()
(vault / "diagram.excalidraw").write_text(
_excalidraw_doc("uniquediagword"), encoding="utf-8"
)
file_info = {
"path": "diagram.excalidraw",
"title": "diagram",
"tags": [],
"content": "",
"content_preview": "",
"size": 0,
"modified": "",
"extension": ".excalidraw",
"excalidraw_text_pending": True,
}
with idx._index_lock:
idx.index["ExcalV"] = {
"files": [file_info],
"tags": {},
"path": str(vault),
"paths": [],
}
try:
count = await idx.enrich_pdf_texts("ExcalV")
assert count == 1
assert "uniquediagword" in file_info["content"]
assert file_info["content_preview"]
assert "excalidraw_text_pending" not in file_info
finally:
with idx._index_lock:
idx.index.pop("ExcalV", None)
# ═══════════════════════════════════════════════════════════════════
# Differential scan
# ═══════════════════════════════════════════════════════════════════
class TestDifferentialScan:
def test_first_scan_reports_zero_reused(self, test_vault_dir):
from backend.indexer import _scan_vault
result = _scan_vault("TestVault", test_vault_dir)
assert result["reused"] == 0
assert len(result["files"]) >= 3
def test_unchanged_files_are_reused(self, test_vault_dir):
from backend.indexer import _scan_vault
first = _scan_vault("TestVault", test_vault_dir)
previous = {f["path"]: f for f in first["files"]}
second = _scan_vault("TestVault", test_vault_dir, None, previous)
assert second["reused"] == len(first["files"])
assert {f["path"] for f in second["files"]} == {f["path"] for f in first["files"]}
# Tags and titles survive the reuse path.
assert second["tags"] == first["tags"]
for f in second["files"]:
assert f["content"] == previous[f["path"]]["content"]
def test_changed_file_is_reparsed(self, test_vault_dir):
from backend.indexer import _scan_vault
first = _scan_vault("TestVault", test_vault_dir)
previous = {f["path"]: f for f in first["files"]}
target = Path(test_vault_dir) / "note1.md"
target.write_text(
target.read_text(encoding="utf-8") + "\nMot unique de reparse differentials.",
encoding="utf-8",
)
# Force a visibly different mtime (coarse filesystems).
os.utime(target, (9999999999, 9999999999))
second = _scan_vault("TestVault", test_vault_dir, None, previous)
assert second["reused"] == len(first["files"]) - 1
changed = next(f for f in second["files"] if f["path"] == "note1.md")
assert "Mot unique de reparse differentials" in changed["content"]
def test_added_and_deleted_files(self, test_vault_dir):
from backend.indexer import _scan_vault
first = _scan_vault("TestVault", test_vault_dir)
previous = {f["path"]: f for f in first["files"]}
(Path(test_vault_dir) / "brand-new.md").write_text(
"# Brand new\nFresh content here.", encoding="utf-8"
)
(Path(test_vault_dir) / "config.json").unlink()
second = _scan_vault("TestVault", test_vault_dir, None, previous)
paths = {f["path"] for f in second["files"]}
assert "brand-new.md" in paths
assert "config.json" not in paths
assert second["reused"] == len(first["files"]) - 1
# ═══════════════════════════════════════════════════════════════════
# replace_in_files size guard
# ═══════════════════════════════════════════════════════════════════
class TestReplaceSizeGuard:
def test_oversized_file_is_skipped(self, tmp_path: Path, monkeypatch):
"""Files over MAX_REPLACE_FILE_BYTES are skipped, not read."""
from backend.services import mutations
big = tmp_path / "big.md"
big.write_text("findme " * 100, encoding="utf-8")
monkeypatch.setattr(mutations, "MAX_REPLACE_FILE_BYTES", 10)
monkeypatch.setattr(
"backend.services.search.advanced_search_vaults",
lambda *a, **k: {
"results": [{"vault": "V", "path": "big.md", "title": "big"}],
},
)
monkeypatch.setattr(
mutations, "get_vault_root", lambda vault: tmp_path
)
out = mutations.replace_in_files("findme", "replaced", vault="V", dry_run=True)
assert out["matches"] == []
assert out["total_matches"] == 0
def test_small_file_still_processed(self, tmp_path: Path, monkeypatch):
from backend.services import mutations
small = tmp_path / "small.md"
small.write_text("findme once", encoding="utf-8")
monkeypatch.setattr(mutations, "MAX_REPLACE_FILE_BYTES", 10_000_000)
monkeypatch.setattr(
"backend.services.search.advanced_search_vaults",
lambda *a, **k: {
"results": [{"vault": "V", "path": "small.md", "title": "small"}],
},
)
monkeypatch.setattr(
mutations, "get_vault_root", lambda vault: tmp_path
)
out = mutations.replace_in_files("findme", "replaced", vault="V", dry_run=True)
assert out["total_matches"] == 1
assert out["matches"][0]["path"] == "small.md"
+130 -2
View File
@@ -134,8 +134,8 @@ class TestWebauthnModule:
w._pending.clear() w._pending.clear()
w._store_challenge("u2:register") w._store_challenge("u2:register")
key = "u2:register" key = "u2:register"
ch, _ = w._pending[key] ch, _ = w._pending[key][0]
w._pending[key] = (ch, _t.time() - 1) w._pending[key] = [(ch, _t.time() - 1)]
assert w._take_challenge(key) is None assert w._take_challenge(key) is None
def test_full_registration_and_authentication_roundtrip(self): def test_full_registration_and_authentication_roundtrip(self):
@@ -337,3 +337,131 @@ class TestWebauthnApi:
assert r2.status_code == 200 assert r2.status_code == 200
st = wa_client.get("/api/auth/mfa/status", headers=headers).json() st = wa_client.get("/api/auth/mfa/status", headers=headers).json()
assert st["mfa_enabled"] is False assert st["mfa_enabled"] is False
# ── BUG-070: relying party derived from the request ─────────────────────
#
# The old defaults (rp_id "localhost", origins ["http://localhost"]) rejected
# every real access URL: "Unexpected client data origin
# "http://localhost:2020", expected one of ['http://localhost']".
def _fake_request(host, scheme="http", forwarded_host=None, forwarded_proto=None):
from fastapi import Request
headers = [(b"host", host.encode())]
if forwarded_host is not None:
headers.append((b"x-forwarded-host", forwarded_host.encode()))
if forwarded_proto is not None:
headers.append((b"x-forwarded-proto", forwarded_proto.encode()))
return Request({
"type": "http", "method": "POST", "path": "/",
"headers": headers, "scheme": scheme,
"server": ("testserver", 80), "client": ("127.0.0.1", 5000),
})
class TestRelyingPartyResolution:
def test_defaults_without_request(self, monkeypatch):
import backend.auth.webauthn_mfa as w
monkeypatch.delenv("OBSIGATE_WEBAUTHN_RP_ID", raising=False)
monkeypatch.delenv("OBSIGATE_WEBAUTHN_ORIGINS", raising=False)
assert w.resolve_relying_party(None) == ("localhost", ["http://localhost"])
def test_derives_host_with_port(self, monkeypatch):
"""Exact BUG-070 report: http://localhost:2020 was rejected."""
import backend.auth.webauthn_mfa as w
monkeypatch.delenv("OBSIGATE_WEBAUTHN_RP_ID", raising=False)
monkeypatch.delenv("OBSIGATE_WEBAUTHN_ORIGINS", raising=False)
rp, origins = w.resolve_relying_party(_fake_request("localhost:2020"))
assert rp == "localhost"
assert origins == ["http://localhost:2020"]
def test_derives_ip_host(self, monkeypatch):
import backend.auth.webauthn_mfa as w
monkeypatch.delenv("OBSIGATE_WEBAUTHN_RP_ID", raising=False)
monkeypatch.delenv("OBSIGATE_WEBAUTHN_ORIGINS", raising=False)
rp, origins = w.resolve_relying_party(_fake_request("127.0.0.1:2020"))
assert rp == "127.0.0.1"
assert origins == ["http://127.0.0.1:2020"]
def test_explicit_env_wins_over_request(self, monkeypatch):
import backend.auth.webauthn_mfa as w
monkeypatch.setenv("OBSIGATE_WEBAUTHN_RP_ID", "obs.example.com")
monkeypatch.setenv("OBSIGATE_WEBAUTHN_ORIGINS",
"https://obs.example.com, https://www.obs.example.com")
rp, origins = w.resolve_relying_party(_fake_request("localhost:2020"))
assert rp == "obs.example.com"
assert origins == ["https://obs.example.com",
"https://www.obs.example.com"]
def test_forwarded_headers_require_trust(self, monkeypatch):
import backend.auth.webauthn_mfa as w
monkeypatch.delenv("OBSIGATE_WEBAUTHN_RP_ID", raising=False)
monkeypatch.delenv("OBSIGATE_WEBAUTHN_ORIGINS", raising=False)
monkeypatch.setenv("OBSIGATE_TRUST_PROXY", "false")
req = _fake_request("internal:8080", scheme="http",
forwarded_host="obs.example.com",
forwarded_proto="https")
assert w.resolve_relying_party(req) == ("internal", ["http://internal:8080"])
monkeypatch.setenv("OBSIGATE_TRUST_PROXY", "true")
assert w.resolve_relying_party(req) == ("obs.example.com",
["https://obs.example.com"])
def test_hostname_only(self):
import backend.auth.webauthn_mfa as w
assert w._hostname_only("example.com:2020") == "example.com"
assert w._hostname_only("example.com") == "example.com"
assert w._hostname_only("[::1]:8080") == "::1"
assert w._hostname_only("127.0.0.1:2020") == "127.0.0.1"
def test_retry_after_reoptions_still_verifies(self):
"""A re-requested options call (double-click) must not kill the
in-flight ceremony: "challenge was not expected challenge"."""
import backend.auth.webauthn_mfa as w
w._pending.clear()
auth = VirtualAuthenticator()
first = w._store_challenge("bob:register")
w._store_challenge("bob:register") # second options call overwrites
cred = auth.make_registration({"challenge": _b64url(first)})
rec = w.complete_registration("bob", cred, rp_id_override="localhost",
origins_override=["http://localhost"])
assert rec["credential_id"] == cred["id"]
def test_register_flow_without_env_config(self, wa_client, monkeypatch):
"""Full register + login roundtrip with no WEBAUTHN env at all: the
relying party derives from the request (TestClient host)."""
import backend.auth.webauthn_mfa as w
monkeypatch.delenv("OBSIGATE_WEBAUTHN_RP_ID", raising=False)
monkeypatch.delenv("OBSIGATE_WEBAUTHN_ORIGINS", raising=False)
w._pending.clear()
headers = _login_headers(wa_client)
r = wa_client.post("/api/auth/mfa/webauthn/register/options", headers=headers)
assert r.status_code == 200
options = r.json()["options"]
assert options["rp"]["id"] == "testserver"
auth = VirtualAuthenticator()
auth.RP_ID = "testserver"
auth.ORIGIN = "http://testserver"
cred = auth.make_registration(options)
r2 = wa_client.post("/api/auth/mfa/webauthn/register", headers=headers,
json={"credential": cred, "label": "Key"})
assert r2.status_code == 200, r2.text
opts_r = wa_client.post("/api/auth/mfa/webauthn/options",
json={"username": "testuser"})
assertion = auth.make_assertion(opts_r.json()["options"])
v = wa_client.post("/api/auth/mfa/webauthn/verify",
json={"username": "testuser", "credential": assertion})
assert v.status_code == 200, v.text
assert "access_token" in v.json()