feat: add document file conversion

This commit is contained in:
ephraimduncan
2025-12-30 19:24:23 +00:00
parent 3976531045
commit 74db3d7a1c
16 changed files with 1308 additions and 31 deletions
File diff suppressed because it is too large Load Diff
+36 -5
View File
@@ -13,11 +13,32 @@ type DownloadPDFProps = {
/**
* Specifies which version of the document to download.
* 'signed': Downloads the signed version (default).
* 'original': Downloads the original version.
* 'original': Downloads the original version (may be DOCX, PNG, JPEG if converted).
*/
version?: DocumentVersion;
};
const getFilenameFromContentDisposition = (header: string | null): string | null => {
if (!header) return null;
const filenameStarMatch = header.match(/filename\*=(?:UTF-8''|utf-8'')([^;]+)/i);
if (filenameStarMatch) {
return decodeURIComponent(filenameStarMatch[1]);
}
const filenameMatch = header.match(/filename="([^"]+)"/);
if (filenameMatch) {
return filenameMatch[1];
}
const filenameNoQuotesMatch = header.match(/filename=([^;\s]+)/);
if (filenameNoQuotesMatch) {
return filenameNoQuotesMatch[1];
}
return null;
};
export const downloadPDF = async ({
envelopeItem,
token,
@@ -31,13 +52,23 @@ export const downloadPDF = async ({
version,
});
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
const response = await fetch(downloadUrl);
const blob = await response.blob();
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
const contentDisposition = response.headers.get('Content-Disposition');
const serverFilename = getFilenameFromContentDisposition(contentDisposition);
let filename: string;
if (serverFilename) {
filename = serverFilename;
} else {
const baseTitle = (fileName ?? 'document').replace(/\.[^/.]+$/, '');
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
filename = `${baseTitle}${suffix}`;
}
downloadFile({
filename: `${baseTitle}${suffix}`,
filename,
data: blob,
});
};
+10
View File
@@ -11,3 +11,13 @@ export const isAllowedMimeType = (mimeType: string): boolean =>
mimeType in ALLOWED_UPLOAD_MIME_TYPES;
export const getGotenbergUrl = (): string | undefined => env('NEXT_PRIVATE_GOTENBERG_URL');
export const getGotenbergTimeout = (): number => {
const timeout = env('NEXT_PRIVATE_GOTENBERG_TIMEOUT');
return timeout ? parseInt(timeout, 10) : 30_000;
};
export const getFileExtensionForMimeType = (mimeType: string): string => {
const extensions = ALLOWED_UPLOAD_MIME_TYPES[mimeType];
return extensions?.[0] ?? '.pdf';
};
@@ -5,12 +5,14 @@ import { prisma } from '@documenso/prisma';
export type CreateDocumentDataOptions = {
type: DocumentDataType;
data: string;
originalData?: string;
originalMimeType?: string;
};
export const createDocumentData = async ({
type,
data,
originalData,
originalMimeType,
}: CreateDocumentDataOptions) => {
return await prisma.documentData.create({
@@ -18,6 +20,7 @@ export const createDocumentData = async ({
type,
data,
initialData: data,
originalData,
originalMimeType,
},
});
@@ -10,6 +10,7 @@ type FileInput = {
export type ConvertToPdfResult = {
pdfBuffer: Buffer;
originalBuffer: Buffer | null;
originalMimeType: string;
};
@@ -24,17 +25,17 @@ export const convertToPdfIfNeeded = async (file: FileInput): Promise<ConvertToPd
});
}
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
if (originalMimeType === 'application/pdf') {
const arrayBuffer = await file.arrayBuffer();
return {
pdfBuffer: Buffer.from(arrayBuffer),
pdfBuffer: buffer,
originalBuffer: null,
originalMimeType,
};
}
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const pdfBuffer = await convertFileToPdfViaGotenberg({
file: buffer,
filename: file.name,
@@ -43,6 +44,7 @@ export const convertToPdfIfNeeded = async (file: FileInput): Promise<ConvertToPd
return {
pdfBuffer,
originalBuffer: buffer,
originalMimeType,
};
};
@@ -1,4 +1,4 @@
import { getGotenbergUrl } from '../../constants/upload';
import { getGotenbergTimeout, getGotenbergUrl } from '../../constants/upload';
import { AppError } from '../../errors/app-error';
export type ConvertFileToPdfOptions = {
@@ -7,8 +7,6 @@ export type ConvertFileToPdfOptions = {
mimeType: string;
};
const CONVERSION_TIMEOUT_MS = 30_000;
export const convertFileToPdfViaGotenberg = async ({
file,
filename,
@@ -29,7 +27,7 @@ export const convertFileToPdfViaGotenberg = async ({
formData.append('files', blob, filename);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CONVERSION_TIMEOUT_MS);
const timeoutId = setTimeout(() => controller.abort(), getGotenbergTimeout());
try {
const response = await fetch(`${gotenbergUrl}/forms/libreoffice/convert`, {
@@ -46,6 +46,7 @@ export const putPdfFileServerSide = async (file: File) => {
type PutNormalizedPdfOptions = {
file: File;
originalData?: string;
originalMimeType?: string;
};
@@ -54,6 +55,7 @@ type PutNormalizedPdfOptions = {
*/
export const putNormalizedPdfFileServerSide = async ({
file,
originalData,
originalMimeType,
}: PutNormalizedPdfOptions) => {
const buffer = Buffer.from(await file.arrayBuffer());
@@ -71,6 +73,7 @@ export const putNormalizedPdfFileServerSide = async ({
return await createDocumentData({
type: documentData.type,
data: documentData.data,
originalData,
originalMimeType,
});
};
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "DocumentData" ADD COLUMN "originalData" TEXT;
+1
View File
@@ -490,6 +490,7 @@ model DocumentData {
type DocumentDataType
data String
initialData String
originalData String?
originalMimeType String?
envelopeItem EnvelopeItem?
}
@@ -3,8 +3,12 @@ import { EnvelopeType } from '@prisma/client';
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 { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import {
putFileServerSide,
putNormalizedPdfFileServerSide,
} from '@documenso/lib/universal/upload/put-file.server';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { authenticatedProcedure } from '../trpc';
@@ -36,7 +40,9 @@ export const createDocumentRoute = authenticatedProcedure
attachments,
} = payload;
let pdf = Buffer.from(await file.arrayBuffer());
const { pdfBuffer, originalBuffer, originalMimeType } = await convertToPdfIfNeeded(file);
let pdf = pdfBuffer;
if (formValues) {
// eslint-disable-next-line require-atomic-updates
@@ -46,12 +52,24 @@ export const createDocumentRoute = authenticatedProcedure
});
}
let originalData: string | undefined;
if (originalBuffer) {
const stored = await putFileServerSide({
name: `original-${file.name}`,
type: originalMimeType,
arrayBuffer: async () => Promise.resolve(originalBuffer),
});
originalData = stored.data;
}
const { id: documentDataId } = await putNormalizedPdfFileServerSide({
file: {
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdf),
},
originalData,
originalMimeType,
});
ctx.logger.info({
@@ -2,7 +2,10 @@ 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 {
putFileServerSide,
putNormalizedPdfFileServerSide,
} from '@documenso/lib/universal/upload/put-file.server';
import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf';
import { authenticatedProcedure } from '../trpc';
@@ -61,11 +64,9 @@ export const createEnvelopeRoute = authenticatedProcedure
});
}
// For each file, convert to PDF if needed, then store.
const envelopeItems = await Promise.all(
files.map(async (file) => {
// Convert to PDF if needed (DOCX, images, etc)
const { pdfBuffer, originalMimeType } = await convertToPdfIfNeeded(file);
const { pdfBuffer, originalBuffer, originalMimeType } = await convertToPdfIfNeeded(file);
let pdf = pdfBuffer;
@@ -77,12 +78,23 @@ export const createEnvelopeRoute = authenticatedProcedure
});
}
let originalData: string | undefined;
if (originalBuffer) {
const stored = await putFileServerSide({
name: `original-${file.name}`,
type: originalMimeType,
arrayBuffer: async () => Promise.resolve(originalBuffer),
});
originalData = stored.data;
}
const { id: documentDataId } = await putNormalizedPdfFileServerSide({
file: {
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdf),
},
originalData,
originalMimeType,
});