"""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, require_auth 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"]) @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", "") expires = body.get("expires_in_hours") share = create_share(vault_name, path, current_user["username"], expires) 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 all shares (optionally filtered by vault).""" shares = list_shares(vault) 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)): 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): """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") 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):
"""Download the raw (original) shared document."""
share = get_share_by_token(token)
if not share:
raise HTTPException(404, "Share not found or expired")
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):
"""Public share view — no authentication required."""
from backend.csp import inject_csp_nonce
share = get_share_by_token(token)
if not share:
raise HTTPException(404, "Share not found or expired")
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'