mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
d5ce222482
Adds Cloud Signature Consortium (CSC) integration for AES/QES signing against a configured TSP. v1 ships as instance-wide configuration via environment variables, with per-envelope signature level selection, license gating, and an OAuth-driven signing flow (capture + FIFO signers, SAD session, blocking/in-progress recipient pages). Includes signature level compatibility checks (role, signing order, dictate next signer), envelope mutability assertions, Prisma migration for signature level and CSC tables, and docs for the new signing certificate options.
512 lines
16 KiB
TypeScript
512 lines
16 KiB
TypeScript
import path from 'node:path';
|
|
import { PDFDocument } from '@cantoo/pdf-lib';
|
|
import { finalizeTspEnvelopeCompletion } from '@documenso/ee/server-only/signing/csc/finalize-tsp-completion';
|
|
import { addRejectionStampToPdf } from '@documenso/lib/server-only/pdf/add-rejection-stamp-to-pdf';
|
|
import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf';
|
|
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
|
|
import { getLastPageDimensions } from '@documenso/lib/server-only/pdf/get-page-size';
|
|
import { prisma } from '@documenso/prisma';
|
|
import { signPdf } from '@documenso/signing';
|
|
import { PDF } from '@libpdf/core';
|
|
import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client';
|
|
import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus, WebhookTriggerEvents } from '@prisma/client';
|
|
import { nanoid } from 'nanoid';
|
|
import { groupBy } from 'remeda';
|
|
|
|
import { NEXT_PRIVATE_USE_PLAYWRIGHT_PDF } from '../../../constants/app';
|
|
import { AppError, AppErrorCode } from '../../../errors/app-error';
|
|
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
|
|
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
|
|
import { insertFieldInPDFV1 } from '../../../server-only/pdf/insert-field-in-pdf-v1';
|
|
import { insertFieldInPDFV2 } from '../../../server-only/pdf/insert-field-in-pdf-v2';
|
|
import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-field-in-pdf';
|
|
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
|
|
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
|
|
import { DOCUMENT_AUDIT_LOG_TYPE, type TDocumentAuditLog } from '../../../types/document-audit-logs';
|
|
import { isTspEnvelope } from '../../../types/signature-level';
|
|
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../../types/webhook-payload';
|
|
import { prefixedId } from '../../../universal/id';
|
|
import { getFileServerSide } from '../../../universal/upload/get-file.server';
|
|
import { putPdfFileServerSide } from '../../../universal/upload/put-file.server';
|
|
import { fieldsContainUnsignedRequiredField } from '../../../utils/advanced-fields-helpers';
|
|
import { isDocumentCompleted } from '../../../utils/document';
|
|
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
|
|
import { mapDocumentIdToSecondaryId } from '../../../utils/envelope';
|
|
import { jobs } from '../../client';
|
|
import type { JobRunIO } from '../../client/_internal/job';
|
|
import type { TSealDocumentJobDefinition } from './seal-document';
|
|
|
|
export const run = async ({ payload, io }: { payload: TSealDocumentJobDefinition; io: JobRunIO }) => {
|
|
const { documentId, sendEmail = true, isResealing = false, requestMetadata } = payload;
|
|
|
|
const { envelopeId, envelopeStatus, isRejected } = await io.runTask('seal-document', async () => {
|
|
const envelope = await prisma.envelope.findFirstOrThrow({
|
|
where: {
|
|
type: EnvelopeType.DOCUMENT,
|
|
secondaryId: mapDocumentIdToSecondaryId(documentId),
|
|
},
|
|
include: {
|
|
user: {
|
|
select: {
|
|
name: true,
|
|
email: true,
|
|
},
|
|
},
|
|
documentMeta: true,
|
|
recipients: true,
|
|
fields: {
|
|
include: {
|
|
signature: true,
|
|
},
|
|
},
|
|
envelopeItems: {
|
|
include: {
|
|
documentData: true,
|
|
field: {
|
|
include: {
|
|
signature: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (envelope.envelopeItems.length === 0) {
|
|
throw new Error('At least one envelope item required');
|
|
}
|
|
|
|
const settings = await getTeamSettings({
|
|
userId: envelope.userId,
|
|
teamId: envelope.teamId,
|
|
});
|
|
|
|
// Ensure all CC recipients are marked as signed
|
|
await prisma.recipient.updateMany({
|
|
where: {
|
|
envelopeId: envelope.id,
|
|
role: RecipientRole.CC,
|
|
},
|
|
data: {
|
|
signingStatus: SigningStatus.SIGNED,
|
|
},
|
|
});
|
|
|
|
const isComplete =
|
|
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
|
|
envelope.recipients.every(
|
|
(recipient) => recipient.signingStatus === SigningStatus.SIGNED || recipient.role === RecipientRole.CC,
|
|
);
|
|
|
|
if (!isComplete) {
|
|
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
|
message: 'Document is not complete',
|
|
});
|
|
}
|
|
|
|
let { envelopeItems } = envelope;
|
|
|
|
const fields = envelope.fields;
|
|
|
|
if (envelopeItems.length < 1) {
|
|
throw new Error(`Document ${envelope.id} has no envelope items`);
|
|
}
|
|
|
|
const recipientsWithoutCCers = envelope.recipients.filter((recipient) => recipient.role !== RecipientRole.CC);
|
|
|
|
// Determine if the document has been rejected by checking if any recipient has rejected it
|
|
const rejectedRecipient = recipientsWithoutCCers.find(
|
|
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
|
|
);
|
|
|
|
const isRejected = Boolean(rejectedRecipient);
|
|
|
|
// Get the rejection reason from the rejected recipient
|
|
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
|
|
|
|
// Skip the field check if the document is rejected
|
|
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
|
|
throw new Error(`Document ${envelope.id} has unsigned required fields`);
|
|
}
|
|
|
|
if (isResealing) {
|
|
// If we're resealing we want to use the initial data for the document
|
|
// so we aren't placing fields on top of eachother.
|
|
envelopeItems = envelopeItems.map((envelopeItem) => ({
|
|
...envelopeItem,
|
|
documentData: {
|
|
...envelopeItem.documentData,
|
|
data: envelopeItem.documentData.initialData,
|
|
},
|
|
}));
|
|
}
|
|
|
|
if (!envelope.qrToken) {
|
|
await prisma.envelope.update({
|
|
where: {
|
|
id: envelope.id,
|
|
},
|
|
data: {
|
|
qrToken: prefixedId('qr'),
|
|
},
|
|
});
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
const envelopeCompletedAuditLog = createDocumentAuditLogData({
|
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
|
|
envelopeId: envelope.id,
|
|
requestMetadata,
|
|
user: null,
|
|
data: {
|
|
transactionId: nanoid(),
|
|
...(isRejected ? { isRejected: true, rejectionReason: rejectionReason } : {}),
|
|
},
|
|
});
|
|
|
|
const finalEnvelopeStatus = isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED;
|
|
|
|
if (isTspEnvelope(envelope)) {
|
|
if (isResealing) {
|
|
throw new AppError(AppErrorCode.NOT_SETUP, {
|
|
message: 'Re-sealing TSP envelopes is not supported — recipient signatures cannot be regenerated externally.',
|
|
});
|
|
}
|
|
|
|
if (isRejected) {
|
|
throw new AppError(AppErrorCode.NOT_SETUP, {
|
|
message:
|
|
'TSP envelope rejection is not supported in V1 — rejection stamps would invalidate PAdES signatures.',
|
|
});
|
|
}
|
|
|
|
await finalizeTspEnvelopeCompletion({
|
|
envelope,
|
|
envelopeCompletedAuditLog,
|
|
requestMetadata,
|
|
});
|
|
|
|
return {
|
|
envelopeId: envelope.id,
|
|
envelopeStatus: envelope.status,
|
|
isRejected,
|
|
};
|
|
}
|
|
|
|
// Pre-fetch all PDF data so we can read dimensions and pass it
|
|
// to decorateAndSignPdf without fetching again.
|
|
const prefetchedItems = await Promise.all(
|
|
envelopeItems.map(async (envelopeItem) => {
|
|
const pdfData = await getFileServerSide(envelopeItem.documentData);
|
|
|
|
return { envelopeItem, pdfData };
|
|
}),
|
|
);
|
|
|
|
const usePlaywrightPdf = NEXT_PRIVATE_USE_PLAYWRIGHT_PDF();
|
|
|
|
const needsCertificate = settings.includeSigningCertificate;
|
|
const needsAuditLog = settings.includeAuditLog;
|
|
|
|
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
|
|
|
|
for (const { envelopeItem, pdfData } of prefetchedItems) {
|
|
const envelopeItemFields = envelope.envelopeItems.find((item) => item.id === envelopeItem.id)?.field;
|
|
|
|
if (!envelopeItemFields) {
|
|
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
|
|
}
|
|
|
|
let certificateDoc: PDF | null = null;
|
|
let auditLogDoc: PDF | null = null;
|
|
|
|
if (needsCertificate || needsAuditLog) {
|
|
const pdfDoc = await PDF.load(pdfData);
|
|
|
|
const { width: pageWidth, height: pageHeight } = getLastPageDimensions(pdfDoc);
|
|
|
|
const additionalAuditLogs = [
|
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
{
|
|
...envelopeCompletedAuditLog,
|
|
id: '',
|
|
createdAt: new Date(),
|
|
} as TDocumentAuditLog,
|
|
];
|
|
|
|
const certificatePayload = {
|
|
envelope: {
|
|
...envelope,
|
|
status: finalEnvelopeStatus,
|
|
},
|
|
recipients: envelope.recipients,
|
|
fields,
|
|
language: envelope.documentMeta.language,
|
|
envelopeOwner: {
|
|
email: envelope.user.email,
|
|
name: envelope.user.name || '',
|
|
},
|
|
envelopeItems: envelopeItems.map((item) => item.title),
|
|
pageWidth,
|
|
pageHeight,
|
|
additionalAuditLogs,
|
|
};
|
|
|
|
const makeCertificatePdf = async () =>
|
|
usePlaywrightPdf
|
|
? getCertificatePdf({
|
|
documentId,
|
|
language: envelope.documentMeta.language,
|
|
}).then(async (buffer) => PDF.load(buffer))
|
|
: generateCertificatePdf(certificatePayload);
|
|
|
|
const makeAuditLogPdf = async () =>
|
|
usePlaywrightPdf
|
|
? getAuditLogsPdf({
|
|
documentId,
|
|
language: envelope.documentMeta.language,
|
|
}).then(async (buffer) => PDF.load(buffer))
|
|
: generateAuditLogPdf(certificatePayload);
|
|
|
|
[certificateDoc, auditLogDoc] = await Promise.all([
|
|
needsCertificate ? makeCertificatePdf() : null,
|
|
needsAuditLog ? makeAuditLogPdf() : null,
|
|
]);
|
|
}
|
|
|
|
const result = await decorateAndSignPdf({
|
|
envelope,
|
|
envelopeItem,
|
|
envelopeItemFields,
|
|
isRejected,
|
|
rejectionReason,
|
|
pdfData,
|
|
certificateDoc,
|
|
auditLogDoc,
|
|
});
|
|
|
|
newDocumentData.push(result);
|
|
}
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) {
|
|
await tx.envelopeItem.update({
|
|
where: {
|
|
envelopeId: envelope.id,
|
|
documentDataId: oldDocumentDataId,
|
|
},
|
|
data: {
|
|
documentDataId: newDocumentDataId,
|
|
},
|
|
});
|
|
}
|
|
|
|
await tx.envelope.update({
|
|
where: {
|
|
id: envelope.id,
|
|
},
|
|
data: {
|
|
status: finalEnvelopeStatus,
|
|
completedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
await tx.documentAuditLog.create({
|
|
data: envelopeCompletedAuditLog,
|
|
});
|
|
});
|
|
|
|
return {
|
|
envelopeId: envelope.id,
|
|
envelopeStatus: envelope.status,
|
|
isRejected,
|
|
};
|
|
});
|
|
|
|
const updatedEnvelope = await prisma.envelope.findFirstOrThrow({
|
|
where: {
|
|
id: envelopeId,
|
|
},
|
|
include: {
|
|
documentMeta: true,
|
|
recipients: true,
|
|
},
|
|
});
|
|
|
|
await triggerWebhook({
|
|
event: isRejected ? WebhookTriggerEvents.DOCUMENT_REJECTED : WebhookTriggerEvents.DOCUMENT_COMPLETED,
|
|
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(updatedEnvelope)),
|
|
userId: updatedEnvelope.userId,
|
|
teamId: updatedEnvelope.teamId ?? undefined,
|
|
});
|
|
|
|
let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected;
|
|
|
|
if (isResealing && !isDocumentCompleted(envelopeStatus)) {
|
|
shouldSendCompletedEmail = sendEmail;
|
|
}
|
|
|
|
if (shouldSendCompletedEmail) {
|
|
await jobs.triggerJob({
|
|
name: 'send.document.completed.emails',
|
|
payload: {
|
|
envelopeId,
|
|
requestMetadata,
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
type DecorateAndSignPdfOptions = {
|
|
envelope: Pick<Envelope, 'id' | 'title' | 'useLegacyFieldInsertion' | 'internalVersion'>;
|
|
envelopeItem: EnvelopeItem & { documentData: DocumentData };
|
|
envelopeItemFields: Field[];
|
|
isRejected: boolean;
|
|
rejectionReason: string;
|
|
pdfData: Uint8Array;
|
|
certificateDoc: PDF | null;
|
|
auditLogDoc: PDF | null;
|
|
};
|
|
|
|
/**
|
|
* Normalize, flatten and insert fields into a PDF document.
|
|
*/
|
|
const decorateAndSignPdf = async ({
|
|
envelope,
|
|
envelopeItem,
|
|
envelopeItemFields,
|
|
isRejected,
|
|
rejectionReason,
|
|
pdfData,
|
|
certificateDoc,
|
|
auditLogDoc,
|
|
}: DecorateAndSignPdfOptions) => {
|
|
let pdfDoc = await PDF.load(pdfData);
|
|
|
|
// Normalize and flatten layers that could cause issues with the signature
|
|
pdfDoc.flattenAll();
|
|
// Upgrade to PDF 1.7 for better compatibility with signing
|
|
pdfDoc.upgradeVersion('1.7');
|
|
|
|
// Add rejection stamp if the document is rejected
|
|
if (isRejected) {
|
|
await addRejectionStampToPdf(pdfDoc, rejectionReason);
|
|
}
|
|
|
|
if (certificateDoc) {
|
|
await pdfDoc.copyPagesFrom(
|
|
certificateDoc,
|
|
Array.from({ length: certificateDoc.getPageCount() }, (_, index) => index),
|
|
);
|
|
}
|
|
|
|
if (auditLogDoc) {
|
|
await pdfDoc.copyPagesFrom(
|
|
auditLogDoc,
|
|
Array.from({ length: auditLogDoc.getPageCount() }, (_, index) => index),
|
|
);
|
|
}
|
|
|
|
// Handle V1 and legacy insertions.
|
|
if (envelope.internalVersion === 1) {
|
|
const legacy_pdfLibDoc = await PDFDocument.load(await pdfDoc.save({ useXRefStream: true }));
|
|
|
|
for (const field of envelopeItemFields) {
|
|
if (field.inserted) {
|
|
if (envelope.useLegacyFieldInsertion) {
|
|
await legacy_insertFieldInPDF(legacy_pdfLibDoc, field);
|
|
} else {
|
|
await insertFieldInPDFV1(legacy_pdfLibDoc, field);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Should never run into issues with this flatten since all
|
|
// arcoFields are created by pdf-lib itself.
|
|
legacy_pdfLibDoc.getForm().flatten();
|
|
|
|
await pdfDoc.reload(await legacy_pdfLibDoc.save());
|
|
}
|
|
|
|
// Handle V2 envelope insertions.
|
|
if (envelope.internalVersion === 2) {
|
|
const fieldsGroupedByPage = groupBy(envelopeItemFields, (field) => field.page);
|
|
|
|
for (const [pageNumber, fields] of Object.entries(fieldsGroupedByPage)) {
|
|
const page = pdfDoc.getPage(Number(pageNumber) - 1);
|
|
|
|
if (!page) {
|
|
throw new Error(`Page ${pageNumber} does not exist`);
|
|
}
|
|
|
|
const pageWidth = page.width;
|
|
const pageHeight = page.height;
|
|
|
|
const overlayBytes = await insertFieldInPDFV2({
|
|
pageWidth,
|
|
pageHeight,
|
|
fields,
|
|
});
|
|
|
|
const overlayPdf = await PDF.load(overlayBytes);
|
|
|
|
const embeddedPage = await pdfDoc.embedPage(overlayPdf, 0);
|
|
|
|
// Rotate the page to the orientation that the react-pdf renders on the frontend.
|
|
let translateX = 0;
|
|
let translateY = 0;
|
|
|
|
switch (page.rotation) {
|
|
case 90:
|
|
translateX = pageHeight;
|
|
translateY = 0;
|
|
break;
|
|
case 180:
|
|
translateX = pageWidth;
|
|
translateY = pageHeight;
|
|
break;
|
|
case 270:
|
|
translateX = 0;
|
|
translateY = pageWidth;
|
|
break;
|
|
}
|
|
|
|
// Draw the overlay on the page
|
|
page.drawPage(embeddedPage, {
|
|
x: translateX,
|
|
y: translateY,
|
|
rotate: {
|
|
angle: page.rotation,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// Re-flatten the form to handle our checkbox and radio fields that
|
|
// create native arcoFields
|
|
pdfDoc.flattenAll();
|
|
|
|
pdfDoc = await PDF.load(await pdfDoc.save({ useXRefStream: true }));
|
|
|
|
const pdfBytes = await signPdf({ pdf: pdfDoc });
|
|
|
|
const { name } = path.parse(envelopeItem.title);
|
|
|
|
// Add suffix based on document status
|
|
const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf';
|
|
|
|
const { documentData: newDocumentData } = await putPdfFileServerSide(
|
|
{
|
|
name: `${name}${suffix}`,
|
|
type: 'application/pdf',
|
|
arrayBuffer: async () => Promise.resolve(pdfBytes),
|
|
},
|
|
envelopeItem.documentData.initialData,
|
|
);
|
|
|
|
return {
|
|
oldDocumentDataId: envelopeItem.documentData.id,
|
|
newDocumentDataId: newDocumentData.id,
|
|
};
|
|
};
|