From 7a499270beefd71cec3a0c201d422172ae27ef20 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Mon, 15 Dec 2025 11:58:42 +0000 Subject: [PATCH] feat: document file conversion --- .../envelope/envelope-upload-button.tsx | 13 +++ apps/remix/server/api/files/files.ts | 2 +- docker/development/compose.yml | 9 ++ .../e2e/folders/team-account-folders.spec.ts | 4 +- packages/lib/constants/upload.ts | 13 +++ .../document-data/create-document-data.ts | 8 +- .../document/get-document-by-access-token.ts | 1 + .../lib/server-only/document/send-document.ts | 1 + .../envelope/duplicate-envelope.ts | 1 + .../file-conversion/convert-to-pdf.ts | 48 +++++++++++ .../server-only/gotenberg/gotenberg-client.ts | 83 +++++++++++++++++++ packages/lib/types/document.ts | 1 + packages/lib/types/template.ts | 1 + .../lib/universal/upload/put-file.server.ts | 11 ++- .../migration.sql | 2 + packages/prisma/schema.prisma | 11 +-- .../server/document-router/create-document.ts | 8 +- .../get-multi-sign-document.types.ts | 1 + .../envelope-router/create-envelope-items.ts | 2 +- .../server/envelope-router/create-envelope.ts | 24 +++--- .../get-envelope-items.types.ts | 1 + .../server/envelope-router/use-envelope.ts | 2 +- .../trpc/server/template-router/router.ts | 2 +- .../pdf-viewer/pdf-viewer-konva-lazy.tsx | 2 +- packages/ui/primitives/document-dropzone.tsx | 57 +++++++------ .../ui/primitives/document-upload-button.tsx | 7 +- 26 files changed, 252 insertions(+), 63 deletions(-) create mode 100644 packages/lib/constants/upload.ts create mode 100644 packages/lib/server-only/file-conversion/convert-to-pdf.ts create mode 100644 packages/lib/server-only/gotenberg/gotenberg-client.ts create mode 100644 packages/prisma/migrations/20251210213745_add_original_mime_type_to_document_data/migration.sql diff --git a/apps/remix/app/components/general/envelope/envelope-upload-button.tsx b/apps/remix/app/components/general/envelope/envelope-upload-button.tsx index 0fe29659e..6820abca1 100644 --- a/apps/remix/app/components/general/envelope/envelope-upload-button.tsx +++ b/apps/remix/app/components/general/envelope/envelope-upload-button.tsx @@ -127,6 +127,19 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo const errorMessage = match(error.code) .with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs`) + .with( + 'UNSUPPORTED_FILE_TYPE', + () => t`This file type is not supported. Please upload a PDF, DOCX, JPEG, or PNG file.`, + ) + .with( + 'CONVERSION_SERVICE_UNAVAILABLE', + () => t`File conversion is temporarily unavailable. Please upload a PDF file instead.`, + ) + .with( + 'CONVERSION_FAILED', + () => + t`Failed to convert the file to PDF. Please try again or upload a PDF file instead.`, + ) .with( AppErrorCode.LIMIT_EXCEEDED, () => t`You have reached your document limit for this month. Please upgrade your plan.`, diff --git a/apps/remix/server/api/files/files.ts b/apps/remix/server/api/files/files.ts index 18db5d959..62b2e2a49 100644 --- a/apps/remix/server/api/files/files.ts +++ b/apps/remix/server/api/files/files.ts @@ -46,7 +46,7 @@ export const filesRoute = new Hono() return c.json({ error: 'File too large' }, 400); } - const result = await putNormalizedPdfFileServerSide(file); + const result = await putNormalizedPdfFileServerSide({ file }); return c.json(result); } catch (error) { diff --git a/docker/development/compose.yml b/docker/development/compose.yml index 3922ce614..a371cac58 100644 --- a/docker/development/compose.yml +++ b/docker/development/compose.yml @@ -40,6 +40,15 @@ services: entrypoint: sh command: -c 'mkdir -p /data/documenso && minio server /data --console-address ":9001" --address ":9002"' + gotenberg: + image: gotenberg/gotenberg:8 + container_name: gotenberg + ports: + - 3001:3000 + command: + - 'gotenberg' + - '--api-timeout=30s' + volumes: minio: documenso_database: diff --git a/packages/app-tests/e2e/folders/team-account-folders.spec.ts b/packages/app-tests/e2e/folders/team-account-folders.spec.ts index 494ff28e1..333518882 100644 --- a/packages/app-tests/e2e/folders/team-account-folders.spec.ts +++ b/packages/app-tests/e2e/folders/team-account-folders.spec.ts @@ -384,11 +384,9 @@ test('[TEAMS]: can create a template inside a template folder', async ({ page }) await page.getByText('Upload Template Document').click(); - await page.locator('input[type="file"]').nth(0).waitFor({ state: 'attached' }); - await page .locator('input[type="file"]') - .nth(0) + .first() .setInputFiles(path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf')); await page.waitForTimeout(3000); diff --git a/packages/lib/constants/upload.ts b/packages/lib/constants/upload.ts new file mode 100644 index 000000000..50a0e5ab5 --- /dev/null +++ b/packages/lib/constants/upload.ts @@ -0,0 +1,13 @@ +import { env } from '@documenso/lib/utils/env'; + +export const ALLOWED_UPLOAD_MIME_TYPES: Record = { + 'application/pdf': ['.pdf'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + 'image/jpeg': ['.jpg', '.jpeg'], + 'image/png': ['.png'], +}; + +export const isAllowedMimeType = (mimeType: string): boolean => + mimeType in ALLOWED_UPLOAD_MIME_TYPES; + +export const getGotenbergUrl = (): string | undefined => env('NEXT_PRIVATE_GOTENBERG_URL'); diff --git a/packages/lib/server-only/document-data/create-document-data.ts b/packages/lib/server-only/document-data/create-document-data.ts index 9cf2d7979..1947ceaf2 100644 --- a/packages/lib/server-only/document-data/create-document-data.ts +++ b/packages/lib/server-only/document-data/create-document-data.ts @@ -5,14 +5,20 @@ import { prisma } from '@documenso/prisma'; export type CreateDocumentDataOptions = { type: DocumentDataType; data: string; + originalMimeType?: string; }; -export const createDocumentData = async ({ type, data }: CreateDocumentDataOptions) => { +export const createDocumentData = async ({ + type, + data, + originalMimeType, +}: CreateDocumentDataOptions) => { return await prisma.documentData.create({ data: { type, data, initialData: data, + originalMimeType, }, }); }; diff --git a/packages/lib/server-only/document/get-document-by-access-token.ts b/packages/lib/server-only/document/get-document-by-access-token.ts index 444d7d678..725a4e39e 100644 --- a/packages/lib/server-only/document/get-document-by-access-token.ts +++ b/packages/lib/server-only/document/get-document-by-access-token.ts @@ -44,6 +44,7 @@ export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTok type: true, data: true, initialData: true, + originalMimeType: true, }, }, }, diff --git a/packages/lib/server-only/document/send-document.ts b/packages/lib/server-only/document/send-document.ts index f4729f331..f324469e5 100644 --- a/packages/lib/server-only/document/send-document.ts +++ b/packages/lib/server-only/document/send-document.ts @@ -82,6 +82,7 @@ export const sendDocument = async ({ id: true, data: true, initialData: true, + originalMimeType: true, }, }, }, diff --git a/packages/lib/server-only/envelope/duplicate-envelope.ts b/packages/lib/server-only/envelope/duplicate-envelope.ts index c83bc85a2..c40ce6ec9 100644 --- a/packages/lib/server-only/envelope/duplicate-envelope.ts +++ b/packages/lib/server-only/envelope/duplicate-envelope.ts @@ -42,6 +42,7 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop data: true, initialData: true, type: true, + originalMimeType: true, }, }, }, diff --git a/packages/lib/server-only/file-conversion/convert-to-pdf.ts b/packages/lib/server-only/file-conversion/convert-to-pdf.ts new file mode 100644 index 000000000..303d4d22d --- /dev/null +++ b/packages/lib/server-only/file-conversion/convert-to-pdf.ts @@ -0,0 +1,48 @@ +import { isAllowedMimeType } from '../../constants/upload'; +import { AppError } from '../../errors/app-error'; +import { convertFileToPdfViaGotenberg } from '../gotenberg/gotenberg-client'; + +type FileInput = { + name: string; + type: string; + arrayBuffer: () => Promise; +}; + +export type ConvertToPdfResult = { + pdfBuffer: Buffer; + originalMimeType: string; +}; + +export const convertToPdfIfNeeded = async (file: FileInput): Promise => { + const originalMimeType = file.type; + + if (!isAllowedMimeType(originalMimeType)) { + throw new AppError('UNSUPPORTED_FILE_TYPE', { + message: `File type '${originalMimeType}' is not supported`, + userMessage: 'This file type is not supported. Please upload a PDF, DOCX, JPEG, or PNG file.', + statusCode: 400, + }); + } + + if (originalMimeType === 'application/pdf') { + const arrayBuffer = await file.arrayBuffer(); + return { + pdfBuffer: Buffer.from(arrayBuffer), + originalMimeType, + }; + } + + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + const pdfBuffer = await convertFileToPdfViaGotenberg({ + file: buffer, + filename: file.name, + mimeType: originalMimeType, + }); + + return { + pdfBuffer, + originalMimeType, + }; +}; diff --git a/packages/lib/server-only/gotenberg/gotenberg-client.ts b/packages/lib/server-only/gotenberg/gotenberg-client.ts new file mode 100644 index 000000000..3977e24d5 --- /dev/null +++ b/packages/lib/server-only/gotenberg/gotenberg-client.ts @@ -0,0 +1,83 @@ +import { getGotenbergUrl } from '../../constants/upload'; +import { AppError } from '../../errors/app-error'; + +export type ConvertFileToPdfOptions = { + file: Buffer; + filename: string; + mimeType: string; +}; + +const CONVERSION_TIMEOUT_MS = 30_000; + +export const convertFileToPdfViaGotenberg = async ({ + file, + filename, + mimeType, +}: ConvertFileToPdfOptions): Promise => { + const gotenbergUrl = getGotenbergUrl(); + + if (!gotenbergUrl) { + throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', { + message: 'Gotenberg URL is not configured', + userMessage: 'File conversion service is not available. Please upload a PDF file instead.', + statusCode: 503, + }); + } + + const formData = new FormData(); + const blob = new Blob([file], { type: mimeType }); + formData.append('files', blob, filename); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), CONVERSION_TIMEOUT_MS); + + try { + const response = await fetch(`${gotenbergUrl}/forms/libreoffice/convert`, { + method: 'POST', + body: formData, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + + console.error(`Gotenberg conversion failed: ${response.status} - ${errorText}`); + + throw new AppError('CONVERSION_FAILED', { + message: `Gotenberg returned status ${response.status}: ${errorText}`, + userMessage: + 'Failed to convert the file to PDF. Please try again or upload a PDF file instead.', + statusCode: 400, + }); + } + + const arrayBuffer = await response.arrayBuffer(); + return Buffer.from(arrayBuffer); + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof AppError) { + throw error; + } + + if (error instanceof Error && error.name === 'AbortError') { + throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', { + message: 'Gotenberg request timed out', + userMessage: + 'File conversion timed out. Please try again with a smaller file or upload a PDF instead.', + statusCode: 503, + }); + } + + console.error('Gotenberg conversion error:', error); + + throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', { + message: `Failed to reach Gotenberg: ${error instanceof Error ? error.message : 'Unknown error'}`, + userMessage: + 'File conversion service is temporarily unavailable. Please upload a PDF file instead.', + statusCode: 503, + }); + } +}; diff --git a/packages/lib/types/document.ts b/packages/lib/types/document.ts index 140e453cb..346574ce5 100644 --- a/packages/lib/types/document.ts +++ b/packages/lib/types/document.ts @@ -51,6 +51,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({ id: true, data: true, initialData: true, + originalMimeType: true, }).extend({ envelopeItemId: z.string(), }), diff --git a/packages/lib/types/template.ts b/packages/lib/types/template.ts index 3b8fcd0d8..748b424bc 100644 --- a/packages/lib/types/template.ts +++ b/packages/lib/types/template.ts @@ -45,6 +45,7 @@ export const ZTemplateSchema = TemplateSchema.pick({ id: true, data: true, initialData: true, + originalMimeType: true, }).extend({ envelopeItemId: z.string(), }), diff --git a/packages/lib/universal/upload/put-file.server.ts b/packages/lib/universal/upload/put-file.server.ts index 47c2be747..e67009ad7 100644 --- a/packages/lib/universal/upload/put-file.server.ts +++ b/packages/lib/universal/upload/put-file.server.ts @@ -44,10 +44,18 @@ export const putPdfFileServerSide = async (file: File) => { return await createDocumentData({ type, data }); }; +type PutNormalizedPdfOptions = { + file: File; + originalMimeType?: string; +}; + /** * Uploads a pdf file and normalizes it. */ -export const putNormalizedPdfFileServerSide = async (file: File) => { +export const putNormalizedPdfFileServerSide = async ({ + file, + originalMimeType, +}: PutNormalizedPdfOptions) => { const buffer = Buffer.from(await file.arrayBuffer()); const normalized = await normalizePdf(buffer); @@ -63,6 +71,7 @@ export const putNormalizedPdfFileServerSide = async (file: File) => { return await createDocumentData({ type: documentData.type, data: documentData.data, + originalMimeType, }); }; diff --git a/packages/prisma/migrations/20251210213745_add_original_mime_type_to_document_data/migration.sql b/packages/prisma/migrations/20251210213745_add_original_mime_type_to_document_data/migration.sql new file mode 100644 index 000000000..34df69d05 --- /dev/null +++ b/packages/prisma/migrations/20251210213745_add_original_mime_type_to_document_data/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "DocumentData" ADD COLUMN "originalMimeType" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 54ae04c10..6abf314c1 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -486,11 +486,12 @@ enum DocumentSigningOrder { } model DocumentData { - id String @id @default(cuid()) - type DocumentDataType - data String - initialData String - envelopeItem EnvelopeItem? + id String @id @default(cuid()) + type DocumentDataType + data String + initialData String + originalMimeType String? + envelopeItem EnvelopeItem? } enum DocumentDistributionMethod { diff --git a/packages/trpc/server/document-router/create-document.ts b/packages/trpc/server/document-router/create-document.ts index a515db87f..8df992154 100644 --- a/packages/trpc/server/document-router/create-document.ts +++ b/packages/trpc/server/document-router/create-document.ts @@ -47,9 +47,11 @@ export const createDocumentRoute = authenticatedProcedure } const { id: documentDataId } = await putNormalizedPdfFileServerSide({ - name: file.name, - type: 'application/pdf', - arrayBuffer: async () => Promise.resolve(pdf), + file: { + name: file.name, + type: 'application/pdf', + arrayBuffer: async () => Promise.resolve(pdf), + }, }); ctx.logger.info({ diff --git a/packages/trpc/server/embedding-router/get-multi-sign-document.types.ts b/packages/trpc/server/embedding-router/get-multi-sign-document.types.ts index f54eaf026..0b015fdd2 100644 --- a/packages/trpc/server/embedding-router/get-multi-sign-document.types.ts +++ b/packages/trpc/server/embedding-router/get-multi-sign-document.types.ts @@ -18,6 +18,7 @@ export const ZGetMultiSignDocumentResponseSchema = ZDocumentLiteSchema.extend({ id: true, data: true, initialData: true, + originalMimeType: true, }), documentMeta: DocumentMetaSchema.pick({ signingOrder: true, diff --git a/packages/trpc/server/envelope-router/create-envelope-items.ts b/packages/trpc/server/envelope-router/create-envelope-items.ts index 46b15d8e4..a867d4d56 100644 --- a/packages/trpc/server/envelope-router/create-envelope-items.ts +++ b/packages/trpc/server/envelope-router/create-envelope-items.ts @@ -87,7 +87,7 @@ export const createEnvelopeItemsRoute = authenticatedProcedure // For each file, stream to s3 and create the document data. const envelopeItems = await Promise.all( files.map(async (file) => { - const { id: documentDataId } = await putNormalizedPdfFileServerSide(file); + const { id: documentDataId } = await putNormalizedPdfFileServerSide({ file }); return { title: file.name, diff --git a/packages/trpc/server/envelope-router/create-envelope.ts b/packages/trpc/server/envelope-router/create-envelope.ts index dfb7927f3..a8cc6a0d9 100644 --- a/packages/trpc/server/envelope-router/create-envelope.ts +++ b/packages/trpc/server/envelope-router/create-envelope.ts @@ -1,6 +1,7 @@ import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope'; +import { convertToPdfIfNeeded } from '@documenso/lib/server-only/file-conversion/convert-to-pdf'; import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf'; @@ -59,17 +60,13 @@ export const createEnvelopeRoute = authenticatedProcedure }); } - if (files.some((file) => !file.type.startsWith('application/pdf'))) { - throw new AppError('INVALID_DOCUMENT_FILE', { - message: 'You cannot upload non-PDF files', - statusCode: 400, - }); - } - - // For each file, stream to s3 and create the document data. + // For each file, convert to PDF if needed, then store. const envelopeItems = await Promise.all( files.map(async (file) => { - let pdf = Buffer.from(await file.arrayBuffer()); + // Convert to PDF if needed (DOCX, images, etc) + const { pdfBuffer, originalMimeType } = await convertToPdfIfNeeded(file); + + let pdf = pdfBuffer; if (formValues) { // eslint-disable-next-line require-atomic-updates @@ -80,9 +77,12 @@ export const createEnvelopeRoute = authenticatedProcedure } const { id: documentDataId } = await putNormalizedPdfFileServerSide({ - name: file.name, - type: 'application/pdf', - arrayBuffer: async () => Promise.resolve(pdf), + file: { + name: file.name, + type: 'application/pdf', + arrayBuffer: async () => Promise.resolve(pdf), + }, + originalMimeType, }); return { diff --git a/packages/trpc/server/envelope-router/get-envelope-items.types.ts b/packages/trpc/server/envelope-router/get-envelope-items.types.ts index 674f895f7..59a4f531f 100644 --- a/packages/trpc/server/envelope-router/get-envelope-items.types.ts +++ b/packages/trpc/server/envelope-router/get-envelope-items.types.ts @@ -19,6 +19,7 @@ export const ZGetEnvelopeItemsResponseSchema = z.object({ id: true, data: true, initialData: true, + originalMimeType: true, }), }) .array(), diff --git a/packages/trpc/server/envelope-router/use-envelope.ts b/packages/trpc/server/envelope-router/use-envelope.ts index ebe6af280..90824f393 100644 --- a/packages/trpc/server/envelope-router/use-envelope.ts +++ b/packages/trpc/server/envelope-router/use-envelope.ts @@ -79,7 +79,7 @@ export const useEnvelopeRoute = authenticatedProcedure // Process uploaded files and create document data for them const uploadedFiles = await Promise.all( filesToUpload.map(async (file) => { - const { id: documentDataId } = await putNormalizedPdfFileServerSide(file); + const { id: documentDataId } = await putNormalizedPdfFileServerSide({ file }); return { name: file.name, diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index 3791a2181..157d3acec 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -191,7 +191,7 @@ export const templateRouter = router({ attachments, } = payload; - const { id: templateDocumentDataId } = await putNormalizedPdfFileServerSide(file); + const { id: templateDocumentDataId } = await putNormalizedPdfFileServerSide({ file }); ctx.logger.info({ input: { diff --git a/packages/ui/components/pdf-viewer/pdf-viewer-konva-lazy.tsx b/packages/ui/components/pdf-viewer/pdf-viewer-konva-lazy.tsx index 2d5d38658..ab85b329d 100644 --- a/packages/ui/components/pdf-viewer/pdf-viewer-konva-lazy.tsx +++ b/packages/ui/components/pdf-viewer/pdf-viewer-konva-lazy.tsx @@ -17,7 +17,7 @@ const EnvelopePdfViewer = lazy(async () => import('./pdf-viewer-konva')); export const PDFViewerKonvaLazy = (props: PDFViewerProps) => { return ( - Loading client component...}> + Loading...}> ); diff --git a/packages/ui/primitives/document-dropzone.tsx b/packages/ui/primitives/document-dropzone.tsx index 91d8eff2b..96e5f31a6 100644 --- a/packages/ui/primitives/document-dropzone.tsx +++ b/packages/ui/primitives/document-dropzone.tsx @@ -10,6 +10,7 @@ import { Link } from 'react-router'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { ALLOWED_UPLOAD_MIME_TYPES } from '@documenso/lib/constants/upload'; import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; import { @@ -55,9 +56,7 @@ export const DocumentDropzone = ({ const organisation = useCurrentOrganisation(); const { getRootProps, getInputProps } = useDropzone({ - accept: { - 'application/pdf': ['.pdf'], - }, + accept: ALLOWED_UPLOAD_MIME_TYPES, multiple: allowMultiple, disabled, onDrop: (acceptedFiles) => { @@ -87,7 +86,7 @@ export const DocumentDropzone = ({ - + {disabled ? ( // Disabled State
-
-
-
+
+
+
-
-
-
+
+
+
) : ( // Non Disabled State
-
-
-
+
+
+
-
-
-
+
+
+
)} -

{_(heading[type])}

+

{_(heading[type])}

-

- {_(disabled ? disabledMessage : msg`Drag & drop your PDF here.`)} +

+ {_(disabled ? disabledMessage : msg`Drag & drop PDF, DOCX, or images here.`)}

{disabled && IS_BILLING_ENABLED() && ( -