mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 01:45:08 +10:00
feat: document file conversion
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -51,6 +51,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
|
||||
id: true,
|
||||
data: true,
|
||||
initialData: true,
|
||||
originalMimeType: true,
|
||||
}).extend({
|
||||
envelopeItemId: z.string(),
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user