mirror of
https://github.com/documenso/documenso.git
synced 2026-10-04 02:33:54 +10:00
fix: trim out of bound contents when pdf is replaced
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user