"""Media, PDF, export & vault-settings endpoints (ROADMAP #85, tranche 6c). Handlers déplacés depuis :mod:`backend.main` sans changement de comportement : mêmes chemins (``/api/file/*/pdf*``, ``/api/export/*``, ``/api/guide/download``, ``/api/image/*``, ``/api/media*``, ``/api/attachments/*``, ``/api/vaults/*/settings``, ``/api/vault/*/files``, ``/api/vaults/settings/all``), mêmes modèles de réponse, mêmes dépendances d'authentification. Adaptations strictement équivalentes : - ``_resolve_safe_path`` → :mod:`backend.services.paths` (pass-through). - ``_render_markdown`` vient de :mod:`backend.render` (#85 T9, sans cycle d'import). - ``_resolve_export_target`` / ``_safe_export_name`` (export uniquement) sont définis ici ; ``stream_file_with_range`` vit dans :mod:`backend.routers.helpers` (partagé). """ import asyncio import logging from pathlib import Path from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request from fastapi.responses import FileResponse, Response from backend.attachment_indexer import get_attachment_stats, rescan_vault_attachments from backend.auth.middleware import check_vault_access, require_admin, require_auth from backend.export import ExportError, export_epub, export_html, export_md_bundle from backend.history import record_open from backend.indexer import get_vault_data, index, parse_markdown_file from backend.media_thumbs import generate_thumbnail, is_decodable from backend.media_types import is_audio, is_image, is_video, media_mime_type from backend.render import _render_markdown from backend.routers.helpers import media_max_inline_bytes, stream_file_with_range from backend.schemas import ( AllVaultSettingsResponse, AttachmentRescanResponse, AttachmentStatsResponse, PdfInfoResponse, VaultFilesResponse, VaultSettingsResponse, ) from backend.secret_redactor import redact_file_content from backend.services.paths import resolve_safe_path from backend.services.vaults import list_all_files from backend.vault_settings import get_vault_setting, update_vault_setting 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() # pas de tags : assignation par chemin via openapi_docs.tag_for_path (comme avant) def _resolve_export_target(vault_name: str, path: str, current_user: dict) -> tuple[Path, Path]: """Resolve a vault + relative path into (vault_root, absolute file path). Enforces auth (vault access) and path traversal protection. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") vault_data = get_vault_data(vault_name) if not vault_data: raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") vault_root = Path(vault_data["path"]) target = resolve_safe_path(vault_root, path) return vault_root, target def _safe_export_name(name: str) -> str: """ASCII-safe, filename-safe download name (falls back to 'document').""" cleaned = "".join(c for c in name if c.isascii() and (c.isalnum() or c in " _-.")).strip() return cleaned or "document" @router.get( "/api/file/{vault_name}/pdf", response_class=Response, responses={200: {"content": {"application/pdf": {}}, "description": "PDF document"}}, ) async def api_file_pdf(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)): """Download a markdown file as PDF.""" if generate_pdf is None: raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)") if not check_vault_access(vault_name, current_user): raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'") vault_data = get_vault_data(vault_name) if not vault_data: raise HTTPException(404, f"Vault '{vault_name}' not found") vault_root = Path(vault_data["path"]) file_path = resolve_safe_path(vault_root, path) if not file_path.exists(): raise HTTPException(404, f"File not found: {path}") try: raw = file_path.read_text(encoding="utf-8", errors="replace") except Exception: raise HTTPException(500, "Cannot read file") record_open(current_user.get("username"), vault_name, path) raw = redact_file_content(raw, str(file_path)) post = parse_markdown_file(raw) html = _render_markdown(post.content, vault_name, file_path) 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( "/api/export/html", response_class=Response, responses={200: {"content": {"text/html": {}}, "description": "Standalone HTML file"}}, ) async def api_export_html( vault: str = Query(..., description="Vault name"), path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth), ): """Export a markdown note as a standalone HTML file.""" try: vault_root, target = _resolve_export_target(vault, path, current_user) html_bytes = export_html(vault_root, target) except ExportError as e: raise HTTPException(status_code=400, detail=str(e)) record_open(current_user.get("username"), vault, path) safe_name = _safe_export_name(target.stem) return Response( content=html_bytes, media_type="text/html; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{safe_name}.html"'}, ) @router.get( "/api/export/md-bundle", response_class=Response, responses={200: {"content": {"application/zip": {}}, "description": "Markdown ZIP bundle"}}, ) async def api_export_md_bundle( vault: str = Query(..., description="Vault name"), path: str = Query(..., description="Relative path to directory or file"), current_user=Depends(require_auth), ): """Export a directory (or single file) of markdown as a ZIP bundle.""" try: vault_root, target = _resolve_export_target(vault, path, current_user) zip_bytes = export_md_bundle(vault_root, target) except ExportError as e: raise HTTPException(status_code=400, detail=str(e)) safe_name = _safe_export_name(target.name) return Response( content=zip_bytes, media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{safe_name}.zip"'}, ) @router.get( "/api/export/epub", response_class=Response, responses={200: {"content": {"application/epub+zip": {}}, "description": "ePub document"}}, ) async def api_export_epub( vault: str = Query(..., description="Vault name"), path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth), ): """Export a markdown note as an ePub document.""" try: vault_root, target = _resolve_export_target(vault, path, current_user) epub_bytes = export_epub(vault_root, target) except ExportError as e: raise HTTPException(status_code=400, detail=str(e)) record_open(current_user.get("username"), vault, path) safe_name = _safe_export_name(target.stem) return Response( content=epub_bytes, media_type="application/epub+zip", headers={"Content-Disposition": f'attachment; filename="{safe_name}.epub"'}, ) @router.get( "/api/guide/download", response_class=Response, responses={200: {"content": {"application/pdf": {}, "text/markdown": {}}}}, ) async def api_guide_download( format: str = Query("md", description="Download format: 'md' or 'pdf'"), lang: str = Query("fr", description="Guide language: 'fr' or 'en'"), current_user=Depends(require_auth), ): """Download the in-app user guide as Markdown or PDF (#105). The document is generated from the live help modal in index.html resolved through the locale files, so it always mirrors exactly what the user sees. """ from backend.guide_export import get_guide_document if format not in ("md", "pdf"): raise HTTPException(status_code=400, detail="format doit être 'md' ou 'pdf'") try: payload, media, fname = get_guide_document(format, lang) except Exception as e: # weasyprint/reportlab unavailable logger.exception("guide export failed") raise HTTPException(status_code=500, detail=f"Export impossible: {e}") from e return Response( content=payload, media_type=media, headers={"Content-Disposition": f'attachment; filename="{fname}"'}, ) @router.get("/api/file/{vault_name}/pdf/stream", response_class=FileResponse) async def api_pdf_stream( request: Request, vault_name: str, path: str = Query(...), current_user=Depends(require_auth), ): """Stream a PDF file with Content-Type: application/pdf for inline browser viewing. Supports HTTP Range requests (206 Partial Content) so browsers can progressively render large PDFs in the native viewer. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") vault_data = get_vault_data(vault_name) if not vault_data: raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") vault_root = Path(vault_data["path"]) file_path = resolve_safe_path(vault_root, path) if not file_path.exists() or not file_path.is_file(): raise HTTPException(status_code=404, detail=f"File not found: {path}") if file_path.suffix.lower() != ".pdf": raise HTTPException(status_code=400, detail="Not a PDF file") return stream_file_with_range(file_path, request, "application/pdf") @router.get("/api/file/{vault_name}/pdf/info", response_model=PdfInfoResponse) async def api_pdf_info( vault_name: str, path: str = Query(..., description="Relative path to PDF file"), current_user=Depends(require_auth), ): """Return PDF metadata (pages, title, author, size) without the document content. Lets the UI display file info before loading a heavy PDF into the viewer. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") vault_data = get_vault_data(vault_name) if not vault_data: raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") vault_root = Path(vault_data["path"]) file_path = resolve_safe_path(vault_root, path) if not file_path.exists() or not file_path.is_file(): raise HTTPException(status_code=404, detail=f"File not found: {path}") if file_path.suffix.lower() != ".pdf": raise HTTPException(status_code=400, detail="Not a PDF file") from backend.pdf_reader import extract_pdf_metadata meta = extract_pdf_metadata(file_path) stat = file_path.stat() return { "vault": vault_name, "path": path, "pages": meta.get("pages", 0), "title": meta.get("title") or file_path.name, "author": meta.get("author", ""), "size_bytes": stat.st_size, } @router.get( "/api/image/{vault_name}", response_class=Response, responses={200: {"content": {"application/octet-stream": {}}, "description": "Image bytes"}}, ) async def api_image(vault_name: str, path: str = Query(..., description="Relative path to image"), current_user=Depends(require_auth)): """Serve an image file with proper MIME type. Args: vault_name: Name of the vault. path: Relative file path within the vault. Returns: Image file with appropriate content-type header. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") vault_data = get_vault_data(vault_name) if not vault_data: raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") vault_root = Path(vault_data["path"]) file_path = resolve_safe_path(vault_root, path) if not file_path.exists() or not file_path.is_file(): raise HTTPException(status_code=404, detail=f"Image not found: {path}") mime_type = media_mime_type(str(file_path)) # #108-B3 — a standalone SVG opened in a tab executes its embedded JS # (same-origin XSS). ``sandbox`` forces a unique opaque origin with no # script execution; inside an tag the header is irrelevant. headers = {"X-Content-Type-Options": "nosniff"} if file_path.suffix.lower() == ".svg": headers["Content-Security-Policy"] = "sandbox" try: # Read and return the image file content = file_path.read_bytes() return Response(content=content, media_type=mime_type, headers=headers) except PermissionError: raise HTTPException(status_code=403, detail="Permission denied") except Exception as e: logger.error(f"Error serving image {vault_name}/{path}: {e}") raise HTTPException(status_code=500, detail=f"Error serving image: {e!s}") @router.get("/api/media/{vault_name}", response_class=FileResponse) async def api_media_stream( request: Request, vault_name: str, path: str = Query(..., description="Relative path to audio/video file"), current_user=Depends(require_auth), ): """Stream an audio/video file with HTTP Range support (roadmap #109-A2). Serves the bytes with the correct MIME type and honours ``Range`` requests (``206 Partial Content`` + ``Content-Range``/``Accept-Ranges``), which is what enables scrubbing in ``