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")