fix: trim out of bound contents when pdf is replaced

This commit is contained in:
David Nguyen
2026-10-01 17:02:12 +10:00
parent 656d1347b9
commit f38c151b1b
16 changed files with 147 additions and 27 deletions
+7 -2
View File
@@ -11,6 +11,7 @@ import { Hono } from 'hono';
import type { HonoEnv } from '../../router';
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers';
import {
type TUploadPdfResponse,
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
ZGetEnvelopeItemFileRequestParamsSchema,
ZGetEnvelopeItemFileRequestQuerySchema,
@@ -51,9 +52,13 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'File too large' }, 400);
}
const result = await putNormalizedPdfFileServerSide(file);
const { documentData } = await putNormalizedPdfFileServerSide(file);
return c.json(result);
// Typed so the response cannot drift from the shape `putPdfFile` reads
// on the client.
const response: TUploadPdfResponse = documentData;
return c.json(response);
} catch (error) {
console.error('Upload failed:', error);
return c.json({ error: 'Upload failed' }, 500);
+1 -1
View File
@@ -818,7 +818,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
formValues: body.formValues,
});
const newDocumentData = await putNormalizedPdfFileServerSide({
const { documentData: newDocumentData } = await putNormalizedPdfFileServerSide({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
@@ -2,8 +2,12 @@ import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { EnvelopeContentType, type TEnvelopeContentMetaInput } from '@documenso/lib/types/envelope-content-meta';
import { nanoid } from '@documenso/lib/universal/id';
import {
EnvelopeContentType,
type TEnvelopeContentMetaInput,
ZEnvelopeContentMetaSchema,
} from '@documenso/lib/types/envelope-content-meta';
import { generateDatabaseId, nanoid } from '@documenso/lib/universal/id';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
@@ -33,6 +37,9 @@ test.describe.configure({
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
// Three pages, against the single page of `example.pdf`.
const multiPagePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'));
const textMeta = (text: string): TEnvelopeContentMetaInput => ({
type: EnvelopeContentType.TEXT,
page: 1,
@@ -346,6 +353,70 @@ test.describe('Copy contents to new envelopes', () => {
expect(await prisma.documentAuditLog.count({ where: { envelopeId: sourceId } })).toBe(sourceAuditLogCount);
});
test('envelope/use leaves behind contents on pages a custom file does not have', async ({ request }) => {
// A template with a three page file.
const createForm = new FormData();
createForm.append(
'payload',
JSON.stringify({ type: EnvelopeType.TEMPLATE, title: 'Template With Contents On Later Pages' }),
);
createForm.append('files', new File([multiPagePdfBuffer], 'multi-page.pdf', { type: 'application/pdf' }));
const createRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: createForm,
});
expect(createRes.ok(), await createRes.text()).toBeTruthy();
const { id: templateId } = (await createRes.json()) as TCreateEnvelopeResponse;
const template = await prisma.envelope.findUniqueOrThrow({
where: { id: templateId },
include: { envelopeItems: true },
});
// A content on its first and third page, as the editor would have placed.
await prisma.envelopeContent.createMany({
data: [1, 3].map((page) => ({
id: generateDatabaseId('envelope_content'),
envelopeId: templateId,
envelopeItemId: template.envelopeItems[0].id,
contentMeta: ZEnvelopeContentMetaSchema.parse({ ...textMeta(`On page ${page}`), page }),
})),
});
// Use it with a single page file in place of the template's own.
const payload: TUseEnvelopePayload = {
envelopeId: templateId,
customDocumentData: [{ identifier: 0, envelopeItemId: template.envelopeItems[0].id }],
};
const useForm = new FormData();
useForm.append('payload', JSON.stringify(payload));
useForm.append('files', new File([examplePdfBuffer], 'single-page.pdf', { type: 'application/pdf' }));
const useRes = await request.post(`${baseUrl}/envelope/use`, {
headers: { Authorization: `Bearer ${token}` },
multipart: useForm,
});
expect(useRes.ok(), await useRes.text()).toBeTruthy();
const { id: documentId } = (await useRes.json()) as TUseEnvelopeResponse;
// Only the page 1 content made it across, the template keeps both.
const copied = await getContentSummaries(documentId);
expect(copied).toHaveLength(1);
expect(copied[0].contentMeta).toMatchObject({ page: 1, text: 'On page 1' });
expect(await prisma.envelopeContent.count({ where: { envelopeId: templateId } })).toBe(2);
});
test('envelope/duplicate logs nothing for a template', async ({ request }) => {
const sourceId = await createSourceEnvelope(request, token, EnvelopeType.TEMPLATE, { withSignerFields: true });
@@ -454,7 +454,7 @@ const injectFormValuesIntoDocument = async (
fileName = `${envelope.title}.pdf`;
}
const newDocumentData = await putNormalizedPdfFileServerSide({
const { documentData: newDocumentData } = await putNormalizedPdfFileServerSide({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
@@ -53,7 +53,7 @@ export const UNSAFE_createEnvelopeItems = async ({
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues });
}
const normalized = await normalizePdf(buffer, {
const { pdf: normalized } = await normalizePdf(buffer, {
flattenForm: envelope.type !== 'TEMPLATE',
});
@@ -89,7 +89,7 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues });
}
const normalized = await normalizePdf(buffer, {
const { pdf: normalized } = await normalizePdf(buffer, {
flattenForm: envelope.type !== 'TEMPLATE',
});
@@ -261,7 +261,7 @@ export const createEnvelope = async ({
const buffer = await getFileServerSide(documentData);
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer), {
const { pdf: normalizedPdf } = await makeNormalizedPdf(Buffer.from(buffer), {
flattenForm: type !== EnvelopeType.TEMPLATE,
});
@@ -3,6 +3,7 @@ import type { PDF } from '@libpdf/core';
import { PDF as PDFDocument } from '@libpdf/core';
import { groupBy, unique } from 'remeda';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { ContentImageMap } from '../../universal/content-renderer/content-renderer';
import { insertFieldInPDFV2, type OverlayContent } from './insert-field-in-pdf-v2';
@@ -33,7 +34,9 @@ export const insertPageOverlays = async ({ pdfDoc, fields, contents = [], images
const page = pdfDoc.getPage(pageNumber - 1);
if (!page) {
throw new Error(`Page ${pageNumber} does not exist`);
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Page ${pageNumber} does not exist`,
});
}
const pageWidth = page.width;
+12 -1
View File
@@ -2,6 +2,14 @@ import { PDF } from '@libpdf/core';
import { AppError } from '../../errors/app-error';
/**
* Normalize a PDF for storage: unlock it, flatten its layers and optionally
* its form.
*
* Returns the normalized bytes along with the page count, since the document
* is already parsed here and callers otherwise have to parse it again to
* learn how many pages it has.
*/
export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean } = {}) => {
const shouldFlattenForm = options.flattenForm ?? true;
@@ -34,5 +42,8 @@ export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean
const normalizedPdfBytes = await pdfDoc.save();
return Buffer.from(normalizedPdfBytes);
return {
pdf: Buffer.from(normalizedPdfBytes),
pageCount: pdfDoc.getPageCount(),
};
};
@@ -451,6 +451,13 @@ export const createDocumentFromTemplate = async ({
// Value = duplicated envelope item ID.
const oldEnvelopeItemToNewEnvelopeItemIdMap: Record<string, string> = {};
// Key = original envelope item ID
// Value = the page count of the custom file which replaced its PDF.
//
// Only items given a custom file are counted, since the template's own
// files have the pages its contents were placed on.
const replacedEnvelopeItemPageCounts = new Map<string, number>();
// Duplicate the envelope item data.
// Note: This is duplicated in createDocumentFromDirectTemplate
const envelopeItemsToCreate = await Promise.all(
@@ -496,7 +503,7 @@ export const createDocumentFromTemplate = async ({
// The copy keeps the source as its initial data, so the two share the
// stored file rather than re-uploading it.
const newDocumentData = await putNormalizedPdfFileServerSide(
const { documentData: newDocumentData, filePageCount } = await putNormalizedPdfFileServerSide(
{
name: titleToUse,
type: 'application/pdf',
@@ -507,6 +514,10 @@ export const createDocumentFromTemplate = async ({
},
);
if (foundCustomDocumentData) {
replacedEnvelopeItemPageCounts.set(item.id, filePageCount);
}
const newEnvelopeItemId = prefixedId('envelope_item');
oldEnvelopeItemToNewEnvelopeItemIdMap[item.id] = newEnvelopeItemId;
@@ -583,11 +594,18 @@ export const createDocumentFromTemplate = async ({
const envelopeId = prefixedId('envelope');
// Trim the contents if the replaced PDF has fewer pages than the content's page.
const contentsToCopy = template.contents.filter((content) => {
const replacedPageCount = replacedEnvelopeItemPageCounts.get(content.envelopeItemId);
return replacedPageCount === undefined || content.contentMeta.page <= replacedPageCount;
});
// The template's contents are remapped onto the new envelope items up front
// so they can be inserted as soon as the envelope exists.
const contentsToCreate = includeContents
? buildEnvelopeContentCopyData({
contents: template.contents,
contents: contentsToCopy,
envelopeId,
envelopeItemIdMap: oldEnvelopeItemToNewEnvelopeItemIdMap,
})
+5 -1
View File
@@ -54,7 +54,11 @@ export const ZContentLetterSpacingSchema = z
* All content geometry is percentage based (0-100) relative to the page, unlike
* fields which are stored in page units and clamped separately.
*/
export const ZContentPageNumberSchema = z.number().min(1).describe('The page number the content will be on.');
export const ZContentPageNumberSchema = z
.number()
.int()
.min(1)
.describe('The page number the content will be on. Starts from 1.');
export const ZContentPercentageSchema = z.number().min(0).max(100);
@@ -66,6 +66,9 @@ type PutNormalizedPdfFileOptions = {
/**
* Uploads a pdf file and normalizes it.
*
* Returns the created document data and the file's page count, in the same
* shape as `putPdfFileServerSide`.
*/
export const putNormalizedPdfFileServerSide = async (
file: File,
@@ -73,21 +76,26 @@ export const putNormalizedPdfFileServerSide = async (
) => {
const buffer = Buffer.from(await file.arrayBuffer());
const normalized = await normalizePdf(buffer, normalizePdfOptions);
const { pdf: normalized, pageCount } = await normalizePdf(buffer, normalizePdfOptions);
const fileName = file.name.endsWith('.pdf') ? file.name : `${file.name}.pdf`;
const documentData = await putFileServerSide({
const uploadedFile = await putFileServerSide({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(normalized),
});
return await createDocumentData({
type: documentData.type,
data: documentData.data,
const documentData = await createDocumentData({
type: uploadedFile.type,
data: uploadedFile.data,
initialData,
});
return {
documentData,
filePageCount: pageCount,
};
};
/**
@@ -46,7 +46,7 @@ export const createDocumentRoute = authenticatedProcedure
});
}
const { id: documentDataId } = await putNormalizedPdfFileServerSide({
const { documentData } = await putNormalizedPdfFileServerSide({
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdf),
@@ -86,14 +86,14 @@ export const createDocumentRoute = authenticatedProcedure
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
documentDataId,
documentDataId: documentData.id,
})),
})),
folderId,
envelopeItems: [
{
// If you ever allow more than 1 in this endpoint, make sure to use `maximumEnvelopeItemCount` to limit it.
documentDataId,
documentDataId: documentData.id,
},
],
},
@@ -144,7 +144,7 @@ export const createEnvelopeRouteCaller = async ({
});
}
const normalized = await normalizePdf(pdf, {
const { pdf: normalized } = await normalizePdf(pdf, {
flattenForm: type !== EnvelopeType.TEMPLATE,
});
@@ -76,13 +76,13 @@ export const useEnvelopeRoute = authenticatedProcedure
const uploadedFiles = await Promise.all(
filesToUpload.map(async (file) => {
// We disable flattening here since `createDocumentFromTemplate` will handle it.
const { id: documentDataId } = await putNormalizedPdfFileServerSide(file, {
const { documentData } = await putNormalizedPdfFileServerSide(file, {
flattenForm: false,
});
return {
name: file.name,
documentDataId,
documentDataId: documentData.id,
};
}),
);
@@ -237,7 +237,7 @@ export const templateRouter = router({
const pdf = await convertToPdf(file, ctx.logger);
const { id: templateDocumentDataId } = await putNormalizedPdfFileServerSide(
const { documentData: templateDocumentData } = await putNormalizedPdfFileServerSide(
{
name: file.name,
type: 'application/pdf',
@@ -263,7 +263,7 @@ export const templateRouter = router({
title,
envelopeItems: [
{
documentDataId: templateDocumentDataId,
documentDataId: templateDocumentData.id,
},
],
folderId,