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');
}