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..b3ccda70 100644
--- a/apps/server/src/core/auth/dto/jwt-payload.ts
+++ b/apps/server/src/core/auth/dto/jwt-payload.ts
@@ -5,6 +5,8 @@ export enum JwtType {
ATTACHMENT = 'attachment',
MFA_TOKEN = 'mfa_token',
API_KEY = 'api_key',
+ PDF_RENDER = 'pdf_render',
+ PDF_EXPORT_DOWNLOAD = 'pdf_export_download',
}
export type JwtPayload = {
sub: string;
@@ -45,3 +47,15 @@ export type JwtApiKeyPayload = {
apiKeyId: string;
type: 'api_key';
};
+
+export type JwtPdfRenderPayload = {
+ pageId: string;
+ workspaceId: string;
+ type: 'pdf_render';
+};
+
+export type JwtPdfExportDownloadPayload = {
+ fileTaskId: string;
+ workspaceId: string;
+ type: 'pdf_export_download';
+};
diff --git a/apps/server/src/core/auth/services/token.service.ts b/apps/server/src/core/auth/services/token.service.ts
index b9035ba3..1cc10a07 100644
--- a/apps/server/src/core/auth/services/token.service.ts
+++ b/apps/server/src/core/auth/services/token.service.ts
@@ -13,6 +13,8 @@ import {
JwtExchangePayload,
JwtMfaTokenPayload,
JwtPayload,
+ JwtPdfExportDownloadPayload,
+ JwtPdfRenderPayload,
JwtType,
} from '../dto/jwt-payload';
import { User } from '@docmost/db/types/entity.types';
@@ -115,6 +117,30 @@ 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 generatePdfExportDownloadToken(
+ fileTaskId: string,
+ workspaceId: string,
+ ): Promise {
+ const payload: JwtPdfExportDownloadPayload = {
+ fileTaskId,
+ workspaceId,
+ type: JwtType.PDF_EXPORT_DOWNLOAD,
+ };
+ return this.jwtService.sign(payload, { expiresIn: '1h' });
+ }
+
async verifyJwt(token: string, tokenType: string) {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.environmentService.getAppSecret(),
diff --git a/apps/server/src/database/migrations/20260414T124451-update-file_tasks.ts b/apps/server/src/database/migrations/20260414T124451-update-file_tasks.ts
new file mode 100644
index 00000000..4e95cab7
--- /dev/null
+++ b/apps/server/src/database/migrations/20260414T124451-update-file_tasks.ts
@@ -0,0 +1,32 @@
+import { Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .alterTable('file_tasks')
+ .addColumn('page_id', 'uuid', (col) =>
+ col.references('pages.id').onDelete('set null').ifNotExists(),
+ )
+ .execute();
+
+ await db.schema
+ .alterTable('file_tasks')
+ .addColumn('metadata', 'jsonb', (col) => col.ifNotExists())
+ .execute();
+
+ await db.schema
+ .createIndex('idx_file_tasks_page_export')
+ .ifNotExists()
+ .on('file_tasks')
+ .columns(['page_id', 'workspace_id'])
+ .where(sql.ref('type'), '=', 'export')
+ .where(sql.ref('deleted_at'), 'is', null)
+ .execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema.dropIndex('idx_file_tasks_page_export').execute();
+
+ await db.schema.alterTable('file_tasks').dropColumn('page_id').execute();
+
+ await db.schema.alterTable('file_tasks').dropColumn('metadata').execute();
+}
diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts
index 9df706ca..0890df93 100644
--- a/apps/server/src/database/types/db.d.ts
+++ b/apps/server/src/database/types/db.d.ts
@@ -196,6 +196,8 @@ export interface FileTasks {
filePath: string;
fileSize: Int8 | null;
id: Generated;
+ metadata: Json | null;
+ pageId: string | null;
source: string | null;
spaceId: string | null;
status: string | null;
diff --git a/apps/server/src/ee b/apps/server/src/ee
index a5b5e10e..03f67751 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit a5b5e10eec0363463d920c7ffdd9f5e51bb474ff
+Subproject commit 03f67751cce3da340ecea39d23348b547e13d381
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');
}
diff --git a/apps/server/src/integrations/import/processors/file-task.processor.ts b/apps/server/src/integrations/import/processors/file-task.processor.ts
index 20001dd7..03527707 100644
--- a/apps/server/src/integrations/import/processors/file-task.processor.ts
+++ b/apps/server/src/integrations/import/processors/file-task.processor.ts
@@ -5,6 +5,9 @@ import { QueueJob, QueueName } from 'src/integrations/queue/constants';
import { FileImportTaskService } from '../services/file-import-task.service';
import { FileTaskStatus } from '../utils/file.utils';
import { StorageService } from '../../storage/storage.service';
+import { ModuleRef } from '@nestjs/core';
+import { InjectKysely } from 'nestjs-kysely';
+import { KyselyDB } from '@docmost/db/types/kysely.types';
@Processor(QueueName.FILE_TASK_QUEUE)
export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
@@ -13,6 +16,8 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
constructor(
private readonly fileTaskService: FileImportTaskService,
private readonly storageService: StorageService,
+ private readonly moduleRef: ModuleRef,
+ @InjectKysely() private readonly db: KyselyDB,
) {
super();
}
@@ -23,8 +28,11 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
case QueueJob.IMPORT_TASK:
await this.fileTaskService.processZIpImport(job.data.fileTaskId);
break;
- case QueueJob.EXPORT_TASK:
- // TODO: export task
+ case QueueJob.PDF_EXPORT_TASK:
+ await this.processExportTask(job.data.fileTaskId);
+ break;
+ case QueueJob.PDF_EXPORT_CLEANUP:
+ await this.processExportCleanup();
break;
}
} catch (err) {
@@ -33,6 +41,24 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
}
}
+ private getPdfExportService() {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const PdfExportModule = require('./../../../ee/pdf-export/pdf-export.service');
+ return this.moduleRef.get(PdfExportModule.PdfExportService, {
+ strict: false,
+ });
+ }
+
+ private async processExportTask(fileTaskId: string): Promise {
+ const pdfExportService = this.getPdfExportService();
+ await pdfExportService.generateAndStorePdf(fileTaskId);
+ }
+
+ private async processExportCleanup(): Promise {
+ const pdfExportService = this.getPdfExportService();
+ await pdfExportService.cleanupExpiredExports();
+ }
+
@OnWorkerEvent('active')
onActive(job: Job) {
this.logger.debug(`Processing ${job.name} job`);
@@ -41,32 +67,39 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
@OnWorkerEvent('failed')
async onFailed(job: Job) {
this.logger.error(
- `Error processing ${job.name} job. Import Task ID: ${job.data.fileTaskId}. Reason: ${job.failedReason}`,
+ `Error processing ${job.name} job. File Task ID: ${job.data?.fileTaskId}. Reason: ${job.failedReason}`,
);
- await this.handleFailedJob(job);
+ if (job.name === QueueJob.IMPORT_TASK) {
+ await this.handleFailedImportJob(job);
+ } else if (job.name === QueueJob.PDF_EXPORT_TASK) {
+ await this.handleFailedExportJob(job);
+ }
}
@OnWorkerEvent('completed')
async onCompleted(job: Job) {
this.logger.log(
- `Completed ${job.name} job for File task ID ${job.data.fileTaskId}`,
+ `Completed ${job.name} job for File task ID ${job.data?.fileTaskId}`,
);
- try {
- const fileTask = await this.fileTaskService.getFileTask(
- job.data.fileTaskId,
- );
- if (fileTask) {
- await this.storageService.delete(fileTask.filePath);
- this.logger.debug(`Deleted imported zip file: ${fileTask.filePath}`);
+ if (job.name === QueueJob.IMPORT_TASK) {
+ try {
+ const fileTask = await this.fileTaskService.getFileTask(
+ job.data.fileTaskId,
+ );
+ if (fileTask) {
+ await this.storageService.delete(fileTask.filePath);
+ this.logger.debug(`Deleted imported zip file: ${fileTask.filePath}`);
+ }
+ } catch (err) {
+ this.logger.error(`Failed to delete imported zip file:`, err);
}
- } catch (err) {
- this.logger.error(`Failed to delete imported zip file:`, err);
}
+ // Export tasks: do NOT delete the file on completion (kept for 24h cache)
}
- private async handleFailedJob(job: Job) {
+ private async handleFailedImportJob(job: Job) {
try {
const fileTaskId = job.data.fileTaskId;
const reason = job.failedReason || 'Unknown error';
@@ -86,6 +119,25 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
}
}
+ private async handleFailedExportJob(job: Job) {
+ try {
+ const fileTaskId = job.data.fileTaskId;
+ const reason = job.failedReason || 'Unknown error';
+
+ await this.db
+ .updateTable('fileTasks')
+ .set({
+ status: FileTaskStatus.Failed,
+ errorMessage: reason,
+ updatedAt: new Date(),
+ })
+ .where('id', '=', fileTaskId)
+ .execute();
+ } catch (err) {
+ this.logger.error(err);
+ }
+ }
+
async onModuleDestroy(): Promise {
if (this.worker) {
await this.worker.close();
diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts
index 03460739..c783ec05 100644
--- a/apps/server/src/integrations/queue/constants/queue.constants.ts
+++ b/apps/server/src/integrations/queue/constants/queue.constants.ts
@@ -80,4 +80,7 @@ export enum QueueJob {
AUDIT_LOG = 'audit-log',
AUDIT_CLEANUP = 'audit-cleanup',
+
+ PDF_EXPORT_TASK = 'pdf-export-task',
+ PDF_EXPORT_CLEANUP = 'pdf-export-cleanup',
}