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=
|
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
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
||||||
|
|
|
||||||
|
|
@ -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.
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:
|
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
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",
|
"@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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { 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) => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 { 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());
|
||||||
|
|
|
||||||
|
|
@ -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 { 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');
|
||||||
|
|
|
||||||
|
|
@ -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 = () =>
|
||||||
|
|
|
||||||
|
|
@ -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 { 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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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": "*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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_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';
|
||||||
|
|
||||||
|
|
|
||||||
12
render.yaml
12
render.yaml
|
|
@ -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
|
||||||
|
|
|
||||||
34
turbo.json
34
turbo.json
|
|
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue