feat: document file conversion

This commit is contained in:
Ephraim Atta-Duncan
2025-12-15 11:58:42 +00:00
parent 43486d8448
commit 7a499270be
26 changed files with 252 additions and 63 deletions
+13
View File
@@ -0,0 +1,13 @@
import { env } from '@documenso/lib/utils/env';
export const ALLOWED_UPLOAD_MIME_TYPES: Record<string, string[]> = {
'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');
@@ -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,
},
});
};
@@ -44,6 +44,7 @@ export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTok
type: true,
data: true,
initialData: true,
originalMimeType: true,
},
},
},
@@ -82,6 +82,7 @@ export const sendDocument = async ({
id: true,
data: true,
initialData: true,
originalMimeType: true,
},
},
},
@@ -42,6 +42,7 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
data: true,
initialData: true,
type: true,
originalMimeType: true,
},
},
},
@@ -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<ArrayBuffer>;
};
export type ConvertToPdfResult = {
pdfBuffer: Buffer;
originalMimeType: string;
};
export const convertToPdfIfNeeded = async (file: FileInput): Promise<ConvertToPdfResult> => {
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,
};
};
@@ -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<Buffer> => {
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,
});
}
};
+1
View File
@@ -51,6 +51,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
id: true,
data: true,
initialData: true,
originalMimeType: true,
}).extend({
envelopeItemId: z.string(),
}),
+1
View File
@@ -45,6 +45,7 @@ export const ZTemplateSchema = TemplateSchema.pick({
id: true,
data: true,
initialData: true,
originalMimeType: true,
}).extend({
envelopeItemId: z.string(),
}),
@@ -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,
});
};