"""Public share endpoints (ROADMAP #85, tranche 3). Handlers déplacés depuis :mod:`backend.main` sans changement de comportement : mêmes chemins (``/api/share/*``, ``/api/shares``, ``/s/{token}*``), mêmes modèles de réponse, mêmes dépendances d'authentification (les pages ``/s/*`` restent publiques). La logique métier vit déjà dans :mod:`backend.share`. Adaptations strictement équivalentes (pas de changement de comportement) : - ``_resolve_safe_path`` / ``_backup_file`` de ``main`` n'étaient que des wrappers directs : appelés ici via :mod:`backend.services.paths` et :mod:`backend.services.backups` (mêmes signatures, mêmes exceptions ``ServiceError`` toujours mappées par le handler global de ``main``). - ``_render_markdown`` vient de :mod:`backend.render` (#85 T9, sans cycle d'import). """ import html as html_mod import json as _json import logging from pathlib import Path 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, 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 from backend.secret_redactor import redact_file_content from backend.services.backups import create_backup from backend.services.paths import resolve_safe_path from backend.share import ( create_share, get_share_by_token, list_shares, record_access, revoke_share, ) logger = logging.getLogger("obsigate") # Lazy import: WeasyPrint PDF export (requires GTK, may not be available everywhere) try: from backend.pdf_export import build_pdf_html, generate_pdf except Exception: # pragma: no cover - WeasyPrint/GTK missing generate_pdf = None # type: ignore[assignment] build_pdf_html = None # type: ignore[assignment] logging.getLogger("obsigate").warning("PDF export unavailable (WeasyPrint/GTK not found)") 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, body: dict = Body(...), current_user=Depends(require_auth), ): """Create a public share link for a document. Also sets ``publish: true`` in the file's YAML frontmatter so the frontend can visually indicate the file is publicly shared. """ if not check_vault_access(vault_name, current_user): raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'") path = body.get("path") or "" if not path: raise HTTPException(400, "Chemin de fichier requis") expires = body.get("expires_in_hours") # #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 vault_data = get_vault_data(vault_name) if vault_data: file_path = resolve_safe_path(Path(vault_data["path"]), path) if file_path.exists() and file_path.suffix == ".md": try: raw = file_path.read_text(encoding="utf-8", errors="replace") post = frontmatter.loads(raw) if not post.metadata.get("publish"): post.metadata["publish"] = True new_raw = frontmatter.dumps(post) create_backup(file_path, vault_name, path) file_path.write_text(new_raw, encoding="utf-8") await update_single_file(vault_name, str(file_path)) logger.info(f"Set publish:true on {vault_name}/{path}") except Exception as e: logger.warning(f"Failed to set publish metadata on {vault_name}/{path}: {e}") return share @router.get("/api/shares", response_model=list[ShareModel]) async def api_shares_list(vault: str | None = Query(None), current_user=Depends(require_auth)): """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 @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"} @router.get( "/s/{token}/pdf", response_class=Response, responses={200: {"content": {"application/pdf": {}}, "description": "Shared document as PDF"}}, ) 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) share = _gate_share(share, current_user) vault_data = get_vault_data(share["vault"]) if not vault_data: raise HTTPException(404, "Vault not found") vault_root = Path(vault_data["path"]) file_path = resolve_safe_path(vault_root, share["path"]) if not file_path.exists(): raise HTTPException(404, "File not found") try: raw = file_path.read_text(encoding="utf-8", errors="replace") except Exception: raise HTTPException(500, "Cannot read file") record_access(token) raw = redact_file_content(raw, str(file_path)) post = parse_markdown_file(raw) ext = file_path.suffix.lower() if ext == ".md": html = _render_markdown(post.content, share["vault"], file_path) else: html = f'
{html_mod.escape(raw)}'
title = post.metadata.get("title", file_path.stem)
pdf_html = build_pdf_html(html, str(title))
pdf_bytes = generate_pdf(pdf_html, str(title))
safe_name = "".join(c for c in str(title) if c.isascii() and (c.isalnum() or c in " _-.")).strip() or "document"
return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{safe_name}.pdf"'})
@router.get("/s/{token}/raw", response_class=FileResponse)
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)
share = _gate_share(share, current_user)
vault_data = get_vault_data(share["vault"])
if not vault_data:
raise HTTPException(404, "Vault not found")
vault_root = Path(vault_data["path"])
file_path = resolve_safe_path(vault_root, share["path"])
if not file_path.exists():
raise HTTPException(404, "File not found")
record_access(token)
return FileResponse(path=str(file_path), filename=file_path.name, media_type="application/octet-stream")
@router.get("/s/{token}", response_class=HTMLResponse)
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)
share = _gate_share(share, current_user)
vault_data = get_vault_data(share["vault"])
if not vault_data:
raise HTTPException(404, "Vault not found")
vault_root = Path(vault_data["path"])
file_path = resolve_safe_path(vault_root, share["path"])
if not file_path.exists():
raise HTTPException(404, "File not found")
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
except Exception:
raise HTTPException(500, "Cannot read file")
record_access(token)
raw = redact_file_content(raw, str(file_path))
post = parse_markdown_file(raw)
ext = file_path.suffix.lower()
if ext == ".md":
html = _render_markdown(post.content, share["vault"], file_path)
else:
escaped = html_mod.escape(raw)
html = f'{escaped}'
title = post.metadata.get("title", file_path.stem)
# Escape everything user-controlled before embedding in HTML/JS (BUG-022).
title_esc = html_mod.escape(str(title))
# Neutralise ```` in the JS string literal too.
title_download_js = (
_json.dumps(f"{title}.md")
.replace("<", "\\u003c")
.replace(">", "\\u003e")
.replace("&", "\\u0026")
)
# JSON-escape raw content for embedding in HTML, and neutralise ````.
raw_json = (
_json.dumps(raw)
.replace("<", "\\u003c")
.replace(">", "\\u003e")
.replace("&", "\\u0026")
)
fm_html = ""
if post.metadata:
fm_items = []
skip_keys = {"title", "titre"}
for k, v in post.metadata.items():
if k in skip_keys:
continue
if isinstance(v, list):
v = ", ".join(str(x) for x in v)
elif isinstance(v, bool):
v = "✓" if v else "✗"
elif v is None:
v = "—"
fm_items.append(
f'