Compare commits

..
3 Commits
Author SHA1 Message Date
bruno 49c715199d feat: partage dirigé entre utilisateurs #196
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
CI / lint (push) Canceled after 1m29s
- create_share/shared_with + validation des destinataires
- gate auth sur /s/{token} (+pdf, raw) : 404 opaque hors créateur/admin/destinataires
- GET /api/shares scopé (non-admin = créés + reçus), révocation créateur/admin
- UI : dialogue dirigé/public + destinataires, dashboard « partagé par X »
- i18n FR/EN, tests test_directed_shares.py (9), fiche feature
2026-10-10 20:14:29 -04:00
bruno fd192df036 fix: admin — icône fermeture modale utilisateurs + sections du dashboard affichées une à la fois BUG-111
CI / lint (push) Successful in 2m54s
CI / security (push) Successful in 1m41s
CI / test (push) Successful in 4m36s
CI / build (push) Successful in 1m34s
CI / e2e (push) Successful in 17m56s
2026-10-10 17:17:14 -04:00
bruno 8662d23ec8 fix: un dossier perso n'est plus visible par les autres comptes #194
CI / lint (push) Successful in 2m57s
CI / security (push) Successful in 1m40s
CI / test (push) Successful in 4m39s
CI / build (push) Successful in 1m34s
CI / e2e (push) Successful in 17m44s
L'admin (vaults: ["*"]) voyait le home de chaque utilisateur dans sa
barre latérale : "*" ouvrait tous les vaults, home-* compris.

- backend/auth/middleware.py : check_vault_access exige un octroi
  explicite pour tout vault home-* (nouveau is_home_vault()).
- Filtres « * » en dur remplacés par check_vault_access : dashboard,
  conflits, liens retour, favoris, abonnements push.
- /api/search : search_vaults(is_allowed=…) filtre les bruts avant
  pagination (total et page restent justes).
- backend/user_home.py : _grant n'écarte plus les comptes « * » —
  l'admin reçoit son propre home-admin (auto-réparé au démarrage).
- Tests : test_user_home.py +2, assertion API inversée dans
  test_auth_api.py (admin ne voit plus home-alice).
