From c802d29b852f669f9b17a7b22dd00ce9c844bb63 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:50:00 +0100 Subject: [PATCH] feat(ee): server side PDF export --- .env.example | 3 + apps/client/src/App.tsx | 2 + .../src/ee/pdf-export/pdf-render-page.tsx | 64 +++++++++++++++++++ apps/server/src/common/features.ts | 1 + apps/server/src/core/auth/dto/jwt-payload.ts | 7 ++ .../src/core/auth/services/token.service.ts | 13 ++++ .../environment/environment.service.ts | 4 ++ 7 files changed, 94 insertions(+) create mode 100644 apps/client/src/ee/pdf-export/pdf-render-page.tsx diff --git a/.env.example b/.env.example index 6d537708..b218bdb8 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,9 @@ POSTMARK_TOKEN= # for custom drawio server DRAWIO_URL= +# Gotenberg URL for server-side PDF export +GOTENBERG_URL= + DISABLE_TELEMETRY=false # Enable debug logging in production (default: false) diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index ede4dd72..a75afc22 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -26,6 +26,7 @@ import Security from "@/ee/security/pages/security.tsx"; import License from "@/ee/licence/pages/license.tsx"; import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx"; import SharedPage from "@/pages/share/shared-page.tsx"; +import PdfRenderPage from "@/ee/pdf-export/pdf-render-page.tsx"; import Shares from "@/pages/settings/shares/shares.tsx"; import ShareLayout from "@/features/share/components/share-layout.tsx"; import ShareRedirect from "@/pages/share/share-redirect.tsx"; @@ -81,6 +82,7 @@ export default function App() { } /> + } /> } /> } /> diff --git a/apps/client/src/ee/pdf-export/pdf-render-page.tsx b/apps/client/src/ee/pdf-export/pdf-render-page.tsx new file mode 100644 index 00000000..ac302b96 --- /dev/null +++ b/apps/client/src/ee/pdf-export/pdf-render-page.tsx @@ -0,0 +1,64 @@ +import "@/features/editor/styles/index.css"; +import { useEffect, useState } from "react"; +import { useParams, useSearchParams } from "react-router-dom"; +import ReadonlyPageEditor from "@/features/editor/readonly-page-editor"; +import { Container } from "@mantine/core"; + +type PdfRenderData = { + pageId: string; + title: string; + content: any; +}; + +export default function PdfRenderPage() { + const { pageId } = useParams<{ pageId: string }>(); + const [searchParams] = useSearchParams(); + const token = searchParams.get("token"); + + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!pageId || !token) { + setError("Missing page ID or token"); + return; + } + + fetch('/api/pdf/render', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pageId, token }), + }) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((result) => setData(result.data)) + .catch((err) => setError(err.message)); + }, [pageId, token]); + + useEffect(() => { + if (data?.title) { + document.title = data.title; + } + }, [data?.title]); + + if (error) { + return
{error}
; + } + + if (!data) { + return null; + } + + return ( + + + + ); +} diff --git a/apps/server/src/common/features.ts b/apps/server/src/common/features.ts index 4db35d3f..38f226a8 100644 --- a/apps/server/src/common/features.ts +++ b/apps/server/src/common/features.ts @@ -18,6 +18,7 @@ export const Feature = { SHARING_CONTROLS: 'sharing:controls', VIEWER_COMMENTS: 'comment:viewer', TEMPLATES: 'templates', + PDF_EXPORT: 'export:pdf', } as const; export type FeatureKey = (typeof Feature)[keyof typeof Feature]; diff --git a/apps/server/src/core/auth/dto/jwt-payload.ts b/apps/server/src/core/auth/dto/jwt-payload.ts index f70848b9..8dd94990 100644 --- a/apps/server/src/core/auth/dto/jwt-payload.ts +++ b/apps/server/src/core/auth/dto/jwt-payload.ts @@ -5,6 +5,7 @@ export enum JwtType { ATTACHMENT = 'attachment', MFA_TOKEN = 'mfa_token', API_KEY = 'api_key', + PDF_RENDER = 'pdf_render', } export type JwtPayload = { sub: string; @@ -45,3 +46,9 @@ export type JwtApiKeyPayload = { apiKeyId: string; type: 'api_key'; }; + +export type JwtPdfRenderPayload = { + pageId: string; + workspaceId: string; + type: 'pdf_render'; +}; diff --git a/apps/server/src/core/auth/services/token.service.ts b/apps/server/src/core/auth/services/token.service.ts index b9035ba3..b58e573e 100644 --- a/apps/server/src/core/auth/services/token.service.ts +++ b/apps/server/src/core/auth/services/token.service.ts @@ -13,6 +13,7 @@ import { JwtExchangePayload, JwtMfaTokenPayload, JwtPayload, + JwtPdfRenderPayload, JwtType, } from '../dto/jwt-payload'; import { User } from '@docmost/db/types/entity.types'; @@ -115,6 +116,18 @@ export class TokenService { return this.jwtService.sign(payload, expiresIn ? { expiresIn } : {}); } + async generatePdfRenderToken( + pageId: string, + workspaceId: string, + ): Promise { + const payload: JwtPdfRenderPayload = { + pageId, + workspaceId, + type: JwtType.PDF_RENDER, + }; + return this.jwtService.sign(payload, { expiresIn: '60s' }); + } + async verifyJwt(token: string, tokenType: string) { const payload = await this.jwtService.verifyAsync(token, { secret: this.environmentService.getAppSecret(), diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts index ceb2eae7..af35e934 100644 --- a/apps/server/src/integrations/environment/environment.service.ts +++ b/apps/server/src/integrations/environment/environment.service.ts @@ -75,6 +75,10 @@ export class EnvironmentService { return new Date(Date.now() + msUntilExpiry); } + getGotenbergUrl(): string | undefined { + return this.configService.get('GOTENBERG_URL'); + } + getStorageDriver(): string { return this.configService.get('STORAGE_DRIVER', 'local'); }