feat: support DOCX uploads via Gotenberg (#2801)

Uploaded .docx files are converted to PDF on the server using a
Gotenberg
sidecar before entering the normal envelope pipeline. The feature is
opt-in via NEXT_PRIVATE_DOCUMENT_CONVERSION_URL; when unset, only PDF
uploads are accepted.

A per-process circuit breaker opens for 30s after a conversion failure
to shed load.

Ships a dev Dockerfile that layers Microsoft Core Fonts and additional
language fonts
onto the upstream Gotenberg image for better fidelity.

Co-authored-by: Ephraim Duncan
<55143799+ephraimduncan@users.noreply.github.com>

Co-authored-by: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com>
This commit is contained in:
Lucas Smith
2026-05-13 15:06:21 +10:00
committed by GitHub
parent 8dfd548c08
commit bc184d445f
23 changed files with 1062 additions and 41 deletions
@@ -1,5 +1,6 @@
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { convertToPdf } from '@documenso/lib/server-only/document-conversion';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
@@ -35,7 +36,7 @@ export const createDocumentRoute = authenticatedProcedure
attachments,
} = payload;
let pdf = Buffer.from(await file.arrayBuffer());
let pdf = await convertToPdf(file, ctx.logger);
if (formValues) {
// eslint-disable-next-line require-atomic-updates
@@ -37,5 +37,6 @@ export const createEmbeddingEnvelopeRoute = procedure
bypassDefaultRecipients: true,
},
apiRequestMetadata: ctx.metadata,
logger: ctx.logger,
});
});
@@ -1,11 +1,13 @@
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { convertToPdf } from '@documenso/lib/server-only/document-conversion';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { extractPdfPlaceholders } from '@documenso/lib/server-only/pdf/auto-place-fields';
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { EnvelopeType } from '@prisma/client';
import type { Logger } from 'pino';
import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf';
import { authenticatedProcedure } from '../trpc';
@@ -32,6 +34,7 @@ export const createEnvelopeRoute = authenticatedProcedure
teamId: ctx.teamId,
input,
apiRequestMetadata: ctx.metadata,
logger: ctx.logger,
});
});
@@ -48,6 +51,12 @@ type CreateEnvelopeRouteOptions = {
input: TCreateEnvelopeRequest;
apiRequestMetadata: ApiRequestMetadata;
/**
* Optional pino logger threaded from the calling tRPC context. Passed to
* downstream helpers (e.g. `convertToPdf`) for structured logging.
*/
logger?: Logger;
options?: {
bypassDefaultRecipients?: boolean;
};
@@ -58,6 +67,7 @@ export const createEnvelopeRouteCaller = async ({
teamId,
input,
apiRequestMetadata,
logger,
options = {},
}: CreateEnvelopeRouteOptions) => {
const { payload, files } = input;
@@ -96,17 +106,10 @@ export const createEnvelopeRouteCaller = async ({
});
}
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: normalize, extract & clean placeholders, then upload.
// For each file: convert to PDF if needed, normalize, extract & clean placeholders, then upload.
const envelopeItems = await Promise.all(
files.map(async (file) => {
let pdf = Buffer.from(await file.arrayBuffer());
let pdf = await convertToPdf(file, logger);
if (formValues) {
// eslint-disable-next-line require-atomic-updates