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
This commit is contained in:
2026-10-10 20:14:29 -04:00
parent fd192df036
commit 49c715199d
18 changed files with 410 additions and 43 deletions
+51 -14
View File
@@ -24,7 +24,8 @@ import frontmatter
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
from fastapi.responses import FileResponse, HTMLResponse, Response
from backend.auth.middleware import check_vault_access, require_auth
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.render import _render_markdown
from backend.schemas import ShareModel, StatusResponse
@@ -53,6 +54,27 @@ except Exception: # pragma: no cover - WeasyPrint/GTK missing
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)
async def api_share_create(
vault_name: str,
@@ -68,7 +90,14 @@ async def api_share_create(
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
path = body.get("path", "")
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']}"
# 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])
async def api_shares_list(vault: str | None = Query(None), current_user=Depends(require_auth)):
"""List all shares (optionally filtered by vault)."""
shares = list_shares(vault)
"""List shares (optionally filtered by 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:
s["url"] = f"/s/{s['token']}"
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)
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):
raise HTTPException(404, "Share not found")
return {"status": "revoked"}
@@ -113,13 +153,12 @@ async def api_share_revoke(share_id: str, current_user=Depends(require_auth)):
response_class=Response,
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."""
if generate_pdf is None:
raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)")
share = get_share_by_token(token)
if not share:
raise HTTPException(404, "Share not found or expired")
share = _gate_share(share, current_user)
vault_data = get_vault_data(share["vault"])
if not vault_data:
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)
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."""
share = get_share_by_token(token)
if not share:
raise HTTPException(404, "Share not found or expired")
share = _gate_share(share, current_user)
vault_data = get_vault_data(share["vault"])
if not vault_data:
raise HTTPException(404, "Vault not found")
@@ -164,13 +202,12 @@ async def public_share_raw(token: str):
@router.get("/s/{token}", response_class=HTMLResponse)
async def public_share_view(request: Request, token: str):
"""Public share view — no authentication required."""
async def public_share_view(request: Request, token: str, current_user=Depends(get_current_user)):
"""Public share view — no authentication required (unless directed #196)."""
from backend.csp import inject_csp_nonce
share = get_share_by_token(token)
if not share:
raise HTTPException(404, "Share not found or expired")
share = _gate_share(share, current_user)
vault_data = get_vault_data(share["vault"])
if not vault_data:
raise HTTPException(404, "Vault not found")
+1
View File
@@ -1056,6 +1056,7 @@ class ShareModel(BaseModel):
expires_at: str | None = None
access_count: int = 0
last_accessed: str | None = None
shared_with: list[str] = Field(default_factory=list, description="#196 destinataires (partage dirigé)")
class ConflictEntry(BaseModel):
+17 -3
View File
@@ -44,8 +44,14 @@ def create_share(
path: str,
created_by: str,
expires_in_hours: int | None = None,
shared_with: list[str] | None = None,
) -> 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:
data = _read()
token = secrets.token_hex(32) # 64-char hex token
@@ -64,6 +70,7 @@ def create_share(
"expires_at": expires_at,
"access_count": 0,
"last_accessed": None,
"shared_with": list(shared_with) if shared_with else [],
}
data["shares"][token] = share
_write(data)
@@ -107,10 +114,17 @@ def revoke_share(share_id: str) -> bool:
return False
def list_shares(vault_filter: str | None = None) -> list:
"""List all shares, optionally filtered by vault."""
def list_shares(vault_filter: str | None = None, user: str | None = None) -> list:
"""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()
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:
shares = [s for s in shares if s["vault"] == vault_filter]
# Most recent first