refactor: replace pdf-sign with libpdf/core for PDF operations (#2403)

Migrate from @documenso/pdf-sign and @cantoo/pdf-lib to @libpdf/core
for all PDF manipulation and signing operations. This includes:

- New signing transports for Google Cloud KMS and local certificates
- Consolidated PDF operations using libpdf API
- Added TSA (timestamp authority) helper for digital signatures
- Removed deprecated flatten and insert utilities
- Updated tests to use new PDF library
This commit is contained in:
Lucas Smith 2026-01-21 15:16:23 +11:00 committed by GitHub
parent ed7a0011c7
commit 9035240b4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1065 additions and 1468 deletions

View file

@ -59,6 +59,18 @@ NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH=
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS= NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS=
# OPTIONAL: The path to the Google Cloud Credentials file to use for the gcloud-hsm signing transport. # OPTIONAL: The path to the Google Cloud Credentials file to use for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS= NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS=
# OPTIONAL: The path to the certificate chain file for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH=
# OPTIONAL: The base64-encoded contents of the certificate chain for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS=
# OPTIONAL: The Google Secret Manager path to retrieve the certificate for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH=
# OPTIONAL: Comma-separated list of timestamp authority URLs for PDF signing (enables LTV and archival timestamps).
NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY=
# OPTIONAL: Contact info to embed in PDF signatures. Defaults to the webapp URL.
NEXT_PUBLIC_SIGNING_CONTACT_INFO=
# OPTIONAL: Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached.
NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER=
# [[STORAGE]] # [[STORAGE]]
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3 # OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3

View file

@ -291,10 +291,13 @@ For AI setup specifics, see the [AI Recipient & Field Detection (Self-hosting)](
| `NEXT_PUBLIC_SUPPORT_EMAIL` | The support email address displayed to users (default `support@documenso.com`). | | `NEXT_PUBLIC_SUPPORT_EMAIL` | The support email address displayed to users (default `support@documenso.com`). |
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). | | `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). | | `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) | | `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default), gcloud-hsm |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. | | `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file will be used instead of the file path. | | `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file will be used instead of the file path. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. | | `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated list of timestamp authority URLs for PDF signing. Enables LTV and archival timestamps. |
| `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info to embed in PDF signatures. Defaults to the webapp URL. |
| `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached. |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport for file uploads (database or s3). | | `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport for file uploads (database or s3). |
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). | | `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. | | `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |

View file

@ -53,15 +53,21 @@ Have the Certificate Authority sign the Certificate Signing Request.
Configure your instance to use the new certificate by configuring the following environment variables in your `.env` file: Configure your instance to use the new certificate by configuring the following environment variables in your `.env` file:
| Environment Variable | Description | | Environment Variable | Description |
| :-------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | | :------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The transport used for document signing. Available options: local (default), gcloud-hsm | | `NEXT_PRIVATE_SIGNING_TRANSPORT` | The transport used for document signing. Available options: local (default), gcloud-hsm |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the local file-based signing transport. This field is optional. | | `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the local file-based signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The local file path to the .p12 file to use for the local signing transport. This field is optional. | | `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The local file path to the .p12 file to use for the local signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the .p12 file to use for the local signing transport. This field is optional. | | `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the .p12 file to use for the local signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH` | The Google Cloud HSM key path for the gcloud-hsm signing transport. This field is optional. | | `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH` | The Google Cloud HSM key path for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM _PUBLIC_CRT_FILE_PATH` | The path to the Google Cloud HSM public certificate file to use for the gcloud-hsm signing transport. This field is optional. | | `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH` | The path to the Google Cloud HSM public certificate file to use for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM _PUBLIC_CRT_FILE_CONTENTS` | The base64-encoded contents of the Google Cloud HSM public certificate file for the gcloud-hsm signing transport. This field is optional. | | `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS` | The base64-encoded contents of the Google Cloud HSM public certificate file for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_ APPLICATION_CREDENTIALS_CONTENTS` | The Google Cloud Credentials file path for the gcloud-hsm signing transport. This field is optional. | | `NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS` | The base64-encoded Google Cloud Credentials for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH` | The path to the certificate chain file for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS` | The base64-encoded contents of the certificate chain for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH` | The Google Secret Manager path to retrieve the certificate for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated list of timestamp authority URLs for PDF signing. Enables LTV and archival timestamps. This field is optional. |
| `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info to embed in PDF signatures. Defaults to the webapp URL. This field is optional. |
| `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached. This field is optional. |
</Steps> </Steps>

Binary file not shown.

View file

@ -42,16 +42,17 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="<your-certificate-password>"
4. Set up your signing certificate. You have three options: 4. Set up your signing certificate. You have three options:
**Option A: Generate Certificate Inside Container (Recommended)** **Option A: Generate Certificate Inside Container (Recommended)**
Start your containers first, then generate a self-signed certificate: Start your containers first, then generate a self-signed certificate:
```bash ```bash
# Start containers # Start containers
docker-compose up -d docker-compose up -d
# Set certificate password securely (won't appear in command history) # Set certificate password securely (won't appear in command history)
read -s -p "Enter certificate password: " CERT_PASS read -s -p "Enter certificate password: " CERT_PASS
echo echo
# Generate certificate inside container using environment variable # Generate certificate inside container using environment variable
docker exec -e CERT_PASS="$CERT_PASS" -it documenso-production-documenso-1 bash -c " docker exec -e CERT_PASS="$CERT_PASS" -it documenso-production-documenso-1 bash -c "
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
@ -63,19 +64,19 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="<your-certificate-password>"
-passout env:CERT_PASS && \ -passout env:CERT_PASS && \
rm /tmp/private.key /tmp/certificate.crt rm /tmp/private.key /tmp/certificate.crt
" "
# Restart container # Restart container
docker-compose restart documenso docker-compose restart documenso
``` ```
**Option B: Use Existing Certificate** **Option B: Use Existing Certificate**
If you have an existing `.p12` certificate, update the volume binding in `compose.yml`: If you have an existing `.p12` certificate, update the volume binding in `compose.yml`:
```yaml ```yaml
volumes: volumes:
- /path/to/your/cert.p12:/opt/documenso/cert.p12:ro - /path/to/your/cert.p12:/opt/documenso/cert.p12:ro
``` ```
5. Run the following command to start the containers: 5. Run the following command to start the containers:
@ -157,7 +158,6 @@ If you encounter errors related to certificate access, here are common solutions
docker exec -it <container_name> ls -la /opt/documenso/cert.p12 docker exec -it <container_name> ls -la /opt/documenso/cert.p12
``` ```
### Container Logs ### Container Logs
Check application logs for detailed error information: Check application logs for detailed error information:
@ -202,45 +202,55 @@ The environment variables listed above are a subset of those that are available
Here's a markdown table documenting all the provided environment variables: Here's a markdown table documenting all the provided environment variables:
| Variable | Description | | Variable | Description |
| -------------------------------------------- | --------------------------------------------------------------------------------------------------- | | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `PORT` | The port to run the Documenso application on, defaults to `3000`. | | `PORT` | The port to run the Documenso application on, defaults to `3000`. |
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. | | `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). | | `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). | | `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). | | `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). | | `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. | | `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). | | `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). | | `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) | | `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default), gcloud-hsm |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. | | `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file, will be used instead of file path. | | `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file, will be used instead of file path. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. | | `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport to use for file uploads (database or s3). | | `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH` | The Google Cloud HSM key path for the gcloud-hsm signing transport. |
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). | | `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH` | The path to the Google Cloud HSM public certificate file for the gcloud-hsm transport. |
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. | | `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS` | The base64-encoded Google Cloud HSM public certificate for the gcloud-hsm transport. |
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). | | `NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS` | The base64-encoded Google Cloud Credentials for the gcloud-hsm transport. |
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. | | `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH` | The path to the certificate chain file for the gcloud-hsm transport. |
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. | | `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS` | The base64-encoded certificate chain for the gcloud-hsm transport. |
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. | | `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH` | The Google Secret Manager path to retrieve the certificate for the gcloud-hsm transport. |
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to use for sending emails (smtp-auth, smtp-api, resend, or mailchannels). | | `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated list of timestamp authority URLs for PDF signing (enables LTV). |
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. | | `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info to embed in PDF signatures. Defaults to the webapp URL. |
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. | | `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Set to "true" to use legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached. |
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. | | `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport to use for file uploads (database or s3). |
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. | | `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. | | `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. | | `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. | | `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | If true, then no TLS will be used (even if STARTTLS is supported) | | `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. | | `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. | | `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to use for sending emails (smtp-auth, smtp-api, resend, or mailchannels). |
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. | | `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. | | `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. | | `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. | | `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. | | `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. | | `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). | | `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. | | `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | If true, then no TLS will be used (even if STARTTLS is supported) |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. | | `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |

982
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -87,6 +87,7 @@
"@ai-sdk/google-vertex": "3.0.81", "@ai-sdk/google-vertex": "3.0.81",
"@documenso/pdf-sign": "^0.1.0", "@documenso/pdf-sign": "^0.1.0",
"@documenso/prisma": "*", "@documenso/prisma": "*",
"@libpdf/core": "^0.1.0",
"@lingui/conf": "^5.6.0", "@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0", "@lingui/core": "^5.6.0",
"ai": "^5.0.104", "ai": "^5.0.104",
@ -103,4 +104,4 @@
"typescript": "5.6.2", "typescript": "5.6.2",
"zod": "^3.25.76" "zod": "^3.25.76"
} }
} }

View file

@ -1,4 +1,4 @@
import { PDFDocument } from '@cantoo/pdf-lib'; import { PDF } from '@libpdf/core';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DocumentStatus, FieldType } from '@prisma/client'; import { DocumentStatus, FieldType } from '@prisma/client';
@ -43,7 +43,7 @@ test.describe('Signing Certificate Tests', () => {
return fetch(documentUrl).then(async (res) => await res.arrayBuffer()); return fetch(documentUrl).then(async (res) => await res.arrayBuffer());
}); });
const originalPdf = await PDFDocument.load(documentData); const originalPdf = await PDF.load(new Uint8Array(documentData));
// Sign the document // Sign the document
await page.goto(`/sign/${recipient.token}`); await page.goto(`/sign/${recipient.token}`);
@ -101,7 +101,7 @@ test.describe('Signing Certificate Tests', () => {
const completedDocumentData = new Uint8Array(pdfData); const completedDocumentData = new Uint8Array(pdfData);
// Load the PDF and check number of pages // Load the PDF and check number of pages
const pdfDoc = await PDFDocument.load(completedDocumentData); const pdfDoc = await PDF.load(new Uint8Array(completedDocumentData));
expect(pdfDoc.getPageCount()).toBe(originalPdf.getPageCount() + 1); // Original + Certificate expect(pdfDoc.getPageCount()).toBe(originalPdf.getPageCount() + 1); // Original + Certificate
}); });
@ -153,7 +153,7 @@ test.describe('Signing Certificate Tests', () => {
return fetch(documentUrl).then(async (res) => await res.arrayBuffer()); return fetch(documentUrl).then(async (res) => await res.arrayBuffer());
}); });
const originalPdf = await PDFDocument.load(documentData); const originalPdf = await PDF.load(new Uint8Array(documentData));
// Sign the document // Sign the document
await page.goto(`/sign/${recipient.token}`); await page.goto(`/sign/${recipient.token}`);
@ -206,7 +206,7 @@ test.describe('Signing Certificate Tests', () => {
const completedDocumentData = new Uint8Array(pdfData); const completedDocumentData = new Uint8Array(pdfData);
// Load the PDF and check number of pages // Load the PDF and check number of pages
const completedPdf = await PDFDocument.load(completedDocumentData); const completedPdf = await PDF.load(new Uint8Array(completedDocumentData));
expect(completedPdf.getPageCount()).toBe(originalPdf.getPageCount() + 1); // Original + Certificate expect(completedPdf.getPageCount()).toBe(originalPdf.getPageCount() + 1); // Original + Certificate
}); });
@ -258,7 +258,7 @@ test.describe('Signing Certificate Tests', () => {
return fetch(documentUrl).then(async (res) => await res.arrayBuffer()); return fetch(documentUrl).then(async (res) => await res.arrayBuffer());
}); });
const originalPdf = await PDFDocument.load(new Uint8Array(documentData)); const originalPdf = await PDF.load(new Uint8Array(documentData));
// Sign the document // Sign the document
await page.goto(`/sign/${recipient.token}`); await page.goto(`/sign/${recipient.token}`);
@ -309,7 +309,7 @@ test.describe('Signing Certificate Tests', () => {
); );
// Load the PDF and check number of pages // Load the PDF and check number of pages
const completedPdf = await PDFDocument.load(completedDocumentData); const completedPdf = await PDF.load(new Uint8Array(completedDocumentData));
expect(completedPdf.getPageCount()).toBe(originalPdf.getPageCount()); expect(completedPdf.getPageCount()).toBe(originalPdf.getPageCount());
}); });

View file

@ -1,4 +1,4 @@
import { PDFDocument } from '@cantoo/pdf-lib'; import { PDF } from '@libpdf/core';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
@ -39,24 +39,30 @@ const TEST_FORM_VALUES = {
* Returns true if the PDF has form fields, false if they've been flattened. * Returns true if the PDF has form fields, false if they've been flattened.
*/ */
async function pdfHasFormFields(pdfBuffer: Uint8Array): Promise<boolean> { async function pdfHasFormFields(pdfBuffer: Uint8Array): Promise<boolean> {
const pdfDoc = await PDFDocument.load(pdfBuffer); const pdfDoc = await PDF.load(new Uint8Array(pdfBuffer));
const form = pdfDoc.getForm(); const form = await pdfDoc.getForm();
const fields = form.getFields();
return fields.length > 0; if (!form) {
return false;
}
return form.fieldCount > 0;
} }
/** /**
* Helper to get form field names from a PDF. * Helper to get form field names from a PDF.
*/ */
async function getPdfFormFieldNames(pdfBuffer: Uint8Array): Promise<string[]> { async function getPdfFormFieldNames(pdfBuffer: Uint8Array): Promise<string[]> {
const pdfDoc = await PDFDocument.load(pdfBuffer); const pdfDoc = await PDF.load(new Uint8Array(pdfBuffer));
const form = pdfDoc.getForm(); const form = await pdfDoc.getForm();
const fields = form.getFields();
return fields.map((field) => field.getName()); if (!form) {
return [];
}
return form.getFieldNames();
} }
/** /**
@ -66,17 +72,21 @@ async function getPdfTextFieldValue(
pdfBuffer: Uint8Array, pdfBuffer: Uint8Array,
fieldName: string, fieldName: string,
): Promise<string | undefined> { ): Promise<string | undefined> {
const pdfDoc = await PDFDocument.load(pdfBuffer); const pdfDoc = await PDF.load(new Uint8Array(pdfBuffer));
const form = pdfDoc.getForm(); const form = await pdfDoc.getForm();
try { if (!form) {
const textField = form.getTextField(fieldName);
return textField.getText() ?? '';
} catch {
return undefined; return undefined;
} }
const textField = form.getTextField(fieldName);
if (!textField) {
return undefined;
}
return textField.getValue();
} }
test.describe.configure({ test.describe.configure({

View file

@ -6,6 +6,12 @@ export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
export const NEXT_PUBLIC_WEBAPP_URL = () => export const NEXT_PUBLIC_WEBAPP_URL = () =>
env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000'; env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000';
export const NEXT_PUBLIC_SIGNING_CONTACT_INFO = () =>
env('NEXT_PUBLIC_SIGNING_CONTACT_INFO') ?? NEXT_PUBLIC_WEBAPP_URL();
export const NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER = () =>
env('NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER') === 'true';
export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = () => export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = () =>
env('NEXT_PRIVATE_INTERNAL_WEBAPP_URL') ?? NEXT_PUBLIC_WEBAPP_URL(); env('NEXT_PRIVATE_INTERNAL_WEBAPP_URL') ?? NEXT_PUBLIC_WEBAPP_URL();
@ -30,3 +36,6 @@ export const IS_AI_FEATURES_CONFIGURED = () =>
*/ */
export const NEXT_PRIVATE_USE_PLAYWRIGHT_PDF = () => export const NEXT_PRIVATE_USE_PLAYWRIGHT_PDF = () =>
env('NEXT_PRIVATE_USE_PLAYWRIGHT_PDF') === 'true'; env('NEXT_PRIVATE_USE_PLAYWRIGHT_PDF') === 'true';
export const NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY = () =>
env('NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY');

View file

@ -1,12 +1,5 @@
import { import { PDFDocument } from '@cantoo/pdf-lib';
PDFDocument, import { PDF } from '@libpdf/core';
RotationTypes,
popGraphicsState,
pushGraphicsState,
radiansToDegrees,
rotateDegrees,
translate,
} from '@cantoo/pdf-lib';
import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client'; import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client';
import { import {
DocumentStatus, DocumentStatus,
@ -18,8 +11,8 @@ import {
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import path from 'node:path'; import path from 'node:path';
import { groupBy } from 'remeda'; import { groupBy } from 'remeda';
import { match } from 'ts-pattern';
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 { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf';
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf'; import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
@ -31,14 +24,9 @@ import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email'; import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf'; import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf'; import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
import { flattenForm } from '../../../server-only/pdf/flatten-form';
import { getPageSize } from '../../../server-only/pdf/get-page-size';
import { insertFieldInPDFV1 } from '../../../server-only/pdf/insert-field-in-pdf-v1'; import { insertFieldInPDFV1 } from '../../../server-only/pdf/insert-field-in-pdf-v1';
import { insertFieldInPDFV2 } from '../../../server-only/pdf/insert-field-in-pdf-v2'; import { insertFieldInPDFV2 } from '../../../server-only/pdf/insert-field-in-pdf-v2';
import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-field-in-pdf'; import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../../../server-only/pdf/normalize-signature-appearances';
import { getTeamSettings } from '../../../server-only/team/get-team-settings'; import { getTeamSettings } from '../../../server-only/team/get-team-settings';
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
@ -181,8 +169,8 @@ export const run = async ({
}); });
} }
let certificateDoc: PDFDocument | null = null; let certificateDoc: PDF | null = null;
let auditLogDoc: PDFDocument | null = null; let auditLogDoc: PDF | null = null;
if (settings.includeSigningCertificate || settings.includeAuditLog) { if (settings.includeSigningCertificate || settings.includeAuditLog) {
const certificatePayload = { const certificatePayload = {
@ -208,7 +196,7 @@ export const run = async ({
? getCertificatePdf({ ? getCertificatePdf({
documentId, documentId,
language: envelope.documentMeta.language, language: envelope.documentMeta.language,
}).then(async (buffer) => PDFDocument.load(buffer)) }).then(async (buffer) => PDF.load(buffer))
: generateCertificatePdf(certificatePayload); : generateCertificatePdf(certificatePayload);
const makeAuditLogPdf = async () => const makeAuditLogPdf = async () =>
@ -216,7 +204,7 @@ export const run = async ({
? getAuditLogsPdf({ ? getAuditLogsPdf({
documentId, documentId,
language: envelope.documentMeta.language, language: envelope.documentMeta.language,
}).then(async (buffer) => PDFDocument.load(buffer)) }).then(async (buffer) => PDF.load(buffer))
: generateAuditLogPdf(certificatePayload); : generateAuditLogPdf(certificatePayload);
const [createdCertificatePdf, createdAuditLogPdf] = await Promise.all([ const [createdCertificatePdf, createdAuditLogPdf] = await Promise.all([
@ -342,8 +330,8 @@ type DecorateAndSignPdfOptions = {
envelopeItemFields: Field[]; envelopeItemFields: Field[];
isRejected: boolean; isRejected: boolean;
rejectionReason: string; rejectionReason: string;
certificateDoc: PDFDocument | null; certificateDoc: PDF | null;
auditLogDoc: PDFDocument | null; auditLogDoc: PDF | null;
}; };
/** /**
@ -360,48 +348,47 @@ const decorateAndSignPdf = async ({
}: DecorateAndSignPdfOptions) => { }: DecorateAndSignPdfOptions) => {
const pdfData = await getFileServerSide(envelopeItem.documentData); const pdfData = await getFileServerSide(envelopeItem.documentData);
const pdfDoc = await PDFDocument.load(pdfData); let pdfDoc = await PDF.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature // Normalize and flatten layers that could cause issues with the signature
normalizeSignatureAppearances(pdfDoc); pdfDoc.flattenAll();
await flattenForm(pdfDoc); // Upgrade to PDF 1.7 for better compatibility with signing
flattenAnnotations(pdfDoc); pdfDoc.upgradeVersion('1.7');
// Add rejection stamp if the document is rejected // Add rejection stamp if the document is rejected
if (isRejected && rejectionReason) { if (isRejected) {
await addRejectionStampToPdf(pdfDoc, rejectionReason); await addRejectionStampToPdf(pdfDoc, rejectionReason);
} }
if (certificateDoc) { if (certificateDoc) {
const certificatePages = await pdfDoc.copyPages( await pdfDoc.copyPagesFrom(
certificateDoc, certificateDoc,
certificateDoc.getPageIndices(), Array.from({ length: certificateDoc.getPageCount() }, (_, index) => index),
); );
certificatePages.forEach((page) => {
pdfDoc.addPage(page);
});
} }
if (auditLogDoc) { if (auditLogDoc) {
const auditLogPages = await pdfDoc.copyPages(auditLogDoc, auditLogDoc.getPageIndices()); await pdfDoc.copyPagesFrom(
auditLogDoc,
auditLogPages.forEach((page) => { Array.from({ length: auditLogDoc.getPageCount() }, (_, index) => index),
pdfDoc.addPage(page); );
});
} }
// Handle V1 and legacy insertions. // Handle V1 and legacy insertions.
if (envelope.internalVersion === 1) { if (envelope.internalVersion === 1) {
const legacy_pdfLibDoc = await PDFDocument.load(await pdfDoc.save({ useXRefStream: true }));
for (const field of envelopeItemFields) { for (const field of envelopeItemFields) {
if (field.inserted) { if (field.inserted) {
if (envelope.useLegacyFieldInsertion) { if (envelope.useLegacyFieldInsertion) {
await legacy_insertFieldInPDF(pdfDoc, field); await legacy_insertFieldInPDF(legacy_pdfLibDoc, field);
} else { } else {
await insertFieldInPDFV1(pdfDoc, field); await insertFieldInPDFV1(legacy_pdfLibDoc, field);
} }
} }
} }
await pdfDoc.reload(await legacy_pdfLibDoc.save());
} }
// Handle V2 envelope insertions. // Handle V2 envelope insertions.
@ -410,87 +397,61 @@ const decorateAndSignPdf = async ({
for (const [pageNumber, fields] of Object.entries(fieldsGroupedByPage)) { for (const [pageNumber, fields] of Object.entries(fieldsGroupedByPage)) {
const page = pdfDoc.getPage(Number(pageNumber) - 1); const page = pdfDoc.getPage(Number(pageNumber) - 1);
const pageRotation = page.getRotation();
let { width: pageWidth, height: pageHeight } = getPageSize(page); if (!page) {
throw new Error(`Page ${pageNumber} does not exist`);
let pageRotationInDegrees = match(pageRotation.type)
.with(RotationTypes.Degrees, () => pageRotation.angle)
.with(RotationTypes.Radians, () => radiansToDegrees(pageRotation.angle))
.exhaustive();
// Round to the closest multiple of 90 degrees.
pageRotationInDegrees = Math.round(pageRotationInDegrees / 90) * 90;
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
// However when we load the PDF in the backend, the rotation is applied.
// To account for this, we swap the width and height for pages that are rotated by 90/270
// degrees. This is so we can calculate the virtual position the field was placed if it
// was correctly oriented in the frontend.
if (pageRotationInDegrees === 90 || pageRotationInDegrees === 270) {
[pageWidth, pageHeight] = [pageHeight, pageWidth];
} }
// Rotate the page to the orientation that the react-pdf renders on the frontend. const pageWidth = page.width;
// Note: These transformations are undone at the end of the function. const pageHeight = page.height;
// If you change this if statement, update the if statement at the end as well
if (pageRotationInDegrees !== 0) {
let translateX = 0;
let translateY = 0;
switch (pageRotationInDegrees) { const overlayBytes = await insertFieldInPDFV2({
case 90:
translateX = pageHeight;
translateY = 0;
break;
case 180:
translateX = pageWidth;
translateY = pageHeight;
break;
case 270:
translateX = 0;
translateY = pageWidth;
break;
case 0:
default:
translateX = 0;
translateY = 0;
}
page.pushOperators(pushGraphicsState());
page.pushOperators(translate(translateX, translateY), rotateDegrees(pageRotationInDegrees));
}
const renderedPdfOverlay = await insertFieldInPDFV2({
pageWidth, pageWidth,
pageHeight, pageHeight,
fields, fields,
}); });
const [embeddedPage] = await pdfDoc.embedPdf(renderedPdfOverlay); const overlayPdf = await PDF.load(overlayBytes);
// Draw the SVG on the page const embeddedPage = await pdfDoc.embedPage(overlayPdf, 0);
page.drawPage(embeddedPage, {
x: 0,
y: 0,
width: pageWidth,
height: pageHeight,
});
// Remove the transformations applied to the page if any were applied. // Rotate the page to the orientation that the react-pdf renders on the frontend.
if (pageRotationInDegrees !== 0) { let translateX = 0;
page.pushOperators(popGraphicsState()); 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 // Re-flatten the form to handle our checkbox and radio fields that
// create native arcoFields // create native arcoFields
await flattenForm(pdfDoc); pdfDoc.flattenAll();
const pdfBytes = await pdfDoc.save(); pdfDoc = await PDF.load(await pdfDoc.save({ useXRefStream: true }));
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) }); const pdfBytes = await signPdf({ pdf: pdfDoc });
const { name } = path.parse(envelopeItem.title); const { name } = path.parse(envelopeItem.title);
@ -500,7 +461,7 @@ const decorateAndSignPdf = async ({
const newDocumentData = await putPdfFileServerSide({ const newDocumentData = await putPdfFileServerSide({
name: `${name}${suffix}`, name: `${name}${suffix}`,
type: 'application/pdf', type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer), arrayBuffer: async () => Promise.resolve(pdfBytes),
}); });
return { return {

View file

@ -1,88 +1,72 @@
import type { PDFDocument } from '@cantoo/pdf-lib'; import { type PDF, rgb } from '@libpdf/core';
import { TextAlignment, rgb, setFontAndSize } from '@cantoo/pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app'; import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app';
import { getPageSize } from './get-page-size';
/** /**
* Adds a rejection stamp to each page of a PDF document. * Adds a rejection stamp to each page of a PDF document.
* The stamp is placed in the center of the page. * The stamp is placed in the center of the page.
*/ */
export async function addRejectionStampToPdf( export async function addRejectionStampToPdf(pdf: PDF, reason: string): Promise<PDF> {
pdfDoc: PDFDocument, const pages = pdf.getPages();
reason: string,
): Promise<PDFDocument> {
const pages = pdfDoc.getPages();
pdfDoc.registerFontkit(fontkit);
const fontBytes = await fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then( const fontBytes = await fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(
async (res) => res.arrayBuffer(), async (res) => res.arrayBuffer(),
); );
const font = await pdfDoc.embedFont(fontBytes, { const font = pdf.embedFont(new Uint8Array(fontBytes));
customName: 'Noto',
});
const form = pdfDoc.getForm(); for (const page of pages) {
const height = page.height;
for (let i = 0; i < pages.length; i++) { const width = page.width;
const page = pages[i];
const { width, height } = getPageSize(page);
// Draw the "REJECTED" text // Draw the "REJECTED" text
const rejectedTitleText = 'DOCUMENT REJECTED'; const rejectedTitleText = 'DOCUMENT REJECTED';
const rejectedTitleFontSize = 36; const rejectedTitleFontSize = 36;
const rejectedTitleTextField = form.createTextField(`internal-document-rejected-title-${i}`); const rotationAngle = 45;
if (!rejectedTitleTextField.acroField.getDefaultAppearance()) {
rejectedTitleTextField.acroField.setDefaultAppearance(
setFontAndSize('Noto', rejectedTitleFontSize).toString(),
);
}
rejectedTitleTextField.updateAppearances(font);
rejectedTitleTextField.setFontSize(rejectedTitleFontSize);
rejectedTitleTextField.setText(rejectedTitleText);
rejectedTitleTextField.setAlignment(TextAlignment.Center);
const rejectedTitleTextWidth =
font.widthOfTextAtSize(rejectedTitleText, rejectedTitleFontSize) * 1.2;
const rejectedTitleTextHeight = font.heightAtSize(rejectedTitleFontSize);
// Calculate the center position of the page // Calculate the center position of the page
const centerX = width / 2; const centerX = width / 2;
const centerY = height / 2; const centerY = height / 2;
// Position the title text at the center of the page const widthOfText = font.getTextWidth(rejectedTitleText, rejectedTitleFontSize);
const rejectedTitleTextX = centerX - rejectedTitleTextWidth / 2;
const rejectedTitleTextY = centerY - rejectedTitleTextHeight / 2;
// Add padding for the rectangle // Add padding for the rectangle
const padding = 20; const padding = 20;
const rectWidth = widthOfText + padding;
const rectHeight = rejectedTitleFontSize + padding;
const rectX = centerX - rectWidth / 2;
const rectY = centerY - rectHeight / 4;
// Draw the stamp background
page.drawRectangle({ page.drawRectangle({
x: rejectedTitleTextX - padding / 2, x: rectX,
y: rejectedTitleTextY - padding / 2, y: rectY,
width: rejectedTitleTextWidth + padding, width: rectWidth,
height: rejectedTitleTextHeight + padding, height: rectHeight,
borderColor: rgb(220 / 255, 38 / 255, 38 / 255), borderColor: rgb(220 / 255, 38 / 255, 38 / 255),
borderWidth: 4, borderWidth: 4,
rotate: {
angle: rotationAngle,
origin: 'center',
},
}); });
rejectedTitleTextField.addToPage(page, { const textX = centerX - widthOfText / 2;
x: rejectedTitleTextX, const textY = centerY;
y: rejectedTitleTextY,
width: rejectedTitleTextWidth, // Draw the text centered within the rectangle
height: rejectedTitleTextHeight, page.drawText(rejectedTitleText, {
textColor: rgb(220 / 255, 38 / 255, 38 / 255), x: textX,
backgroundColor: undefined, y: textY,
borderWidth: 0, size: rejectedTitleFontSize,
borderColor: undefined, font,
color: rgb(220 / 255, 38 / 255, 38 / 255),
rotate: {
angle: rotationAngle,
origin: 'center',
},
}); });
} }
return pdfDoc; return pdf;
} }

View file

@ -1,63 +0,0 @@
import { PDFAnnotation, PDFRef } from '@cantoo/pdf-lib';
import {
PDFDict,
type PDFDocument,
PDFName,
drawObject,
popGraphicsState,
pushGraphicsState,
rotateInPlace,
translate,
} from '@cantoo/pdf-lib';
export const flattenAnnotations = (document: PDFDocument) => {
const pages = document.getPages();
for (const page of pages) {
const annotations = page.node.Annots()?.asArray() ?? [];
annotations.forEach((annotation) => {
if (!(annotation instanceof PDFRef)) {
return;
}
const actualAnnotation = page.node.context.lookup(annotation);
if (!(actualAnnotation instanceof PDFDict)) {
return;
}
const pdfAnnot = PDFAnnotation.fromDict(actualAnnotation);
const appearance = pdfAnnot.ensureAP();
// Skip annotations without a normal appearance
if (!appearance.has(PDFName.of('N'))) {
return;
}
const normalAppearance = pdfAnnot.getNormalAppearance();
const rectangle = pdfAnnot.getRectangle();
if (!(normalAppearance instanceof PDFRef)) {
// Not sure how to get the reference to the normal appearance yet
// so we should skip this annotation for now
return;
}
const xobj = page.node.newXObject('FlatAnnot', normalAppearance);
const operators = [
pushGraphicsState(),
translate(rectangle.x, rectangle.y),
...rotateInPlace({ ...rectangle, rotation: 0 }),
drawObject(xobj),
popGraphicsState(),
].filter((op) => !!op);
page.pushOperators(...operators);
page.node.removeAnnot(annotation);
});
}
};

View file

@ -1,170 +0,0 @@
import type { PDFField, PDFWidgetAnnotation } from '@cantoo/pdf-lib';
import {
PDFCheckBox,
PDFDict,
type PDFDocument,
PDFName,
PDFNumber,
PDFRadioGroup,
PDFRef,
PDFStream,
drawObject,
popGraphicsState,
pushGraphicsState,
rotateInPlace,
translate,
} from '@cantoo/pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app';
export const removeOptionalContentGroups = (document: PDFDocument) => {
const context = document.context;
const catalog = context.lookup(context.trailerInfo.Root);
if (catalog instanceof PDFDict) {
catalog.delete(PDFName.of('OCProperties'));
}
};
export const flattenForm = async (document: PDFDocument) => {
removeOptionalContentGroups(document);
const form = document.getForm();
const fontNoto = await fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(
async (res) => res.arrayBuffer(),
);
document.registerFontkit(fontkit);
const font = await document.embedFont(fontNoto);
form.updateFieldAppearances(font);
for (const field of form.getFields()) {
for (const widget of field.acroField.getWidgets()) {
flattenWidget(document, field, widget);
}
try {
form.removeField(field);
} catch (error) {
console.error(error);
}
}
};
const getPageForWidget = (document: PDFDocument, widget: PDFWidgetAnnotation) => {
const pageRef = widget.P();
let page = document.getPages().find((page) => page.ref === pageRef);
if (!page) {
const widgetRef = document.context.getObjectRef(widget.dict);
if (!widgetRef) {
return null;
}
page = document.findPageForAnnotationRef(widgetRef);
if (!page) {
return null;
}
}
return page;
};
const getAppearanceRefForWidget = (field: PDFField, widget: PDFWidgetAnnotation) => {
try {
const normalAppearance = widget.getNormalAppearance();
let normalAppearanceRef: PDFRef | null = null;
if (normalAppearance instanceof PDFRef) {
normalAppearanceRef = normalAppearance;
}
if (
normalAppearance instanceof PDFDict &&
(field instanceof PDFCheckBox || field instanceof PDFRadioGroup)
) {
const value = field.acroField.getValue();
const ref = normalAppearance.get(value) ?? normalAppearance.get(PDFName.of('Off'));
if (ref instanceof PDFRef) {
normalAppearanceRef = ref;
}
}
return normalAppearanceRef;
} catch (error) {
console.error(error);
return null;
}
};
/**
* Ensures that an appearance stream has the required dictionary entries to be
* used as a Form XObject. Some PDFs have appearance streams that are missing
* the /Subtype /Form entry, which causes Adobe Reader to fail to render them.
*
* Per PDF spec, a Form XObject stream requires:
* - /Subtype /Form (required)
* - /BBox (required, but should already exist for appearance streams)
* - /FormType 1 (optional, defaults to 1)
*/
const normalizeAppearanceStream = (document: PDFDocument, appearanceRef: PDFRef) => {
const appearanceStream = document.context.lookup(appearanceRef);
if (!(appearanceStream instanceof PDFStream)) {
return;
}
const dict = appearanceStream.dict;
// Ensure /Subtype /Form is set (required for XObject Form)
if (!dict.has(PDFName.of('Subtype'))) {
dict.set(PDFName.of('Subtype'), PDFName.of('Form'));
}
// Ensure /FormType is set (optional, but good practice)
if (!dict.has(PDFName.of('FormType'))) {
dict.set(PDFName.of('FormType'), PDFNumber.of(1));
}
};
const flattenWidget = (document: PDFDocument, field: PDFField, widget: PDFWidgetAnnotation) => {
try {
const page = getPageForWidget(document, widget);
if (!page) {
return;
}
const appearanceRef = getAppearanceRefForWidget(field, widget);
if (!appearanceRef) {
return;
}
// Ensure the appearance stream has required XObject Form dictionary entries
normalizeAppearanceStream(document, appearanceRef);
const xObjectKey = page.node.newXObject('FlatWidget', appearanceRef);
const rectangle = widget.getRectangle();
const operators = [
pushGraphicsState(),
translate(rectangle.x, rectangle.y),
...rotateInPlace({ ...rectangle, rotation: 0 }),
drawObject(xObjectKey),
popGraphicsState(),
].filter((op) => !!op);
page.pushOperators(...operators);
} catch (error) {
console.error(error);
}
};

View file

@ -1,3 +1,4 @@
import { PDF } from '@libpdf/core';
import { i18n } from '@lingui/core'; import { i18n } from '@lingui/core';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
@ -7,7 +8,6 @@ import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
import { getTranslations } from '../../utils/i18n'; import { getTranslations } from '../../utils/i18n';
import { getOrganisationClaimByTeamId } from '../organisation/get-organisation-claims'; import { getOrganisationClaimByTeamId } from '../organisation/get-organisation-claims';
import type { GenerateCertificatePdfOptions } from './generate-certificate-pdf'; import type { GenerateCertificatePdfOptions } from './generate-certificate-pdf';
import { mergeFilesIntoPdf } from './generate-certificate-pdf';
import { renderAuditLogs } from './render-audit-logs'; import { renderAuditLogs } from './render-audit-logs';
type GenerateAuditLogPdfOptions = GenerateCertificatePdfOptions & { type GenerateAuditLogPdfOptions = GenerateCertificatePdfOptions & {
@ -43,7 +43,9 @@ export const generateAuditLogPdf = async (options: GenerateAuditLogPdfOptions) =
i18n, i18n,
}); });
return await mergeFilesIntoPdf(auditLogPages); return await PDF.merge(auditLogPages, {
includeAnnotations: true,
});
}; };
const getAuditLogs = async (envelopeId: string) => { const getAuditLogs = async (envelopeId: string) => {

View file

@ -1,4 +1,4 @@
import { PDFDocument } from '@cantoo/pdf-lib'; import { PDF } from '@libpdf/core';
import { i18n } from '@lingui/core'; import { i18n } from '@lingui/core';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import type { DocumentMeta } from '@prisma/client'; import type { DocumentMeta } from '@prisma/client';
@ -144,17 +144,5 @@ export const generateCertificatePdf = async (options: GenerateCertificatePdfOpti
const certificatePages = await renderCertificate(payload); const certificatePages = await renderCertificate(payload);
return await mergeFilesIntoPdf(certificatePages); return await PDF.merge(certificatePages);
}; };
export async function mergeFilesIntoPdf(buffers: Uint8Array[]) {
const mergedPdf = await PDFDocument.create();
for (const buffer of buffers) {
const pdf = await PDFDocument.load(buffer);
const pages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
pages.forEach((p) => mergedPdf.addPage(p));
}
return mergedPdf;
}

View file

@ -1,10 +1,4 @@
import { import { PDF } from '@libpdf/core';
PDFCheckBox,
PDFDocument,
PDFDropdown,
PDFRadioGroup,
PDFTextField,
} from '@cantoo/pdf-lib';
export type InsertFormValuesInPdfOptions = { export type InsertFormValuesInPdfOptions = {
pdf: Buffer; pdf: Buffer;
@ -12,7 +6,7 @@ export type InsertFormValuesInPdfOptions = {
}; };
export const insertFormValuesInPdf = async ({ pdf, formValues }: InsertFormValuesInPdfOptions) => { export const insertFormValuesInPdf = async ({ pdf, formValues }: InsertFormValuesInPdfOptions) => {
const doc = await PDFDocument.load(pdf); const doc = await PDF.load(pdf);
const form = doc.getForm(); const form = doc.getForm();
@ -20,41 +14,12 @@ export const insertFormValuesInPdf = async ({ pdf, formValues }: InsertFormValue
return pdf; return pdf;
} }
for (const [key, value] of Object.entries(formValues)) { const filledForm = Object.entries(formValues).map(([key, value]) => [
try { key,
const field = form.getField(key); typeof value === 'boolean' ? value : value.toString(),
]);
if (!field) { form.fill(Object.fromEntries(filledForm));
continue;
}
if (typeof value === 'boolean' && field instanceof PDFCheckBox) { return await doc.save({ incremental: true }).then((buf) => Buffer.from(buf));
if (value) {
field.check();
} else {
field.uncheck();
}
}
if (field instanceof PDFTextField) {
field.setText(value.toString());
}
if (field instanceof PDFDropdown) {
field.select(value.toString());
}
if (field instanceof PDFRadioGroup) {
field.select(value.toString());
}
} catch (err) {
if (err instanceof Error) {
console.error(`Error setting value for field ${key}: ${err.message}`);
} else {
console.error(`Error setting value for field ${key}`);
}
}
}
return await doc.save().then((buf) => Buffer.from(buf));
}; };

View file

@ -1,26 +0,0 @@
import { PDFDocument } from '@cantoo/pdf-lib';
export async function insertImageInPDF(
pdfAsBase64: string,
image: string | Uint8Array | ArrayBuffer,
positionX: number,
positionY: number,
page = 0,
): Promise<string> {
const existingPdfBytes = pdfAsBase64;
const pdfDoc = await PDFDocument.load(existingPdfBytes);
const pages = pdfDoc.getPages();
const pdfPage = pages[page];
const pngImage = await pdfDoc.embedPng(image);
const drawSize = { width: 192, height: 64 };
pdfPage.drawImage(pngImage, {
x: positionX,
y: pdfPage.getHeight() - positionY - drawSize.height,
width: drawSize.width,
height: drawSize.height,
});
const pdfAsUint8Array = await pdfDoc.save();
return Buffer.from(pdfAsUint8Array).toString('base64');
}

View file

@ -1,54 +0,0 @@
import { PDFDocument, StandardFonts, rgb } from '@cantoo/pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import { CAVEAT_FONT_PATH } from '../../constants/pdf';
export async function insertTextInPDF(
pdfAsBase64: string,
text: string,
positionX: number,
positionY: number,
page = 0,
useHandwritingFont = true,
customFontSize?: number,
): Promise<string> {
// Fetch the font file from the public URL.
const fontResponse = await fetch(CAVEAT_FONT_PATH());
const fontCaveat = await fontResponse.arrayBuffer();
const pdfDoc = await PDFDocument.load(pdfAsBase64);
pdfDoc.registerFontkit(fontkit);
const font = await pdfDoc.embedFont(useHandwritingFont ? fontCaveat : StandardFonts.Helvetica);
const pages = pdfDoc.getPages();
const pdfPage = pages[page];
const textSize = customFontSize || (useHandwritingFont ? 50 : 15);
const textWidth = font.widthOfTextAtSize(text, textSize);
const textHeight = font.heightAtSize(textSize);
const fieldSize = { width: 250, height: 64 };
// Because pdf-lib use a bottom-left coordinate system, we need to invert the y position
// we then center the text in the middle by adding half the height of the text
// plus the height of the field and divide the result by 2
const invertedYPosition =
pdfPage.getHeight() - positionY - (fieldSize.height + textHeight / 2) / 2;
// We center the text by adding the width of the field, subtracting the width of the text
// and dividing the result by 2
const centeredXPosition = positionX + (fieldSize.width - textWidth) / 2;
pdfPage.drawText(text, {
x: centeredXPosition,
y: invertedYPosition,
size: textSize,
color: rgb(0, 0, 0),
font,
});
const pdfAsUint8Array = await pdfDoc.save();
return Buffer.from(pdfAsUint8Array).toString('base64');
}

View file

@ -1,13 +1,11 @@
import { PDFDocument } from '@cantoo/pdf-lib'; import { PDF } from '@libpdf/core';
import { AppError } from '../../errors/app-error'; import { AppError } from '../../errors/app-error';
import { flattenAnnotations } from './flatten-annotations';
import { flattenForm, removeOptionalContentGroups } from './flatten-form';
export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean } = {}) => { export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean } = {}) => {
const shouldFlattenForm = options.flattenForm ?? true; const shouldFlattenForm = options.flattenForm ?? true;
const pdfDoc = await PDFDocument.load(pdf).catch((e) => { const pdfDoc = await PDF.load(pdf).catch((e) => {
console.error(`PDF normalization error: ${e.message}`); console.error(`PDF normalization error: ${e.message}`);
throw new AppError('INVALID_DOCUMENT_FILE', { throw new AppError('INVALID_DOCUMENT_FILE', {
@ -21,11 +19,13 @@ export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean
}); });
} }
removeOptionalContentGroups(pdfDoc); pdfDoc.flattenLayers();
if (shouldFlattenForm) { const form = pdfDoc.getForm();
await flattenForm(pdfDoc);
flattenAnnotations(pdfDoc); if (shouldFlattenForm && form) {
form.flatten();
pdfDoc.flattenAnnotations();
} }
return Buffer.from(await pdfDoc.save()); return Buffer.from(await pdfDoc.save());

View file

@ -1,26 +0,0 @@
import type { PDFDocument } from '@cantoo/pdf-lib';
import { PDFSignature, rectangle } from '@cantoo/pdf-lib';
export const normalizeSignatureAppearances = (document: PDFDocument) => {
const form = document.getForm();
for (const field of form.getFields()) {
if (field instanceof PDFSignature) {
field.acroField.getWidgets().forEach((widget) => {
widget.ensureAP();
try {
widget.getNormalAppearance();
} catch {
const { context } = widget.dict;
const xobj = context.formXObject([rectangle(0, 0, 0, 0)]);
const streamRef = context.register(xobj);
widget.setNormalAppearance(streamRef);
}
});
}
}
};

View file

@ -1,4 +1,4 @@
import { PDFDocument } from '@cantoo/pdf-lib'; import { PDF } from '@libpdf/core';
import { DocumentDataType } from '@prisma/client'; import { DocumentDataType } from '@prisma/client';
import { base64 } from '@scure/base'; import { base64 } from '@scure/base';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -25,7 +25,7 @@ export const putPdfFileServerSide = async (file: File) => {
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer).catch((e) => { const pdf = await PDF.load(new Uint8Array(arrayBuffer)).catch((e) => {
console.error(`PDF upload parse error: ${e.message}`); console.error(`PDF upload parse error: ${e.message}`);
throw new AppError('INVALID_DOCUMENT_FILE'); throw new AppError('INVALID_DOCUMENT_FILE');

View file

@ -6,14 +6,20 @@ declare global {
} }
} }
type EnvironmentVariable = keyof NodeJS.ProcessEnv; // eslint-disable-next-line @typescript-eslint/ban-types
type EnvKey = keyof NodeJS.ProcessEnv | (string & {});
type EnvValue<K extends EnvKey> = K extends keyof NodeJS.ProcessEnv
? NodeJS.ProcessEnv[K]
: string | undefined;
export const env = (variable: EnvironmentVariable | (string & object)): string | undefined => { export const env = <K extends EnvKey>(variable: K): EnvValue<K> => {
if (typeof window !== 'undefined' && typeof window.__ENV__ === 'object') { if (typeof window !== 'undefined' && typeof window.__ENV__ === 'object') {
return window.__ENV__[variable]; // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return window.__ENV__[variable as string] as EnvValue<K>;
} }
return typeof process !== 'undefined' ? process?.env?.[variable] : undefined; // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return (typeof process !== 'undefined' ? process?.env?.[variable] : undefined) as EnvValue<K>;
}; };
export const createPublicEnv = () => export const createPublicEnv = () =>

View file

@ -1,3 +0,0 @@
// We use stars as a placeholder since it's easy to find and replace,
// the length of the placeholder is to support larger pdf files
export const BYTE_RANGE_PLACEHOLDER = '**********';

View file

@ -1,96 +0,0 @@
import {
PDFArray,
PDFDict,
PDFDocument,
PDFHexString,
PDFName,
PDFNumber,
PDFString,
rectangle,
} from '@cantoo/pdf-lib';
import { BYTE_RANGE_PLACEHOLDER } from '../constants/byte-range';
export type AddSigningPlaceholderOptions = {
pdf: Buffer;
};
export const addSigningPlaceholder = async ({ pdf }: AddSigningPlaceholderOptions) => {
const doc = await PDFDocument.load(pdf);
const [firstPage] = doc.getPages();
const byteRange = PDFArray.withContext(doc.context);
byteRange.push(PDFNumber.of(0));
byteRange.push(PDFName.of(BYTE_RANGE_PLACEHOLDER));
byteRange.push(PDFName.of(BYTE_RANGE_PLACEHOLDER));
byteRange.push(PDFName.of(BYTE_RANGE_PLACEHOLDER));
const signature = doc.context.register(
doc.context.obj({
Type: 'Sig',
Filter: 'Adobe.PPKLite',
SubFilter: 'adbe.pkcs7.detached',
ByteRange: byteRange,
Contents: PDFHexString.fromText(' '.repeat(8192)),
Reason: PDFString.of('Signed by Documenso'),
M: PDFString.fromDate(new Date()),
}),
);
const widget = doc.context.register(
doc.context.obj({
Type: 'Annot',
Subtype: 'Widget',
FT: 'Sig',
Rect: [0, 0, 0, 0],
V: signature,
T: PDFString.of('Signature1'),
F: 4,
P: firstPage.ref,
AP: doc.context.obj({
N: doc.context.register(doc.context.formXObject([rectangle(0, 0, 0, 0)])),
}),
}),
);
let widgets: PDFArray;
try {
widgets = firstPage.node.lookup(PDFName.of('Annots'), PDFArray);
} catch {
widgets = PDFArray.withContext(doc.context);
firstPage.node.set(PDFName.of('Annots'), widgets);
}
widgets.push(widget);
let arcoForm: PDFDict;
try {
arcoForm = doc.catalog.lookup(PDFName.of('AcroForm'), PDFDict);
} catch {
arcoForm = doc.context.obj({
Fields: PDFArray.withContext(doc.context),
});
doc.catalog.set(PDFName.of('AcroForm'), arcoForm);
}
let fields: PDFArray;
try {
fields = arcoForm.lookup(PDFName.of('Fields'), PDFArray);
} catch {
fields = PDFArray.withContext(doc.context);
arcoForm.set(PDFName.of('Fields'), fields);
}
fields.push(widget);
arcoForm.set(PDFName.of('SigFlags'), PDFNumber.of(3));
return Buffer.from(await doc.save({ useObjectStreams: false }));
};

View file

@ -0,0 +1,33 @@
import { HttpTimestampAuthority } from '@libpdf/core';
import { once } from 'remeda';
import { NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY } from '@documenso/lib/constants/app';
const setupTimestampAuthorities = once(() => {
const timestampAuthority = NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY();
if (!timestampAuthority) {
return null;
}
const timestampAuthorities = timestampAuthority
.trim()
.split(',')
.filter(Boolean)
.map((url) => {
return new HttpTimestampAuthority(url);
});
return timestampAuthorities;
});
export const getTimestampAuthority = () => {
const authorities = setupTimestampAuthorities();
if (!authorities) {
return null;
}
// Pick a random authority
return authorities[Math.floor(Math.random() * authorities.length)];
};

View file

@ -1,72 +0,0 @@
import { describe, expect, it } from 'vitest';
import { updateSigningPlaceholder } from './update-signing-placeholder';
describe('updateSigningPlaceholder', () => {
const pdf = Buffer.from(`
20 0 obj
<<
/Type /Sig
/Filter /Adobe.PPKLite
/SubFilter /adbe.pkcs7.detached
/ByteRange [ 0 /********** /********** /********** ]
/Contents <0000000000000000000000000000000000000000000000000000000>
/Reason (Signed by Documenso)
/M (D:20210101000000Z)
>>
endobj
`);
it('should not throw an error', () => {
expect(() => updateSigningPlaceholder({ pdf })).not.toThrowError();
});
it('should not modify the original PDF', () => {
const result = updateSigningPlaceholder({ pdf });
expect(result.pdf).not.toEqual(pdf);
});
it('should return a PDF with the same length as the original', () => {
const result = updateSigningPlaceholder({ pdf });
expect(result.pdf).toHaveLength(pdf.length);
});
it('should update the byte range and return it', () => {
const result = updateSigningPlaceholder({ pdf });
expect(result.byteRange).toEqual([0, 184, 241, 92]);
});
it('should only update the last signature in the PDF', () => {
const pdf = Buffer.from(`
20 0 obj
<<
/Type /Sig
/Filter /Adobe.PPKLite
/SubFilter /adbe.pkcs7.detached
/ByteRange [ 0 /********** /********** /********** ]
/Contents <0000000000000000000000000000000000000000000000000000000>
/Reason (Signed by Documenso)
/M (D:20210101000000Z)
>>
endobj
21 0 obj
<<
/Type /Sig
/Filter /Adobe.PPKLite
/SubFilter /adbe.pkcs7.detached
/ByteRange [ 0 /********** /********** /********** ]
/Contents <0000000000000000000000000000000000000000000000000000000>
/Reason (Signed by Documenso)
/M (D:20210101000000Z)
>>
endobj
`);
const result = updateSigningPlaceholder({ pdf });
expect(result.byteRange).toEqual([0, 512, 569, 92]);
});
});

View file

@ -1,39 +0,0 @@
export type UpdateSigningPlaceholderOptions = {
pdf: Buffer;
};
export const updateSigningPlaceholder = ({ pdf }: UpdateSigningPlaceholderOptions) => {
const length = pdf.length;
const byteRangePos = pdf.lastIndexOf('/ByteRange');
const byteRangeStart = pdf.indexOf('[', byteRangePos);
const byteRangeEnd = pdf.indexOf(']', byteRangePos);
const byteRangeSlice = pdf.subarray(byteRangeStart, byteRangeEnd + 1);
const signaturePos = pdf.indexOf('/Contents', byteRangeEnd);
const signatureStart = pdf.indexOf('<', signaturePos);
const signatureEnd = pdf.indexOf('>', signaturePos);
const signatureSlice = pdf.subarray(signatureStart, signatureEnd + 1);
const byteRange = [0, 0, 0, 0];
byteRange[1] = signatureStart;
byteRange[2] = byteRange[1] + signatureSlice.length;
byteRange[3] = length - byteRange[2];
const newByteRange = `[${byteRange.join(' ')}]`.padEnd(byteRangeSlice.length, ' ');
const updatedPdf = Buffer.concat([
new Uint8Array(pdf.subarray(0, byteRangeStart)),
new Uint8Array(Buffer.from(newByteRange)),
new Uint8Array(pdf.subarray(byteRangeEnd + 1)),
]);
if (updatedPdf.length !== length) {
throw new Error('Updated PDF length does not match original length');
}
return { pdf: updatedPdf, byteRange };
};

View file

@ -1,21 +1,59 @@
import type { Signer } from '@libpdf/core';
import type { PDF } from '@libpdf/core';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import {
NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER,
NEXT_PUBLIC_SIGNING_CONTACT_INFO,
NEXT_PUBLIC_WEBAPP_URL,
} from '@documenso/lib/constants/app';
import { env } from '@documenso/lib/utils/env'; import { env } from '@documenso/lib/utils/env';
import { signWithGoogleCloudHSM } from './transports/google-cloud-hsm'; import { getTimestampAuthority } from './helpers/tsa';
import { signWithLocalCert } from './transports/local-cert'; import { createGoogleCloudSigner } from './transports/google-cloud';
import { createLocalSigner } from './transports/local';
export type SignOptions = { export type SignOptions = {
pdf: Buffer; pdf: PDF;
}; };
export const signPdf = async ({ pdf }: SignOptions) => { let signer: Signer | null = null;
const getSigner = async () => {
if (signer) {
return signer;
}
const transport = env('NEXT_PRIVATE_SIGNING_TRANSPORT') || 'local'; const transport = env('NEXT_PRIVATE_SIGNING_TRANSPORT') || 'local';
return await match(transport) // eslint-disable-next-line require-atomic-updates
.with('local', async () => signWithLocalCert({ pdf })) signer = await match(transport)
.with('gcloud-hsm', async () => signWithGoogleCloudHSM({ pdf })) .with('local', async () => await createLocalSigner())
.with('gcloud-hsm', async () => await createGoogleCloudSigner())
.otherwise(() => { .otherwise(() => {
throw new Error(`Unsupported signing transport: ${transport}`); throw new Error(`Unsupported signing transport: ${transport}`);
}); });
return signer;
};
export const signPdf = async ({ pdf }: SignOptions) => {
const signer = await getSigner();
const tsa = getTimestampAuthority();
const { bytes } = await pdf.sign({
signer,
reason: 'Signed by Documenso',
location: NEXT_PUBLIC_WEBAPP_URL(),
contactInfo: NEXT_PUBLIC_SIGNING_CONTACT_INFO(),
subFilter: NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER()
? 'adbe.pkcs7.detached'
: 'ETSI.CAdES.detached',
timestampAuthority: tsa ?? undefined,
longTermValidation: !!tsa,
archivalTimestamp: !!tsa,
});
return bytes;
}; };

View file

@ -12,11 +12,11 @@
"test": "vitest" "test": "vitest"
}, },
"dependencies": { "dependencies": {
"@documenso/pdf-sign": "^0.1.0", "@google-cloud/kms": "^5.2.1",
"@documenso/tsconfig": "*", "@google-cloud/secret-manager": "^6.1.1",
"ts-pattern": "^5.9.0" "ts-pattern": "^5.9.0"
}, },
"devDependencies": { "devDependencies": {
"vitest": "^3.2.4" "@documenso/tsconfig": "*"
} }
} }

View file

@ -1,79 +0,0 @@
import fs from 'node:fs';
import { env } from '@documenso/lib/utils/env';
import { signWithGCloud } from '@documenso/pdf-sign';
import { addSigningPlaceholder } from '../helpers/add-signing-placeholder';
import { updateSigningPlaceholder } from '../helpers/update-signing-placeholder';
export type SignWithGoogleCloudHSMOptions = {
pdf: Buffer;
};
export const signWithGoogleCloudHSM = async ({ pdf }: SignWithGoogleCloudHSMOptions) => {
const keyPath = env('NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH');
if (!keyPath) {
throw new Error('No certificate path provided for Google Cloud HSM signing');
}
const googleApplicationCredentials = env('GOOGLE_APPLICATION_CREDENTIALS');
const googleApplicationCredentialsContents = env(
'NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS',
);
// To handle hosting in serverless environments like Vercel we can supply the base64 encoded
// application credentials as an environment variable and write it to a file if it doesn't exist
if (googleApplicationCredentials && googleApplicationCredentialsContents) {
if (!fs.existsSync(googleApplicationCredentials)) {
const contents = new Uint8Array(Buffer.from(googleApplicationCredentialsContents, 'base64'));
fs.writeFileSync(googleApplicationCredentials, contents);
}
}
const { pdf: pdfWithPlaceholder, byteRange } = updateSigningPlaceholder({
pdf: await addSigningPlaceholder({ pdf }),
});
const pdfWithoutSignature = Buffer.concat([
new Uint8Array(pdfWithPlaceholder.subarray(0, byteRange[1])),
new Uint8Array(pdfWithPlaceholder.subarray(byteRange[2])),
]);
const signatureLength = byteRange[2] - byteRange[1];
let cert: Buffer | null = null;
const googleCloudHsmPublicCrtFileContents = env(
'NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS',
);
if (googleCloudHsmPublicCrtFileContents) {
cert = Buffer.from(googleCloudHsmPublicCrtFileContents, 'base64');
}
if (!cert) {
cert = Buffer.from(
fs.readFileSync(
env('NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH') || './example/cert.crt',
),
);
}
const signature = signWithGCloud({
keyPath,
cert,
content: pdfWithoutSignature,
});
const signatureAsHex = signature.toString('hex');
const signedPdf = Buffer.concat([
new Uint8Array(pdfWithPlaceholder.subarray(0, byteRange[1])),
new Uint8Array(Buffer.from(`<${signatureAsHex.padEnd(signatureLength - 2, '0')}>`)),
new Uint8Array(pdfWithPlaceholder.subarray(byteRange[2])),
]);
return signedPdf;
};

View file

@ -0,0 +1,85 @@
import { GoogleKmsSigner, parsePem } from '@libpdf/core';
import fs from 'node:fs';
import { env } from '@documenso/lib/utils/env';
const loadCertificates = async (): Promise<Uint8Array[]> => {
// Try chain file first (takes precedence)
const chainContents = env('NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS');
const chainFilePath = env('NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH');
if (chainContents) {
return parsePem(Buffer.from(chainContents, 'base64').toString('utf-8')).map(
(block) => block.der,
);
}
if (chainFilePath) {
return parsePem(fs.readFileSync(chainFilePath).toString('utf-8')).map((block) => block.der);
}
// Fall back to single certificate (existing behavior)
const certContents = env('NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS');
const certFilePath = env('NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH');
if (certContents) {
return parsePem(Buffer.from(certContents, 'base64').toString('utf-8')).map(
(block) => block.der,
);
}
if (certFilePath) {
return parsePem(fs.readFileSync(certFilePath).toString('utf-8')).map((block) => block.der);
}
// Would use: NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH
const certPath = env('NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH');
if (certPath) {
const { cert, chain } = await GoogleKmsSigner.getCertificateFromSecretManager(certPath);
if (chain) {
return [cert, ...chain];
}
return [cert];
}
throw new Error('No certificate found for Google Cloud HSM signing');
};
export const createGoogleCloudSigner = async () => {
const keyPath = env('NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH');
if (!keyPath) {
throw new Error('No key path provided for Google Cloud HSM signing');
}
const googleAuthCredentials = env('GOOGLE_APPLICATION_CREDENTIALS');
const googleAuthCredentialContents = env(
'NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS',
);
// To handle hosting in serverless environments like Vercel we can supply the base64 encoded
// application credentials as an environment variable and write it to a file if it doesn't exist
if (googleAuthCredentials && googleAuthCredentialContents) {
if (!fs.existsSync(googleAuthCredentials)) {
const contents = new Uint8Array(Buffer.from(googleAuthCredentialContents, 'base64'));
fs.writeFileSync(googleAuthCredentials, contents);
}
}
const certs = await loadCertificates();
if (certs.length === 0) {
throw new Error('No valid certificates found');
}
return GoogleKmsSigner.create({
keyVersionName: keyPath,
certificate: certs[0],
certificateChain: certs.length > 1 ? certs.slice(1) : undefined,
buildChain: true,
});
};

View file

@ -1,80 +0,0 @@
import * as fs from 'node:fs';
import { getCertificateStatus } from '@documenso/lib/server-only/cert/cert-status';
import { env } from '@documenso/lib/utils/env';
import { signWithP12 } from '@documenso/pdf-sign';
import { addSigningPlaceholder } from '../helpers/add-signing-placeholder';
import { updateSigningPlaceholder } from '../helpers/update-signing-placeholder';
export type SignWithLocalCertOptions = {
pdf: Buffer;
};
export const signWithLocalCert = async ({ pdf }: SignWithLocalCertOptions) => {
const { pdf: pdfWithPlaceholder, byteRange } = updateSigningPlaceholder({
pdf: await addSigningPlaceholder({ pdf }),
});
const pdfWithoutSignature = Buffer.concat([
new Uint8Array(pdfWithPlaceholder.subarray(0, byteRange[1])),
new Uint8Array(pdfWithPlaceholder.subarray(byteRange[2])),
]);
const signatureLength = byteRange[2] - byteRange[1];
const certStatus = getCertificateStatus();
if (!certStatus.isAvailable) {
console.error('Certificate error: Certificate not available for document signing');
throw new Error('Document signing failed: Certificate not available');
}
let cert: Buffer | null = null;
const localFileContents = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS');
if (localFileContents) {
try {
cert = Buffer.from(localFileContents, 'base64');
} catch {
throw new Error('Failed to decode certificate contents');
}
}
if (!cert) {
let certPath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH') || '/opt/documenso/cert.p12';
// We don't want to make the development server suddenly crash when using the `dx` script
// so we retain this when NODE_ENV isn't set to production which it should be in most production
// deployments.
//
// Our docker image automatically sets this so it shouldn't be an issue for self-hosters.
if (env('NODE_ENV') !== 'production') {
certPath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH') || './example/cert.p12';
}
try {
cert = Buffer.from(fs.readFileSync(certPath));
} catch {
console.error('Certificate error: Failed to read certificate file');
throw new Error('Document signing failed: Certificate file not accessible');
}
}
const signature = signWithP12({
cert,
content: pdfWithoutSignature,
password: env('NEXT_PRIVATE_SIGNING_PASSPHRASE') || undefined,
});
const signatureAsHex = signature.toString('hex');
const signedPdf = Buffer.concat([
new Uint8Array(pdfWithPlaceholder.subarray(0, byteRange[1])),
new Uint8Array(Buffer.from(`<${signatureAsHex.padEnd(signatureLength - 2, '0')}>`)),
new Uint8Array(pdfWithPlaceholder.subarray(byteRange[2])),
]);
return signedPdf;
};

View file

@ -0,0 +1,32 @@
import { P12Signer } from '@libpdf/core';
import * as fs from 'node:fs';
import { env } from '@documenso/lib/utils/env';
const loadP12 = (): Uint8Array => {
const localFileContents = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS');
if (localFileContents) {
return Buffer.from(localFileContents, 'base64');
}
const localFilePath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH');
if (localFilePath) {
return fs.readFileSync(localFilePath);
}
if (env('NODE_ENV') !== 'production') {
return fs.readFileSync('./example/cert.p12');
}
throw new Error('No certificate found for local signing');
};
export const createLocalSigner = async () => {
const p12 = loadP12();
return await P12Signer.create(p12, env('NEXT_PRIVATE_SIGNING_PASSPHRASE') || '', {
buildChain: true,
});
};

View file

@ -41,6 +41,12 @@ declare namespace NodeJS {
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH?: string; NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH?: string;
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS?: string; NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS?: string;
NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS?: string; NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS?: string;
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH?: string;
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS?: string;
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH?: string;
NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY?: string;
NEXT_PUBLIC_SIGNING_CONTACT_INFO?: string;
NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER?: string;
NEXT_PRIVATE_SMTP_TRANSPORT?: 'mailchannels' | 'resend' | 'smtp-auth' | 'smtp-api'; NEXT_PRIVATE_SMTP_TRANSPORT?: 'mailchannels' | 'resend' | 'smtp-auth' | 'smtp-api';

View file

@ -117,6 +117,18 @@ services:
sync: false sync: false
- key: NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS - key: NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS
sync: false sync: false
- key: NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH
sync: false
- key: NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS
sync: false
- key: NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH
sync: false
- key: NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY
sync: false
- key: NEXT_PUBLIC_SIGNING_CONTACT_INFO
sync: false
- key: NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER
sync: false
# SMTP Optional # SMTP Optional
- key: NEXT_PRIVATE_SMTP_APIKEY_USER - key: NEXT_PRIVATE_SMTP_APIKEY_USER

View file

@ -2,20 +2,12 @@
"$schema": "https://turbo.build/schema.json", "$schema": "https://turbo.build/schema.json",
"pipeline": { "pipeline": {
"build": { "build": {
"dependsOn": [ "dependsOn": ["prebuild", "^build"],
"prebuild", "outputs": [".next/**", "!.next/cache/**"]
"^build"
],
"outputs": [
".next/**",
"!.next/cache/**"
]
}, },
"prebuild": { "prebuild": {
"cache": false, "cache": false,
"dependsOn": [ "dependsOn": ["^prebuild"]
"^prebuild"
]
}, },
"lint": { "lint": {
"cache": false "cache": false
@ -31,9 +23,7 @@
"persistent": true "persistent": true
}, },
"start": { "start": {
"dependsOn": [ "dependsOn": ["^build"],
"^build"
],
"cache": false, "cache": false,
"persistent": true "persistent": true
}, },
@ -41,15 +31,11 @@
"cache": false "cache": false
}, },
"test:e2e": { "test:e2e": {
"dependsOn": [ "dependsOn": ["^build"],
"^build"
],
"cache": false "cache": false
} }
}, },
"globalDependencies": [ "globalDependencies": ["**/.env.*local"],
"**/.env.*local"
],
"globalEnv": [ "globalEnv": [
"APP_VERSION", "APP_VERSION",
"PORT", "PORT",
@ -75,6 +61,12 @@
"NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH", "NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH",
"NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS", "NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS",
"NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS", "NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS",
"NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH",
"NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS",
"NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH",
"NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY",
"NEXT_PUBLIC_SIGNING_CONTACT_INFO",
"NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER",
"NEXT_PRIVATE_GOOGLE_CLIENT_ID", "NEXT_PRIVATE_GOOGLE_CLIENT_ID",
"NEXT_PRIVATE_GOOGLE_CLIENT_SECRET", "NEXT_PRIVATE_GOOGLE_CLIENT_SECRET",
"NEXT_PRIVATE_OIDC_WELL_KNOWN", "NEXT_PRIVATE_OIDC_WELL_KNOWN",
@ -143,4 +135,4 @@
"NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS", "NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS",
"NEXT_PRIVATE_OIDC_PROMPT" "NEXT_PRIVATE_OIDC_PROMPT"
] ]
} }