diff --git a/Dockerfile b/Dockerfile index 9795090..8e690f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM python:3.11-slim AS builder RUN apt-get update \ - && apt-get install -y --no-install-recommends gcc libffi-dev libc6-dev \ + && apt-get install -y --no-install-recommends gcc libffi-dev libc6-dev libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 shared-mime-info \ && rm -rf /var/lib/apt/lists/* WORKDIR /build @@ -22,6 +22,11 @@ WORKDIR /app # Copy installed packages from builder stage COPY --from=builder /install /usr/local +# WeasyPrint runtime dependencies +RUN apt-get update \ + && apt-get install -y --no-install-recommends libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 shared-mime-info \ + && rm -rf /var/lib/apt/lists/* + # Copy application code COPY backend/ ./backend/ COPY frontend/ ./frontend/ diff --git a/backend/main.py b/backend/main.py index ae94726..09cb54c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -18,7 +18,7 @@ from typing import Optional, List, Dict, Any import frontmatter import mistune -from fastapi import FastAPI, HTTPException, Query, Body, Depends +from fastapi import FastAPI, HTTPException, Query, Body, Depends, Response from fastapi.staticfiles import StaticFiles from fastapi.responses import HTMLResponse, FileResponse, Response, StreamingResponse from pydantic import BaseModel, Field @@ -558,7 +558,7 @@ app.add_middleware(SecurityHeadersMiddleware) from backend.auth.router import router as auth_router from backend.auth.middleware import require_auth, require_admin, check_vault_access from backend.secret_redactor import redact_file_content -from backend.audit import log_file_save, log_file_delete +from backend.pdf_export import generate_pdf, build_pdf_html from backend.share import create_share, get_share_by_token, record_access, revoke_share, list_shares from backend.webhooks import get_webhooks, create_webhook, update_webhook, delete_webhook, dispatch_webhooks @@ -1178,6 +1178,33 @@ async def api_file_download(vault_name: str, path: str = Query(..., description= ) +@app.get("/api/file/{vault_name}/pdf") +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 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.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"'}) + + @app.put("/api/file/{vault_name}/save", response_model=FileSaveResponse) async def api_file_save( vault_name: str, @@ -2834,6 +2861,34 @@ table{{border-collapse:collapse;width:100%}}th,td{{border:1px solid #ddd;padding ) +@app.get("/s/{token}/pdf") +async def public_share_pdf_download(token: str): + """Download shared document as real PDF via WeasyPrint.""" + 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) + html = _render_markdown(post.content, share["vault"], 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.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"'}) + + @app.get("/s/{token}") async def public_share_view(token: str): """Public share view — no authentication required.""" @@ -2902,7 +2957,7 @@ body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color: .md - @@ -2912,7 +2967,6 @@ body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color: function toggleTheme(){{var t=document.documentElement;var isDark=t.dataset.theme==="dark";t.dataset.theme=isDark?"light":"dark";document.getElementById("theme-icon-dark").style.display=isDark?"none":"";document.getElementById("theme-icon-light").style.display=isDark?"":"none";localStorage.setItem("obsigate-share-theme",t.dataset.theme)}} (function(){{var s=localStorage.getItem("obsigate-share-theme");if(!s)s="dark";document.documentElement.dataset.theme=s;var isDark=s==="dark";document.getElementById("theme-icon-dark").style.display=isDark?"":"none";document.getElementById("theme-icon-light").style.display=isDark?"none":""}})(); function exportMD(){{var t=document.getElementById("content").innerText;var b=new Blob([t],{{type:"text/markdown"}});var a=document.createElement("a");a.href=URL.createObjectURL(b);a.download="{title}.md";a.click()}} -function exportPDF(){{var w=window.open("","_blank","width=800,height=600");w.document.write("{title}

{title}

"+document.getElementById("content").innerHTML+"");w.document.close();w.focus();setTimeout(function(){{w.print();w.close()}},500)}} """) diff --git a/backend/pdf_export.py b/backend/pdf_export.py new file mode 100644 index 0000000..109668e --- /dev/null +++ b/backend/pdf_export.py @@ -0,0 +1,92 @@ +""" +PDF export utility using WeasyPrint. + +Generates PDF documents from rendered markdown HTML. +Used by both the authenticated API and public share views. +""" + +import logging +from pathlib import Path +from typing import Optional + +from weasyprint import HTML + +logger = logging.getLogger("obsigate.pdf") + + +def generate_pdf(html_content: str, title: str = "document", base_url: Optional[str] = None) -> bytes: + """Generate a PDF from HTML content. + + Args: + html_content: Full HTML document string. + title: Document title (used for metadata). + base_url: Base URL for resolving relative URLs in the HTML. + + Returns: + PDF file as bytes. + """ + html = HTML(string=html_content, base_url=base_url or "") + return html.write_pdf() + + +def build_pdf_html(body_html: str, title: str, theme: str = "light") -> str: + """Wrap rendered markdown HTML in a complete print-friendly HTML document. + + Args: + body_html: Rendered markdown HTML (without / wrappers). + title: Document title. + theme: 'light' or 'dark' — light is recommended for PDF. + + Returns: + Complete HTML document string. + """ + return f""" + +{title} + +

{title}

+{body_html} +""" diff --git a/backend/requirements.txt b/backend/requirements.txt index 8158463..c51f4b5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,3 +8,4 @@ aiohttp>=3.9.0 watchdog>=4.0.0 argon2-cffi>=23.1.0 python-jose>=3.3.0 +weasyprint>=60.0 diff --git a/frontend/app.js b/frontend/app.js index 5d25192..70b3601 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -3244,6 +3244,13 @@ const downloadBtn = el("button", { class: "btn-action", title: "Télécharger" }, [icon("download", 14), document.createTextNode("Télécharger")]); downloadBtn.addEventListener("click", () => { + const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`; + window.open(dlUrl, "_blank"); + }); + + // MD download button + const mdBtn = el("button", { class: "btn-action", title: "Télécharger en .md" }, [icon("file-text", 14), document.createTextNode(".md")]); + mdBtn.addEventListener("click", () => { const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`; const a = document.createElement("a"); a.href = dlUrl; @@ -3253,6 +3260,13 @@ document.body.removeChild(a); }); + // PDF download button + const pdfBtn = el("button", { class: "btn-action", title: "Télécharger en PDF" }, [icon("file", 14), document.createTextNode("PDF")]); + pdfBtn.addEventListener("click", () => { + const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf?path=${encodeURIComponent(data.path)}`; + window.open(pdfUrl, "_blank"); + }); + const editBtn = el("button", { class: "btn-action", title: "Éditer" }, [icon("edit", 14), document.createTextNode("Éditer")]); editBtn.addEventListener("click", () => { openEditor(data.vault, data.path); @@ -3345,7 +3359,7 @@ // Assemble area.innerHTML = ""; area.appendChild(breadcrumb); - area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, [copyBtn, sourceBtn, downloadBtn, editBtn, openNewWindowBtn, tocBtn, shareBtn, bookmarkBtn])])); + area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, [copyBtn, sourceBtn, downloadBtn, mdBtn, pdfBtn, editBtn, openNewWindowBtn, tocBtn, shareBtn, bookmarkBtn])])); if (fmSection) area.appendChild(fmSection); area.appendChild(mdDiv); area.appendChild(rawDiv);