mirror of
https://github.com/documenso/documenso.git
synced 2026-10-03 10:13:52 +10:00
feat: add document file conversion
This commit is contained in:
@@ -20,6 +20,7 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import { ALLOWED_UPLOAD_MIME_TYPES } from '@documenso/lib/constants/upload';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
@@ -132,6 +133,18 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
|
||||
() => t`You have reached the limit of the number of files per envelope.`,
|
||||
)
|
||||
.with(
|
||||
'CONVERSION_SERVICE_UNAVAILABLE',
|
||||
() => t`File conversion is not available. Please upload a PDF file.`,
|
||||
)
|
||||
.with(
|
||||
'CONVERSION_FAILED',
|
||||
() => t`Failed to convert file. Please try uploading a PDF instead.`,
|
||||
)
|
||||
.with(
|
||||
'UNSUPPORTED_FILE_TYPE',
|
||||
() => t`This file type is not supported. Please upload a PDF, Word document, or image.`,
|
||||
)
|
||||
.otherwise(() => t`An error occurred during upload.`);
|
||||
|
||||
toast({
|
||||
@@ -180,7 +193,9 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
.with(ErrorCode.FileTooLarge, () => (
|
||||
<Trans>File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB</Trans>
|
||||
))
|
||||
.with(ErrorCode.FileInvalidType, () => <Trans>Only PDF files are allowed</Trans>)
|
||||
.with(ErrorCode.FileInvalidType, () => (
|
||||
<Trans>Supported formats: PDF, Word documents, JPEG, and PNG images</Trans>
|
||||
))
|
||||
.with(ErrorCode.FileTooSmall, () => <Trans>File is too small</Trans>)
|
||||
.with(ErrorCode.TooManyFiles, () => (
|
||||
<Trans>Only one file can be uploaded at a time</Trans>
|
||||
@@ -208,9 +223,7 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
});
|
||||
};
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
},
|
||||
accept: ALLOWED_UPLOAD_MIME_TYPES,
|
||||
multiple: true,
|
||||
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
|
||||
maxFiles: maximumEnvelopeItemCount,
|
||||
@@ -237,7 +250,7 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
</h2>
|
||||
|
||||
<p className="text-md mt-4 text-muted-foreground">
|
||||
<Trans>Drag and drop your PDF file here</Trans>
|
||||
<Trans>Drag and drop your document here</Trans>
|
||||
</p>
|
||||
|
||||
{isUploadDisabled && IS_BILLING_ENABLED() && (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type DocumentDataType, DocumentStatus } from '@prisma/client';
|
||||
import contentDisposition from 'content-disposition';
|
||||
import { type Context } from 'hono';
|
||||
|
||||
import { getFileExtensionForMimeType } from '@documenso/lib/constants/upload';
|
||||
import { sha256 } from '@documenso/lib/universal/crypto';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
|
||||
@@ -14,6 +15,8 @@ type HandleEnvelopeItemFileRequestOptions = {
|
||||
type: DocumentDataType;
|
||||
data: string;
|
||||
initialData: string;
|
||||
originalData?: string | null;
|
||||
originalMimeType?: string | null;
|
||||
};
|
||||
version: 'signed' | 'original';
|
||||
isDownload: boolean;
|
||||
@@ -31,7 +34,21 @@ export const handleEnvelopeItemFileRequest = async ({
|
||||
isDownload,
|
||||
context: c,
|
||||
}: HandleEnvelopeItemFileRequestOptions) => {
|
||||
const documentDataToUse = version === 'signed' ? documentData.data : documentData.initialData;
|
||||
const shouldServeOriginalSourceFile =
|
||||
version === 'original' &&
|
||||
documentData.originalData &&
|
||||
documentData.originalMimeType &&
|
||||
documentData.originalMimeType !== 'application/pdf';
|
||||
|
||||
const documentDataToUse = shouldServeOriginalSourceFile
|
||||
? documentData.originalData!
|
||||
: version === 'signed'
|
||||
? documentData.data
|
||||
: documentData.initialData;
|
||||
|
||||
const contentType = shouldServeOriginalSourceFile
|
||||
? documentData.originalMimeType!
|
||||
: 'application/pdf';
|
||||
|
||||
const etag = Buffer.from(sha256(documentDataToUse)).toString('hex');
|
||||
|
||||
@@ -52,7 +69,7 @@ export const handleEnvelopeItemFileRequest = async ({
|
||||
return c.json({ error: 'File not found' }, 404);
|
||||
}
|
||||
|
||||
c.header('Content-Type', 'application/pdf');
|
||||
c.header('Content-Type', contentType);
|
||||
c.header('ETag', etag);
|
||||
|
||||
if (!isDownload) {
|
||||
@@ -64,10 +81,17 @@ export const handleEnvelopeItemFileRequest = async ({
|
||||
}
|
||||
|
||||
if (isDownload) {
|
||||
// Generate filename following the pattern from envelope-download-dialog.tsx
|
||||
const baseTitle = title.replace(/\.pdf$/, '');
|
||||
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
|
||||
const filename = `${baseTitle}${suffix}`;
|
||||
const baseTitle = title.replace(/\.[^/.]+$/, '');
|
||||
|
||||
let filename: string;
|
||||
if (version === 'signed') {
|
||||
filename = `${baseTitle}_signed.pdf`;
|
||||
} else if (shouldServeOriginalSourceFile) {
|
||||
const extension = getFileExtensionForMimeType(documentData.originalMimeType!);
|
||||
filename = `${baseTitle}${extension}`;
|
||||
} else {
|
||||
filename = `${baseTitle}.pdf`;
|
||||
}
|
||||
|
||||
c.header('Content-Disposition', contentDisposition(filename));
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 809 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user