mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 18:04:55 +10:00
fix(pdf): address AcroForm import review feedback
This commit is contained in:
@@ -13,8 +13,10 @@ import type {
|
|||||||
TCreateEnvelopePayload,
|
TCreateEnvelopePayload,
|
||||||
TCreateEnvelopeResponse,
|
TCreateEnvelopeResponse,
|
||||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||||
import { PDF } from '@libpdf/core';
|
import { PDF, PdfString } from '@libpdf/core';
|
||||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
import { type APIRequestContext, expect, type Page, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||||
@@ -40,8 +42,9 @@ const API_REQUEST_METADATA: ApiRequestMetadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type TestUser = Awaited<ReturnType<typeof seedUser>>['user'];
|
type TestUser = Awaited<ReturnType<typeof seedUser>>['user'];
|
||||||
|
type TestTeam = Awaited<ReturnType<typeof seedUser>>['team'];
|
||||||
|
|
||||||
const seedUserWithApiToken = async (): Promise<{ token: string; user: TestUser }> => {
|
const seedUserWithApiToken = async (): Promise<{ token: string; user: TestUser; team: TestTeam }> => {
|
||||||
const { user, team } = await seedUser();
|
const { user, team } = await seedUser();
|
||||||
const { token } = await createApiToken({
|
const { token } = await createApiToken({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -50,7 +53,7 @@ const seedUserWithApiToken = async (): Promise<{ token: string; user: TestUser }
|
|||||||
expiresIn: null,
|
expiresIn: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { token, user };
|
return { token, user, team };
|
||||||
};
|
};
|
||||||
|
|
||||||
const pdfHasFormFields = async (pdf: Uint8Array): Promise<boolean> => {
|
const pdfHasFormFields = async (pdf: Uint8Array): Promise<boolean> => {
|
||||||
@@ -60,19 +63,36 @@ const pdfHasFormFields = async (pdf: Uint8Array): Promise<boolean> => {
|
|||||||
return (form?.fieldCount ?? 0) > 0;
|
return (form?.fieldCount ?? 0) > 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createSignedSignatureAcroFormPdf = (): Promise<Uint8Array> => {
|
||||||
|
const pdf = PDF.create();
|
||||||
|
const page = pdf.addPage({ size: 'letter' });
|
||||||
|
const form = pdf.getOrCreateForm();
|
||||||
|
const textField = form.createTextField('full_name');
|
||||||
|
const signatureField = form.createSignatureField('signed_signature');
|
||||||
|
|
||||||
|
page.drawField(textField, { x: 100, y: 700, width: 200, height: 24 });
|
||||||
|
signatureField.getDict().set('V', PdfString.fromString('fake-signature'));
|
||||||
|
|
||||||
|
return pdf.save();
|
||||||
|
};
|
||||||
|
|
||||||
const uploadAcroFormEnvelope = async ({
|
const uploadAcroFormEnvelope = async ({
|
||||||
request,
|
request,
|
||||||
token,
|
token,
|
||||||
payload = ACROFORM_DOCUMENT_PAYLOAD,
|
payload = ACROFORM_DOCUMENT_PAYLOAD,
|
||||||
|
file = ACROFORM_FIXTURE,
|
||||||
|
fileName = 'acroform-import-test.pdf',
|
||||||
}: {
|
}: {
|
||||||
request: APIRequestContext;
|
request: APIRequestContext;
|
||||||
token: string;
|
token: string;
|
||||||
payload?: TCreateEnvelopePayload;
|
payload?: TCreateEnvelopePayload;
|
||||||
|
file?: Uint8Array;
|
||||||
|
fileName?: string;
|
||||||
}): Promise<TCreateEnvelopeResponse> => {
|
}): Promise<TCreateEnvelopeResponse> => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
formData.append('payload', JSON.stringify(payload));
|
formData.append('payload', JSON.stringify(payload));
|
||||||
formData.append('files', new File([ACROFORM_FIXTURE], 'acroform-import-test.pdf', { type: 'application/pdf' }));
|
formData.append('files', new File([file], fileName, { type: 'application/pdf' }));
|
||||||
|
|
||||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
@@ -84,6 +104,23 @@ const uploadAcroFormEnvelope = async ({
|
|||||||
return (await res.json()) as TCreateEnvelopeResponse;
|
return (await res.json()) as TCreateEnvelopeResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const importAcroFormFieldsWithSession = ({
|
||||||
|
page,
|
||||||
|
teamId,
|
||||||
|
envelopeId,
|
||||||
|
}: {
|
||||||
|
page: Page;
|
||||||
|
teamId: number;
|
||||||
|
envelopeId: string;
|
||||||
|
}) =>
|
||||||
|
page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.field.importFromPdf`, {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-team-id': String(teamId),
|
||||||
|
},
|
||||||
|
data: JSON.stringify({ json: { envelopeId } }),
|
||||||
|
});
|
||||||
|
|
||||||
const loadEnvelopeForImport = async (envelopeId: string) =>
|
const loadEnvelopeForImport = async (envelopeId: string) =>
|
||||||
prisma.envelope.findUniqueOrThrow({
|
prisma.envelope.findUniqueOrThrow({
|
||||||
where: { id: envelopeId },
|
where: { id: envelopeId },
|
||||||
@@ -237,4 +274,95 @@ test.describe('AcroForm Import', () => {
|
|||||||
expect(after.fields.length).toBeGreaterThanOrEqual(8);
|
expect(after.fields.length).toBeGreaterThanOrEqual(8);
|
||||||
expect(after.fields.every((f) => f.recipientId === after.recipients[0].id)).toBe(true);
|
expect(after.fields.every((f) => f.recipientId === after.recipients[0].id)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('import endpoint rejects template envelopes without mutating stored widgets', async ({ page, request }) => {
|
||||||
|
const { token, user, team } = await seedUserWithApiToken();
|
||||||
|
|
||||||
|
const response = await uploadAcroFormEnvelope({
|
||||||
|
request,
|
||||||
|
token,
|
||||||
|
payload: {
|
||||||
|
type: EnvelopeType.TEMPLATE,
|
||||||
|
title: 'AcroForm template',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({ page, email: user.email });
|
||||||
|
|
||||||
|
const res = await importAcroFormFieldsWithSession({
|
||||||
|
page,
|
||||||
|
teamId: team.id,
|
||||||
|
envelopeId: response.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBeFalsy();
|
||||||
|
expect(res.status()).toBe(404);
|
||||||
|
|
||||||
|
const after = await prisma.envelope.findUniqueOrThrow({
|
||||||
|
where: { id: response.id },
|
||||||
|
include: {
|
||||||
|
envelopeItems: { include: { documentData: true } },
|
||||||
|
fields: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(after.fields).toHaveLength(0);
|
||||||
|
|
||||||
|
const pdfBuffer = await getFileServerSide(after.envelopeItems[0].documentData);
|
||||||
|
|
||||||
|
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('import does not duplicate fields when signed signatures prevent flattening', async ({ request }) => {
|
||||||
|
const { token } = await seedUserWithApiToken();
|
||||||
|
const signedPdf = await createSignedSignatureAcroFormPdf();
|
||||||
|
|
||||||
|
const response = await uploadAcroFormEnvelope({
|
||||||
|
request,
|
||||||
|
token,
|
||||||
|
file: signedPdf,
|
||||||
|
fileName: 'signed-acroform.pdf',
|
||||||
|
});
|
||||||
|
|
||||||
|
const envelope = await loadEnvelopeForImport(response.id);
|
||||||
|
|
||||||
|
const firstResult = await UNSAFE_importAcroFormFieldsFromEnvelope({
|
||||||
|
envelope,
|
||||||
|
apiRequestMetadata: API_REQUEST_METADATA,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(firstResult.fieldsCreated).toBeGreaterThan(0);
|
||||||
|
expect(firstResult.itemsProcessed).toBe(1);
|
||||||
|
expect(firstResult.signedSignatureCount).toBe(1);
|
||||||
|
|
||||||
|
const afterFirst = await prisma.envelope.findUniqueOrThrow({
|
||||||
|
where: { id: response.id },
|
||||||
|
include: {
|
||||||
|
envelopeItems: { include: { documentData: true } },
|
||||||
|
fields: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const firstFieldCount = afterFirst.fields.length;
|
||||||
|
|
||||||
|
const preservedPdf = await getFileServerSide(afterFirst.envelopeItems[0].documentData);
|
||||||
|
|
||||||
|
expect(await pdfHasFormFields(preservedPdf)).toBe(true);
|
||||||
|
|
||||||
|
const secondEnvelope = await loadEnvelopeForImport(response.id);
|
||||||
|
|
||||||
|
const secondResult = await UNSAFE_importAcroFormFieldsFromEnvelope({
|
||||||
|
envelope: secondEnvelope,
|
||||||
|
apiRequestMetadata: API_REQUEST_METADATA,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(secondResult.fieldsCreated).toBe(0);
|
||||||
|
expect(secondResult.itemsProcessed).toBe(0);
|
||||||
|
|
||||||
|
const afterSecond = await prisma.envelope.findUniqueOrThrow({
|
||||||
|
where: { id: response.id },
|
||||||
|
include: { fields: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(afterSecond.fields).toHaveLength(firstFieldCount);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { DocumentData, Envelope, EnvelopeItem, Field, Recipient } from '@prisma/client';
|
import type { DocumentData, Envelope, EnvelopeItem, Field, Recipient } from '@prisma/client';
|
||||||
import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
|
import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
|
||||||
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import { nanoid } from '../../universal/id';
|
import { nanoid } from '../../universal/id';
|
||||||
@@ -53,6 +54,12 @@ export const UNSAFE_importAcroFormFieldsFromEnvelope = async ({
|
|||||||
envelope,
|
envelope,
|
||||||
apiRequestMetadata,
|
apiRequestMetadata,
|
||||||
}: UnsafeImportAcroFormFieldsOptions): Promise<ImportAcroFormFieldsResult> => {
|
}: UnsafeImportAcroFormFieldsOptions): Promise<ImportAcroFormFieldsResult> => {
|
||||||
|
if (envelope.type !== EnvelopeType.DOCUMENT) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'AcroForm import is only supported for document envelopes',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const prepared: PreparedItem[] = await Promise.all(
|
const prepared: PreparedItem[] = await Promise.all(
|
||||||
envelope.envelopeItems.map(async (item): Promise<PreparedItem> => {
|
envelope.envelopeItems.map(async (item): Promise<PreparedItem> => {
|
||||||
const buffer = await getFileServerSide(item.documentData);
|
const buffer = await getFileServerSide(item.documentData);
|
||||||
@@ -182,6 +189,54 @@ export const UNSAFE_importAcroFormFieldsFromEnvelope = async ({
|
|||||||
})[0];
|
})[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const signedItemIds = prepared
|
||||||
|
.filter((item) => item.extraction.hasSignedSignature && item.extraction.fields.length > 0)
|
||||||
|
.map((item) => item.envelopeItemId);
|
||||||
|
|
||||||
|
const alreadyImportedSignedItemIds = new Set<string>();
|
||||||
|
|
||||||
|
if (signedItemIds.length > 0) {
|
||||||
|
const existingImportedFields = await tx.field.findMany({
|
||||||
|
where: {
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
envelopeItemId: {
|
||||||
|
in: signedItemIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
envelopeItemId: true,
|
||||||
|
fieldMeta: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const field of existingImportedFields) {
|
||||||
|
const fieldMeta = field.fieldMeta;
|
||||||
|
|
||||||
|
if (
|
||||||
|
fieldMeta &&
|
||||||
|
typeof fieldMeta === 'object' &&
|
||||||
|
!Array.isArray(fieldMeta) &&
|
||||||
|
(fieldMeta as { source?: unknown }).source === 'acroform'
|
||||||
|
) {
|
||||||
|
alreadyImportedSignedItemIds.add(field.envelopeItemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsToImport = prepared.filter((item) => {
|
||||||
|
if (item.extraction.fields.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !(item.extraction.hasSignedSignature && alreadyImportedSignedItemIds.has(item.envelopeItemId));
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdFields: Field[] = [];
|
||||||
|
|
||||||
|
if (itemsToImport.length === 0) {
|
||||||
|
return { createdFields, importedItemsCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
let recipient = pickFirstSignableRecipient(
|
let recipient = pickFirstSignableRecipient(
|
||||||
await tx.recipient.findMany({
|
await tx.recipient.findMany({
|
||||||
where: { envelopeId: envelope.id },
|
where: { envelopeId: envelope.id },
|
||||||
@@ -207,14 +262,9 @@ export const UNSAFE_importAcroFormFieldsFromEnvelope = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdFields: Field[] = [];
|
|
||||||
let importedItemsCount = 0;
|
let importedItemsCount = 0;
|
||||||
|
|
||||||
for (const item of prepared) {
|
for (const item of itemsToImport) {
|
||||||
if (item.extraction.fields.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.newDocumentData) {
|
if (item.newDocumentData) {
|
||||||
await tx.envelopeItem.update({
|
await tx.envelopeItem.update({
|
||||||
where: { id: item.envelopeItemId },
|
where: { id: item.envelopeItemId },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|||||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||||
import { UNSAFE_importAcroFormFieldsFromEnvelope } from '@documenso/lib/server-only/envelope-item/import-acroform-fields';
|
import { UNSAFE_importAcroFormFieldsFromEnvelope } from '@documenso/lib/server-only/envelope-item/import-acroform-fields';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus } from '@prisma/client';
|
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||||
|
|
||||||
import { authenticatedProcedure } from '../trpc';
|
import { authenticatedProcedure } from '../trpc';
|
||||||
import {
|
import {
|
||||||
@@ -21,7 +21,7 @@ export const importAcroFormFieldsRoute = authenticatedProcedure
|
|||||||
|
|
||||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||||
id: { type: 'envelopeId', id: envelopeId },
|
id: { type: 'envelopeId', id: envelopeId },
|
||||||
type: null,
|
type: EnvelopeType.DOCUMENT,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId,
|
teamId,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user