2026-10-10 16:42:00 -04:00
34 changed files with 583 additions and 107 deletions
+61 -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.65.0**. > [Unreleased](#unreleased). La dernière version livrée est **2.66.0**.
--- ---
@@ -14,6 +14,48 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
--- ---
## [2.66.0] — 2026-10-10
### Ajouté
- **#196 — Partage dirigé entre utilisateurs**
- Un utilisateur peut partager un document à un ou plusieurs comptes
précis (champ destinataires dans le dialogue de partage, noms séparés
par des virgules) : les pages `/s/{token}` (+ PDF, téléchargement brut)
exigent alors une session et n'acceptent que le créateur, les admins et
les destinataires — 404 opaque sinon (l'existence d'un token dirigé
n'est pas confirmable de l'extérieur).
- Dashboard : les partages reçus s'affichent avec « Partagé par X » et
s'ouvrent en lecture seule via la page de partage ; pas de bouton
révoquer pour un destinataire.
- Rétrocompatible : les partages publics existants (sans destinataires)
restent accessibles anonymement.
### Sécurité
- **#196 — `GET /api/shares` scopé** : un utilisateur non-admin ne voit plus
TOUS les partages de l'instance, mais uniquement ceux qu'il a créés ou
reçus (fuite préexistante, intenable avec le partage user-to-user). La
révocation d'un partage est réservée à son créateur ou à un admin.
- i18n FR/EN (7 nouvelles clés), tests `tests/test_directed_shares.py` (9),
fiche `docs/features/directed-shares-196.md`.
---
## [2.65.2] — 2026-10-10
### Corrigé
- **BUG-111 — Admin : icône de fermeture + navigation du tableau de bord**
- La modale « Administration — Utilisateurs » ouverte depuis la page `/admin` n'affichait pas l'icône du bouton fermer (lucide absent de `admin.html`) — même version du script UMD que `index.html` ajoutée.
- Le tableau de bord admin affiche désormais **une seule section** à la fois (état initial : hash de l'URL ou « Statistiques temps réel ») au lieu des 4 sections empilées.
---
## [2.65.1] — 2026-10-10
---
## [2.65.0] — 2026-10-10 ## [2.65.0] — 2026-10-10
### Ajouté ### Ajouté
@@ -31,6 +73,24 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Tests : `sidebar-filters.test.mjs` +2 (icône maison pour `home-*`, - Tests : `sidebar-filters.test.mjs` +2 (icône maison pour `home-*`,
gemme violette inchangée ailleurs). gemme violette inchangée ailleurs).
### Corrigé
- **#194 — Un dossier perso n'est plus visible par les autres comptes**
- `check_vault_access` (`backend/auth/middleware.py`) refuse désormais tout
vault `home-*` qui n'est pas **explicitement** octroyé au compte : le
`vaults: ["*"]` de l'admin ne couvre plus les dossiers persos (avant,
l'admin voyait `home-<user>` de chaque utilisateur dans sa barre latérale).
- Les filtres `« * »` écrits en dur dans les routes ont été remplacés par
cet appel unique — dashboard, conflits, liens retour, favoris,
abonnements push : partout la même règle.
- `/api/search` filtre les résultats par vault accessible **avant**
pagination (`search_vaults(is_allowed=…)`), sinon les notes personnelles
d'un autre pouvait apparaître dans les résultats.
- `_grant` octroie désormais le home aussi aux comptes `« * »` : l'admin
voit **son** `home-admin`, réparé au démarrage pour les comptes existants.
- Tests : `test_user_home.py` +2 (`« * »` sans homes, filtrage de la
recherche), assertion API inversée dans `test_auth_api.py`.
--- ---
## [2.64.0] — 2026-10-10 ## [2.64.0] — 2026-10-10
+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.65.0-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.66.0-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/)
@@ -977,8 +977,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.65.0). Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.66.0).
--- ---
*Projet : ObsiGate | Version : 2.65.0 | Dernière mise à jour : Septembre 2026* *Projet : ObsiGate | Version : 2.66.0 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. **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.65.0-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.66.0-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/)
@@ -1152,8 +1152,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.65.0). See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.66.0).
--- ---
*Project: ObsiGate | Version: 2.65.0 | Last updated: September 2026* *Project: ObsiGate | Version: 2.66.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.65.0 2.66.0
+10
View File
@@ -142,6 +142,11 @@ def require_admin(current_user=Depends(require_auth)):
return current_user return current_user
def is_home_vault(vault_name: str) -> bool:
"""Un dossier personnel (#194) : vault « home-<user> »."""
return vault_name.startswith("home-")
def check_vault_access(vault_name: str, user: dict) -> bool: def check_vault_access(vault_name: str, user: dict) -> bool:
"""Check if a user has access to a specific vault. """Check if a user has access to a specific vault.
@@ -149,8 +154,13 @@ def check_vault_access(vault_name: str, user: dict) -> bool:
- vaults == ["*"] → full access (admin default) - vaults == ["*"] → full access (admin default)
- vault_name in vaults → access granted - vault_name in vaults → access granted
- otherwise → denied - otherwise → denied
#194 : un dossier personnel n'est **jamais** couvert par ``*`` — sinon
un admin (``vaults: ["*"]``) verrait le dossier de chaque utilisateur.
""" """
vaults = user.get("_token_vaults") or user.get("vaults", []) vaults = user.get("_token_vaults") or user.get("vaults", [])
if is_home_vault(vault_name):
return vault_name in vaults
if "*" in vaults: if "*" in vaults:
return True return True
return vault_name in vaults return vault_name in vaults
+4 -4
View File
@@ -13,7 +13,7 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from backend.auth.middleware import require_auth from backend.auth.middleware import check_vault_access, require_auth
logger = logging.getLogger("obsigate.push") logger = logging.getLogger("obsigate.push")
@@ -167,9 +167,9 @@ async def subscribe_push(
"""Subscribe to push notifications for a vault.""" """Subscribe to push notifications for a vault."""
username = current_user.get("username", "unknown") username = current_user.get("username", "unknown")
# Check if user has access to this vault # Check if user has access to this vault (#194 : "*" n'inclut pas les
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", []) # dossiers persos, check_vault_access est la source unique).
if "*" not in user_vaults and request.vault not in user_vaults: if not check_vault_access(request.vault, current_user):
raise HTTPException(status_code=403, detail="No access to this vault") raise HTTPException(status_code=403, detail="No access to this vault")
# Check if subscription already exists # Check if subscription already exists
+2 -3
View File
@@ -24,7 +24,7 @@ from pathlib import Path
from fastapi import APIRouter, Body, Depends, HTTPException, Query from fastapi import APIRouter, Body, Depends, HTTPException, Query
from backend.ai import PROVIDERS, _read_ai_keys, get_ai_key from backend.ai import PROVIDERS, _read_ai_keys, get_ai_key
from backend.auth.middleware import require_admin, require_auth from backend.auth.middleware import check_vault_access, require_admin, require_auth
from backend.indexer import index from backend.indexer import index
from backend.media_types import IMAGE_EXTENSIONS from backend.media_types import IMAGE_EXTENSIONS
from backend.schemas import ( from backend.schemas import (
@@ -495,14 +495,13 @@ async def api_diagnostics(current_user=Depends(require_admin)):
@router.get("/api/dashboard", response_model=DashboardResponse) @router.get("/api/dashboard", response_model=DashboardResponse)
async def api_dashboard(current_user=Depends(require_auth)): async def api_dashboard(current_user=Depends(require_auth)):
"""Aggregated dashboard statistics across all accessible vaults.""" """Aggregated dashboard statistics across all accessible vaults."""
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
vault_stats = [] vault_stats = []
total_files = 0 total_files = 0
total_tags = set() total_tags = set()
total_size = 0 total_size = 0
total_images = 0 total_images = 0
for vname, vdata in index.items(): for vname, vdata in index.items():
if "*" not in user_vaults and vname not in user_vaults: if not check_vault_access(vname, current_user):
continue continue
files = vdata.get("files", []) files = vdata.get("files", [])
fc = len(files) fc = len(files)
+2 -3
View File
@@ -31,10 +31,9 @@ router = APIRouter(tags=["conflicts"])
@router.get("/api/conflicts", response_model=ConflictsResponse) @router.get("/api/conflicts", response_model=ConflictsResponse)
async def api_conflicts(current_user=Depends(require_auth)): async def api_conflicts(current_user=Depends(require_auth)):
"""List sync-conflict files across accessible vaults.""" """List sync-conflict files across accessible vaults."""
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
all_conflicts = get_conflicts() all_conflicts = get_conflicts()
if "*" not in user_vaults: # #194 : filtrage via check_vault_access ("*" n'inclut pas les homes).
all_conflicts = [c for c in all_conflicts if c["vault"] in user_vaults] all_conflicts = [c for c in all_conflicts if check_vault_access(c["vault"], current_user)]
return {"conflicts": all_conflicts, "total": len(all_conflicts)} return {"conflicts": all_conflicts, "total": len(all_conflicts)}
+3 -4
View File
@@ -166,12 +166,11 @@ async def api_file_backlinks(
if not vault_data: if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
backlinks = get_backlinks(vault_name, path) backlinks = get_backlinks(vault_name, path)
# Filter by user-accessible vaults # Filter by user-accessible vaults (#194 : check_vault_access, "*" sans
if "*" not in user_vaults: # les dossiers persos).
backlinks = [b for b in backlinks if b["vault"] in user_vaults] backlinks = [b for b in backlinks if check_vault_access(b["vault"], current_user)]
return { return {
"vault": vault_name, "vault": vault_name,
+2 -2
View File
@@ -59,7 +59,6 @@ async def api_recent(limit: int | None = Query(None), vault: str | None = Query(
@router.get("/api/bookmarks", response_model=BookmarksResponse) @router.get("/api/bookmarks", response_model=BookmarksResponse)
async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(require_auth)): async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(require_auth)):
username = current_user.get("username") username = current_user.get("username")
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
if not username: if not username:
return {"files": []} return {"files": []}
@@ -68,7 +67,8 @@ async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(re
files_resp = [] files_resp = []
for item in history: for item in history:
v_name = item["vault"] v_name = item["vault"]
if "*" not in user_vaults and v_name not in user_vaults: # #194 : check_vault_access — "*" n'inclut pas les dossiers persos.
if not check_vault_access(v_name, current_user):
continue continue
# Find in index to get metadata # Find in index to get metadata
+6 -1
View File
@@ -82,7 +82,12 @@ async def api_search(
# paginate in the shared service so routes and tools share the same logic. # paginate in the shared service so routes and tools share the same logic.
return await loop.run_in_executor( return await loop.run_in_executor(
get_search_executor(), get_search_executor(),
partial(search_vaults, q, vault, tag, limit, offset), # #194 : filtre par vault accessible AVANT pagination ("*" sans les
# dossiers persos) — sinon un user voyait les notes des autres.
partial(
search_vaults, q, vault, tag, limit, offset,
is_allowed=lambda v: check_vault_access(v, current_user),
),
) )
+51 -14
View File
@@ -24,7 +24,8 @@ import frontmatter
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
from fastapi.responses import FileResponse, HTMLResponse, Response from fastapi.responses import FileResponse, HTMLResponse, Response
from backend.auth.middleware import check_vault_access, require_auth from backend.auth.middleware import check_vault_access, get_current_user, require_auth
from backend.auth.user_store import get_user
from backend.indexer import get_vault_data, parse_markdown_file, update_single_file from backend.indexer import get_vault_data, parse_markdown_file, update_single_file
from backend.render import _render_markdown from backend.render import _render_markdown
from backend.schemas import ShareModel, StatusResponse from backend.schemas import ShareModel, StatusResponse
@@ -53,6 +54,27 @@ except Exception: # pragma: no cover - WeasyPrint/GTK missing
router = APIRouter(tags=["sharing"]) router = APIRouter(tags=["sharing"])
def _gate_share(share: dict | None, user: dict | None) -> dict:
"""#196 — directed shares: auth + membership check for ``/s/*`` pages.
Public share (``shared_with`` empty) → pass-through, no auth required.
Directed share → requires an authenticated user who is the creator, an
admin, or listed in ``shared_with``. Anything else answers **404** (never
403) so an outsider cannot learn that a token exists.
"""
if share is None:
raise HTTPException(404, "Share not found or expired")
recipients = share.get("shared_with") or []
if not recipients:
return share
if user is None:
raise HTTPException(404, "Share not found or expired")
if user["username"] == share.get("created_by") or user.get("role") == "admin" \
or user["username"] in recipients:
return share
raise HTTPException(404, "Share not found or expired")
@router.post("/api/share/{vault_name}", response_model=ShareModel) @router.post("/api/share/{vault_name}", response_model=ShareModel)
async def api_share_create( async def api_share_create(
vault_name: str, vault_name: str,
@@ -68,7 +90,14 @@ async def api_share_create(
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'") raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
path = body.get("path", "") path = body.get("path", "")
expires = body.get("expires_in_hours") expires = body.get("expires_in_hours")
share = create_share(vault_name, path, current_user["username"], expires) # #196 — directed share: validate recipients before creating anything.
recipients = body.get("shared_with") or []
if not isinstance(recipients, list):
raise HTTPException(400, "shared_with doit être une liste d'utilisateurs")
for name in recipients:
if not isinstance(name, str) or not get_user(name):
raise HTTPException(400, f"Utilisateur inconnu : {name}")
share = create_share(vault_name, path, current_user["username"], expires, recipients)
share["url"] = f"/s/{share['token']}" share["url"] = f"/s/{share['token']}"
# Set publish: true in the file's frontmatter # Set publish: true in the file's frontmatter
@@ -94,8 +123,13 @@ async def api_share_create(
@router.get("/api/shares", response_model=list[ShareModel]) @router.get("/api/shares", response_model=list[ShareModel])
async def api_shares_list(vault: str | None = Query(None), current_user=Depends(require_auth)): async def api_shares_list(vault: str | None = Query(None), current_user=Depends(require_auth)):
"""List all shares (optionally filtered by vault).""" """List shares (optionally filtered by vault).
shares = list_shares(vault)
#196 : un non-admin ne voit que SES partages (créés ou reçus) ; un admin
voit tout (comportement historique).
"""
username = None if current_user.get("role") == "admin" else current_user["username"]
shares = list_shares(vault, username)
for s in shares: for s in shares:
s["url"] = f"/s/{s['token']}" s["url"] = f"/s/{s['token']}"
return shares return shares
@@ -103,6 +137,12 @@ async def api_shares_list(vault: str | None = Query(None), current_user=Depends(
@router.delete("/api/share/{share_id}", response_model=StatusResponse) @router.delete("/api/share/{share_id}", response_model=StatusResponse)
async def api_share_revoke(share_id: str, current_user=Depends(require_auth)): async def api_share_revoke(share_id: str, current_user=Depends(require_auth)):
# #196 — only the creator or an admin may revoke; a recipient cannot.
from backend.share import _read
share = _read()["shares"].get(share_id)
if share and current_user.get("role") != "admin" \
and share.get("created_by") != current_user["username"]:
raise HTTPException(403, "Seul le créateur du partage peut le révoquer")
if not revoke_share(share_id): if not revoke_share(share_id):
raise HTTPException(404, "Share not found") raise HTTPException(404, "Share not found")
return {"status": "revoked"} return {"status": "revoked"}
@@ -113,13 +153,12 @@ async def api_share_revoke(share_id: str, current_user=Depends(require_auth)):
response_class=Response, response_class=Response,
responses={200: {"content": {"application/pdf": {}}, "description": "Shared document as PDF"}}, responses={200: {"content": {"application/pdf": {}}, "description": "Shared document as PDF"}},
) )
async def public_share_pdf_download(token: str): async def public_share_pdf_download(token: str, current_user=Depends(get_current_user)):
"""Download shared document as real PDF via WeasyPrint.""" """Download shared document as real PDF via WeasyPrint."""
if generate_pdf is None: if generate_pdf is None:
raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)") raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)")
share = get_share_by_token(token) share = get_share_by_token(token)
if not share: share = _gate_share(share, current_user)
raise HTTPException(404, "Share not found or expired")
vault_data = get_vault_data(share["vault"]) vault_data = get_vault_data(share["vault"])
if not vault_data: if not vault_data:
raise HTTPException(404, "Vault not found") raise HTTPException(404, "Vault not found")
@@ -147,11 +186,10 @@ async def public_share_pdf_download(token: str):
@router.get("/s/{token}/raw", response_class=FileResponse) @router.get("/s/{token}/raw", response_class=FileResponse)
async def public_share_raw(token: str): async def public_share_raw(token: str, current_user=Depends(get_current_user)):
"""Download the raw (original) shared document.""" """Download the raw (original) shared document."""
share = get_share_by_token(token) share = get_share_by_token(token)
if not share: share = _gate_share(share, current_user)
raise HTTPException(404, "Share not found or expired")
vault_data = get_vault_data(share["vault"]) vault_data = get_vault_data(share["vault"])
if not vault_data: if not vault_data:
raise HTTPException(404, "Vault not found") raise HTTPException(404, "Vault not found")
@@ -164,13 +202,12 @@ async def public_share_raw(token: str):
@router.get("/s/{token}", response_class=HTMLResponse) @router.get("/s/{token}", response_class=HTMLResponse)
async def public_share_view(request: Request, token: str): async def public_share_view(request: Request, token: str, current_user=Depends(get_current_user)):
"""Public share view — no authentication required.""" """Public share view — no authentication required (unless directed #196)."""
from backend.csp import inject_csp_nonce from backend.csp import inject_csp_nonce
share = get_share_by_token(token) share = get_share_by_token(token)
if not share: share = _gate_share(share, current_user)
raise HTTPException(404, "Share not found or expired")
vault_data = get_vault_data(share["vault"]) vault_data = get_vault_data(share["vault"])
if not vault_data: if not vault_data:
raise HTTPException(404, "Vault not found") raise HTTPException(404, "Vault not found")
+1
View File
@@ -1056,6 +1056,7 @@ class ShareModel(BaseModel):
expires_at: str | None = None expires_at: str | None = None
access_count: int = 0 access_count: int = 0
last_accessed: str | None = None last_accessed: str | None = None
shared_with: list[str] = Field(default_factory=list, description="#196 destinataires (partage dirigé)")
class ConflictEntry(BaseModel): class ConflictEntry(BaseModel):
+5
View File
@@ -12,6 +12,7 @@ import time
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from backend.auth.middleware import is_home_vault
from backend.history import get_recent_opened, is_bookmarked from backend.history import get_recent_opened, is_bookmarked
from backend.indexer import find_file_in_index, index from backend.indexer import find_file_in_index, index
@@ -31,6 +32,10 @@ def humanize_mtime(mtime: float) -> str:
def _can_access(vault: str, user_vaults: list[str]) -> bool: def _can_access(vault: str, user_vaults: list[str]) -> bool:
# #194 : un dossier perso ne bénéficie jamais de "*" (même règle que
# backend.auth.middleware.check_vault_access).
if is_home_vault(vault):
return vault in user_vaults
return "*" in user_vaults or vault in user_vaults return "*" in user_vaults or vault in user_vaults
+7 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from typing import Any from typing import Any
@@ -11,15 +12,19 @@ def search_vaults(
tag: str | None = None, tag: str | None = None,
limit: int = 50, limit: int = 50,
offset: int = 0, offset: int = 0,
is_allowed: Callable[[str], bool] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Full-text search with pagination, returned as the API response payload. """Full-text search with pagination, returned as the API response payload.
No permission filtering is applied here: callers that need it (the tool ``is_allowed`` (#194) filters the raw hits **before** pagination, so a
layer) filter the ``results`` list themselves. restricted vault set neither distorts ``total`` nor the returned page.
The tool layer filters on its own and leaves it as ``None``.
""" """
from backend.search import search from backend.search import search
all_results = search(q, vault_filter=vault, tag_filter=tag) all_results = search(q, vault_filter=vault, tag_filter=tag)
if is_allowed is not None:
all_results = [r for r in all_results if is_allowed(r.get("vault", ""))]
total = len(all_results) total = len(all_results)
page = all_results[offset: offset + limit] page = all_results[offset: offset + limit]
return { return {
+17 -3
View File
@@ -44,8 +44,14 @@ def create_share(
path: str, path: str,
created_by: str, created_by: str,
expires_in_hours: int | None = None, expires_in_hours: int | None = None,
shared_with: list[str] | None = None,
) -> dict: ) -> dict:
"""Create a new share token for a document.""" """Create a new share token for a document.
``shared_with`` non vide (#196) : partage **dirigé** — la page ``/s/…``
exige alors une session et n'accepte que les destinataires listés (plus
le créateur et les admins). Vide/absent : comportement public inchangé.
"""
with _lock: with _lock:
data = _read() data = _read()
token = secrets.token_hex(32) # 64-char hex token token = secrets.token_hex(32) # 64-char hex token
@@ -64,6 +70,7 @@ def create_share(
"expires_at": expires_at, "expires_at": expires_at,
"access_count": 0, "access_count": 0,
"last_accessed": None, "last_accessed": None,
"shared_with": list(shared_with) if shared_with else [],
} }
data["shares"][token] = share data["shares"][token] = share
_write(data) _write(data)
@@ -107,10 +114,17 @@ def revoke_share(share_id: str) -> bool:
return False return False
def list_shares(vault_filter: str | None = None) -> list: def list_shares(vault_filter: str | None = None, user: str | None = None) -> list:
"""List all shares, optionally filtered by vault.""" """List shares, optionally filtered by vault and/or requesting user.
#196 : un non-admin ne voit que les partages qu'il a créés **ou** qui lui
sont dirigés. Un admin voit tout. ``user=None`` (anciens appels, tests
unitaires) conserve l'ancien comportement : tout lister.
"""
data = _read() data = _read()
shares = list(data["shares"].values()) shares = list(data["shares"].values())
if user is not None:
shares = [s for s in shares if user in (s.get("created_by"), *(s.get("shared_with") or []))]
if vault_filter: if vault_filter:
shares = [s for s in shares if s["vault"] == vault_filter] shares = [s for s in shares if s["vault"] == vault_filter]
# Most recent first # Most recent first
+6 -2
View File
@@ -112,14 +112,18 @@ async def release_user_home(username: str) -> None:
def _grant(username: str, vault_name: str) -> None: def _grant(username: str, vault_name: str) -> None:
"""Ajoute le vault à ``user.vaults`` si l'utilisateur n'a pas déjà ``*``.""" """Ajoute le vault à ``user.vaults`` s'il n'y est pas déjà (#194).
L'octroi est explicite même pour un admin (``vaults: ["*"]``) : ``*`` ne
couvre jamais un dossier perso (voir ``check_vault_access``).
"""
from backend.auth.user_store import get_user, update_user from backend.auth.user_store import get_user, update_user
user = get_user(username) user = get_user(username)
if not user: if not user:
return # créé hors API (bootstrap avant users.json) → réparé au boot suivant return # créé hors API (bootstrap avant users.json) → réparé au boot suivant
vaults = user.get("vaults") or [] vaults = user.get("vaults") or []
if "*" in vaults or vault_name in vaults: if vault_name in vaults:
return return
update_user(username, {"vaults": [*vaults, vault_name]}) update_user(username, {"vaults": [*vaults, vault_name]})
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]] [[package]]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.65.0" version = "2.66.0"
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.65.0" version = "2.66.0"
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.65.0", "version": "2.66.0",
"identifier": "com.obsigate.desktop", "identifier": "com.obsigate.desktop",
"build": { "build": {
"frontendDist": "../frontend", "frontendDist": "../frontend",
+4
View File
@@ -217,6 +217,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-107* | Drag & drop de fichiers depuis l'Explorateur inutilisable sur desktop : Tauri/wry pose son propre `IDropTarget` par-dessus de celui du WebView2 et aucun événement natif n'est écouté — #89 ne fonctionnait que sur le web | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/tauri.conf.json` (`create: false`), `desktop/src/main.rs` (`WebviewWindowBuilder::…disable_drag_drop_handler`) | App desktop : glisser un fichier de l'Explorateur sur la fenêtre → aucun survol, aucun dépôt — les gestionnaires HTML5 de `dragdrop.js` ne sont jamais déclenchés | Création manuelle de la fenêtre avec `disable_drag_drop_handler()` (doc Tauri : « required to use HTML5 drag and drop APIs on the frontend on Windows ») ; `create: false` évite la double création par la boucle Tauri | `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires) + lancement OK (fenêtre unique, boot normal) ; dépôt réel à valider par l'utilisateur | | *BUG-107* | Drag & drop de fichiers depuis l'Explorateur inutilisable sur desktop : Tauri/wry pose son propre `IDropTarget` par-dessus de celui du WebView2 et aucun événement natif n'est écouté — #89 ne fonctionnait que sur le web | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/tauri.conf.json` (`create: false`), `desktop/src/main.rs` (`WebviewWindowBuilder::…disable_drag_drop_handler`) | App desktop : glisser un fichier de l'Explorateur sur la fenêtre → aucun survol, aucun dépôt — les gestionnaires HTML5 de `dragdrop.js` ne sont jamais déclenchés | Création manuelle de la fenêtre avec `disable_drag_drop_handler()` (doc Tauri : « required to use HTML5 drag and drop APIs on the frontend on Windows ») ; `create: false` évite la double création par la boucle Tauri | `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires) + lancement OK (fenêtre unique, boot normal) ; dépôt réel à valider par l'utilisateur |
| *BUG-108* | Tableur : le « Coller » du menu contextuel ne fait rien (copier-coller par menu muet) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/xlsx/context-menu.js` (`closeContextMenu`) | Ouvrir un `.xlsx`, clic droit « Copier » sur une plage, clic droit cible puis « Coller » → rien n'est collé (le menu se ferme sans action) | `closeContextMenu()` retirait le nœud du DOM **avant** de remettre `_menu` à `null` : retirer le menu qui tient le focus émet `focusout` en synchrone, qui rappelle `closeContextMenu()` → second `remove()` sur un nœud déjà démonté → `NotFoundError` qui avorte le handler de l'action « Coller » du viewer. Introduit par #179 (fermeture au focus). Correctif : `_menu = null` avant `menu.remove()` | Reproduit en local sur HEAD (e2e « coller une plage » : attendu `Date`, obtenu la valeur datée) puis vert après fix : E2E `-g "coller une plage"` 1/1, `xlsx-menus` 11/11, `ai-quick-actions` 13/13 | | *BUG-108* | Tableur : le « Coller » du menu contextuel ne fait rien (copier-coller par menu muet) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/xlsx/context-menu.js` (`closeContextMenu`) | Ouvrir un `.xlsx`, clic droit « Copier » sur une plage, clic droit cible puis « Coller » → rien n'est collé (le menu se ferme sans action) | `closeContextMenu()` retirait le nœud du DOM **avant** de remettre `_menu` à `null` : retirer le menu qui tient le focus émet `focusout` en synchrone, qui rappelle `closeContextMenu()` → second `remove()` sur un nœud déjà démonté → `NotFoundError` qui avorte le handler de l'action « Coller » du viewer. Introduit par #179 (fermeture au focus). Correctif : `_menu = null` avant `menu.remove()` | Reproduit en local sur HEAD (e2e « coller une plage » : attendu `Date`, obtenu la valeur datée) puis vert après fix : E2E `-g "coller une plage"` 1/1, `xlsx-menus` 11/11, `ai-quick-actions` 13/13 |
| *BUG-109* | Chat : dans la tuile d'une URL (link preview) l'image ne s'affiche jamais | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/file_chat.py` (`build_preview`, `_proxy_image`) | Poster une URL avec `og:image` dans le chat général → la carte s'affiche mais sans vignette (console : `Refused to load the image … Content Security Policy`) | La CSP de réponse (`img-src 'self' data: blob:`) interdit toute image externe, et les `og:image` **relatives** étaient résolues contre l'origine d'ObsiGate (404). La vignette est désormais **téléversée à l'envoi** dans `chat_uploads` et servie same-origin (`/api/chat/attachment/<uuid>`) : résolution `urljoin` contre la page, garde SSRF réutilisée, plafond 2 Mo, allow-list d'extensions ; échec du téléchargement → carte conservée sans vignette (jamais de message perdu) | Tests `test_preview_image_is_proxied_same_origin` + `test_preview_image_failure_keeps_the_card` (contre-preuve : proxy neutralisé → `image == ""`). Cartes antérieures inchangées : leur URL distante était déjà bloquée | | *BUG-109* | Chat : dans la tuile d'une URL (link preview) l'image ne s'affiche jamais | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/file_chat.py` (`build_preview`, `_proxy_image`) | Poster une URL avec `og:image` dans le chat général → la carte s'affiche mais sans vignette (console : `Refused to load the image … Content Security Policy`) | La CSP de réponse (`img-src 'self' data: blob:`) interdit toute image externe, et les `og:image` **relatives** étaient résolues contre l'origine d'ObsiGate (404). La vignette est désormais **téléversée à l'envoi** dans `chat_uploads` et servie same-origin (`/api/chat/attachment/<uuid>`) : résolution `urljoin` contre la page, garde SSRF réutilisée, plafond 2 Mo, allow-list d'extensions ; échec du téléchargement → carte conservée sans vignette (jamais de message perdu) | Tests `test_preview_image_is_proxied_same_origin` + `test_preview_image_failure_keeps_the_card` (contre-preuve : proxy neutralisé → `image == ""`). Cartes antérieures inchangées : leur URL distante était déjà bloquée |
| *BUG-111* | Admin : modale « Administration — Utilisateurs » sans icône de fermeture (lucide absent de admin.html) et tableau de bord admin affichant toutes les sections au lieu de la seule section sélectionnée | 2026-10-10 | Utilisateur | `frontend/admin.html` (ajout du script lucide UMD) + `frontend/js/admin.js` (`_wireSectionNav` : show/hide au lieu du scrollspy) ; suites frontend + backend vertes, vérifié sur instance locale 2020 | lucide n'était chargé que dans index.html ; le scrollspy laissait les 4 sections visibles |
| *BUG-110* | Chat : refonte visuelle des posts — boutons discrets sous l'image, image ouverte dans un onglet de l'app, point de non-lus sur l'icône chat, notification OS hors focus, annulation **réelle** d'une suppression | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/filechat.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/filechat.test.mjs` | Clic sur une image du chat → ouverture dans un onglet du navigateur au lieu du visualiseur de l'app ; boutons de pièce jointe bavards (icône + libellé) ; aucun repère visuel persistant sur l'icône chat ; aucune alerte OS quand l'app est en arrière-plan ; le bouton « Annuler » d'une suppression ne restaurait rien (copie DOM d'un message déjà supprimé côté serveur) | `TabManager.openChatImage()` (+ branche `chatImage` de `activate()`, `renderImageViewer()` accepte une `url` directe) ouvre la pièce jointe dans un **onglet** avec le visualiseur des images de vault (zoom/pan/plein écran) ; boutons **icônes seules** (24 px, `aria-label`) sous l'image, alignés à droite ; **point rouge pulsant** (`.chat-has-unread`) tant qu'il reste des non-lus, en plus du badge chiffré ; `_notifyNewMessage()` — notification OS si `document.hidden`/hors focus, permission demandée à l'ouverture du chat ; suppression **différée de 10 s** (ligne « Ce message a été supprimé » + « Annuler », DELETE envoyé à l'échéance seulement, échec réseau → le message revient) | Vérifié : `filechat.test.mjs` 25/25 (dont 6 tests #110), `validate-imports` 42 modules / 372 exports 0 erreur, `unit.test.mjs` 13/13 | | *BUG-110* | Chat : refonte visuelle des posts — boutons discrets sous l'image, image ouverte dans un onglet de l'app, point de non-lus sur l'icône chat, notification OS hors focus, annulation **réelle** d'une suppression | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/filechat.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/filechat.test.mjs` | Clic sur une image du chat → ouverture dans un onglet du navigateur au lieu du visualiseur de l'app ; boutons de pièce jointe bavards (icône + libellé) ; aucun repère visuel persistant sur l'icône chat ; aucune alerte OS quand l'app est en arrière-plan ; le bouton « Annuler » d'une suppression ne restaurait rien (copie DOM d'un message déjà supprimé côté serveur) | `TabManager.openChatImage()` (+ branche `chatImage` de `activate()`, `renderImageViewer()` accepte une `url` directe) ouvre la pièce jointe dans un **onglet** avec le visualiseur des images de vault (zoom/pan/plein écran) ; boutons **icônes seules** (24 px, `aria-label`) sous l'image, alignés à droite ; **point rouge pulsant** (`.chat-has-unread`) tant qu'il reste des non-lus, en plus du badge chiffré ; `_notifyNewMessage()` — notification OS si `document.hidden`/hors focus, permission demandée à l'ouverture du chat ; suppression **différée de 10 s** (ligne « Ce message a été supprimé » + « Annuler », DELETE envoyé à l'échéance seulement, échec réseau → le message revient) | Vérifié : `filechat.test.mjs` 25/25 (dont 6 tests #110), `validate-imports` 42 modules / 372 exports 0 erreur, `unit.test.mjs` 13/13 |
| | | | | | | | | | | | | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches) ### TODOs techniques (améliorations / nouvelles tâches)
@@ -335,6 +336,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-10-08 | #190 | Fonctionnalité | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js` (réécrit), `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/js/sidebar.js`, `frontend/js/app.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `CHANGELOG.md` | **#190 — chat : suivi du document, chat général en onglet sidebar, onglets au-dessus du filtre** : (A) l'en-tête du panneau affiche le document ciblé (titre + chemin) et `followFileChat()` appelé par `renderFile()` re-cible le panneau ouvert à chaque changement de document ; (B) onglet **Chat** (dernier) → conversation générale stockée dans le même store #169 via les sentinelles `__global__/general` (`GET/POST /api/chat`), **pastille de messages non lus** (`localStorage` + badge sur l'onglet), **pièces jointes image/vidéo** (`POST /api/chat/upload` : allow-list d'extensions, 25 MB, nom UUID — jamais le nom client ; `GET /api/chat/attachment/{name}` résolu contre l'allow-list), **URL cliquables** dans le rendu (linkification `http(s)://`), date/heure d'envoi ; (C) barre de filtre **déplacée sous les onglets** et routée vers `filterChatMessages()` (texte + auteur) quand l'onglet Chat est actif. Transport : broadcast SSE `chat_message` réutilisé (vault `__global__` route vers le panneau sidebar, sinon panneau fichier) — pas de second WebSocket. Contre-preuves : `__global__` non routé → test « routes __global__ » échoue ; panneau sans classe `active` → le message part en toast au lieu du rendu (révélé par le stub JSDOM). Vérifié : pytest **1655 passed / 2 skipped** (27 tests chat dont 12 nouveaux), ruff 0, mypy 0 (113 fichiers), validate-imports 42 modules/368 exports, unit 13/13, filechat.test.mjs **11/11** (5 nouveaux), toolbar-order, sidebar-filters 8/8 | ✅ livré (en attente vérif utilisateur) | | 2026-10-08 | #190 | Fonctionnalité | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js` (réécrit), `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/js/sidebar.js`, `frontend/js/app.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `CHANGELOG.md` | **#190 — chat : suivi du document, chat général en onglet sidebar, onglets au-dessus du filtre** : (A) l'en-tête du panneau affiche le document ciblé (titre + chemin) et `followFileChat()` appelé par `renderFile()` re-cible le panneau ouvert à chaque changement de document ; (B) onglet **Chat** (dernier) → conversation générale stockée dans le même store #169 via les sentinelles `__global__/general` (`GET/POST /api/chat`), **pastille de messages non lus** (`localStorage` + badge sur l'onglet), **pièces jointes image/vidéo** (`POST /api/chat/upload` : allow-list d'extensions, 25 MB, nom UUID — jamais le nom client ; `GET /api/chat/attachment/{name}` résolu contre l'allow-list), **URL cliquables** dans le rendu (linkification `http(s)://`), date/heure d'envoi ; (C) barre de filtre **déplacée sous les onglets** et routée vers `filterChatMessages()` (texte + auteur) quand l'onglet Chat est actif. Transport : broadcast SSE `chat_message` réutilisé (vault `__global__` route vers le panneau sidebar, sinon panneau fichier) — pas de second WebSocket. Contre-preuves : `__global__` non routé → test « routes __global__ » échoue ; panneau sans classe `active` → le message part en toast au lieu du rendu (révélé par le stub JSDOM). Vérifié : pytest **1655 passed / 2 skipped** (27 tests chat dont 12 nouveaux), ruff 0, mypy 0 (113 fichiers), validate-imports 42 modules/368 exports, unit 13/13, filechat.test.mjs **11/11** (5 nouveaux), toolbar-order, sidebar-filters 8/8 | ✅ livré (en attente vérif utilisateur) |
| 2026-10-08 | #169 | Fonctionnalité | `backend/file_chat.py` (nouveau), `backend/routers/file_chat.py` (nouveau), `backend/schemas.py`, `backend/main.py`, `frontend/js/filechat.js` (nouveau), `frontend/js/viewer.js`, `frontend/js/sync.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py` (nouveau), `tests/frontend/filechat.test.mjs` (nouveau), `tests/frontend/toolbar-order.test.mjs`, `.gitea/workflows/ci.yml`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md` (nouveau), `docs/GUIDES/PRISE_EN_MAIN.md`, `CHANGELOG.md` | **#169 — chat intégré par fichier** : store JSON par (vault, path) sous `data/chats/` (nom SHA-256 → traversal impossible, plafond 500 msgs, texte 4000 car., écriture atomique, fichier corrompu → vide) ; `GET/POST /api/file/{vault}/chat` (auth + `check_vault_access` + `resolve_safe_path`, 400/404/403, `response_model`) + broadcast SSE `chat_message` sur le transport #62 (**pas de 2ᵉ WebSocket**) ; panneau latéral `filechat.js` (bouton 💬 toolbar, rendu chronologique `textContent`, envoi optimiste + dédup par id, toast si panneau fermé/autre fichier, plein écran ≤ 768 px) ; relais SSE en import dynamique dans `sync.js` ; i18n FR/EN 10 clés `chat.*`. En route : tag OpenAPI maison `file-chat` non déclaré → `test_used_tags_are_declared` rouge (retiré, dérivé « Files »), regex navBtns de `toolbar-order.test.mjs` (échappements → `includes`), ruff UP012/TRY004/I001. Vérifié : `test_file_chat.py` 15 passed, `filechat.test.mjs` 6/6, suite 1643 passed, ruff/mypy 0, validate-imports 42 modules, CI run #1938 5/5 success (v2.57.0) | ✅ livré (en attente vérif utilisateur) | | 2026-10-08 | #169 | Fonctionnalité | `backend/file_chat.py` (nouveau), `backend/routers/file_chat.py` (nouveau), `backend/schemas.py`, `backend/main.py`, `frontend/js/filechat.js` (nouveau), `frontend/js/viewer.js`, `frontend/js/sync.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py` (nouveau), `tests/frontend/filechat.test.mjs` (nouveau), `tests/frontend/toolbar-order.test.mjs`, `.gitea/workflows/ci.yml`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md` (nouveau), `docs/GUIDES/PRISE_EN_MAIN.md`, `CHANGELOG.md` | **#169 — chat intégré par fichier** : store JSON par (vault, path) sous `data/chats/` (nom SHA-256 → traversal impossible, plafond 500 msgs, texte 4000 car., écriture atomique, fichier corrompu → vide) ; `GET/POST /api/file/{vault}/chat` (auth + `check_vault_access` + `resolve_safe_path`, 400/404/403, `response_model`) + broadcast SSE `chat_message` sur le transport #62 (**pas de 2ᵉ WebSocket**) ; panneau latéral `filechat.js` (bouton 💬 toolbar, rendu chronologique `textContent`, envoi optimiste + dédup par id, toast si panneau fermé/autre fichier, plein écran ≤ 768 px) ; relais SSE en import dynamique dans `sync.js` ; i18n FR/EN 10 clés `chat.*`. En route : tag OpenAPI maison `file-chat` non déclaré → `test_used_tags_are_declared` rouge (retiré, dérivé « Files »), regex navBtns de `toolbar-order.test.mjs` (échappements → `includes`), ruff UP012/TRY004/I001. Vérifié : `test_file_chat.py` 15 passed, `filechat.test.mjs` 6/6, suite 1643 passed, ruff/mypy 0, validate-imports 42 modules, CI run #1938 5/5 success (v2.57.0) | ✅ livré (en attente vérif utilisateur) |
| 2026-10-08 | #192 + BUG-109 | Fonctionnalité + correction | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/js/sync.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#192 — chat : saisie auto-agrandissante + accusé de réception.** La boîte d'envoi (panneau document **et** sidebar) devient un `textarea` dont la hauteur suit le contenu (plafond 160 px puis défilement, repasse à 1 ligne après l'envoi), `Entrée` envoie / `Maj+Entrée` saute une ligne ; placeholder sidebar désormais traduit à l'init. **Accusé de réception** : chaque document de conversation porte `read = {utilisateur: ts}` (verrou global sur le read-modify-write — une lecture ne peut plus faire disparaître un message), `GET` d'historique renvoie la carte, nouveau `POST /api/chat/read` (400 sans vault/path, 403 DM hors pair, ACL vault pour un chat de fichier) marque la lecture et diffuse `chat_read` en SSE ; côté client `✓` envoyé / `✓✓` lu sur **mes** messages (exclut ma propre carte de lecture), bascule en direct via `onChatRead()` + toast « {user} a lu votre message », accusé émis à l'ouverture d'une conversation et à la réception d'un message déjà affiché. **BUG-109** : la vignette des tuiles de lien ne s'affichait jamais (CSP `img-src 'self'` + `og:image` relative résolue contre ObsiGate) → `_proxy_image()` téléverse l'image dans `chat_uploads` (same-origin, `urljoin` contre la page, garde SSRF, 2 Mo max, allow-list) et la carte reste affichée si le téléchargement échoue. Tests : pytest `test_file_chat.py` 45→**54** (`TestReadReceipts` 7, proxy image 2, 2 assertions de réponse adaptées), JSDOM `filechat.test.mjs` 16→**19** ; ruff 0, mypy 0 (113 fichiers), validate-imports 42/371, unit 13/13 | ✅ livré (en attente vérif utilisateur) | | 2026-10-08 | #192 + BUG-109 | Fonctionnalité + correction | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/js/sync.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#192 — chat : saisie auto-agrandissante + accusé de réception.** La boîte d'envoi (panneau document **et** sidebar) devient un `textarea` dont la hauteur suit le contenu (plafond 160 px puis défilement, repasse à 1 ligne après l'envoi), `Entrée` envoie / `Maj+Entrée` saute une ligne ; placeholder sidebar désormais traduit à l'init. **Accusé de réception** : chaque document de conversation porte `read = {utilisateur: ts}` (verrou global sur le read-modify-write — une lecture ne peut plus faire disparaître un message), `GET` d'historique renvoie la carte, nouveau `POST /api/chat/read` (400 sans vault/path, 403 DM hors pair, ACL vault pour un chat de fichier) marque la lecture et diffuse `chat_read` en SSE ; côté client `✓` envoyé / `✓✓` lu sur **mes** messages (exclut ma propre carte de lecture), bascule en direct via `onChatRead()` + toast « {user} a lu votre message », accusé émis à l'ouverture d'une conversation et à la réception d'un message déjà affiché. **BUG-109** : la vignette des tuiles de lien ne s'affichait jamais (CSP `img-src 'self'` + `og:image` relative résolue contre ObsiGate) → `_proxy_image()` téléverse l'image dans `chat_uploads` (same-origin, `urljoin` contre la page, garde SSRF, 2 Mo max, allow-list) et la carte reste affichée si le téléchargement échoue. Tests : pytest `test_file_chat.py` 45→**54** (`TestReadReceipts` 7, proxy image 2, 2 assertions de réponse adaptées), JSDOM `filechat.test.mjs` 16→**19** ; ruff 0, mypy 0 (113 fichiers), validate-imports 42/371, unit 13/13 | ✅ livré (en attente vérif utilisateur) |
| 2026-10-10 | BUG-111 | Correction UI admin | `frontend/admin.html`, `frontend/js/admin.js`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-111** : (1) la modale « Administration — Utilisateurs » ouverte depuis `/admin` n'affichait pas l'icône du bouton fermer — `safeCreateIcons()` retourne immédiatement car le global `lucide` n'existe pas sur `admin.html` (script UMD absent — seul index.html le chargeait) ; ajout du même `<script defer>` lucide@0.344.0. (2) Le tableau de bord admin affichait les 4 sections d'un coup (scrollspy) — `_wireSectionNav` masque désormais toutes les sections sauf celle cliquée (état initial : hash de l'URL ou section Stats). Vérifié : 1705 passed, suites frontend vertes, instance docker locale 2020. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-09 | BUG-110 | Correction + refonte UI chat | `frontend/js/filechat.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/filechat.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-110 — refonte visuelle du chat** : (1) les boutons de pièce jointe deviennent des **icônes seules** (24 px, `aria-label`) rangées **sous l'image, alignées à droite** — plus de gros boutons à libellé ; (2) le clic sur une image ouvre le **même visualiseur que pour une image de vault** (zoom/pan/plein écran) mais dans un **onglet de l'app** (`TabManager.openChatImage()`, branche `chatImage` de `activate()`, `renderImageViewer()` accepte désormais une `url` directe : une pièce jointe n'est pas dans un vault) au lieu d'un onglet du navigateur ; (3) **point rouge pulsant sur l'icône chat** de la sidebar tant qu'il reste des non-lus (classe `chat-has-unread`, en plus du badge chiffré) ; (4) **notification OS** (`Notification`) quand l'app n'est pas au premier plan (`document.hidden`/hors focus), permission demandée au premier clic d'ouverture du chat ; (5) la suppression n'est plus mensongère : le `DELETE` est **différé de 10 s**, la ligne passe à « Ce message a été supprimé » avec « Annuler » — annuler réarme le message (l'ancien code restaurait une copie DOM d'un message déjà détruit côté serveur en affichant « Message restauré ») ; échec réseau → le message revient. Vérifié : `filechat.test.mjs` 25/25 (dont 6 tests #110 : icônes seules, clic image → `openChatImage`, aucun `DELETE` pendant la fenêtre d'annulation, notification OS), `validate-imports` 42 modules / 372 exports 0 erreur, `unit.test.mjs` 13/13 | 🟢 corrigé (en attente vérif utilisateur) | | 2026-10-09 | BUG-110 | Correction + refonte UI chat | `frontend/js/filechat.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/filechat.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-110 — refonte visuelle du chat** : (1) les boutons de pièce jointe deviennent des **icônes seules** (24 px, `aria-label`) rangées **sous l'image, alignées à droite** — plus de gros boutons à libellé ; (2) le clic sur une image ouvre le **même visualiseur que pour une image de vault** (zoom/pan/plein écran) mais dans un **onglet de l'app** (`TabManager.openChatImage()`, branche `chatImage` de `activate()`, `renderImageViewer()` accepte désormais une `url` directe : une pièce jointe n'est pas dans un vault) au lieu d'un onglet du navigateur ; (3) **point rouge pulsant sur l'icône chat** de la sidebar tant qu'il reste des non-lus (classe `chat-has-unread`, en plus du badge chiffré) ; (4) **notification OS** (`Notification`) quand l'app n'est pas au premier plan (`document.hidden`/hors focus), permission demandée au premier clic d'ouverture du chat ; (5) la suppression n'est plus mensongère : le `DELETE` est **différé de 10 s**, la ligne passe à « Ce message a été supprimé » avec « Annuler » — annuler réarme le message (l'ancien code restaurait une copie DOM d'un message déjà détruit côté serveur en affichant « Message restauré ») ; échec réseau → le message revient. Vérifié : `filechat.test.mjs` 25/25 (dont 6 tests #110 : icônes seules, clic image → `openChatImage`, aucun `DELETE` pendant la fenêtre d'annulation, notification OS), `validate-imports` 42 modules / 372 exports 0 erreur, `unit.test.mjs` 13/13 | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-09 | #193 | Fonctionnalité | `backend/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/style.css`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#193 — un post du chat s'affiche comme un document markdown** : le serveur rend le texte de chaque message avec le **pipeline des documents** (`backend/render.py::_render_markdown` : mistune — tableaux, listes de tâches, notes —, wikilinks, masquage des secrets #188, **sanitizer BUG-021**) et le renvoie dans un champ `html` (ajouté à `ChatMessageItem`, sinon `response_model` le filtrait). Le rendu est calculé **à la lecture** (`get_messages`) et à l'ajout (`_append`) — il n'est donc **jamais persisté** dans `data/chats/*.json` — et l'écho SSE part avec : les autres clients voient le rendu sans refresh. Côté front, `_fillBody()` injecte le HTML dans un `<div class="md-content">` puis appelle `safeHighlight()` sur chaque `pre code` (highlight.js + alias de langages, exactement comme le viewer ; mermaid non déclenché). Repli intact si `html` manque (écho optimiste → texte brut + URL cliquable) et un échec de rendu n'emporte jamais le message. CSS : `.file-chat-md` annule le `pre-wrap` hérité de la bulle (les retours à la ligne du HTML source produisaient des lignes vides) et compacte `.md-content` (`pre`/`table` en `overflow-x: auto`). Vérifié : `test_file_chat.py` 54→**61** (`TestMarkdownRendering` ×7 : html à l'ajout et à la lecture, **absent du JSON**, classe `language-xxx`, listes, `<script>` neutralisé, routes POST/GET, broadcast SSE), `filechat.test.mjs` 25→**27**, suite 1689 passed, ruff 0, mypy 0 (113 fichiers), validate-imports 42 modules / 372 exports | ✅ livré (en attente vérif utilisateur) | | 2026-10-09 | #193 | Fonctionnalité | `backend/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/style.css`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#193 — un post du chat s'affiche comme un document markdown** : le serveur rend le texte de chaque message avec le **pipeline des documents** (`backend/render.py::_render_markdown` : mistune — tableaux, listes de tâches, notes —, wikilinks, masquage des secrets #188, **sanitizer BUG-021**) et le renvoie dans un champ `html` (ajouté à `ChatMessageItem`, sinon `response_model` le filtrait). Le rendu est calculé **à la lecture** (`get_messages`) et à l'ajout (`_append`) — il n'est donc **jamais persisté** dans `data/chats/*.json` — et l'écho SSE part avec : les autres clients voient le rendu sans refresh. Côté front, `_fillBody()` injecte le HTML dans un `<div class="md-content">` puis appelle `safeHighlight()` sur chaque `pre code` (highlight.js + alias de langages, exactement comme le viewer ; mermaid non déclenché). Repli intact si `html` manque (écho optimiste → texte brut + URL cliquable) et un échec de rendu n'emporte jamais le message. CSS : `.file-chat-md` annule le `pre-wrap` hérité de la bulle (les retours à la ligne du HTML source produisaient des lignes vides) et compacte `.md-content` (`pre`/`table` en `overflow-x: auto`). Vérifié : `test_file_chat.py` 54→**61** (`TestMarkdownRendering` ×7 : html à l'ajout et à la lecture, **absent du JSON**, classe `language-xxx`, listes, `<script>` neutralisé, routes POST/GET, broadcast SSE), `filechat.test.mjs` 25→**27**, suite 1689 passed, ruff 0, mypy 0 (113 fichiers), validate-imports 42 modules / 372 exports | ✅ livré (en attente vérif utilisateur) |
@@ -343,6 +345,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-10-09 | #194 | Fonctionnalité (UI) | `frontend/js/sidebar.js`, `frontend/js/viewer.js`, `docker-compose.test-win.yml`, `.gitignore`, `tests/frontend/sidebar-filters.test.mjs`, `docs/ROADMAP.md`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 (incrément UI)** : les vaults `home-<user>` s'affichent **en premier** dans l'arborescence de la sidebar — tri stable appliqué à la source (`_homeFirst` dans `loadVaults()` et dans le soft-refresh, donc `state.allVaults`, l'arborescence et les sélecteurs restent cohérents) — et portent l'icône **maison** lucide (`data-lucide="home"` via `getVaultIcon`, même `safeCreateIcons`/`stroke: currentColor` que les autres icônes, thème-aware). Compose de test : `test_home:/vaults/Home` + `OBSIGATE_HOME_ROOT=/vaults/Home` (dossier `test_home/` ignoré par git). Vérifié : `sidebar-filters.test.mjs` 8→**10/10**, `validate-imports` 42 modules / 372 exports, `unit.test.mjs` 13/13, suites voisines (ai-sidebar, toolbar-order, search-facets, pane-manager, editor-inline) vertes, **live Playwright sur l'instance 2020** : ordre `home-admin, home-bruno, TestDir, TestVault` + `svg.lucide-home` rendu (placeholder `<i>` remplacé) | ✅ livré (en attente vérif utilisateur) | | 2026-10-09 | #194 | Fonctionnalité (UI) | `frontend/js/sidebar.js`, `frontend/js/viewer.js`, `docker-compose.test-win.yml`, `.gitignore`, `tests/frontend/sidebar-filters.test.mjs`, `docs/ROADMAP.md`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 (incrément UI)** : les vaults `home-<user>` s'affichent **en premier** dans l'arborescence de la sidebar — tri stable appliqué à la source (`_homeFirst` dans `loadVaults()` et dans le soft-refresh, donc `state.allVaults`, l'arborescence et les sélecteurs restent cohérents) — et portent l'icône **maison** lucide (`data-lucide="home"` via `getVaultIcon`, même `safeCreateIcons`/`stroke: currentColor` que les autres icônes, thème-aware). Compose de test : `test_home:/vaults/Home` + `OBSIGATE_HOME_ROOT=/vaults/Home` (dossier `test_home/` ignoré par git). Vérifié : `sidebar-filters.test.mjs` 8→**10/10**, `validate-imports` 42 modules / 372 exports, `unit.test.mjs` 13/13, suites voisines (ai-sidebar, toolbar-order, search-facets, pane-manager, editor-inline) vertes, **live Playwright sur l'instance 2020** : ordre `home-admin, home-bruno, TestDir, TestVault` + `svg.lucide-home` rendu (placeholder `<i>` remplacé) | ✅ livré (en attente vérif utilisateur) |
| 2026-10-09 | #194 | Correction (isolation) | `backend/auth/middleware.py`, `backend/services/recent.py`, `backend/services/search.py`, `backend/routers/search.py`, `backend/routers/config.py`, `backend/routers/conflicts.py`, `backend/routers/files_read.py`, `backend/routers/history.py`, `backend/push.py`, `backend/user_home.py`, `tests/test_user_home.py`, `tests/test_auth_api.py`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 — l'admin voyait le home de chaque utilisateur** (retour utilisateur) : `vaults: ["*"]` ouvrait **tous** les vaults, donc `home-<user>` compris. Correctif à la racine : `check_vault_access` exige un octroi **explicite** pour tout vault `home-*` (nouveau `is_home_vault()`), et les filtres `« * »` écrits en dur dans les routes ont été remplacés par cet appel unique — dashboard (`routers/config.py`), conflits, liens retour, favoris, abonnements push (`/api/search/replace` utilisait déjà `is_vault_allowed`). `/api/search` filtre désormais les bruts **avant** pagination via `search_vaults(is_allowed=…)` (sinon notes personnelles d'un autre compte dans les résultats). `_grant` n'écarte plus les comptes `« * »` → l'admin reçoit son propre `home-admin` (auto-réparé au démarrage). Vérifié : `test_user_home.py` 12→**14/14** (dont « * » sans homes + filtrage recherche), `test_auth_api.py` 2/2 (admin ne voit **plus** `home-alice`, alice voit le sien), suite **1705 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, **live Playwright instance 2020** : arborescence admin = `home-admin, TestDir, TestVault` (plus de `home-bruno`), `svg.lucide-home` rendu. Reste ouvert (fuite pré-existante, hors périmètre) : `/api/search/advanced`, `/api/tags`, `/api/tags/suggest`, `/api/tree-search`, `/api/suggest` ne filtrent pas par vault accessible | ✅ livré (en attente vérif utilisateur) |
--- ---
## 📜 Historique des bugs résolus ## 📜 Historique des bugs résolus
+23 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap # ObsiGate — Roadmap
> **Version :** 2.65.0 | **Dernière mise à jour :** 2026-10-10 > **Version :** 2.66.0 | **Dernière mise à jour :** 2026-10-10
> **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)**
@@ -30,6 +30,25 @@
## 🔵 En cours ## 🔵 En cours
### 196. Partage dirigé entre utilisateurs (document → utilisateur)
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-10 | **Statut :** ✅ livré le 2026-10-10
- **Fiche :** [features/directed-shares-196.md](./features/directed-shares-196.md)
- **Description :** un utilisateur peut partager un document à un ou plusieurs
autres comptes (pas un lien public) : les pages `/s/{token}` exigent alors
une session et n'acceptent que créateur + admin + destinataires (404 sinon,
pas de fuite d'existence). Vue « partagé avec moi » dans le dashboard,
destinataires listés dans le dialogue de partage, révocabilité réservée au
créateur/admin, `GET /api/shares` scopé (un non-admin ne voit que ses
partages créés/reçus — corrige une fuite préexistante). Rétrocompatible :
les partages publics existants restent anonymes.
- **Sous-tâches :**
- [x] Store `shared_with` + validation des destinataires à la création
- [x] Gate auth+appartenance sur `/s/{token}` (+`/pdf`, `/raw`) — 404 opaque
- [x] Scope de `GET /api/shares` ; révocation créateur/admin uniquement
- [x] UI : dialogue dirigé/public + destinataires, dashboard « partagé par X »
- [x] i18n FR/EN, tests `test_directed_shares.py` (9), fiche feature
### 194. Dossier personnel par utilisateur (`<racine>/Home/<user>`) ### 194. Dossier personnel par utilisateur (`<racine>/Home/<user>`)
- **Effort :** 1-2 jours | **Impact :** 🟡 | **Ouvert :** 2026-10-09 | **Statut :** 🔵 en cours - **Effort :** 1-2 jours | **Impact :** 🟡 | **Ouvert :** 2026-10-09 | **Statut :** 🔵 en cours
@@ -50,6 +69,9 @@
`docker-compose.test-win.yml` → `test_home:/vaults/Home`) `docker-compose.test-win.yml` → `test_home:/vaults/Home`)
- [x] UI : vaults `home-*` en **premier** dans l'arborescence + icône maison - [x] UI : vaults `home-*` en **premier** dans l'arborescence + icône maison
lucide (`getVaultIcon`), vérifié live sur l'instance 2020 lucide (`getVaultIcon`), vérifié live sur l'instance 2020
- [x] Isolation : « * » (admin) ne couvre **plus** les dossiers persos —
`check_vault_access` + filtres des routes + `/api/search` (avant
pagination) ; octroi du home aussi aux comptes « * »
- [x] Tests (création, persistance après rebuild, isolation 403 entre users) + docs - [x] Tests (création, persistance après rebuild, isolation 403 entre users) + docs
--- ---
+79
View File
@@ -0,0 +1,79 @@
# #196 — Partage dirigé entre utilisateurs
> **Statut :** livré | **Ouvert :** 2026-10-10 | **Type :** feature (sharing + auth)
## Problème
Le seul partage existant (#85) était un lien **public** : quiconque possède
l'URL lit le document, sans compte ni session. Il n'existait aucun moyen de
partager un document *à un utilisateur précis* de la plateforme — un
utilisateur ne pouvait ni adresser un document à un autre, ni consulter les
documents qu'on lui adresse, ni (côté créateur) retirer ce partage.
## Décision d'architecture
Deux formes étaient possibles :
1. **Réutiliser le partage par token existant, dirigé** (retenu) — le record
`data/shares.json` gagne `shared_with: [usernames]`. Si la liste est non
vide, les pages `/s/{token}` (et `/pdf`, `/raw`) exigent une session et
n'acceptent que créateur + admin + destinataires. Vide/absent : le
comportement public historique est inchangé (rétrocompatibilité totale,
zéro migration — les shares existants n'ont pas le champ).
2. Octroyer le vault entier au destinataire (`update_user {"vaults": …}`)
(écartée) — accès lecture-**écriture** au vault complet au lieu d'un
document en lecture seule ; c'est le modèle « collaborateur de vault »,
pas « partager un document ». Reste la bonne réponse pour ce besoin-là.
Points de sécurité délibérés :
- **404, jamais 403/401, sur la gate** — un visiteur sans session (ou un
utilisateur non destinataire) apprend le même « Share not found or
expired » qu'un token inventé : l'existence d'un token dirigé n'est pas
confirmable de l'extérieur.
- **`GET /api/shares` scopé** (correctif au passage) — un non-admin ne voit
que les partages qu'il a créés **ou reçus`** (avant : tout le monde voyait
tous les partages, fuite préexistante devenue intenable avec du user-to-user).
Un admin voit tout.
- **Révocation réservée au créateur/admin** — un destinataire ne peut pas
casser l'accès des autres.
- Les **destinataires sont validés** à la création (`get_user`) : pas de lien
mort vers un compte inexistant.
## Mise en place
| Élément | Où |
|---|---|
| Store : champ `shared_with`, param de `create_share` | `backend/share.py` |
| Gate `_gate_share()` + branchement `/s/{token}`, `/s/{token}/pdf`, `/s/{token}/raw` | `backend/routers/sharing.py` |
| Scope de `GET /api/shares` (non-admin = créés + reçus) | `backend/routers/sharing.py`, `backend/share.py::list_shares(user=…)` |
| Révocabilité créateur/admin uniquement | `backend/routers/sharing.py::api_share_revoke` |
| Validation des destinataires à la création | `backend/routers/sharing.py::api_share_create` |
| `ShareModel.shared_with` | `backend/schemas.py` |
| Dialogue : radio public/dirigé + champ destinataires (virgules) | `frontend/js/config.js::openShareDialog` |
| Destinataires affichés dans la vue « déjà partagé » | `frontend/js/config.js` |
| Dashboard : cartes reçues (« Partagé par X », ouverture `/s/`, pas de bouton révoquer) | `frontend/js/dashboard.js::DashboardSharedWidget` |
| i18n FR/EN (6 clés config.* + dashboard.shared_by) | `frontend/locales/{fr,en}.json` |
Ce qui est gratuit par construction : lecture seule (le destinataire ne
passe jamais par l'éditeur), expiration, compteur d'accès, suivi des
renommages (`update_shares_after_rename`), masquage des secrets
(`redact_file_content` déjà dans les 3 routes `/s/*`), isolation des
dossiers perso #194 (le destinataire ne voit jamais l'arbre du vault de
l'auteur, seulement le document rendu).
## Tests
`tests/test_directed_shares.py` (9) : création dirigée, destinataire inconnu
→ 400, anonyme → 404 / destinataire + créateur → 200, non-destinataire →
404, lien public toujours anonyme, scope de `/api/shares`, destinataire ne
peut pas révoquer (403), créateur révoque, la révocation coupe l'accès.
## Limites connues
- `shared_with` est une liste plate d'usernames — pas de groupes. Ajouter
des groupes quand le besoin sera réel.
- Pas de notification push : le destinataire découvre les partages reçus au
rechargement du dashboard (badge/rafraîchissement suffisent ; SSE existe
si un vrai push est demandé).
- La date d'« expiration » reste globale au partage (pas par destinataire).
+18 -3
View File
@@ -81,9 +81,24 @@ suppression du compte, racine `/NFS/OBSIDIAN_DOC/Home` montée `/vaults/Home`.
- **Test local** : `docker-compose.test-win.yml` monte - **Test local** : `docker-compose.test-win.yml` monte
`test_home:/vaults/Home` + `OBSIGATE_HOME_ROOT=/vaults/Home` `test_home:/vaults/Home` + `OBSIGATE_HOME_ROOT=/vaults/Home`
(`test_home/` est dans `.gitignore`). (`test_home/` est dans `.gitignore`).
- **Vérifié live** (instance 2020, Playwright) : ordre - **Vérifié live** (instance 2020, Playwright) : ordre `home-admin, TestDir,
`home-admin, home-bruno, TestDir, TestVault`, `svg.lucide-home` rendu, TestVault` (l'admin ne voit plus le home des autres), `svg.lucide-home`
placeholder `<i>` remplacé. rendu, placeholder `<i>` remplacé.
## Isolation — « * » ne couvre pas les dossiers persos
- **Racine** : `check_vault_access` (`backend/auth/middleware.py`) renvoie
``vault_name in vaults`` pour tout vault ``home-*``, quel que soit le ``*``.
Toutes les routes (89 usages) passent par cette fonction.
- **Filtres « * » en dur remplacés** par cet appel : dashboard
(`routers/config.py`), conflits, liens retour, favoris, abonnements push.
- **Recherche** : `services/search.search_vaults(is_allowed=…)` filtre les
bruts **avant** pagination — ``total`` et page restent justes.
- **Octroi** : `_grant` n'écarte plus les comptes ``*`` → l'admin reçoit son
propre ``home-admin`` (réparé au démarrage pour les comptes existants).
- **Reste ouvert** (fuite pré-existante, hors #194) : `/api/search/advanced`,
`/api/tags`, `/api/tags/suggest`, `/api/tree-search` et `/api/suggest` ne
filtrent pas par vault accessible — à traiter dans une tâche dédiée.
## Impact utilisateur ## Impact utilisateur
+3
View File
@@ -8,6 +8,9 @@
<link rel="icon" type="image/svg+xml" href="/static/icons/icon-72x72.svg" /> <link rel="icon" type="image/svg+xml" href="/static/icons/icon-72x72.svg" />
<!-- Lucide : requis par safeCreateIcons() (icônes des modales, ex. bouton fermer) -->
<script defer src="https://unpkg.com/[email protected]/dist/umd/lucide.min.js"></script>
<!-- Anti-FOUC : applique vite le mode du thème sauvegardé avant le CSS --> <!-- Anti-FOUC : applique vite le mode du thème sauvegardé avant le CSS -->
<script> <script>
(function () { (function () {
+19 -38
View File
@@ -394,54 +394,35 @@ function _wireAuditFilters() {
} }
} }
// ── Section nav (scrollspy) ─────────────────────────────────────────── // ── Section nav (une seule section visible à la fois) ──────────────────
/** /**
* Wire the sticky section nav: smooth-scroll on click + highlight the section * Wire the sticky section nav: clicking a link shows ONLY that section
* currently in view (IntersectionObserver). Falls back to default anchor jump * (the others are hidden). The first section (Stats) is shown on load.
* if the observer is unavailable.
*/ */
function _wireSectionNav() { function _wireSectionNav() {
const links = Array.from(document.querySelectorAll("#admin-subnav a")); const links = Array.from(document.querySelectorAll("#admin-subnav a"));
if (!links.length) return; if (!links.length) return;
const targets = links const sections = Array.from(document.querySelectorAll("#admin-main .admin-section"));
.map((a) => document.getElementById((a.getAttribute("href") || "#").slice(1)))
.filter(Boolean);
// Smooth scroll on click const show = (id) => {
links.forEach((a) => { sections.forEach((s) => { s.style.display = s.id === id ? "" : "none"; });
a.addEventListener("click", (e) => {
const href = a.getAttribute("href");
if (!href || !href.startsWith("#")) return;
const t = document.getElementById(href.slice(1));
if (!t) return;
e.preventDefault();
t.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
// Scrollspy: mark the section currently at the top of the viewport
let active = null;
const setActive = (id) => {
if (active === id) return;
active = id;
links.forEach((l) => l.classList.toggle("active", l.getAttribute("href") === "#" + id)); links.forEach((l) => l.classList.toggle("active", l.getAttribute("href") === "#" + id));
}; };
if ("IntersectionObserver" in window) { links.forEach((a) => {
const io = new IntersectionObserver( a.addEventListener("click", (e) => {
(entries) => { e.preventDefault();
entries.forEach((en) => { const href = a.getAttribute("href") || "";
if (en.isIntersecting) setActive(en.target.id); if (!href.startsWith("#")) return;
}); show(href.slice(1));
}, window.scrollTo({ top: 0, behavior: "smooth" });
{ rootMargin: "-64px 0px -70% 0px", threshold: 0 } });
); });
targets.forEach((t) => io.observe(t));
} else { // État initial : seule la section demandée par le hash (ou la première) est visible.
// Fallback: highlight the first section only (no scroll observation) const initial = (location.hash && sections.find((s) => "#" + s.id === location.hash)) || sections[0];
links[0] && links[0].classList.add("active"); if (initial) show(initial.id);
}
} }
// ── Auth gate ──────────────────────────────────────────────────────────── // ── Auth gate ────────────────────────────────────────────────────────────
+24 -2
View File
@@ -1656,10 +1656,14 @@ export async function openShareDialog(vault, path) {
const expiresInfo = existingShare.expires_at const expiresInfo = existingShare.expires_at
? `<p style="font-size:0.75rem;color:var(--text-muted)">Expire le ${new Date(existingShare.expires_at).toLocaleDateString("fr-FR")}</p>` ? `<p style="font-size:0.75rem;color:var(--text-muted)">Expire le ${new Date(existingShare.expires_at).toLocaleDateString("fr-FR")}</p>`
: '<p style="font-size:0.75rem;color:var(--text-muted)">Sans expiration</p>'; : '<p style="font-size:0.75rem;color:var(--text-muted)">Sans expiration</p>';
const recipientsInfo = (existingShare.shared_with && existingShare.shared_with.length)
? `<p style="font-size:0.75rem;color:var(--text-muted);margin-bottom:4px">${t('config.share_shared_with')} : ${escapeHtml(existingShare.shared_with.join(", "))}</p>`
: '';
div.innerHTML = ` div.innerHTML = `
<div class="share-dialog"> <div class="share-dialog">
<h3>📤 Document partagé</h3> <h3>📤 Document partagé</h3>
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:4px">${escapeHtml(vault)}/${escapeHtml(path)}</p> <p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:4px">${escapeHtml(vault)}/${escapeHtml(path)}</p>
${recipientsInfo}
${expiresInfo} ${expiresInfo}
<p style="font-size:0.75rem;color:var(--text-muted);margin-bottom:8px">${existingShare.access_count} vue(s)</p> <p style="font-size:0.75rem;color:var(--text-muted);margin-bottom:8px">${existingShare.access_count} vue(s)</p>
<input type="text" class="share-url-input" value="${url}" readonly> <input type="text" class="share-url-input" value="${url}" readonly>
@@ -1697,7 +1701,16 @@ export async function openShareDialog(vault, path) {
<div class="share-dialog"> <div class="share-dialog">
<h3>📤 Partager ce document</h3> <h3>📤 Partager ce document</h3>
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:12px">${escapeHtml(vault)}/${escapeHtml(path)}</p> <p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:12px">${escapeHtml(vault)}/${escapeHtml(path)}</p>
<p style="font-size:0.8rem;margin-bottom:8px">Ce lien sera accessible <strong>publiquement, sans authentification</strong>.</p> <label style="font-size:0.8rem;display:flex;align-items:center;gap:8px;margin-bottom:8px">
<input type="radio" name="share-visibility" value="directed" checked>
${t('config.share_directed')}
</label>
<input type="text" id="share-recipients" placeholder="${t('config.share_recipients_ph')}"
style="width:100%;padding:6px 8px;margin-bottom:8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem">
<label style="font-size:0.8rem;display:flex;align-items:center;gap:8px;margin-bottom:8px">
<input type="radio" name="share-visibility" value="public">
${t('config.share_public')}
</label>
<label style="font-size:0.8rem;display:flex;align-items:center;gap:8px;margin-bottom:12px"> <label style="font-size:0.8rem;display:flex;align-items:center;gap:8px;margin-bottom:12px">
Expiration : Expiration :
<select id="share-expiry" style="padding:4px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem"> <select id="share-expiry" style="padding:4px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem">
@@ -1716,10 +1729,19 @@ export async function openShareDialog(vault, path) {
div.querySelector(".share-create-btn").addEventListener("click", async () => { div.querySelector(".share-create-btn").addEventListener("click", async () => {
try { try {
const expiry = document.getElementById("share-expiry")?.value; const expiry = document.getElementById("share-expiry")?.value;
const visibility = div.querySelector('input[name="share-visibility"]:checked')?.value || "directed";
const recipientsRaw = document.getElementById("share-recipients")?.value || "";
const recipients = visibility === "directed"
? recipientsRaw.split(",").map(s => s.trim()).filter(Boolean)
: [];
if (visibility === "directed" && !recipients.length) {
showToast(t('config.share_need_recipient'), "error");
return;
}
const share = await api(`/api/share/${encodeURIComponent(vault)}`, { const share = await api(`/api/share/${encodeURIComponent(vault)}`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, expires_in_hours: expiry ? parseInt(expiry) : null }), body: JSON.stringify({ path, expires_in_hours: expiry ? parseInt(expiry) : null, shared_with: recipients }),
}); });
existingShare = share; existingShare = share;
renderContent(); renderContent();
+24 -11
View File
@@ -1,5 +1,5 @@
// dashboard.js — extracted from app.js (3414-3806) + DashboardBookmarkWidget (3810-3870) // dashboard.js — extracted from app.js (3414-3806) + DashboardBookmarkWidget (3810-3870)
import { api } from './auth.js'; import { api, AuthManager } from './auth.js';
import { state } from './state.js'; import { state } from './state.js';
import { escapeHtml, safeCreateIcons, getFileIcon } from './utils.js'; import { escapeHtml, safeCreateIcons, getFileIcon } from './utils.js';
import { icon, openFile } from './viewer.js'; import { icon, openFile } from './viewer.js';
@@ -60,24 +60,29 @@ const DashboardSharedWidget = {
const shares = await api("/api/shares"); const shares = await api("/api/shares");
if (!shares.length) { if (empty) empty.style.display = ""; grid.innerHTML = ""; return; } if (!shares.length) { if (empty) empty.style.display = ""; grid.innerHTML = ""; return; }
if (empty) empty.style.display = "none"; if (empty) empty.style.display = "none";
grid.innerHTML = shares.map(s => ` const me = (AuthManager.getUser() || {}).username;
<div class="shared-card" data-vault="${escapeHtml(s.vault)}" data-path="${escapeHtml(s.path)}"> grid.innerHTML = shares.map(s => {
const received = me && s.created_by && s.created_by !== me;
const actions = received
? `<button class="shared-open-btn">${t('dashboard.open_btn')}</button>`
: `<button class="shared-copy-btn" data-url="${window.location.origin}/s/${s.token}">${t('dashboard.copy_btn')}</button>
<button class="shared-open-btn">${t('dashboard.open_btn')}</button>
<button class="shared-revoke-btn" data-id="${s.id}">🗑</button>`;
return `
<div class="shared-card" data-vault="${escapeHtml(s.vault)}" data-path="${escapeHtml(s.path)}" data-token="${s.token}" data-received="${received ? "1" : ""}">
<div class="shared-card-header"> <div class="shared-card-header">
<i data-lucide="file-text" style="width:14px;height:14px"></i> <i data-lucide="file-text" style="width:14px;height:14px"></i>
<span class="shared-card-title">${escapeHtml(s.path.split("/").pop().replace(/\.md$/i, ""))}</span> <span class="shared-card-title">${escapeHtml(s.path.split("/").pop().replace(/\\.md$/i, ""))}</span>
<span class="shared-card-vault">${escapeHtml(s.vault)}</span> <span class="shared-card-vault">${escapeHtml(s.vault)}</span>
</div> </div>
<div class="shared-card-meta"> <div class="shared-card-meta">
${received ? `<span>${t('dashboard.shared_by', { user: s.created_by })}</span>` : ""}
<span>${s.access_count || 0} ${t('dashboard.views', {count: s.access_count || 0})}</span> <span>${s.access_count || 0} ${t('dashboard.views', {count: s.access_count || 0})}</span>
${s.expires_at ? `<span>${t('dashboard.expires', {date: new Date(s.expires_at).toLocaleDateString(getLocale() === 'fr' ? 'fr-FR' : 'en-US')})}</span>` : ""} ${s.expires_at ? `<span>${t('dashboard.expires', {date: new Date(s.expires_at).toLocaleDateString(getLocale() === 'fr' ? 'fr-FR' : 'en-US')})}</span>` : ""}
</div> </div>
<div class="shared-card-actions"> <div class="shared-card-actions">${actions}</div>
<button class="shared-copy-btn" data-url="${window.location.origin}/s/${s.token}">${t('dashboard.copy_btn')}</button> </div>`;
<button class="shared-open-btn">${t('dashboard.open_btn')}</button> }).join("");
<button class="shared-revoke-btn" data-id="${s.id}">🗑</button>
</div>
</div>
`).join("");
lucide.createIcons(); lucide.createIcons();
grid.querySelectorAll(".shared-copy-btn").forEach(b => b.addEventListener("click", async (e) => { grid.querySelectorAll(".shared-copy-btn").forEach(b => b.addEventListener("click", async (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -88,6 +93,10 @@ const DashboardSharedWidget = {
grid.querySelectorAll(".shared-open-btn").forEach(b => b.addEventListener("click", (e) => { grid.querySelectorAll(".shared-open-btn").forEach(b => b.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
const card = b.closest(".shared-card"); const card = b.closest(".shared-card");
if (card && card.dataset.received === "1" && card.dataset.token) {
window.open(`/s/${card.dataset.token}`, "_blank");
return;
}
if (card) TabManager.openPreview(card.dataset.vault, card.dataset.path); if (card) TabManager.openPreview(card.dataset.vault, card.dataset.path);
})); }));
grid.querySelectorAll(".shared-revoke-btn").forEach(b => b.addEventListener("click", async (e) => { grid.querySelectorAll(".shared-revoke-btn").forEach(b => b.addEventListener("click", async (e) => {
@@ -97,6 +106,10 @@ const DashboardSharedWidget = {
this.load(); this.load();
})); }));
grid.querySelectorAll(".shared-card").forEach(card => card.addEventListener("click", () => { grid.querySelectorAll(".shared-card").forEach(card => card.addEventListener("click", () => {
if (card.dataset.received === "1" && card.dataset.token) {
window.open(`/s/${card.dataset.token}`, "_blank");
return;
}
TabManager.openPreview(card.dataset.vault, card.dataset.path); TabManager.openPreview(card.dataset.vault, card.dataset.path);
})); }));
} catch (err) { if (empty) empty.style.display = ""; } } catch (err) { if (empty) empty.style.display = ""; }
+6
View File
@@ -604,6 +604,11 @@
"config.server_config": "Server config", "config.server_config": "Server config",
"config.settings_saved": "✓ Settings saved", "config.settings_saved": "✓ Settings saved",
"config.share_revoked": "Share revoked", "config.share_revoked": "Share revoked",
"config.share_directed": "Share with users (authentication required)",
"config.share_public": "Public link (no authentication)",
"config.share_recipients_ph": "Recipients (usernames, comma-separated)",
"config.share_need_recipient": "Provide at least one recipient",
"config.share_shared_with": "Shared with",
"config.shares": "Public shares", "config.shares": "Public shares",
"config.shares_desc": "Manage public share links for your documents.", "config.shares_desc": "Manage public share links for your documents.",
"config.shares_empty": "No active shares", "config.shares_empty": "No active shares",
@@ -701,6 +706,7 @@
"dashboard.recent": "Recent", "dashboard.recent": "Recent",
"dashboard.recent_hint": "Open a file to see it appear here", "dashboard.recent_hint": "Open a file to see it appear here",
"dashboard.shared_hint": "Share a document to see it appear here", "dashboard.shared_hint": "Share a document to see it appear here",
"dashboard.shared_by": "Shared by {user}",
"dashboard.shares": "Shares", "dashboard.shares": "Shares",
"dashboard.show": "Show", "dashboard.show": "Show",
"dashboard.size_bytes": " B", "dashboard.size_bytes": " B",
+6
View File
@@ -604,6 +604,11 @@
"config.server_config": "Configuration serveur", "config.server_config": "Configuration serveur",
"config.settings_saved": "✓ Paramètres sauvegardés", "config.settings_saved": "✓ Paramètres sauvegardés",
"config.share_revoked": "Partage révoqué", "config.share_revoked": "Partage révoqué",
"config.share_directed": "Partager avec des utilisateurs (authentification requise)",
"config.share_public": "Lien public (sans authentification)",
"config.share_recipients_ph": "Destinataires (noms d'utilisateur, séparés par des virgules)",
"config.share_need_recipient": "Indiquez au moins un destinataire",
"config.share_shared_with": "Partagé avec",
"config.shares": "Partages publics", "config.shares": "Partages publics",
"config.shares_desc": "Gérez les liens de partage public de vos documents.", "config.shares_desc": "Gérez les liens de partage public de vos documents.",
"config.shares_empty": "Aucun partage actif", "config.shares_empty": "Aucun partage actif",
@@ -701,6 +706,7 @@
"dashboard.recent": "Récents", "dashboard.recent": "Récents",
"dashboard.recent_hint": "Ouvrez un fichier pour le voir apparaître ici", "dashboard.recent_hint": "Ouvrez un fichier pour le voir apparaître ici",
"dashboard.shared_hint": "Partagez un document pour le voir apparaître ici", "dashboard.shared_hint": "Partagez un document pour le voir apparaître ici",
"dashboard.shared_by": "Partagé par {user}",
"dashboard.shares": "Partages", "dashboard.shares": "Partages",
"dashboard.show": "Afficher", "dashboard.show": "Afficher",
"dashboard.size_bytes": " o", "dashboard.size_bytes": " o",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "obsigate", "name": "obsigate",
"version": "2.65.0", "version": "2.66.0",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "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": {
+5 -2
View File
@@ -514,8 +514,11 @@ class TestUserHome:
assert (home / "alice").is_dir() assert (home / "alice").is_dir()
names = [v["name"] for v in auth_client.get("/api/vaults", headers=admin).json()] names = [v["name"] for v in auth_client.get("/api/vaults", headers=admin).json()]
assert "home-alice" in names # #194 : « * » n'inclut pas les dossiers persos — l'admin ne voit pas
# L'octroi atterrit bien dans le user → visible dans son JWT/liste de vaults # le home d'alice (il ne verrait sinon celui de chaque utilisateur).
assert "home-alice" not in names
assert "TestVault" in names
# L'octroi atterrit bien dans le user
assert "home-alice" in resp.json()["vaults"] assert "home-alice" in resp.json()["vaults"]
# Isolation : alice ne voit QUE son propre dossier # Isolation : alice ne voit QUE son propre dossier
+142
View File
@@ -0,0 +1,142 @@
# #196 — Partage dirigé entre utilisateurs : gate des pages /s/*, scope de
# /api/shares, révocabilité, validation des destinataires.
# Fixture admin_client (conftest.py) : users admin (role admin, vaults ["*"])
# et normaluser (role user, vaults ["TestVault"]), auth activée.
import os
from pathlib import Path
import pytest
def _logged_client(username, password):
"""Fresh TestClient with a session (cookie) — /s/* reads the access_token cookie."""
from fastapi.testclient import TestClient
from backend.main import app
c = TestClient(app)
resp = c.post("/api/auth/login", json={"username": username, "password": password})
assert resp.status_code == 200, resp.text
return c
def _ensure_shared_file():
# admin_client chdir(tmp_path) mais le vault indexé est VAULT_1_PATH
# (absolu, résolu avant le chdir) → écrire là, pas dans cwd.
vault = Path(os.environ["VAULT_1_PATH"])
vault.mkdir(parents=True, exist_ok=True)
target = vault / "share_directed.md"
target.write_text("# Partagé\n\ncontenu dirigé\n", encoding="utf-8")
return "share_directed.md"
@pytest.fixture
def sessions(admin_client):
"""admin (creator) and normaluser (recipient) clients, logged in via cookie."""
return _logged_client("admin", "chab30"), _logged_client("normaluser", "normal123")
class TestDirectedShareGate:
def test_create_directed_share(self, sessions):
admin_client, _ = sessions
path = _ensure_shared_file()
resp = admin_client.post("/api/share/TestVault", json={
"path": path, "shared_with": ["normaluser"],
})
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["shared_with"] == ["normaluser"]
def test_create_directed_unknown_recipient_rejected(self, sessions):
admin_client, _ = sessions
path = _ensure_shared_file()
resp = admin_client.post("/api/share/TestVault", json={
"path": path, "shared_with": ["ghost"],
})
assert resp.status_code == 400
def test_recipient_can_view_but_anon_cannot(self, sessions):
admin_client, user_client = sessions
path = _ensure_shared_file()
resp = admin_client.post("/api/share/TestVault", json={
"path": path, "shared_with": ["normaluser"],
})
token = resp.json()["token"]
# Anonymous → 404 (pas 401/403 : on ne fuite pas l'existence du token)
from fastapi.testclient import TestClient
from backend.main import app
anon = TestClient(app)
assert anon.get(f"/s/{token}").status_code == 404
# Recipient (cookie de session via Bearer) → 200
assert user_client.get(f"/s/{token}").status_code == 200
# Creator → 200
assert admin_client.get(f"/s/{token}").status_code == 200
def test_other_user_cannot_view(self, sessions):
admin_client, _ = sessions
path = _ensure_shared_file()
resp = admin_client.post("/api/share/TestVault", json={
"path": path, "shared_with": ["admin"], # dirigé, mais pas normaluser
})
token = resp.json()["token"]
other = _logged_client("normaluser", "normal123")
assert other.get(f"/s/{token}").status_code == 404
def test_public_share_still_anonymous(self, sessions):
admin_client, _ = sessions
path = _ensure_shared_file()
resp = admin_client.post("/api/share/TestVault", json={"path": path})
token = resp.json()["token"]
from fastapi.testclient import TestClient
from backend.main import app
anon = TestClient(app)
assert anon.get(f"/s/{token}").status_code == 200
def test_shares_list_scoped_for_user(self, sessions):
admin_client, user_client = sessions
path = _ensure_shared_file()
admin_client.post("/api/share/TestVault", json={
"path": path, "shared_with": ["normaluser"],
})
# normaluser (non-admin) voit le partage reçu
resp = user_client.get("/api/shares")
assert resp.status_code == 200
tokens = [s["token"] for s in resp.json()]
assert tokens, "destinataire doit voir le partage reçu"
# Un admin voit tout
resp = admin_client.get("/api/shares")
assert resp.status_code == 200
assert len(resp.json()) >= 1
def test_recipient_cannot_revoke(self, sessions):
admin_client, user_client = sessions
path = _ensure_shared_file()
resp = admin_client.post("/api/share/TestVault", json={
"path": path, "shared_with": ["normaluser"],
})
sid = resp.json()["id"]
resp = user_client.delete(f"/api/share/{sid}")
assert resp.status_code == 403
def test_creator_can_revoke(self, sessions):
admin_client, _ = sessions
path = _ensure_shared_file()
resp = admin_client.post("/api/share/TestVault", json={
"path": path, "shared_with": ["normaluser"],
})
sid = resp.json()["id"]
assert admin_client.delete(f"/api/share/{sid}").status_code == 200
def test_revoke_cuts_recipient_access(self, sessions):
admin_client, user_client = sessions
path = _ensure_shared_file()
resp = admin_client.post("/api/share/TestVault", json={
"path": path, "shared_with": ["normaluser"],
})
body = resp.json()
assert user_client.get(f"/s/{body['token']}").status_code == 200
admin_client.delete(f"/api/share/{body['id']}")
assert user_client.get(f"/s/{body['token']}").status_code == 404
+42
View File
@@ -191,6 +191,48 @@ class TestIsolation:
assert check_vault_access("home-alice", get_user("bob")) is False assert check_vault_access("home-alice", get_user("bob")) is False
assert check_vault_access("home-bob", get_user("alice")) is False assert check_vault_access("home-bob", get_user("alice")) is False
def test_wildcard_admin_does_not_cover_homes(self, home_env):
"""#194 : « * » n'inclut JAMAIS un dossier perso, même pour l'admin."""
from backend.auth.middleware import check_vault_access
from backend.auth.user_store import create_user, get_user
from backend.user_home import ensure_user_home
create_user("root", "Motdepasse1!", role="admin", vaults=["*"])
_create_user("alice")
_run(ensure_user_home("alice"))
admin = get_user("root")
assert check_vault_access("home-alice", admin) is False # pas le sien
assert check_vault_access("TestVault", admin) is True # « * » intact
# L'octroi explicite suffit (y compris pour l'admin, cf. _grant)
_run(ensure_user_home("root"))
assert check_vault_access("home-root", get_user("root")) is True
class TestSearchFiltering:
def test_search_service_filters_before_pagination(self, monkeypatch):
"""#194 : /api/search ne doit pas renvoyer les notes d'un autre user."""
import backend.search as search_mod
from backend.services import search as search_service
hits = [
{"vault": "home-bruno", "path": "secret.md"},
{"vault": "TestVault", "path": "note.md"},
{"vault": "home-bruno", "path": "autre.md"},
]
monkeypatch.setattr(
search_mod, "search",
lambda q, vault_filter="all", tag_filter=None: list(hits),
)
page = search_service.search_vaults("x", limit=50, is_allowed=lambda v: v == "TestVault")
assert page["total"] == 1, "total = résultats filtrés"
assert [r["vault"] for r in page["results"]] == ["TestVault"]
# Sans filtre (tool layer, qui filtre lui-même) : inchangé.
assert search_service.search_vaults("x", limit=50)["total"] == 3
class TestEnsureAll: class TestEnsureAll:
def test_repairs_every_existing_user(self, home_env): def test_repairs_every_existing_user(self, home_env):