mirror of
https://github.com/documenso/documenso
synced 2026-04-21 13:27:18 +00:00
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:
parent
ed7a0011c7
commit
9035240b4d
37 changed files with 1065 additions and 1468 deletions
12
.env.example
12
.env.example
|
|
@ -59,6 +59,18 @@ NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH=
|
|||
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.
|
||||
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]]
|
||||
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
||||
|
|
|
|||
|
|
@ -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_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_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_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_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_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. |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
| Environment Variable | Description |
|
||||
| :-------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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_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_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_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. |
|
||||
| Environment Variable | Description |
|
||||
| :------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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_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_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_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 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>
|
||||
|
|
|
|||
Binary file not shown.
110
docker/README.md
110
docker/README.md
|
|
@ -42,16 +42,17 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="<your-certificate-password>"
|
|||
4. Set up your signing certificate. You have three options:
|
||||
|
||||
**Option A: Generate Certificate Inside Container (Recommended)**
|
||||
|
||||
|
||||
Start your containers first, then generate a self-signed certificate:
|
||||
|
||||
```bash
|
||||
# Start containers
|
||||
docker-compose up -d
|
||||
|
||||
|
||||
# Set certificate password securely (won't appear in command history)
|
||||
read -s -p "Enter certificate password: " CERT_PASS
|
||||
echo
|
||||
|
||||
|
||||
# Generate certificate inside container using environment variable
|
||||
docker exec -e CERT_PASS="$CERT_PASS" -it documenso-production-documenso-1 bash -c "
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
|
|
@ -63,19 +64,19 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="<your-certificate-password>"
|
|||
-passout env:CERT_PASS && \
|
||||
rm /tmp/private.key /tmp/certificate.crt
|
||||
"
|
||||
|
||||
|
||||
# Restart container
|
||||
docker-compose restart documenso
|
||||
```
|
||||
|
||||
|
||||
**Option B: Use Existing Certificate**
|
||||
|
||||
|
||||
If you have an existing `.p12` certificate, update the volume binding in `compose.yml`:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /path/to/your/cert.p12:/opt/documenso/cert.p12:ro
|
||||
```
|
||||
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
### Container Logs
|
||||
|
||||
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:
|
||||
|
||||
| Variable | Description |
|
||||
| -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `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. |
|
||||
| `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_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_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_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_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_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_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_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
|
||||
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to use for sending emails (smtp-auth, smtp-api, resend, or mailchannels). |
|
||||
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | If true, then no TLS will be used (even if STARTTLS is supported) |
|
||||
| `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. |
|
||||
| Variable | Description |
|
||||
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `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. |
|
||||
| `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_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_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_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), gcloud-hsm |
|
||||
| `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_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH` | The Google Cloud HSM key path for the gcloud-hsm signing transport. |
|
||||
| `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_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS` | The base64-encoded Google Cloud HSM public certificate for the gcloud-hsm transport. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS` | The base64-encoded Google Cloud Credentials for the gcloud-hsm transport. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH` | The path to the certificate chain file for the gcloud-hsm transport. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS` | The base64-encoded certificate chain for the gcloud-hsm 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_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated list of timestamp authority URLs for PDF signing (enables LTV). |
|
||||
| `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 legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached. |
|
||||
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport to use 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_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
|
||||
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to use for sending emails (smtp-auth, smtp-api, resend, or mailchannels). |
|
||||
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | If true, then no TLS will be used (even if STARTTLS is supported) |
|
||||
| `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
982
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -87,6 +87,7 @@
|
|||
"@ai-sdk/google-vertex": "3.0.81",
|
||||
"@documenso/pdf-sign": "^0.1.0",
|
||||
"@documenso/prisma": "*",
|
||||
"@libpdf/core": "^0.1.0",
|
||||
"@lingui/conf": "^5.6.0",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"ai": "^5.0.104",
|
||||
|
|
@ -103,4 +104,4 @@
|
|||
"typescript": "5.6.2",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import { PDF } from '@libpdf/core';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, FieldType } from '@prisma/client';
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ test.describe('Signing Certificate Tests', () => {
|
|||
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
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
|
|
@ -101,7 +101,7 @@ test.describe('Signing Certificate Tests', () => {
|
|||
const completedDocumentData = new Uint8Array(pdfData);
|
||||
|
||||
// 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
|
||||
});
|
||||
|
|
@ -153,7 +153,7 @@ test.describe('Signing Certificate Tests', () => {
|
|||
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
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
|
|
@ -206,7 +206,7 @@ test.describe('Signing Certificate Tests', () => {
|
|||
const completedDocumentData = new Uint8Array(pdfData);
|
||||
|
||||
// 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
|
||||
});
|
||||
|
|
@ -258,7 +258,7 @@ test.describe('Signing Certificate Tests', () => {
|
|||
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
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
|
|
@ -309,7 +309,7 @@ test.describe('Signing Certificate Tests', () => {
|
|||
);
|
||||
|
||||
// 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,4 +1,4 @@
|
|||
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import { PDF } from '@libpdf/core';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import fs from 'node:fs';
|
||||
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.
|
||||
*/
|
||||
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 fields = form.getFields();
|
||||
const form = await pdfDoc.getForm();
|
||||
|
||||
return fields.length > 0;
|
||||
if (!form) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return form.fieldCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get form field names from a PDF.
|
||||
*/
|
||||
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 fields = form.getFields();
|
||||
const form = await pdfDoc.getForm();
|
||||
|
||||
return fields.map((field) => field.getName());
|
||||
if (!form) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return form.getFieldNames();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -66,17 +72,21 @@ async function getPdfTextFieldValue(
|
|||
pdfBuffer: Uint8Array,
|
||||
fieldName: string,
|
||||
): 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 {
|
||||
const textField = form.getTextField(fieldName);
|
||||
|
||||
return textField.getText() ?? '';
|
||||
} catch {
|
||||
if (!form) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const textField = form.getTextField(fieldName);
|
||||
|
||||
if (!textField) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return textField.getValue();
|
||||
}
|
||||
|
||||
test.describe.configure({
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
|
|||
export const NEXT_PUBLIC_WEBAPP_URL = () =>
|
||||
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 = () =>
|
||||
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 = () =>
|
||||
env('NEXT_PRIVATE_USE_PLAYWRIGHT_PDF') === 'true';
|
||||
|
||||
export const NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY = () =>
|
||||
env('NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY');
|
||||
|
|
|
|||
|
|
@ -1,12 +1,5 @@
|
|||
import {
|
||||
PDFDocument,
|
||||
RotationTypes,
|
||||
popGraphicsState,
|
||||
pushGraphicsState,
|
||||
radiansToDegrees,
|
||||
rotateDegrees,
|
||||
translate,
|
||||
} from '@cantoo/pdf-lib';
|
||||
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import { PDF } from '@libpdf/core';
|
||||
import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client';
|
||||
import {
|
||||
DocumentStatus,
|
||||
|
|
@ -18,8 +11,8 @@ import {
|
|||
import { nanoid } from 'nanoid';
|
||||
import path from 'node:path';
|
||||
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 { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
|
||||
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 { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-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 { insertFieldInPDFV2 } from '../../../server-only/pdf/insert-field-in-pdf-v2';
|
||||
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 { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
|
||||
|
|
@ -181,8 +169,8 @@ export const run = async ({
|
|||
});
|
||||
}
|
||||
|
||||
let certificateDoc: PDFDocument | null = null;
|
||||
let auditLogDoc: PDFDocument | null = null;
|
||||
let certificateDoc: PDF | null = null;
|
||||
let auditLogDoc: PDF | null = null;
|
||||
|
||||
if (settings.includeSigningCertificate || settings.includeAuditLog) {
|
||||
const certificatePayload = {
|
||||
|
|
@ -208,7 +196,7 @@ export const run = async ({
|
|||
? getCertificatePdf({
|
||||
documentId,
|
||||
language: envelope.documentMeta.language,
|
||||
}).then(async (buffer) => PDFDocument.load(buffer))
|
||||
}).then(async (buffer) => PDF.load(buffer))
|
||||
: generateCertificatePdf(certificatePayload);
|
||||
|
||||
const makeAuditLogPdf = async () =>
|
||||
|
|
@ -216,7 +204,7 @@ export const run = async ({
|
|||
? getAuditLogsPdf({
|
||||
documentId,
|
||||
language: envelope.documentMeta.language,
|
||||
}).then(async (buffer) => PDFDocument.load(buffer))
|
||||
}).then(async (buffer) => PDF.load(buffer))
|
||||
: generateAuditLogPdf(certificatePayload);
|
||||
|
||||
const [createdCertificatePdf, createdAuditLogPdf] = await Promise.all([
|
||||
|
|
@ -342,8 +330,8 @@ type DecorateAndSignPdfOptions = {
|
|||
envelopeItemFields: Field[];
|
||||
isRejected: boolean;
|
||||
rejectionReason: string;
|
||||
certificateDoc: PDFDocument | null;
|
||||
auditLogDoc: PDFDocument | null;
|
||||
certificateDoc: PDF | null;
|
||||
auditLogDoc: PDF | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -360,48 +348,47 @@ const decorateAndSignPdf = async ({
|
|||
}: DecorateAndSignPdfOptions) => {
|
||||
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
|
||||
normalizeSignatureAppearances(pdfDoc);
|
||||
await flattenForm(pdfDoc);
|
||||
flattenAnnotations(pdfDoc);
|
||||
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 && rejectionReason) {
|
||||
if (isRejected) {
|
||||
await addRejectionStampToPdf(pdfDoc, rejectionReason);
|
||||
}
|
||||
|
||||
if (certificateDoc) {
|
||||
const certificatePages = await pdfDoc.copyPages(
|
||||
await pdfDoc.copyPagesFrom(
|
||||
certificateDoc,
|
||||
certificateDoc.getPageIndices(),
|
||||
Array.from({ length: certificateDoc.getPageCount() }, (_, index) => index),
|
||||
);
|
||||
|
||||
certificatePages.forEach((page) => {
|
||||
pdfDoc.addPage(page);
|
||||
});
|
||||
}
|
||||
|
||||
if (auditLogDoc) {
|
||||
const auditLogPages = await pdfDoc.copyPages(auditLogDoc, auditLogDoc.getPageIndices());
|
||||
|
||||
auditLogPages.forEach((page) => {
|
||||
pdfDoc.addPage(page);
|
||||
});
|
||||
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(pdfDoc, field);
|
||||
await legacy_insertFieldInPDF(legacy_pdfLibDoc, field);
|
||||
} else {
|
||||
await insertFieldInPDFV1(pdfDoc, field);
|
||||
await insertFieldInPDFV1(legacy_pdfLibDoc, field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await pdfDoc.reload(await legacy_pdfLibDoc.save());
|
||||
}
|
||||
|
||||
// Handle V2 envelope insertions.
|
||||
|
|
@ -410,87 +397,61 @@ const decorateAndSignPdf = async ({
|
|||
|
||||
for (const [pageNumber, fields] of Object.entries(fieldsGroupedByPage)) {
|
||||
const page = pdfDoc.getPage(Number(pageNumber) - 1);
|
||||
const pageRotation = page.getRotation();
|
||||
|
||||
let { width: pageWidth, height: pageHeight } = getPageSize(page);
|
||||
|
||||
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];
|
||||
if (!page) {
|
||||
throw new Error(`Page ${pageNumber} does not exist`);
|
||||
}
|
||||
|
||||
// Rotate the page to the orientation that the react-pdf renders on the frontend.
|
||||
// Note: These transformations are undone at the end of the function.
|
||||
// If you change this if statement, update the if statement at the end as well
|
||||
if (pageRotationInDegrees !== 0) {
|
||||
let translateX = 0;
|
||||
let translateY = 0;
|
||||
const pageWidth = page.width;
|
||||
const pageHeight = page.height;
|
||||
|
||||
switch (pageRotationInDegrees) {
|
||||
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({
|
||||
const overlayBytes = await insertFieldInPDFV2({
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
fields,
|
||||
});
|
||||
|
||||
const [embeddedPage] = await pdfDoc.embedPdf(renderedPdfOverlay);
|
||||
const overlayPdf = await PDF.load(overlayBytes);
|
||||
|
||||
// Draw the SVG on the page
|
||||
page.drawPage(embeddedPage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: pageWidth,
|
||||
height: pageHeight,
|
||||
});
|
||||
const embeddedPage = await pdfDoc.embedPage(overlayPdf, 0);
|
||||
|
||||
// Remove the transformations applied to the page if any were applied.
|
||||
if (pageRotationInDegrees !== 0) {
|
||||
page.pushOperators(popGraphicsState());
|
||||
// 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
|
||||
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);
|
||||
|
||||
|
|
@ -500,7 +461,7 @@ const decorateAndSignPdf = async ({
|
|||
const newDocumentData = await putPdfFileServerSide({
|
||||
name: `${name}${suffix}`,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(pdfBuffer),
|
||||
arrayBuffer: async () => Promise.resolve(pdfBytes),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,88 +1,72 @@
|
|||
import type { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import { TextAlignment, rgb, setFontAndSize } from '@cantoo/pdf-lib';
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import { type PDF, rgb } from '@libpdf/core';
|
||||
|
||||
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.
|
||||
* The stamp is placed in the center of the page.
|
||||
*/
|
||||
export async function addRejectionStampToPdf(
|
||||
pdfDoc: PDFDocument,
|
||||
reason: string,
|
||||
): Promise<PDFDocument> {
|
||||
const pages = pdfDoc.getPages();
|
||||
pdfDoc.registerFontkit(fontkit);
|
||||
export async function addRejectionStampToPdf(pdf: PDF, reason: string): Promise<PDF> {
|
||||
const pages = pdf.getPages();
|
||||
|
||||
const fontBytes = await fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(
|
||||
async (res) => res.arrayBuffer(),
|
||||
);
|
||||
|
||||
const font = await pdfDoc.embedFont(fontBytes, {
|
||||
customName: 'Noto',
|
||||
});
|
||||
const font = pdf.embedFont(new Uint8Array(fontBytes));
|
||||
|
||||
const form = pdfDoc.getForm();
|
||||
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const page = pages[i];
|
||||
const { width, height } = getPageSize(page);
|
||||
for (const page of pages) {
|
||||
const height = page.height;
|
||||
const width = page.width;
|
||||
|
||||
// Draw the "REJECTED" text
|
||||
const rejectedTitleText = 'DOCUMENT REJECTED';
|
||||
const rejectedTitleFontSize = 36;
|
||||
const rejectedTitleTextField = form.createTextField(`internal-document-rejected-title-${i}`);
|
||||
|
||||
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);
|
||||
const rotationAngle = 45;
|
||||
|
||||
// Calculate the center position of the page
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
|
||||
// Position the title text at the center of the page
|
||||
const rejectedTitleTextX = centerX - rejectedTitleTextWidth / 2;
|
||||
const rejectedTitleTextY = centerY - rejectedTitleTextHeight / 2;
|
||||
const widthOfText = font.getTextWidth(rejectedTitleText, rejectedTitleFontSize);
|
||||
|
||||
// Add padding for the rectangle
|
||||
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({
|
||||
x: rejectedTitleTextX - padding / 2,
|
||||
y: rejectedTitleTextY - padding / 2,
|
||||
width: rejectedTitleTextWidth + padding,
|
||||
height: rejectedTitleTextHeight + padding,
|
||||
x: rectX,
|
||||
y: rectY,
|
||||
width: rectWidth,
|
||||
height: rectHeight,
|
||||
borderColor: rgb(220 / 255, 38 / 255, 38 / 255),
|
||||
borderWidth: 4,
|
||||
rotate: {
|
||||
angle: rotationAngle,
|
||||
origin: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
rejectedTitleTextField.addToPage(page, {
|
||||
x: rejectedTitleTextX,
|
||||
y: rejectedTitleTextY,
|
||||
width: rejectedTitleTextWidth,
|
||||
height: rejectedTitleTextHeight,
|
||||
textColor: rgb(220 / 255, 38 / 255, 38 / 255),
|
||||
backgroundColor: undefined,
|
||||
borderWidth: 0,
|
||||
borderColor: undefined,
|
||||
const textX = centerX - widthOfText / 2;
|
||||
const textY = centerY;
|
||||
|
||||
// Draw the text centered within the rectangle
|
||||
page.drawText(rejectedTitleText, {
|
||||
x: textX,
|
||||
y: textY,
|
||||
size: rejectedTitleFontSize,
|
||||
font,
|
||||
color: rgb(220 / 255, 38 / 255, 38 / 255),
|
||||
rotate: {
|
||||
angle: rotationAngle,
|
||||
origin: 'center',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return pdfDoc;
|
||||
return pdf;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { PDF } from '@libpdf/core';
|
||||
import { i18n } from '@lingui/core';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
|
@ -7,7 +8,6 @@ import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
|
|||
import { getTranslations } from '../../utils/i18n';
|
||||
import { getOrganisationClaimByTeamId } from '../organisation/get-organisation-claims';
|
||||
import type { GenerateCertificatePdfOptions } from './generate-certificate-pdf';
|
||||
import { mergeFilesIntoPdf } from './generate-certificate-pdf';
|
||||
import { renderAuditLogs } from './render-audit-logs';
|
||||
|
||||
type GenerateAuditLogPdfOptions = GenerateCertificatePdfOptions & {
|
||||
|
|
@ -43,7 +43,9 @@ export const generateAuditLogPdf = async (options: GenerateAuditLogPdfOptions) =
|
|||
i18n,
|
||||
});
|
||||
|
||||
return await mergeFilesIntoPdf(auditLogPages);
|
||||
return await PDF.merge(auditLogPages, {
|
||||
includeAnnotations: true,
|
||||
});
|
||||
};
|
||||
|
||||
const getAuditLogs = async (envelopeId: string) => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import { PDF } from '@libpdf/core';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { DocumentMeta } from '@prisma/client';
|
||||
|
|
@ -144,17 +144,5 @@ export const generateCertificatePdf = async (options: GenerateCertificatePdfOpti
|
|||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,4 @@
|
|||
import {
|
||||
PDFCheckBox,
|
||||
PDFDocument,
|
||||
PDFDropdown,
|
||||
PDFRadioGroup,
|
||||
PDFTextField,
|
||||
} from '@cantoo/pdf-lib';
|
||||
import { PDF } from '@libpdf/core';
|
||||
|
||||
export type InsertFormValuesInPdfOptions = {
|
||||
pdf: Buffer;
|
||||
|
|
@ -12,7 +6,7 @@ export type InsertFormValuesInPdfOptions = {
|
|||
};
|
||||
|
||||
export const insertFormValuesInPdf = async ({ pdf, formValues }: InsertFormValuesInPdfOptions) => {
|
||||
const doc = await PDFDocument.load(pdf);
|
||||
const doc = await PDF.load(pdf);
|
||||
|
||||
const form = doc.getForm();
|
||||
|
||||
|
|
@ -20,41 +14,12 @@ export const insertFormValuesInPdf = async ({ pdf, formValues }: InsertFormValue
|
|||
return pdf;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(formValues)) {
|
||||
try {
|
||||
const field = form.getField(key);
|
||||
const filledForm = Object.entries(formValues).map(([key, value]) => [
|
||||
key,
|
||||
typeof value === 'boolean' ? value : value.toString(),
|
||||
]);
|
||||
|
||||
if (!field) {
|
||||
continue;
|
||||
}
|
||||
form.fill(Object.fromEntries(filledForm));
|
||||
|
||||
if (typeof value === 'boolean' && field instanceof PDFCheckBox) {
|
||||
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));
|
||||
return await doc.save({ incremental: true }).then((buf) => Buffer.from(buf));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -1,13 +1,11 @@
|
|||
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import { PDF } from '@libpdf/core';
|
||||
|
||||
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 } = {}) => {
|
||||
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}`);
|
||||
|
||||
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) {
|
||||
await flattenForm(pdfDoc);
|
||||
flattenAnnotations(pdfDoc);
|
||||
const form = pdfDoc.getForm();
|
||||
|
||||
if (shouldFlattenForm && form) {
|
||||
form.flatten();
|
||||
pdfDoc.flattenAnnotations();
|
||||
}
|
||||
|
||||
return Buffer.from(await pdfDoc.save());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import { PDF } from '@libpdf/core';
|
||||
import { DocumentDataType } from '@prisma/client';
|
||||
import { base64 } from '@scure/base';
|
||||
import { match } from 'ts-pattern';
|
||||
|
|
@ -25,7 +25,7 @@ export const putPdfFileServerSide = async (file: File) => {
|
|||
|
||||
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}`);
|
||||
|
||||
throw new AppError('INVALID_DOCUMENT_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') {
|
||||
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 = () =>
|
||||
|
|
|
|||
|
|
@ -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 = '**********';
|
||||
|
|
@ -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 }));
|
||||
};
|
||||
33
packages/signing/helpers/tsa.ts
Normal file
33
packages/signing/helpers/tsa.ts
Normal 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)];
|
||||
};
|
||||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -1,21 +1,59 @@
|
|||
import type { Signer } from '@libpdf/core';
|
||||
import type { PDF } from '@libpdf/core';
|
||||
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 { signWithGoogleCloudHSM } from './transports/google-cloud-hsm';
|
||||
import { signWithLocalCert } from './transports/local-cert';
|
||||
import { getTimestampAuthority } from './helpers/tsa';
|
||||
import { createGoogleCloudSigner } from './transports/google-cloud';
|
||||
import { createLocalSigner } from './transports/local';
|
||||
|
||||
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';
|
||||
|
||||
return await match(transport)
|
||||
.with('local', async () => signWithLocalCert({ pdf }))
|
||||
.with('gcloud-hsm', async () => signWithGoogleCloudHSM({ pdf }))
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
signer = await match(transport)
|
||||
.with('local', async () => await createLocalSigner())
|
||||
.with('gcloud-hsm', async () => await createGoogleCloudSigner())
|
||||
.otherwise(() => {
|
||||
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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@
|
|||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/pdf-sign": "^0.1.0",
|
||||
"@documenso/tsconfig": "*",
|
||||
"@google-cloud/kms": "^5.2.1",
|
||||
"@google-cloud/secret-manager": "^6.1.1",
|
||||
"ts-pattern": "^5.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.4"
|
||||
"@documenso/tsconfig": "*"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
85
packages/signing/transports/google-cloud.ts
Normal file
85
packages/signing/transports/google-cloud.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
32
packages/signing/transports/local.ts
Normal file
32
packages/signing/transports/local.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
6
packages/tsconfig/process-env.d.ts
vendored
6
packages/tsconfig/process-env.d.ts
vendored
|
|
@ -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_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';
|
||||
|
||||
|
|
|
|||
12
render.yaml
12
render.yaml
|
|
@ -117,6 +117,18 @@ services:
|
|||
sync: false
|
||||
- key: NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS
|
||||
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
|
||||
- key: NEXT_PRIVATE_SMTP_APIKEY_USER
|
||||
|
|
|
|||
34
turbo.json
34
turbo.json
|
|
@ -2,20 +2,12 @@
|
|||
"$schema": "https://turbo.build/schema.json",
|
||||
"pipeline": {
|
||||
"build": {
|
||||
"dependsOn": [
|
||||
"prebuild",
|
||||
"^build"
|
||||
],
|
||||
"outputs": [
|
||||
".next/**",
|
||||
"!.next/cache/**"
|
||||
]
|
||||
"dependsOn": ["prebuild", "^build"],
|
||||
"outputs": [".next/**", "!.next/cache/**"]
|
||||
},
|
||||
"prebuild": {
|
||||
"cache": false,
|
||||
"dependsOn": [
|
||||
"^prebuild"
|
||||
]
|
||||
"dependsOn": ["^prebuild"]
|
||||
},
|
||||
"lint": {
|
||||
"cache": false
|
||||
|
|
@ -31,9 +23,7 @@
|
|||
"persistent": true
|
||||
},
|
||||
"start": {
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"dependsOn": ["^build"],
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
|
|
@ -41,15 +31,11 @@
|
|||
"cache": false
|
||||
},
|
||||
"test:e2e": {
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"dependsOn": ["^build"],
|
||||
"cache": false
|
||||
}
|
||||
},
|
||||
"globalDependencies": [
|
||||
"**/.env.*local"
|
||||
],
|
||||
"globalDependencies": ["**/.env.*local"],
|
||||
"globalEnv": [
|
||||
"APP_VERSION",
|
||||
"PORT",
|
||||
|
|
@ -75,6 +61,12 @@
|
|||
"NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH",
|
||||
"NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_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_SECRET",
|
||||
"NEXT_PRIVATE_OIDC_WELL_KNOWN",
|
||||
|
|
@ -143,4 +135,4 @@
|
|||
"NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS",
|
||||
"NEXT_PRIVATE_OIDC_PROMPT"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue